boatramp_node/pg_client.rs
1//! Pure builders for the **SQL export/import client invocation** (Postgres + MySQL) that
2//! the driver runs inside
3//! [`run_ephemeral`](boatramp_core::compute::ComputeBackend::run_ephemeral): the `argv`
4//! and the process environment.
5//!
6//! ## The credential non-leak rule lives here
7//! The password rides in an **environment variable** (`PGPASSWORD` for Postgres,
8//! `MYSQL_PWD` for MySQL), NEVER in argv. Both argv and the container environment are
9//! visible to a privileged `docker inspect`, but argv leaks far more broadly — it shows
10//! in every process listing (`ps`, `/proc/<pid>/cmdline`) inside and outside the
11//! container, to any local observer, no daemon privilege required — whereas the
12//! container environment is only reachable with daemon/inspect access. Env is therefore
13//! the smaller exposure (not zero), and it is the right choice over argv. No DSN string
14//! is assembled at all: the discrete `PG*` / `MYSQL*` connection variables connect the
15//! client, so there is no connection string to accidentally `format!` into a log. The
16//! only secret is the password env var.
17//!
18//! ## `--data-only` vs full
19//! `--data-only` emits/restores ONLY the table data; the target keeps its OWN migrated
20//! schema (Phase 1 is data-only: app data, no identity/RBAC/routing). A full export
21//! carries schema + data. `--no-owner`/`--no-privileges` keep a restore from trying to
22//! reassign ownership/ACLs across environments, and `pg_restore --exit-on-error` makes a
23//! failed object abort loudly rather than leave a half-restored database.
24//!
25//! Kept pure so the data-only divergence and the cred-placement invariant are
26//! unit-mutation-gated in the merge gate.
27
28/// A resolved Postgres connection target for the client. Assembled by the driver from
29/// the sealed-credential unseal + the resolved replica endpoint; consumed only to emit
30/// the `PG*` environment.
31#[derive(Debug, Clone)]
32pub struct PgConnection {
33 /// The reachable host (an external DSN host, or a colocated DB's container bridge IP).
34 pub host: String,
35 /// The server port.
36 pub port: u16,
37 /// The role to connect as.
38 pub user: String,
39 /// The password — the ONLY secret; it goes in `PGPASSWORD`, never argv.
40 pub password: String,
41 /// The physical database to dump/restore.
42 pub database: String,
43 /// A bounded connect timeout (seconds) so a dump/restore against an unreachable DSN
44 /// fails fast instead of pinning the ephemeral container until the backstop.
45 pub connect_timeout_secs: u32,
46}
47
48impl PgConnection {
49 /// The discrete `PG*` connection environment. The password is here (in the
50 /// environment), never in argv.
51 pub fn env(&self) -> Vec<(String, String)> {
52 vec![
53 ("PGHOST".to_string(), self.host.clone()),
54 ("PGPORT".to_string(), self.port.to_string()),
55 ("PGUSER".to_string(), self.user.clone()),
56 ("PGPASSWORD".to_string(), self.password.clone()),
57 ("PGDATABASE".to_string(), self.database.clone()),
58 (
59 "PGCONNECT_TIMEOUT".to_string(),
60 self.connect_timeout_secs.to_string(),
61 ),
62 ]
63 }
64}
65
66/// A fully-built client invocation: the `argv` (no credential) + the environment (the
67/// credential), ready to hand to `run_ephemeral(image, argv, env, …)`.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct SqlInvocation {
70 /// The command + args — NEVER carries a credential.
71 pub argv: Vec<String>,
72 /// The process environment — carries `PGPASSWORD`.
73 pub env: Vec<(String, String)>,
74}
75
76/// Build the `pg_dump` invocation for an EXPORT. Custom format (`-Fc`): compressed and
77/// the natural input to `pg_restore`. `--data-only` emits only the data AND EXCLUDES the
78/// host-owned migration ledger's rows (`boatramp_migrations.*`): the ledger is a schema
79/// INSIDE the tenant database, so a plain `--data-only` dump would carry its rows and a
80/// `pg_restore --data-only --exit-on-error` into the already-migrated target (the
81/// matching-fingerprint case — the PRIMARY use) would hit a primary-key conflict on the
82/// ledger and abort. Excluding it keeps the target's own `boatramp_migrations` ledger
83/// intact (the stated property).
84pub fn pg_dump_invocation(conn: &PgConnection, data_only: bool) -> SqlInvocation {
85 let mut argv = vec![
86 "pg_dump".to_string(),
87 "--no-owner".to_string(),
88 "--no-privileges".to_string(),
89 // `--no-comments`: the atomic shadow-restore applies the dump as a NON-SUPERUSER
90 // ephemeral role (the inc2 RCE-on-import guard), which cannot run a `COMMENT ON
91 // EXTENSION <ext>` — that is OWNER-only, and a NON-trusted extension (e.g. pgvector,
92 // whose control file sets no `trusted = true`) can only be installed, hence owned, by
93 // the superuser that pre-creates it in the shadow. The extension's own comment is
94 // redundant anyway (CREATE EXTENSION re-sets it from the control file), so stripping
95 // comments loses nothing for the extension. PG16's pg_dump has no `--exclude-extension`
96 // (PG17+) to scope this to extensions only, so object comments are not carried through a
97 // project export; that is an accepted fidelity limitation, not a correctness one.
98 "--no-comments".to_string(),
99 "-Fc".to_string(),
100 ];
101 if data_only {
102 argv.push("--data-only".to_string());
103 // Exclude the host-owned migration ledger's DATA (schema-qualified pattern) so
104 // the restore does not collide with the target's existing ledger rows.
105 argv.push("--exclude-table-data=boatramp_migrations.*".to_string());
106 }
107 SqlInvocation {
108 argv,
109 env: conn.env(),
110 }
111}
112
113/// Build the `pg_restore` invocation for an IMPORT (reads the custom-format dump from
114/// stdin). `--data-only` restores only the data into the target's existing migrated
115/// schema; `--exit-on-error` aborts loudly on the first failed object. Unlike `psql`/
116/// `pg_dump`, `pg_restore` does NOT infer the target from `PGDATABASE` — without `-d`
117/// (restore into a database) or `-f` (emit a script) it refuses with "one of -d/--dbname
118/// and -f/--file must be specified"; so the target database is named with `-d` (the host/
119/// port/user/password still come from the env, and the db name is not a credential).
120pub fn pg_restore_invocation(conn: &PgConnection, data_only: bool) -> SqlInvocation {
121 let mut argv = vec![
122 "pg_restore".to_string(),
123 "--no-owner".to_string(),
124 "--no-privileges".to_string(),
125 "--exit-on-error".to_string(),
126 "-d".to_string(),
127 conn.database.clone(),
128 ];
129 if data_only {
130 argv.push("--data-only".to_string());
131 }
132 SqlInvocation {
133 argv,
134 env: conn.env(),
135 }
136}
137
138/// Build a `psql` invocation that runs one **trusted, we-generate-it** DDL/utility statement
139/// (inc2 Stage C). This is the SUPERUSER / maintenance side of the atomic-shadow split: it runs
140/// ONLY our own generated control SQL — `CREATE DATABASE <shadow>`, `pg_terminate_backend`, the
141/// `ALTER DATABASE … RENAME` swap, `DROP DATABASE` — never a byte of the attacker-crafted bundle
142/// dump (that is applied by [`pg_restore_shadow_invocation`] as the NON-superuser target role).
143///
144/// `sql` is placed in argv (via `-c`): it is a constant/identifier-composed statement WE build
145/// (never a credential, never bundle-derived data), so argv is correct here — the only secret, the
146/// password, still rides in `PGPASSWORD` (the env), never argv. `-v ON_ERROR_STOP=1` makes a failed
147/// statement a non-zero exit (so the driver never mistakes a failed `CREATE DATABASE` for success),
148/// and `-tAc` keeps any output terse. The caller runs **one statement per invocation** because
149/// `CREATE DATABASE` / `ALTER DATABASE … RENAME` cannot run inside a transaction block and psql
150/// wraps a multi-statement `-c` string as one implicit transaction. The connect target (incl. the
151/// maintenance `postgres` database for `CREATE/ALTER/DROP DATABASE`) rides in `conn.env`.
152pub fn psql_invocation(conn: &PgConnection, sql: &str) -> SqlInvocation {
153 let argv = vec![
154 "psql".to_string(),
155 "-v".to_string(),
156 "ON_ERROR_STOP=1".to_string(),
157 "-tAc".to_string(),
158 sql.to_string(),
159 ];
160 SqlInvocation {
161 argv,
162 env: conn.env(),
163 }
164}
165
166/// Build the `pg_restore` invocation that applies the **attacker-crafted bundle dump** into a
167/// pre-created SHADOW database (inc2 Stage C) — the UNTRUSTED side of the atomic-shadow split.
168///
169/// It targets the shadow database by NAME (`-d <shadow_db>`, NOT `conn.database`) and is meant to
170/// run as the TARGET PROJECT'S own NON-SUPERUSER role (the driver resolves that credential), so a
171/// crafted dump's superuser-only escapes (`COPY … TO/FROM PROGRAM/FILE`, `CREATE FUNCTION …
172/// LANGUAGE c`, `CREATE EXTENSION`) FAIL at apply — Postgres denies them to a non-superuser. A FULL
173/// restore (schema + data; never `--data-only`): `--single-transaction` makes the shadow restore
174/// itself all-or-nothing, `--exit-on-error` aborts loudly on the first failed object, and
175/// `--no-owner`/`--no-privileges` keep the restore from reassigning ownership/ACLs (so no `SET
176/// ROLE`/`ALTER … OWNER` to the superuser is attempted). The dump is fed on stdin. NEVER pass `-j`
177/// here: parallel restore needs a seekable archive, and the dump arrives as a non-seekable pipe.
178/// `shadow_db` is a driver-derived identifier (`<db>__shadow_<ts>`), not a credential, so argv is
179/// correct; the password stays in `PGPASSWORD`.
180pub fn pg_restore_shadow_invocation(conn: &PgConnection, shadow_db: &str) -> SqlInvocation {
181 let argv = vec![
182 "pg_restore".to_string(),
183 "--no-owner".to_string(),
184 "--no-privileges".to_string(),
185 "--exit-on-error".to_string(),
186 "--single-transaction".to_string(),
187 "-d".to_string(),
188 shadow_db.to_string(),
189 ];
190 SqlInvocation {
191 argv,
192 env: conn.env(),
193 }
194}
195
196/// A resolved MySQL connection target for the client. The password goes in `MYSQL_PWD`
197/// (the env the mysql/mysqldump clients read), never argv.
198#[derive(Debug, Clone)]
199pub struct MysqlConnection {
200 /// The reachable host.
201 pub host: String,
202 /// The server port.
203 pub port: u16,
204 /// The role to connect as.
205 pub user: String,
206 /// The password — the ONLY secret; it goes in `MYSQL_PWD`, never argv.
207 pub password: String,
208 /// The physical database to dump/restore.
209 pub database: String,
210 /// A bounded connect timeout (seconds).
211 pub connect_timeout_secs: u32,
212}
213
214impl MysqlConnection {
215 /// The connection ENVIRONMENT: `MYSQL_PWD` (the password) ONLY. The password is the
216 /// sole secret and goes in the env every MySQL client reads, never argv. Host/port/
217 /// user are NOT placed here — unlike libpq's `PGHOST` (honored by all libpq tools),
218 /// `MYSQL_HOST` is read by the `mysql` CLI ALONE; `mysqldump` ignores it and defaults
219 /// to the local unix socket ("Can't connect to local MySQL server through socket"),
220 /// so the host/port/user travel in argv via [`conn_args`](Self::conn_args).
221 pub fn env(&self) -> Vec<(String, String)> {
222 vec![("MYSQL_PWD".to_string(), self.password.clone())]
223 }
224
225 /// The non-secret connection args every MySQL client (both `mysql` and `mysqldump`)
226 /// honors: `--host`, `--port`, `--user`. A real host IP (not "localhost") forces TCP
227 /// rather than the local socket. None of these is a credential, so argv is correct;
228 /// no DSN string is assembled.
229 pub fn conn_args(&self) -> Vec<String> {
230 vec![
231 format!("--host={}", self.host),
232 format!("--port={}", self.port),
233 format!("--user={}", self.user),
234 ]
235 }
236
237 /// The `--connect-timeout=<secs>` arg the `mysql` client accepts (there is no env for
238 /// it). NOTE: `mysqldump` does NOT register this option — passing it there fails with
239 /// "[ERROR] unknown variable 'connect-timeout=10'" — so only `mysql_restore_invocation`
240 /// uses it, never `mysqldump_invocation`.
241 fn connect_timeout_arg(&self) -> String {
242 format!("--connect-timeout={}", self.connect_timeout_secs)
243 }
244}
245
246/// Build the `mysqldump` invocation for an EXPORT. `--single-transaction` takes a
247/// consistent snapshot without locking (InnoDB); `--no-tablespaces` avoids the `PROCESS`
248/// privilege requirement. Host/port/user are explicit ([`conn_args`](MysqlConnection::conn_args))
249/// because `mysqldump` ignores `MYSQL_HOST`. A FULL dump emits schema + data under
250/// `--databases <db>` (so the stream carries `CREATE DATABASE`/`USE`); `--data-only` emits
251/// only the rows for `<db>` (`--no-create-info`), restored into the target's existing
252/// schema. No `--connect-timeout`: `mysqldump` does not register it (unknown variable);
253/// the overall ephemeral-run timeout bounds a stuck dump instead.
254pub fn mysqldump_invocation(conn: &MysqlConnection, data_only: bool) -> SqlInvocation {
255 let mut argv = vec![
256 "mysqldump".to_string(),
257 "--single-transaction".to_string(),
258 "--no-tablespaces".to_string(),
259 ];
260 argv.extend(conn.conn_args());
261 if data_only {
262 argv.push("--no-create-info".to_string());
263 argv.push(conn.database.clone());
264 } else {
265 argv.push("--databases".to_string());
266 argv.push(conn.database.clone());
267 }
268 SqlInvocation {
269 argv,
270 env: conn.env(),
271 }
272}
273
274/// Build the `mysql` invocation for an IMPORT (reads the SQL dump from stdin). Host/port/
275/// user are explicit ([`conn_args`](MysqlConnection::conn_args)) so the client connects
276/// over TCP, not the local socket. A FULL bundle's dump carries its own `USE <db>` (from
277/// `--databases`), so no positional db is needed; a `--data-only` dump has none, so the
278/// target `<db>` is named on the command line. The database name is NOT a credential, so
279/// passing it in argv is fine.
280pub fn mysql_restore_invocation(conn: &MysqlConnection, data_only: bool) -> SqlInvocation {
281 let mut argv = vec!["mysql".to_string()];
282 argv.extend(conn.conn_args());
283 argv.push(conn.connect_timeout_arg());
284 if data_only {
285 argv.push(conn.database.clone());
286 }
287 SqlInvocation {
288 argv,
289 env: conn.env(),
290 }
291}
292
293/// Build the **tables-only FULL** `mysqldump` for the inc2 chunked/shadow path (Stage D). Unlike
294/// the inc1 FULL dump ([`mysqldump_invocation`] with `--databases <db>`, which emits `CREATE
295/// DATABASE`/`USE <original>` and so TARGETS THE ORIGINAL NAME), this dumps the db's TABLES ONLY —
296/// a positional `<db>` with NO `--databases` — so the stream carries `CREATE TABLE` + data and NO
297/// `CREATE DATABASE`/`USE`. That lets `mysql <shadow>` ([`mysql_shadow_invocation`]) apply it into a
298/// freshly-created shadow database whose name differs from the source. `--single-transaction` takes
299/// a consistent InnoDB snapshot without locking; `--no-tablespaces` avoids the `PROCESS` privilege.
300/// Host/port/user are explicit (`mysqldump` ignores `MYSQL_HOST`); the password stays in `MYSQL_PWD`.
301pub fn mysqldump_tables_only_invocation(conn: &MysqlConnection) -> SqlInvocation {
302 let mut argv = vec![
303 "mysqldump".to_string(),
304 "--single-transaction".to_string(),
305 "--no-tablespaces".to_string(),
306 ];
307 argv.extend(conn.conn_args());
308 // Positional db, NO `--databases` ⇒ tables only, no `CREATE DATABASE`/`USE <original>`.
309 argv.push(conn.database.clone());
310 SqlInvocation {
311 argv,
312 env: conn.env(),
313 }
314}
315
316/// Build the `mysql` invocation that applies the **attacker-crafted** tables-only dump into a
317/// pre-created SHADOW database by NAME (inc2 Stage D) — the UNTRUSTED side of the atomic-shadow
318/// split. Meant to run as the TARGET PROJECT'S own NON-SUPERUSER role (the driver resolves that
319/// credential) so a crafted statement needing elevated privileges (`LOAD DATA INFILE`, `INTO
320/// OUTFILE`, creating a `LANGUAGE`-escalating routine) fails at apply. The dump (which carries NO
321/// `USE`) is fed on stdin and lands in `shadow_db` because it is named as the default database
322/// positionally. Host/port/user explicit; password in `MYSQL_PWD`; `shadow_db` is a driver-derived
323/// identifier (not a credential), so argv is correct.
324pub fn mysql_shadow_invocation(conn: &MysqlConnection, shadow_db: &str) -> SqlInvocation {
325 let mut argv = vec!["mysql".to_string()];
326 argv.extend(conn.conn_args());
327 argv.push(conn.connect_timeout_arg());
328 argv.push(shadow_db.to_string());
329 SqlInvocation {
330 argv,
331 env: conn.env(),
332 }
333}
334
335/// Build a `mysql` invocation that runs **trusted, we-generate-it** SQL fed on stdin — the
336/// SUPERUSER / maintenance side of the inc2 mysql shadow split (Stage D): `CREATE DATABASE
337/// <shadow>`, the atomic `RENAME TABLE` swap, `DROP DATABASE`, and the `information_schema` table
338/// enumeration the swap is built from. No default database is named (so `CREATE DATABASE` works and
339/// every object is schema-qualified in the SQL we build), `-N --batch` give header-less,
340/// tab-separated output for the enumeration query. Host/port/user explicit; password in `MYSQL_PWD`.
341pub fn mysql_admin_invocation(conn: &MysqlConnection) -> SqlInvocation {
342 let mut argv = vec!["mysql".to_string()];
343 argv.extend(conn.conn_args());
344 argv.push(conn.connect_timeout_arg());
345 argv.push("-N".to_string());
346 argv.push("--batch".to_string());
347 SqlInvocation {
348 argv,
349 env: conn.env(),
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn conn() -> PgConnection {
358 PgConnection {
359 host: "10.0.0.5".to_string(),
360 port: 5432,
361 user: "svc".to_string(),
362 password: "sup3r-s3cret-pw".to_string(),
363 database: "app".to_string(),
364 connect_timeout_secs: 10,
365 }
366 }
367
368 #[test]
369 fn data_only_toggles_the_flag_on_both_sides() {
370 // The data-only path MUST diverge from the full path (anti-hollow mutation (b):
371 // drop the data_only honoring and this divergence vanishes).
372 let dump_do = pg_dump_invocation(&conn(), true);
373 let dump_full = pg_dump_invocation(&conn(), false);
374 assert!(dump_do.argv.iter().any(|a| a == "--data-only"));
375 assert!(!dump_full.argv.iter().any(|a| a == "--data-only"));
376 // A --data-only dump EXCLUDES the migration ledger's rows so a restore into an
377 // already-migrated target does not PK-conflict on it; a full dump does not.
378 assert!(
379 dump_do
380 .argv
381 .iter()
382 .any(|a| a == "--exclude-table-data=boatramp_migrations.*")
383 );
384 assert!(
385 !dump_full
386 .argv
387 .iter()
388 .any(|a| a == "--exclude-table-data=boatramp_migrations.*")
389 );
390
391 let restore_do = pg_restore_invocation(&conn(), true);
392 let restore_full = pg_restore_invocation(&conn(), false);
393 assert!(restore_do.argv.iter().any(|a| a == "--data-only"));
394 assert!(!restore_full.argv.iter().any(|a| a == "--data-only"));
395 // pg_restore needs an explicit `-d <db>` target (it does NOT read PGDATABASE);
396 // without it the restore aborts "one of -d/--dbname and -f/--file must be
397 // specified". The db name follows the `-d` flag as its own argv element.
398 for inv in [&restore_do, &restore_full] {
399 let pos = inv
400 .argv
401 .iter()
402 .position(|a| a == "-d")
403 .expect("pg_restore argv must carry -d");
404 assert_eq!(
405 inv.argv.get(pos + 1).map(String::as_str),
406 Some(conn().database.as_str()),
407 "the target database must follow -d"
408 );
409 }
410 }
411
412 #[test]
413 fn shadow_builders_split_trusted_ddl_from_untrusted_apply() {
414 let c = conn();
415 // The TRUSTED maintenance side: psql runs ONE generated statement; ON_ERROR_STOP so a
416 // failed CREATE/ALTER/DROP DATABASE is a non-zero exit (never mistaken for success).
417 let ddl = psql_invocation(&c, "CREATE DATABASE \"app__shadow_1\" OWNER \"owner\"");
418 assert_eq!(ddl.argv[0], "psql");
419 assert!(ddl.argv.iter().any(|a| a == "ON_ERROR_STOP=1"));
420 assert!(
421 ddl.argv
422 .iter()
423 .any(|a| a == "CREATE DATABASE \"app__shadow_1\" OWNER \"owner\"")
424 );
425 // The UNTRUSTED apply side: FULL restore (NEVER --data-only), single-transaction,
426 // exit-on-error, no-owner/no-privileges, targeting the SHADOW by name — and NEVER `-j`
427 // (parallel restore needs a seekable archive; the dump is a pipe).
428 let apply = pg_restore_shadow_invocation(&c, "app__shadow_1");
429 assert_eq!(apply.argv[0], "pg_restore");
430 assert!(apply.argv.iter().any(|a| a == "--single-transaction"));
431 assert!(apply.argv.iter().any(|a| a == "--exit-on-error"));
432 assert!(apply.argv.iter().any(|a| a == "--no-owner"));
433 assert!(apply.argv.iter().any(|a| a == "--no-privileges"));
434 assert!(
435 !apply.argv.iter().any(|a| a == "--data-only"),
436 "the chunked pg shadow path is FULL-restore only (default #1)"
437 );
438 assert!(
439 !apply
440 .argv
441 .iter()
442 .any(|a| a == "-j" || a.starts_with("--jobs")),
443 "NEVER parallel-restore a piped archive"
444 );
445 let pos = apply.argv.iter().position(|a| a == "-d").expect("-d");
446 assert_eq!(
447 apply.argv.get(pos + 1).map(String::as_str),
448 Some("app__shadow_1"),
449 "the shadow db (NOT conn.database) follows -d"
450 );
451 // Both still carry the credential ONLY in the env, never argv.
452 for inv in [&ddl, &apply] {
453 assert!(
454 inv.env
455 .iter()
456 .any(|(k, v)| k == "PGPASSWORD" && v == &c.password)
457 );
458 for a in &inv.argv {
459 assert!(
460 !a.contains(&c.password),
461 "credential leaked into argv: {a:?}"
462 );
463 }
464 }
465 }
466
467 #[test]
468 fn mysql_shadow_builders_are_tables_only_and_target_the_shadow() {
469 let c = myconn();
470 // Tables-only FULL dump: positional db, NO --databases (so no CREATE DATABASE/USE of the
471 // ORIGINAL name), NO --no-create-info (schema + data), NO --connect-timeout (mysqldump
472 // rejects it).
473 let dump = mysqldump_tables_only_invocation(&c);
474 assert_eq!(dump.argv[0], "mysqldump");
475 assert!(!dump.argv.iter().any(|a| a == "--databases"));
476 assert!(!dump.argv.iter().any(|a| a == "--no-create-info"));
477 assert!(!dump.argv.iter().any(|a| a.starts_with("--connect-timeout")));
478 assert!(
479 dump.argv.last().map(String::as_str) == Some("app"),
480 "the db is positional (tables only): {:?}",
481 dump.argv
482 );
483 // The untrusted apply targets the SHADOW by name (positional default db).
484 let apply = mysql_shadow_invocation(&c, "app__shadow_1");
485 assert_eq!(apply.argv[0], "mysql");
486 assert!(apply.argv.iter().any(|a| a == "app__shadow_1"));
487 assert!(!apply.argv.iter().any(|a| a == "app"));
488 // The trusted admin side names NO default db (so CREATE/DROP DATABASE + schema-qualified
489 // RENAME TABLE work) and asks for header-less tab output for the table enumeration.
490 let admin = mysql_admin_invocation(&c);
491 assert_eq!(admin.argv[0], "mysql");
492 assert!(admin.argv.iter().any(|a| a == "-N"));
493 assert!(admin.argv.iter().any(|a| a == "--batch"));
494 assert!(
495 !admin
496 .argv
497 .iter()
498 .any(|a| a == "app" || a == "app__shadow_1"),
499 "the admin connection names no default database: {:?}",
500 admin.argv
501 );
502 // Credential only in MYSQL_PWD, never argv; host explicit (mysqldump ignores MYSQL_HOST).
503 for inv in [&dump, &apply, &admin] {
504 assert!(
505 inv.env
506 .iter()
507 .any(|(k, v)| k == "MYSQL_PWD" && v == &c.password)
508 );
509 assert!(inv.argv.iter().any(|a| a == &format!("--host={}", c.host)));
510 for a in &inv.argv {
511 assert!(
512 !a.contains(&c.password),
513 "credential leaked into argv: {a:?}"
514 );
515 }
516 }
517 }
518
519 #[test]
520 fn credential_is_in_the_env_never_in_argv() {
521 let c = conn();
522 for inv in [
523 pg_dump_invocation(&c, true),
524 pg_restore_invocation(&c, false),
525 ] {
526 // The password is present in the environment...
527 assert!(
528 inv.env
529 .iter()
530 .any(|(k, v)| k == "PGPASSWORD" && v == &c.password),
531 "PGPASSWORD must be set in the environment"
532 );
533 // ...and NEVER appears in argv (which shows in ps/inspect). This is the
534 // non-leak invariant; a regression that put the password on the command
535 // line fails here.
536 for a in &inv.argv {
537 assert!(
538 !a.contains(&c.password),
539 "credential leaked into argv: {a:?}"
540 );
541 }
542 // No connection string is assembled at all — no `postgres://` in argv.
543 for a in &inv.argv {
544 assert!(
545 !a.contains("://"),
546 "a DSN must never be built into argv: {a:?}"
547 );
548 }
549 }
550 }
551
552 #[test]
553 fn env_carries_the_discrete_connection_and_a_bounded_timeout() {
554 let e = conn().env();
555 let get = |k: &str| e.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
556 assert_eq!(get("PGHOST").as_deref(), Some("10.0.0.5"));
557 assert_eq!(get("PGPORT").as_deref(), Some("5432"));
558 assert_eq!(get("PGUSER").as_deref(), Some("svc"));
559 assert_eq!(get("PGDATABASE").as_deref(), Some("app"));
560 // A bounded connect timeout so an unreachable DSN fails fast.
561 assert_eq!(get("PGCONNECT_TIMEOUT").as_deref(), Some("10"));
562 }
563
564 fn myconn() -> MysqlConnection {
565 MysqlConnection {
566 host: "10.0.0.6".to_string(),
567 port: 3306,
568 user: "svc".to_string(),
569 password: "sup3r-s3cret-pw".to_string(),
570 database: "app".to_string(),
571 connect_timeout_secs: 10,
572 }
573 }
574
575 #[test]
576 fn mysql_data_only_toggles_and_credential_stays_in_env() {
577 // data-only diverges: dump uses --no-create-info (no --databases); full uses
578 // --databases; restore names the db only for data-only.
579 let dump_do = mysqldump_invocation(&myconn(), true);
580 let dump_full = mysqldump_invocation(&myconn(), false);
581 assert!(dump_do.argv.iter().any(|a| a == "--no-create-info"));
582 assert!(!dump_full.argv.iter().any(|a| a == "--no-create-info"));
583 assert!(dump_full.argv.iter().any(|a| a == "--databases"));
584 assert!(!dump_do.argv.iter().any(|a| a == "--databases"));
585 // mysqldump does NOT register --connect-timeout (it fails with "unknown variable");
586 // it must never appear on a dump invocation, but the mysql restore client DOES
587 // accept it.
588 for d in [&dump_do, &dump_full] {
589 assert!(
590 !d.argv.iter().any(|a| a.starts_with("--connect-timeout")),
591 "mysqldump must not carry --connect-timeout"
592 );
593 }
594
595 let restore_do = mysql_restore_invocation(&myconn(), true);
596 let restore_full = mysql_restore_invocation(&myconn(), false);
597 assert!(
598 restore_full
599 .argv
600 .iter()
601 .any(|a| a.starts_with("--connect-timeout")),
602 "the mysql restore client should carry --connect-timeout"
603 );
604 assert!(restore_do.argv.iter().any(|a| a == "app"));
605 assert!(!restore_full.argv.iter().any(|a| a == "app"));
606
607 // The password is in MYSQL_PWD (env), never argv; no DSN in argv.
608 let c = myconn();
609 for inv in [
610 mysqldump_invocation(&c, true),
611 mysql_restore_invocation(&c, false),
612 ] {
613 assert!(
614 inv.env
615 .iter()
616 .any(|(k, v)| k == "MYSQL_PWD" && v == &c.password),
617 "MYSQL_PWD must be set in the environment"
618 );
619 // Host must be explicit in argv: mysqldump ignores MYSQL_HOST and would hit
620 // the local socket (error 2002) without it.
621 assert!(
622 inv.argv.iter().any(|a| a == &format!("--host={}", c.host)),
623 "the connection host must be explicit in argv: {:?}",
624 inv.argv
625 );
626 for a in &inv.argv {
627 assert!(
628 !a.contains(&c.password),
629 "credential leaked into argv: {a:?}"
630 );
631 assert!(
632 !a.contains("://"),
633 "a DSN must never be built into argv: {a:?}"
634 );
635 }
636 }
637 }
638
639 #[test]
640 fn mysql_env_carries_the_discrete_connection() {
641 let c = myconn();
642 // The ENV carries ONLY the password (the sole secret) — mysqldump ignores
643 // MYSQL_HOST, so host/port/user must NOT be relied on via env.
644 let e = c.env();
645 assert_eq!(e.len(), 1, "env carries only MYSQL_PWD");
646 assert_eq!(e[0].0, "MYSQL_PWD");
647 assert_eq!(e[0].1, "sup3r-s3cret-pw");
648 // Host/port/user travel in argv (so both mysql and mysqldump connect over TCP),
649 // and never the password.
650 let a = c.conn_args();
651 assert!(a.iter().any(|x| x == "--host=10.0.0.6"));
652 assert!(a.iter().any(|x| x == "--port=3306"));
653 assert!(a.iter().any(|x| x == "--user=svc"));
654 assert!(
655 !a.iter().any(|x| x.contains("sup3r-s3cret-pw")),
656 "password never in conn_args"
657 );
658 }
659}