Skip to main content

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}