Skip to content

Commit 33340e1

Browse files
committed
Replace pgwire-lite (libpq-sys) with pure-Rust PG wire client; use reqwest rustls-tls
pgwire-lite v0.1.0 wraps the native libpq C library via libpq-sys, which requires PostgreSQL client headers/libs at build time on every platform: - macOS: 'libpq-fe.h' not found - Linux cross (aarch64): missing libpq / OpenSSL pkg-config - Windows: linker error LNK1181 cannot open 'libpq.lib' Fix 1: Replace pgwire-lite with src/utils/pgwire.rs — a pure-Rust implementation of the PostgreSQL v3 simple-query wire protocol using only std::net::TcpStream. Zero native dependencies. Matches the exact API surface used (PgwireLite::new, query, Value, Notice). Fix 2: Switch reqwest from default native-tls (openssl-sys) to rustls-tls, eliminating the OpenSSL requirement for cross-compiled Linux targets. https://claude.ai/code/session_01GzGtjMcwBXyVW3uKW4F2Ai
1 parent 16407ed commit 33340e1

8 files changed

Lines changed: 411 additions & 309 deletions

File tree

Cargo.lock

Lines changed: 82 additions & 302 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,8 @@ rustyline = "10.0"
2424
tera = "1.19.0"
2525
log = "0.4"
2626
env_logger = "0.10"
27-
pgwire-lite = "0.1.0"
2827
zip = "0.6"
29-
reqwest = { version = "0.11", features = ["blocking", "json"] }
28+
reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"] }
3029
indicatif = "0.17"
3130
unicode-width = "0.1.10"
3231
once_cell = "1.17.0"

src/commands/base.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::path::Path;
1111
use std::process;
1212

1313
use log::{debug, error, info};
14-
use pgwire_lite::PgwireLite;
14+
use crate::utils::pgwire::PgwireLite;
1515

1616
use crate::core::config::{get_full_context, render_globals, render_string_value};
1717
use crate::core::env::load_env_vars;

src/core/utils.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::thread;
1212
use std::time::{Duration, Instant};
1313

1414
use log::{debug, error, info, warn};
15-
use pgwire_lite::PgwireLite;
15+
use crate::utils::pgwire::PgwireLite;
1616

1717
use crate::utils::query::{execute_query, QueryResult};
1818

src/utils/connection.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
use std::process;
2222

2323
use colored::*;
24-
use pgwire_lite::PgwireLite;
2524

2625
use crate::globals::{server_host, server_port};
26+
use crate::utils::pgwire::PgwireLite;
2727

2828
/// Creates a new PgwireLite client connection
2929
pub fn create_client() -> PgwireLite {
@@ -38,7 +38,7 @@ pub fn create_client() -> PgwireLite {
3838
});
3939

4040
println!("Connected to stackql server at {}:{}", host, port);
41-
println!("Using libpq version: {}", client.libpq_version());
41+
println!("Using pgwire client: {}", client.libpq_version());
4242

4343
client
4444
}

src/utils/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ pub mod connection;
33
pub mod display;
44
pub mod download;
55
pub mod logging;
6+
pub mod pgwire;
67
pub mod platform;
78
pub mod query;
89
pub mod server;

