1use crate::backend::{Backend, Cell, Table};
9use crate::{Result, SqlError};
10use postgres::config::{Host, SslMode};
11use postgres::types::Type;
12use postgres::{Client, Config, NoTls};
13
14pub fn connect_postgres(conn_str: &str) -> Result<Backend> {
26 let config: Config = conn_str
27 .parse()
28 .map_err(|_| SqlError::Connect("invalid postgres connection string (redacted)".into()))?;
29 check_pg_transport(&config)?;
30 let client = config
31 .connect(NoTls)
32 .map_err(|e| SqlError::Connect(format!("postgres connect failed: {}", redact_pg(e))))?;
33 Ok(Backend::Postgres(client))
34}
35
36fn check_pg_transport(config: &Config) -> Result<()> {
39 let no_tls = |why: &str| {
40 Err(SqlError::Connect(format!(
41 "postgres: TLS isn't built into this build of Improv; {why}"
42 )))
43 };
44 let mode = config.get_ssl_mode();
45 if mode == SslMode::Require {
46 return no_tls("sslmode=require cannot be honoured (use an SSH tunnel to a loopback port)");
47 }
48 let host_is_local = |h: &Host| match h {
49 Host::Tcp(name) => {
50 name.eq_ignore_ascii_case("localhost")
51 || name
52 .parse::<std::net::IpAddr>()
53 .is_ok_and(|ip| ip.is_loopback())
54 }
55 #[cfg(unix)]
56 Host::Unix(_) => true,
57 };
58 let local = config.get_hosts().iter().all(host_is_local)
59 && config.get_hostaddrs().iter().all(|ip| ip.is_loopback());
60 if !local && mode != SslMode::Disable {
61 return no_tls(
62 "refusing a non-loopback host without an explicit sslmode=disable \
63 (the password would cross the network in cleartext)",
64 );
65 }
66 Ok(())
67}
68
69fn redact_pg(e: postgres::Error) -> String {
72 if e.as_db_error().is_some() {
75 "database error (redacted)".to_string()
76 } else {
77 "connection error (redacted)".to_string()
78 }
79}
80
81pub(crate) fn pg_query(client: &mut Client, sql: &str) -> Result<Table> {
82 let pg_err =
86 |what: &str, e| SqlError::Import(format!("postgres {what} failed: {}", redact_pg(e)));
87 let mut tx = client
88 .build_transaction()
89 .read_only(true)
90 .start()
91 .map_err(|e| pg_err("begin read only", e))?;
92 let stmt = tx.prepare(sql).map_err(|e| pg_err("prepare", e))?;
95 let rows = tx.query(&stmt, &[]).map_err(|e| pg_err("query", e))?;
96 let columns: Vec<String> = stmt
97 .columns()
98 .iter()
99 .map(|c| c.name().to_string())
100 .collect();
101 tx.commit().map_err(|e| pg_err("commit", e))?;
102
103 let mut out_rows = Vec::with_capacity(rows.len());
104 for row in &rows {
105 let mut cells = Vec::with_capacity(row.len());
106 for i in 0..row.len() {
107 cells.push(pg_cell(row, i)?);
108 }
109 out_rows.push(cells);
110 }
111 Ok(Table {
112 columns,
113 rows: out_rows,
114 })
115}
116
117fn pg_cell(row: &postgres::Row, i: usize) -> Result<Cell> {
120 let ty = row.columns()[i].type_();
121 let mapped = match *ty {
122 Type::BOOL => row.try_get::<_, Option<bool>>(i).map(|o| match o {
123 Some(b) => Cell::Bool(b),
124 None => Cell::Null,
125 }),
126 Type::INT2 => row
127 .try_get::<_, Option<i16>>(i)
128 .map(|o| o.map(|v| Cell::Int(v as i64)).unwrap_or(Cell::Null)),
129 Type::INT4 => row
130 .try_get::<_, Option<i32>>(i)
131 .map(|o| o.map(|v| Cell::Int(v as i64)).unwrap_or(Cell::Null)),
132 Type::INT8 => row
133 .try_get::<_, Option<i64>>(i)
134 .map(|o| o.map(Cell::Int).unwrap_or(Cell::Null)),
135 Type::FLOAT4 => row
136 .try_get::<_, Option<f32>>(i)
137 .map(|o| o.map(|v| Cell::Float(v as f64)).unwrap_or(Cell::Null)),
138 Type::FLOAT8 => row
139 .try_get::<_, Option<f64>>(i)
140 .map(|o| o.map(Cell::Float).unwrap_or(Cell::Null)),
141 Type::NUMERIC => row
143 .try_get::<_, Option<PgNumeric>>(i)
144 .map(|o| o.map(|n| Cell::Float(n.0)).unwrap_or(Cell::Null)),
145 _ => row
147 .try_get::<_, Option<String>>(i)
148 .map(|o| o.map(Cell::Text).unwrap_or(Cell::Null)),
149 };
150 mapped.map_err(|e| {
151 SqlError::Import(format!(
152 "postgres column {i} ({ty}) unmapped: {}",
153 redact_str(&e.to_string())
154 ))
155 })
156}
157
158struct PgNumeric(f64);
161
162impl<'a> postgres::types::FromSql<'a> for PgNumeric {
163 fn from_sql(
166 _ty: &Type,
167 raw: &'a [u8],
168 ) -> std::result::Result<Self, Box<dyn std::error::Error + Sync + Send>> {
169 let word =
170 |k: usize| -> std::result::Result<i16, Box<dyn std::error::Error + Sync + Send>> {
171 raw.get(2 * k..2 * k + 2)
172 .map(|b| i16::from_be_bytes([b[0], b[1]]))
173 .ok_or_else(|| "truncated numeric".into())
174 };
175 let (ndigits, weight, sign) = (word(0)?, word(1)?, word(2)? as u16);
176 if sign == 0xC000 {
177 return Ok(PgNumeric(f64::NAN));
178 }
179 let mut v = 0.0;
180 for k in 0..ndigits.max(0) as usize {
181 v += f64::from(word(4 + k)?) * 10000f64.powi(i32::from(weight) - k as i32);
182 }
183 Ok(PgNumeric(if sign == 0x4000 { -v } else { v }))
184 }
185
186 fn accepts(ty: &Type) -> bool {
187 *ty == Type::NUMERIC
188 }
189}
190
191pub(crate) fn pg_execute(client: &mut Client, sql: &str, params: &[Cell]) -> Result<()> {
192 let owned: Vec<PgParam> = params.iter().map(PgParam::from_cell).collect();
194 let bound: Vec<&(dyn postgres::types::ToSql + Sync)> = owned
195 .iter()
196 .map(|p| p as &(dyn postgres::types::ToSql + Sync))
197 .collect();
198 client
199 .execute(sql, &bound)
200 .map_err(|e| SqlError::Export(format!("postgres execute failed: {}", redact_pg(e))))?;
201 Ok(())
202}
203
204#[derive(Debug)]
206enum PgParam {
207 Null,
208 Int(i64),
209 Float(f64),
210 Text(String),
211 Bool(bool),
212}
213
214impl PgParam {
215 fn from_cell(c: &Cell) -> PgParam {
216 match c {
217 Cell::Null => PgParam::Null,
218 Cell::Int(i) => PgParam::Int(*i),
219 Cell::Float(f) => PgParam::Float(*f),
220 Cell::Text(t) => PgParam::Text(t.clone()),
221 Cell::Bool(b) => PgParam::Bool(*b),
222 }
223 }
224}
225
226impl postgres::types::ToSql for PgParam {
227 fn to_sql(
228 &self,
229 ty: &Type,
230 out: &mut postgres::types::private::BytesMut,
231 ) -> std::result::Result<postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>>
232 {
233 match self {
234 PgParam::Null => Ok(postgres::types::IsNull::Yes),
235 PgParam::Int(i) => i.to_sql(ty, out),
236 PgParam::Float(f) => f.to_sql(ty, out),
237 PgParam::Text(t) => t.to_sql(ty, out),
238 PgParam::Bool(b) => b.to_sql(ty, out),
239 }
240 }
241
242 fn accepts(_ty: &Type) -> bool {
243 true
246 }
247
248 postgres::types::to_sql_checked!();
249}
250
251fn redact_str(s: &str) -> String {
254 if s.contains("password") || s.contains("://") {
255 "(redacted)".to_string()
256 } else {
257 s.to_string()
258 }
259}
260
261#[cfg(test)]
262#[cfg_attr(coverage_nightly, coverage(off))]
263mod tests {
264 use super::*;
265
266 fn transport(dsn: &str) -> Result<()> {
267 check_pg_transport(&dsn.parse().unwrap())
268 }
269
270 #[test]
271 fn transport_allows_loopback_and_sockets() {
272 for ok in [
273 "host=localhost user=u",
274 "host=127.0.0.1 user=u",
275 "host=::1 user=u",
276 "postgresql://u@localhost:5433/db",
277 "host=db.example.com user=u sslmode=disable",
278 ] {
279 assert!(transport(ok).is_ok(), "{ok:?}");
280 }
281 #[cfg(unix)]
284 assert!(transport("host=/var/run/postgresql user=u").is_ok());
285 }
286
287 #[test]
288 fn transport_refuses_cleartext_remote_and_unhonourable_tls() {
289 for bad in [
290 "host=db.example.com user=u",
291 "host=10.0.0.5 user=u sslmode=prefer",
292 "postgresql://u@db.example.com/db",
293 "host=localhost,db.example.com user=u",
294 "host=localhost hostaddr=10.0.0.5 user=u",
295 "host=localhost user=u sslmode=require",
296 ] {
297 let err = transport(bad).unwrap_err().to_string();
298 assert!(err.contains("TLS isn't built in"), "{bad:?}: {err}");
299 }
300 assert!(connect_postgres("host=db.example.com user=u").is_err());
302 assert!(connect_postgres("not a dsn ===").is_err());
303 }
304
305 #[test]
306 fn redact_str_scrubs_secrets() {
307 assert_eq!(redact_str("password=x"), "(redacted)");
308 assert_eq!(redact_str("postgres://u:p@h"), "(redacted)");
309 assert_eq!(redact_str("plain"), "plain");
310 }
311
312 fn live() -> Option<Backend> {
314 let Ok(dsn) = std::env::var("IMPROV_TEST_PG") else {
315 println!("skipped: IMPROV_TEST_PG not set");
316 return None;
317 };
318 Some(connect_postgres(&dsn).expect("connect IMPROV_TEST_PG"))
319 }
320
321 #[test]
322 fn pg_maps_every_column_type() {
323 let Some(mut be) = live() else { return };
324 let t = be
325 .query(
326 "SELECT true AS b, 2::int2 AS s, 3::int4 AS i, 4::int8 AS l, \
327 1.5::float4 AS f, 2.5::float8 AS d, 3.25::numeric AS n, \
328 'x'::text AS t, NULL::int4 AS z, NULL::text AS zt",
329 )
330 .unwrap();
331 assert_eq!(
332 t.columns,
333 ["b", "s", "i", "l", "f", "d", "n", "t", "z", "zt"]
334 );
335 assert_eq!(
336 t.rows,
337 vec![vec![
338 Cell::Bool(true),
339 Cell::Int(2),
340 Cell::Int(3),
341 Cell::Int(4),
342 Cell::Float(1.5),
343 Cell::Float(2.5),
344 Cell::Float(3.25),
345 Cell::Text("x".into()),
346 Cell::Null,
347 Cell::Null,
348 ]]
349 );
350 let e = be.query("SELECT 1 AS a, 'q' AS b WHERE false").unwrap();
352 assert_eq!(e.columns, ["a", "b"]);
353 assert!(e.rows.is_empty());
354 let n = be
355 .query("SELECT -12345.678::numeric AS a, 0::numeric AS b, 'NaN'::numeric AS c")
356 .unwrap();
357 assert_eq!(n.rows[0][..2], [Cell::Float(-12345.678), Cell::Float(0.0)]);
358 assert!(matches!(n.rows[0][2], Cell::Float(f) if f.is_nan()));
359 assert!(be.query("SELECT now() AS ts").is_err());
361 assert!(be.query("SELECT * FROM no_such_table").is_err());
362 }
363
364 #[test]
365 fn pg_refresh_query_runs_read_only() {
366 let Some(mut be) = live() else { return };
367 be.execute("DROP TABLE IF EXISTS improv_pg_ro", &[])
368 .unwrap();
369 be.execute("CREATE TABLE improv_pg_ro (v int8)", &[])
370 .unwrap();
371 be.execute("INSERT INTO improv_pg_ro VALUES ($1)", &[Cell::Int(7)])
372 .unwrap();
373 let cte = "WITH x AS (DELETE FROM improv_pg_ro RETURNING *) SELECT * FROM x";
376 assert!(crate::check_read_only_sql(cte).is_ok());
377 assert!(be.query(cte).is_err());
378 assert!(be.query("DELETE FROM improv_pg_ro").is_err());
379 let t = be.query("SELECT v FROM improv_pg_ro").unwrap();
381 assert_eq!(t.rows, vec![vec![Cell::Int(7)]]);
382 be.execute("DROP TABLE improv_pg_ro", &[]).unwrap();
383 }
384
385 #[test]
386 fn numeric_decoder_rejects_truncated_input() {
387 use postgres::types::FromSql;
388 let err = PgNumeric::from_sql(&Type::NUMERIC, &[0, 1]).err().unwrap();
389 assert_eq!(err.to_string(), "truncated numeric");
390 let err = PgNumeric::from_sql(&Type::NUMERIC, &[0, 1, 0, 0, 0, 0, 0, 0])
392 .err()
393 .unwrap();
394 assert_eq!(err.to_string(), "truncated numeric");
395 assert!(PgNumeric::accepts(&Type::NUMERIC) && !PgNumeric::accepts(&Type::TEXT));
396 }
397
398 #[test]
400 fn refused_connection_is_redacted() {
401 let Err(err) = connect_postgres("host=127.0.0.1 port=1 user=u password=hunter2") else {
402 panic!("connected to port 1");
403 };
404 let msg = err.to_string();
405 assert!(msg.contains("connection error (redacted)"), "{msg}");
406 assert!(!msg.contains("hunter2"), "{msg}");
407 }
408
409 #[test]
410 fn pg_binds_null_and_bool_and_redacts_execute_errors() {
411 let Some(mut be) = live() else { return };
412 be.execute("DROP TABLE IF EXISTS improv_pg_params", &[])
413 .unwrap();
414 be.execute("CREATE TABLE improv_pg_params (a int8, b bool)", &[])
415 .unwrap();
416 be.execute(
417 "INSERT INTO improv_pg_params VALUES ($1, $2), ($3, $4)",
418 &[Cell::Null, Cell::Bool(true), Cell::Int(1), Cell::Null],
419 )
420 .unwrap();
421 let t = be
422 .query("SELECT a, b FROM improv_pg_params ORDER BY a NULLS FIRST")
423 .unwrap();
424 assert_eq!(
425 t.rows,
426 [[Cell::Null, Cell::Bool(true)], [Cell::Int(1), Cell::Null]]
427 );
428 let err = be
429 .execute("INSERT INTO no_such_table VALUES (1)", &[])
430 .unwrap_err();
431 assert!(
432 err.to_string().contains("database error (redacted)"),
433 "{err}"
434 );
435 be.execute("DROP TABLE improv_pg_params", &[]).unwrap();
436 }
437}