src/utils/pgwire.rs

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// utils/pgwire.rs
2+
3+
//! Pure-Rust PostgreSQL simple-query wire protocol client.
4+
//!
5+
//! Implements only what stackql-deploy needs: unencrypted TCP connections
6+
//! to a local StackQL server using the PostgreSQL simple query protocol (v3).
7+
//! No native dependencies (replaces pgwire-lite → libpq-sys).
8+
9+
use std::collections::HashMap;
10+
use std::io::{Read, Write};
11+
use std::net::TcpStream;
12+
13+
/// A single column value returned from a query.
14+
pub enum Value {
15+
String(String),
16+
Null,
17+
Bool(bool),
18+
Integer(i64),
19+
Float(f64),
20+
Bytes(Vec<u8>),
21+
}
22+
23+
/// A server notice (NOTICE, WARNING, etc.).
24+
pub struct Notice {
25+
pub fields: HashMap<String, String>,
26+
}
27+
28+
/// The result of a [`PgwireLite::query`] call.
29+
pub struct PgQueryResult {
30+
pub column_names: Vec<String>,
31+
pub rows: Vec<HashMap<String, Value>>,
32+
pub notices: Vec<Notice>,
33+
/// Row count reported by CommandComplete (INSERT/UPDATE/DELETE n).
34+
pub row_count: usize,
35+
}
36+
37+
/// Minimal PostgreSQL wire-protocol client.
38+
pub struct PgwireLite {
39+
stream: TcpStream,
40+
}
41+
42+
impl PgwireLite {
43+
/// Connect to a PostgreSQL-protocol server (e.g. StackQL) at `host:port`.
44+
///
45+
/// `_ssl` and `_verbosity` are accepted for API compatibility but ignored;
46+
/// the connection is always unencrypted (StackQL default).
47+
pub fn new(host: &str, port: u16, _ssl: bool, _verbosity: &str) -> Result<Self, String> {
48+
let addr = format!("{}:{}", host, port);
49+
let stream = TcpStream::connect(&addr)
50+
.map_err(|e| format!("Connection to {} failed: {}", addr, e))?;
51+
52+
let mut client = PgwireLite { stream };
53+
client.startup()?;
54+
Ok(client)
55+
}
56+
57+
/// Returns a version string (no libpq; just identifies the client).
58+
pub fn libpq_version(&self) -> String {
59+
"pure-rust-pgwire-client".to_string()
60+
}
61+
62+
// ------------------------------------------------------------------
63+
// Startup handshake
64+
// ------------------------------------------------------------------
65+
66+
fn startup(&mut self) -> Result<(), String> {
67+
// Protocol version 3.0 = 0x00_03_00_00
68+
const PROTOCOL_V3: i32 = 196608;
69+
70+
// Startup message: user=stackql, database=stackql, then double-null
71+
let params = b"user\0stackql\0database\0stackql\0\0";
72+
let total_len = 4 + 4 + params.len(); // length field + protocol + params
73+
74+
let mut msg = Vec::with_capacity(total_len);
75+
msg.extend_from_slice(&(total_len as i32).to_be_bytes());
76+
msg.extend_from_slice(&PROTOCOL_V3.to_be_bytes());
77+
msg.extend_from_slice(params);
78+
79+
self.stream
80+
.write_all(&msg)
81+
.map_err(|e| format!("Startup write error: {}", e))?;
82+
83+
// Process auth / parameter-status messages until ReadyForQuery
84+
loop {
85+
let msg_type = self.read_byte()?;
86+
let payload_len = self.read_i32()? as usize;
87+
// payload_len includes the 4 bytes of the length field itself
88+
let data = self.read_bytes(payload_len.saturating_sub(4))?;
89+
90+
match msg_type {
91+
b'R' => {
92+
// AuthenticationRequest
93+
let auth_type =
94+
i32::from_be_bytes(data[..4].try_into().map_err(|_| "Bad auth")?);
95+
if auth_type != 0 {
96+
return Err(format!(
97+
"Unsupported authentication type {} from server",
98+
auth_type
99+
));
100+
}
101+
// AuthenticationOk — nothing to do
102+
}
103+
b'K' => {} // BackendKeyData — ignore
104+
b'S' => {} // ParameterStatus — ignore
105+
b'Z' => break, // ReadyForQuery
106+
b'E' => return Err(parse_error_fields(&data)),
107+
b'N' => {} // NoticeResponse during startup — ignore
108+
_ => {} // Unknown message type — skip
109+
}
110+
}
111+
112+
Ok(())
113+
}
114+
115+
// ------------------------------------------------------------------
116+
// Query
117+
// ------------------------------------------------------------------
118+
119+
/// Execute a simple (non-prepared) SQL query and return structured results.
120+
pub fn query(&mut self, sql: &str) -> Result<PgQueryResult, String> {
121+
// Send Query message: 'Q' | int32(len) | sql\0
122+
let sql_bytes = sql.as_bytes();
123+
let payload_len = 4 + sql_bytes.len() + 1; // length field + sql + null
124+
125+
let mut msg = Vec::with_capacity(1 + payload_len);
126+
msg.push(b'Q');
127+
msg.extend_from_slice(&(payload_len as i32).to_be_bytes());
128+
msg.extend_from_slice(sql_bytes);
129+
msg.push(0u8);
130+
131+
self.stream
132+
.write_all(&msg)
133+
.map_err(|e| format!("Query write error: {}", e))?;
134+
135+
// Collect response messages
136+
let mut column_names: Vec<String> = Vec::new();
137+
let mut rows: Vec<HashMap<String, Value>> = Vec::new();
138+
let mut notices: Vec<Notice> = Vec::new();
139+
let mut row_count: usize = 0;
140+
141+
loop {
142+
let msg_type = self.read_byte()?;
143+
let payload_len = self.read_i32()? as usize;
144+
let data = self.read_bytes(payload_len.saturating_sub(4))?;
145+
146+
match msg_type {
147+
b'T' => {
148+
// RowDescription
149+
column_names = parse_row_description(&data);
150+
}
151+
b'D' => {
152+
// DataRow
153+
let row = parse_data_row(&data, &column_names);
154+
rows.push(row);
155+
}
156+
b'C' => {
157+
// CommandComplete — tag like "SELECT 5", "INSERT 0 1", "UPDATE 3"
158+
let tag = std::str::from_utf8(data.strip_suffix(b"\0").unwrap_or(&data))
159+
.unwrap_or("")
160+
.to_string();
161+
if let Some(n) = tag.split_whitespace().last().and_then(|s| s.parse().ok()) {
162+
row_count = n;
163+
}
164+
}
165+
b'N' => {
166+
notices.push(parse_notice_fields(&data));
167+
}
168+
b'E' => {
169+
return Err(parse_error_fields(&data));
170+
}
171+
b'I' => {} // EmptyQueryResponse
172+
b'Z' => break, // ReadyForQuery — done
173+
_ => {}
174+
}
175+
}
176+
177+
Ok(PgQueryResult {
178+
column_names,
179+
rows,
180+
notices,
181+
row_count,
182+
})
183+
}
184+
185+
// ------------------------------------------------------------------
186+
// Low-level I/O helpers
187+
// ------------------------------------------------------------------
188+
189+
fn read_byte(&mut self) -> Result<u8, String> {
190+
let mut buf = [0u8; 1];
191+
self.stream
192+
.read_exact(&mut buf)
193+
.map_err(|e| format!("Read error: {}", e))?;
194+
Ok(buf[0])
195+
}
196+
197+
fn read_i32(&mut self) -> Result<i32, String> {
198+
let mut buf = [0u8; 4];
199+
self.stream
200+
.read_exact(&mut buf)
201+
.map_err(|e| format!("Read error: {}", e))?;
202+
Ok(i32::from_be_bytes(buf))
203+
}
204+
205+
fn read_bytes(&mut self, n: usize) -> Result<Vec<u8>, String> {
206+
let mut buf = vec![0u8; n];
207+
self.stream
208+
.read_exact(&mut buf)
209+
.map_err(|e| format!("Read error: {}", e))?;
210+
Ok(buf)
211+
}
212+
}
213+
214+
// ------------------------------------------------------------------
215+
// Message parsers (free functions for readability)
216+
// ------------------------------------------------------------------
217+
218+
fn parse_row_description(data: &[u8]) -> Vec<String> {
219+
let mut names = Vec::new();
220+
if data.len() < 2 {
221+
return names;
222+
}
223+
let num_fields = u16::from_be_bytes([data[0], data[1]]) as usize;
224+
let mut pos = 2;
225+
226+
for _ in 0..num_fields {
227+
// Null-terminated field name
228+
let Some(null_off) = data[pos..].iter().position(|&b| b == 0) else {
229+
break;
230+
};
231+
let name = String::from_utf8_lossy(&data[pos..pos + null_off]).into_owned();
232+
names.push(name);
233+
// Skip: name + null(1) + tableOID(4) + attrNum(2) + typeOID(4) + typeSize(2)
234+
// + typeMod(4) + formatCode(2) = 19 bytes after the null
235+
pos += null_off + 1 + 18;
236+
}
237+
names
238+
}
239+
240+
fn parse_data_row(data: &[u8], columns: &[String]) -> HashMap<String, Value> {
241+
let mut row = HashMap::new();
242+
if data.len() < 2 {
243+
return row;
244+
}
245+
let num_cols = u16::from_be_bytes([data[0], data[1]]) as usize;
246+
let mut pos = 2;
247+
248+
for i in 0..num_cols.min(columns.len()) {
249+
if pos + 4 > data.len() {
250+
break;
251+
}
252+
let col_len = i32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
253+
pos += 4;
254+
255+
let value = if col_len < 0 {
256+
Value::Null
257+
} else {
258+
let len = col_len as usize;
259+
if pos + len > data.len() {
260+
break;
261+
}
262+
let s = String::from_utf8_lossy(&data[pos..pos + len]).into_owned();
263+
pos += len;
264+
Value::String(s)
265+
};
266+
267+
row.insert(columns[i].clone(), value);
268+
}
269+
row
270+
}
271+
272+
fn parse_notice_fields(data: &[u8]) -> Notice {
273+
let mut fields = HashMap::new();
274+
let mut pos = 0;
275+
276+
while pos < data.len() {
277+
let field_code = data[pos];
278+
pos += 1;
279+
if field_code == 0 {
280+
break;
281+
}
282+
let Some(null_off) = data[pos..].iter().position(|&b| b == 0) else {
283+
break;
284+
};
285+
let value = String::from_utf8_lossy(&data[pos..pos + null_off]).into_owned();
286+
pos += null_off + 1;
287+
288+
let key = match field_code {
289+
b'S' => "severity",
290+
b'M' => "message",
291+
b'D' => "detail",
292+
b'H' => "hint",
293+
b'C' => "code",
294+
b'P' => "position",
295+
b'W' => "where",
296+
_ => continue,
297+
};
298+
fields.insert(key.to_string(), value);
299+
}
300+
301+
Notice { fields }
302+
}
303+
304+
fn parse_error_fields(data: &[u8]) -> String {
305+
let mut pos = 0;
306+
while pos < data.len() {
307+
let field_code = data[pos];
308+
pos += 1;
309+
if field_code == 0 {
310+
break;
311+
}
312+
let Some(null_off) = data[pos..].iter().position(|&b| b == 0) else {
313+
break;
314+
};
315+
let value = String::from_utf8_lossy(&data[pos..pos + null_off]).into_owned();
316+
pos += null_off + 1;
317+
if field_code == b'M' {
318+
return value;
319+
}
320+
}
321+
"Unknown server error".to_string()
322+
}

src/utils/query.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
//! }
2727
//! ```
2828
29-
use pgwire_lite::{PgwireLite, Value};
29+
use crate::utils::pgwire::{PgwireLite, Value};
3030

3131
/// Represents a column in a query result.
3232
pub struct QueryResultColumn {

0 commit comments

Comments
 (0)