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/// The first `pg_dump` major version that supports `--exclude-extension` (PG17). Below this,
77/// the extension-comment problem (see [`pg_dump_invocation`]) can only be handled with the
78/// blunt `--no-comments`.
79const PG_EXCLUDE_EXTENSION_MIN_MAJOR: u32 = 17;
80
81/// Parse the server's major version from the integer `SHOW server_version_num` banner
82/// (`major*10000 + minor`, PG10+: `"160004"` → 16, `"170000"` → 17). Returns `None` for a
83/// banner we cannot parse (the caller then stays on the conservative `--no-comments` path).
84pub fn pg_server_major(server_version_num: &str) -> Option<u32> {
85    server_version_num
86        .trim()
87        .parse::<u32>()
88        .ok()
89        .map(|n| n / 10000)
90}
91
92/// Build the `pg_dump` invocation for an EXPORT. Custom format (`-Fc`): compressed and
93/// the natural input to `pg_restore`. `--data-only` emits only the data AND EXCLUDES the
94/// host-owned migration ledger's rows (`boatramp_migrations.*`): the ledger is a schema
95/// INSIDE the tenant database, so a plain `--data-only` dump would carry its rows and a
96/// `pg_restore --data-only --exit-on-error` into the already-migrated target (the
97/// matching-fingerprint case — the PRIMARY use) would hit a primary-key conflict on the
98/// ledger and abort. Excluding it keeps the target's own `boatramp_migrations` ledger
99/// intact (the stated property).
100///
101/// ## Extension-comment handling (`--no-comments` vs `--exclude-extension`)
102/// The atomic shadow-restore applies the dump as a NON-SUPERUSER ephemeral role (the inc2
103/// RCE-on-import guard), which cannot run a `COMMENT ON EXTENSION <ext>` — that is OWNER-only,
104/// and a superuser pre-creates the operator trusted-extension allowlist (e.g. pgvector, whose
105/// control file sets no `trusted = true`) in the shadow, so those extensions are SUPERUSER-owned
106/// there. So a dump carrying `COMMENT ON EXTENSION <allowlisted>` would FAIL the non-superuser
107/// apply. Two ways to keep it out of the dump:
108/// - **PG16 and below** (no `--exclude-extension`): the blunt `--no-comments` — strips ALL
109///   comments. Object (table/column) comments are then NOT carried through a project export; an
110///   accepted FIDELITY limitation, not a correctness one.
111/// - **PG17+**: `--exclude-extension=<ext>` per shadow-pre-created extension — drops just those
112///   extensions (their `CREATE EXTENSION` + `COMMENT ON EXTENSION`) from the dump, which the
113///   shadow already holds, so ALL OTHER comments (table/column/…) survive. The dump's objects
114///   that USE the extension (e.g. a `vector`-typed column) still dump fine — the type exists in
115///   the pre-created shadow. This narrowing applies ONLY where the shadow pre-creates the
116///   extensions (the chunked managed path): pass `exclude_extensions` empty for the sidecar/
117///   external path, whose restore NEEDS the dump's own `CREATE EXTENSION` (we never pre-create on
118///   an operator's own DB), so it stays on `--no-comments` regardless of version.
119///
120/// A dump naming an extension NOT in `exclude_extensions` is unaffected: on the shadow path a
121/// non-allowlisted `CREATE EXTENSION` still fails-closed under the non-superuser apply (correct —
122/// an untrusted bundle must not install an arbitrary extension), the same outcome in both modes.
123pub fn pg_dump_invocation(
124    conn: &PgConnection,
125    data_only: bool,
126    server_version_num: &str,
127    exclude_extensions: &[String],
128    no_publications: bool,
129) -> SqlInvocation {
130    let mut argv = vec![
131        "pg_dump".to_string(),
132        "--no-owner".to_string(),
133        "--no-privileges".to_string(),
134    ];
135    // `--no-publications` for a `delta_capture` db (SQL true-delta): the ephemeral `brexp_pub_*`
136    // capture publication must exist from base time (pgoutput decodes the WAL against the
137    // historical catalog), but it is superuser-only to CREATE — so it must be kept OUT of the dump
138    // or the non-superuser atomic-shadow restore fails. Excludes any publication from the dump (an
139    // accepted fidelity limitation for a capture-enabled db, like `--no-comments` for extensions).
140    if no_publications {
141        argv.push("--no-publications".to_string());
142    }
143    let narrow = !exclude_extensions.is_empty()
144        && pg_server_major(server_version_num).is_some_and(|m| m >= PG_EXCLUDE_EXTENSION_MIN_MAJOR);
145    if narrow {
146        // PG17+ with shadow-pre-created extensions: exclude exactly those, keep all other comments.
147        for ext in exclude_extensions {
148            argv.push(format!("--exclude-extension={ext}"));
149        }
150    } else {
151        // PG16 (no --exclude-extension), or no shadow-pre-created extensions (sidecar/external):
152        // the blunt strip. Object comments are not carried (accepted fidelity limitation).
153        argv.push("--no-comments".to_string());
154    }
155    argv.push("-Fc".to_string());
156    if data_only {
157        argv.push("--data-only".to_string());
158        // Exclude the host-owned migration ledger's DATA (schema-qualified pattern) so
159        // the restore does not collide with the target's existing ledger rows.
160        argv.push("--exclude-table-data=boatramp_migrations.*".to_string());
161    }
162    SqlInvocation {
163        argv,
164        env: conn.env(),
165    }
166}
167
168/// Build the `pg_restore` invocation for an IMPORT (reads the custom-format dump from
169/// stdin). `--data-only` restores only the data into the target's existing migrated
170/// schema; `--exit-on-error` aborts loudly on the first failed object. Unlike `psql`/
171/// `pg_dump`, `pg_restore` does NOT infer the target from `PGDATABASE` — without `-d`
172/// (restore into a database) or `-f` (emit a script) it refuses with "one of -d/--dbname
173/// and -f/--file must be specified"; so the target database is named with `-d` (the host/
174/// port/user/password still come from the env, and the db name is not a credential).
175pub fn pg_restore_invocation(conn: &PgConnection, data_only: bool) -> SqlInvocation {
176    let mut argv = vec![
177        "pg_restore".to_string(),
178        "--no-owner".to_string(),
179        "--no-privileges".to_string(),
180        "--exit-on-error".to_string(),
181        "-d".to_string(),
182        conn.database.clone(),
183    ];
184    if data_only {
185        argv.push("--data-only".to_string());
186    }
187    SqlInvocation {
188        argv,
189        env: conn.env(),
190    }
191}
192
193/// Build a `psql` invocation that runs one **trusted, we-generate-it** DDL/utility statement
194/// (inc2 Stage C). This is the SUPERUSER / maintenance side of the atomic-shadow split: it runs
195/// ONLY our own generated control SQL — `CREATE DATABASE <shadow>`, `pg_terminate_backend`, the
196/// `ALTER DATABASE … RENAME` swap, `DROP DATABASE` — never a byte of the attacker-crafted bundle
197/// dump (that is applied by [`pg_restore_shadow_invocation`] as the NON-superuser target role).
198///
199/// `sql` is placed in argv (via `-c`): it is a constant/identifier-composed statement WE build
200/// (never a credential, never bundle-derived data), so argv is correct here — the only secret, the
201/// password, still rides in `PGPASSWORD` (the env), never argv. `-v ON_ERROR_STOP=1` makes a failed
202/// statement a non-zero exit (so the driver never mistakes a failed `CREATE DATABASE` for success),
203/// and `-tAc` keeps any output terse. The caller runs **one statement per invocation** because
204/// `CREATE DATABASE` / `ALTER DATABASE … RENAME` cannot run inside a transaction block and psql
205/// wraps a multi-statement `-c` string as one implicit transaction. The connect target (incl. the
206/// maintenance `postgres` database for `CREATE/ALTER/DROP DATABASE`) rides in `conn.env`.
207pub fn psql_invocation(conn: &PgConnection, sql: &str) -> SqlInvocation {
208    let argv = vec![
209        "psql".to_string(),
210        "-v".to_string(),
211        "ON_ERROR_STOP=1".to_string(),
212        "-tAc".to_string(),
213        sql.to_string(),
214    ];
215    SqlInvocation {
216        argv,
217        env: conn.env(),
218    }
219}
220
221/// Build the `pg_restore` invocation that applies the **attacker-crafted bundle dump** into a
222/// pre-created SHADOW database (inc2 Stage C) — the UNTRUSTED side of the atomic-shadow split.
223///
224/// It targets the shadow database by NAME (`-d <shadow_db>`, NOT `conn.database`) and is meant to
225/// run as the TARGET PROJECT'S own NON-SUPERUSER role (the driver resolves that credential), so a
226/// crafted dump's superuser-only escapes (`COPY … TO/FROM PROGRAM/FILE`, `CREATE FUNCTION …
227/// LANGUAGE c`, `CREATE EXTENSION`) FAIL at apply — Postgres denies them to a non-superuser. A FULL
228/// restore (schema + data; never `--data-only`): `--single-transaction` makes the shadow restore
229/// itself all-or-nothing, `--exit-on-error` aborts loudly on the first failed object, and
230/// `--no-owner`/`--no-privileges` keep the restore from reassigning ownership/ACLs (so no `SET
231/// ROLE`/`ALTER … OWNER` to the superuser is attempted). The dump is fed on stdin. NEVER pass `-j`
232/// here: parallel restore needs a seekable archive, and the dump arrives as a non-seekable pipe.
233/// `shadow_db` is a driver-derived identifier (`<db>__shadow_<ts>`), not a credential, so argv is
234/// correct; the password stays in `PGPASSWORD`.
235pub fn pg_restore_shadow_invocation(conn: &PgConnection, shadow_db: &str) -> SqlInvocation {
236    let argv = vec![
237        "pg_restore".to_string(),
238        "--no-owner".to_string(),
239        "--no-privileges".to_string(),
240        "--exit-on-error".to_string(),
241        "--single-transaction".to_string(),
242        "-d".to_string(),
243        shadow_db.to_string(),
244    ];
245    SqlInvocation {
246        argv,
247        env: conn.env(),
248    }
249}
250
251/// A resolved MySQL connection target for the client. The password goes in `MYSQL_PWD`
252/// (the env the mysql/mysqldump clients read), never argv.
253#[derive(Debug, Clone)]
254pub struct MysqlConnection {
255    /// The reachable host.
256    pub host: String,
257    /// The server port.
258    pub port: u16,
259    /// The role to connect as.
260    pub user: String,
261    /// The password — the ONLY secret; it goes in `MYSQL_PWD`, never argv.
262    pub password: String,
263    /// The physical database to dump/restore.
264    pub database: String,
265    /// A bounded connect timeout (seconds).
266    pub connect_timeout_secs: u32,
267}
268
269impl MysqlConnection {
270    /// The connection ENVIRONMENT: `MYSQL_PWD` (the password) ONLY. The password is the
271    /// sole secret and goes in the env every MySQL client reads, never argv. Host/port/
272    /// user are NOT placed here — unlike libpq's `PGHOST` (honored by all libpq tools),
273    /// `MYSQL_HOST` is read by the `mysql` CLI ALONE; `mysqldump` ignores it and defaults
274    /// to the local unix socket ("Can't connect to local MySQL server through socket"),
275    /// so the host/port/user travel in argv via [`conn_args`](Self::conn_args).
276    pub fn env(&self) -> Vec<(String, String)> {
277        vec![("MYSQL_PWD".to_string(), self.password.clone())]
278    }
279
280    /// The non-secret connection args every MySQL client (both `mysql` and `mysqldump`)
281    /// honors: `--host`, `--port`, `--user`. A real host IP (not "localhost") forces TCP
282    /// rather than the local socket. None of these is a credential, so argv is correct;
283    /// no DSN string is assembled.
284    pub fn conn_args(&self) -> Vec<String> {
285        vec![
286            format!("--host={}", self.host),
287            format!("--port={}", self.port),
288            format!("--user={}", self.user),
289        ]
290    }
291
292    /// The `--connect-timeout=<secs>` arg the `mysql` client accepts (there is no env for
293    /// it). NOTE: `mysqldump` does NOT register this option — passing it there fails with
294    /// "[ERROR] unknown variable 'connect-timeout=10'" — so only `mysql_restore_invocation`
295    /// uses it, never `mysqldump_invocation`.
296    fn connect_timeout_arg(&self) -> String {
297        format!("--connect-timeout={}", self.connect_timeout_secs)
298    }
299}
300
301/// Build the `mysqldump` invocation for an EXPORT. `--single-transaction` takes a
302/// consistent snapshot without locking (InnoDB); `--no-tablespaces` avoids the `PROCESS`
303/// privilege requirement. Host/port/user are explicit ([`conn_args`](MysqlConnection::conn_args))
304/// because `mysqldump` ignores `MYSQL_HOST`. A FULL dump emits schema + data under
305/// `--databases <db>` (so the stream carries `CREATE DATABASE`/`USE`); `--data-only` emits
306/// only the rows for `<db>` (`--no-create-info`), restored into the target's existing
307/// schema. No `--connect-timeout`: `mysqldump` does not register it (unknown variable);
308/// the overall ephemeral-run timeout bounds a stuck dump instead.
309pub fn mysqldump_invocation(conn: &MysqlConnection, data_only: bool) -> SqlInvocation {
310    // `--set-gtid-purged=OFF` is MANDATORY: a `gtid_mode=ON` server (which `delta_capture`
311    // requires, and which the mysql true-delta CI gate launches with) otherwise makes `mysqldump`
312    // emit an ACTIVE `SET @@GLOBAL.GTID_PURGED=…`, which fails to replay into a fresh db on the same
313    // server (`ERROR 3546: the added gtid set must not overlap with GTID_EXECUTED`) and, more
314    // fundamentally, a logical EXPORT bundle must not carry a server-global replication coordinate.
315    // `OFF` keeps the stream portable and is a no-op on a non-gtid server (mirrors the shadow /
316    // capture-base invocations, which already set it for the same reason).
317    let mut argv = vec![
318        "mysqldump".to_string(),
319        "--single-transaction".to_string(),
320        "--no-tablespaces".to_string(),
321        "--set-gtid-purged=OFF".to_string(),
322    ];
323    argv.extend(conn.conn_args());
324    if data_only {
325        argv.push("--no-create-info".to_string());
326        argv.push(conn.database.clone());
327    } else {
328        argv.push("--databases".to_string());
329        argv.push(conn.database.clone());
330    }
331    SqlInvocation {
332        argv,
333        env: conn.env(),
334    }
335}
336
337/// Build the `mysql` invocation for an IMPORT (reads the SQL dump from stdin). Host/port/
338/// user are explicit ([`conn_args`](MysqlConnection::conn_args)) so the client connects
339/// over TCP, not the local socket. A FULL bundle's dump carries its own `USE <db>` (from
340/// `--databases`), so no positional db is needed; a `--data-only` dump has none, so the
341/// target `<db>` is named on the command line. The database name is NOT a credential, so
342/// passing it in argv is fine.
343pub fn mysql_restore_invocation(conn: &MysqlConnection, data_only: bool) -> SqlInvocation {
344    let mut argv = vec!["mysql".to_string()];
345    argv.extend(conn.conn_args());
346    argv.push(conn.connect_timeout_arg());
347    if data_only {
348        argv.push(conn.database.clone());
349    }
350    SqlInvocation {
351        argv,
352        env: conn.env(),
353    }
354}
355
356/// Build the **tables-only FULL** `mysqldump` for the inc2 chunked/shadow path (Stage D). Unlike
357/// the inc1 FULL dump ([`mysqldump_invocation`] with `--databases <db>`, which emits `CREATE
358/// DATABASE`/`USE <original>` and so TARGETS THE ORIGINAL NAME), this dumps the db's TABLES ONLY —
359/// a positional `<db>` with NO `--databases` — so the stream carries `CREATE TABLE` + data and NO
360/// `CREATE DATABASE`/`USE`. That lets `mysql <shadow>` ([`mysql_shadow_invocation`]) apply it into a
361/// freshly-created shadow database whose name differs from the source. `--single-transaction` takes
362/// a consistent InnoDB snapshot without locking; `--no-tablespaces` avoids the `PROCESS` privilege.
363/// Host/port/user are explicit (`mysqldump` ignores `MYSQL_HOST`); the password stays in `MYSQL_PWD`.
364///
365/// `--set-gtid-purged=OFF` is MANDATORY for the shadow path: when the server runs with
366/// `gtid_mode=ON` (which `delta_capture` requires — see [`mysqldump_capture_base_invocation`]),
367/// mysqldump's default (`AUTO`) injects an EXECUTABLE `SET @@GLOBAL.GTID_PURGED=…` plus a
368/// `SET @@SESSION.SQL_LOG_BIN=0` toggle. Both need `SYSTEM_VARIABLES_ADMIN`/`SESSION_VARIABLES_ADMIN`,
369/// which the least-privilege shadow apply user ([`mysql_shadow_invocation`]) does NOT hold — so the
370/// non-superuser restore would fail. `OFF` suppresses both, keeping the tables-only dump a clean,
371/// privilege-free stream that any shadow-scoped user can replay. (A non-gtid server ignores it.)
372pub fn mysqldump_tables_only_invocation(conn: &MysqlConnection) -> SqlInvocation {
373    let mut argv = vec![
374        "mysqldump".to_string(),
375        "--single-transaction".to_string(),
376        "--no-tablespaces".to_string(),
377        "--set-gtid-purged=OFF".to_string(),
378    ];
379    argv.extend(conn.conn_args());
380    // Positional db, NO `--databases` ⇒ tables only, no `CREATE DATABASE`/`USE <original>`.
381    argv.push(conn.database.clone());
382    SqlInvocation {
383        argv,
384        env: conn.env(),
385    }
386}
387
388/// Build the **tables-only, SNAPSHOT-PINNED** `mysqldump` for the MySQL binlog TRUE-DELTA base
389/// (SQL true-delta program — the mysql analogue of the pg capture slot). Identical to
390/// [`mysqldump_tables_only_invocation`] (tables-only so it restores into a differently-named shadow)
391/// EXCEPT it records the dump's consistent-snapshot binlog coordinate with
392/// `--source-data=2 --set-gtid-purged=COMMENTED`:
393/// - `--source-data=2` emits a COMMENTED `CHANGE MASTER TO MASTER_LOG_FILE='…'` — the binlog file the
394///   delta capture streams from.
395/// - `--set-gtid-purged=COMMENTED` emits a COMMENTED `SET @@GLOBAL.GTID_PURGED='+<gtid>'` — the EXACT
396///   `gtid_executed` at the dump's snapshot, which the delta's `--exclude-gtids` replays from. Unlike
397///   pg (whose slot pins a point BEFORE the dump, leaving an overlap window), mysqldump reports its
398///   OWN snapshot coordinate, so the base is snapshot-pinned with NO overlap/gap.
399///
400/// Both coordinates are COMMENTED (inert on restore). But `--source-data` ALSO injects an EXECUTABLE
401/// `SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN` / `SET @@SESSION.SQL_LOG_BIN=0` pair that the
402/// least-privilege shadow user cannot run — the driver STRIPS those lines (see
403/// `mysql_strip_sql_log_bin` in the node driver) before the member is committed, leaving a clean
404/// member whose header still carries the two COMMENTED coordinates for parsing.
405pub fn mysqldump_capture_base_invocation(conn: &MysqlConnection) -> SqlInvocation {
406    let mut argv = vec![
407        "mysqldump".to_string(),
408        "--single-transaction".to_string(),
409        "--no-tablespaces".to_string(),
410        "--source-data=2".to_string(),
411        "--set-gtid-purged=COMMENTED".to_string(),
412    ];
413    argv.extend(conn.conn_args());
414    argv.push(conn.database.clone());
415    SqlInvocation {
416        argv,
417        env: conn.env(),
418    }
419}
420
421/// Build the `mysqlbinlog --read-from-remote-server` invocation that CAPTURES + DB-SCOPES + REWRITES
422/// the binlog delta (SQL true-delta drain). Connects to the live server like a replica (no
423/// filesystem access to the data dir) as the EPHEMERAL least-privilege capture user (`REPLICATION
424/// SLAVE, REPLICATION CLIENT` only), reading from `start_log` (the base dump's recorded
425/// `MASTER_LOG_FILE`) `--to-last-log`, and DECODES to stdout (NOT `--raw`) — the member IS this SQL
426/// stream (`BINLOG '…'` row events), directly pipeable to `mysql` at replay with no re-decode. The
427/// flags are the SECURITY + correctness core:
428/// - `--rewrite-db='<live>-><shadow>'` retargets the source db's events to the SHADOW at CAPTURE (the
429///   member is applied verbatim at restore; SQL text cannot be rewritten after the fact).
430/// - `--database=<shadow>` (applied AFTER the rewrite, filtering on the REWRITTEN name) keeps ONLY
431///   the shadow's events and DROPS every other db's events — so the member NEVER contains `mysql`-
432///   schema account DDL (`CREATE USER`/`GRANT`/`SET PASSWORD` with password hashes) or any other
433///   tenant/db's rows. This is the DISCLOSURE fix: the exported member is strictly the app db's data,
434///   never the server-global binlog (unlike a `--raw` capture, which byte-copies everything).
435/// - `--skip-gtids` omits the `SET @@SESSION.GTID_NEXT=<source gtid>` statements, so the replay onto
436///   the (same) server does not see each source GTID as already-executed and SKIP the transaction;
437///   the replayed rows get fresh auto-assigned GTID values.
438/// - `--exclude-gtids=<base_gtid>` drops everything already in the base dump's snapshot (the start
439///   binlog file holds pre-base events) — no overlap.
440///
441/// `shadow`/`live`/`base_gtid`/`start_log` are driver-derived (binding + parsed dump coordinate),
442/// never credentials. Host/port/user explicit; `mysqlbinlog` honors `MYSQL_PWD`, so the password
443/// rides in the env, NEVER argv.
444pub fn mysqlbinlog_capture_invocation(
445    conn: &MysqlConnection,
446    live_db: &str,
447    shadow_db: &str,
448    base_gtid: &str,
449    start_log: &str,
450) -> SqlInvocation {
451    let argv = vec![
452        "mysqlbinlog".to_string(),
453        "--read-from-remote-server".to_string(),
454        format!("--host={}", conn.host),
455        format!("--port={}", conn.port),
456        format!("--user={}", conn.user),
457        format!("--rewrite-db={live_db}->{shadow_db}"),
458        format!("--database={shadow_db}"),
459        "--skip-gtids".to_string(),
460        format!("--exclude-gtids={base_gtid}"),
461        "--to-last-log".to_string(),
462        start_log.to_string(),
463    ];
464    SqlInvocation {
465        argv,
466        env: conn.env(),
467    }
468}
469
470/// Build the `mysql` invocation that applies the **attacker-crafted** tables-only dump into a
471/// pre-created SHADOW database by NAME (inc2 Stage D) — the UNTRUSTED side of the atomic-shadow
472/// split. Meant to run as the TARGET PROJECT'S own NON-SUPERUSER role (the driver resolves that
473/// credential) so a crafted statement needing elevated privileges (`LOAD DATA INFILE`, `INTO
474/// OUTFILE`, creating a `LANGUAGE`-escalating routine) fails at apply. The dump (which carries NO
475/// `USE`) is fed on stdin and lands in `shadow_db` because it is named as the default database
476/// positionally. Host/port/user explicit; password in `MYSQL_PWD`; `shadow_db` is a driver-derived
477/// identifier (not a credential), so argv is correct.
478///
479/// Used for BOTH the inc2 tables-only base restore AND the binlog true-delta member replay (both
480/// apply attacker-crafted SQL as the least-privilege shadow user). `--local-infile=0` is
481/// belt-and-suspenders: it disables client-side `LOAD DATA LOCAL INFILE`, so a crafted dump/member
482/// cannot read a file off the ephemeral client container (the apply user already lacks `FILE`, which
483/// blocks server-side `LOAD DATA INFILE`/`INTO OUTFILE`).
484pub fn mysql_shadow_invocation(conn: &MysqlConnection, shadow_db: &str) -> SqlInvocation {
485    let mut argv = vec!["mysql".to_string()];
486    argv.extend(conn.conn_args());
487    argv.push(conn.connect_timeout_arg());
488    argv.push("--local-infile=0".to_string());
489    argv.push(shadow_db.to_string());
490    SqlInvocation {
491        argv,
492        env: conn.env(),
493    }
494}
495
496/// Build a `mysql` invocation that runs **trusted, we-generate-it** SQL fed on stdin — the
497/// SUPERUSER / maintenance side of the inc2 mysql shadow split (Stage D): `CREATE DATABASE
498/// <shadow>`, the atomic `RENAME TABLE` swap, `DROP DATABASE`, and the `information_schema` table
499/// enumeration the swap is built from. No default database is named (so `CREATE DATABASE` works and
500/// every object is schema-qualified in the SQL we build), `-N --batch` give header-less,
501/// tab-separated output for the enumeration query. Host/port/user explicit; password in `MYSQL_PWD`.
502pub fn mysql_admin_invocation(conn: &MysqlConnection) -> SqlInvocation {
503    let mut argv = vec!["mysql".to_string()];
504    argv.extend(conn.conn_args());
505    argv.push(conn.connect_timeout_arg());
506    argv.push("-N".to_string());
507    argv.push("--batch".to_string());
508    SqlInvocation {
509        argv,
510        env: conn.env(),
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    fn conn() -> PgConnection {
519        PgConnection {
520            host: "10.0.0.5".to_string(),
521            port: 5432,
522            user: "svc".to_string(),
523            password: "sup3r-s3cret-pw".to_string(),
524            database: "app".to_string(),
525            connect_timeout_secs: 10,
526        }
527    }
528
529    #[test]
530    fn data_only_toggles_the_flag_on_both_sides() {
531        // The data-only path MUST diverge from the full path (anti-hollow mutation (b):
532        // drop the data_only honoring and this divergence vanishes).
533        let dump_do = pg_dump_invocation(&conn(), true, "160004", &[], false);
534        let dump_full = pg_dump_invocation(&conn(), false, "160004", &[], false);
535        assert!(dump_do.argv.iter().any(|a| a == "--data-only"));
536        assert!(!dump_full.argv.iter().any(|a| a == "--data-only"));
537        // A --data-only dump EXCLUDES the migration ledger's rows so a restore into an
538        // already-migrated target does not PK-conflict on it; a full dump does not.
539        assert!(
540            dump_do
541                .argv
542                .iter()
543                .any(|a| a == "--exclude-table-data=boatramp_migrations.*")
544        );
545        assert!(
546            !dump_full
547                .argv
548                .iter()
549                .any(|a| a == "--exclude-table-data=boatramp_migrations.*")
550        );
551
552        let restore_do = pg_restore_invocation(&conn(), true);
553        let restore_full = pg_restore_invocation(&conn(), false);
554        assert!(restore_do.argv.iter().any(|a| a == "--data-only"));
555        assert!(!restore_full.argv.iter().any(|a| a == "--data-only"));
556        // pg_restore needs an explicit `-d <db>` target (it does NOT read PGDATABASE);
557        // without it the restore aborts "one of -d/--dbname and -f/--file must be
558        // specified". The db name follows the `-d` flag as its own argv element.
559        for inv in [&restore_do, &restore_full] {
560            let pos = inv
561                .argv
562                .iter()
563                .position(|a| a == "-d")
564                .expect("pg_restore argv must carry -d");
565            assert_eq!(
566                inv.argv.get(pos + 1).map(String::as_str),
567                Some(conn().database.as_str()),
568                "the target database must follow -d"
569            );
570        }
571    }
572
573    #[test]
574    fn pg_server_major_parses_the_version_num_banner() {
575        assert_eq!(pg_server_major("160004"), Some(16));
576        assert_eq!(pg_server_major("170000"), Some(17));
577        assert_eq!(pg_server_major(" 180002 "), Some(18));
578        assert_eq!(pg_server_major("not-a-number"), None);
579    }
580
581    #[test]
582    fn no_publications_toggles_the_dump_flag() {
583        // A `delta_capture` db dumps with `--no-publications` so the ephemeral `brexp_pub_*`
584        // capture publication (superuser-only to CREATE) never lands in the dump and cannot fail
585        // the non-superuser atomic-shadow restore. Off ⇒ the flag is absent (publications dumped).
586        let with = pg_dump_invocation(&conn(), false, "160004", &[], true);
587        assert!(with.argv.iter().any(|a| a == "--no-publications"));
588        let without = pg_dump_invocation(&conn(), false, "160004", &[], false);
589        assert!(!without.argv.iter().any(|a| a == "--no-publications"));
590    }
591
592    #[test]
593    fn exclude_extension_narrows_no_comments_only_on_pg17_with_a_shadow_allowlist() {
594        let exts = vec!["vector".to_string(), "pg_trgm".to_string()];
595        let has = |inv: &SqlInvocation, a: &str| inv.argv.iter().any(|x| x == a);
596        let has_exclude = |inv: &SqlInvocation, e: &str| {
597            inv.argv
598                .iter()
599                .any(|x| x == &format!("--exclude-extension={e}"))
600        };
601
602        // PG17 + a shadow-pre-created allowlist: NARROW — exclude exactly those extensions and
603        // DROP `--no-comments`, so all OTHER (table/column) comments survive the export.
604        let pg17 = pg_dump_invocation(&conn(), false, "170000", &exts, false);
605        assert!(!has(&pg17, "--no-comments"), "PG17 keeps object comments");
606        assert!(has_exclude(&pg17, "vector") && has_exclude(&pg17, "pg_trgm"));
607
608        // PG16 + the SAME allowlist: no `--exclude-extension` exists, so fall back to the blunt
609        // `--no-comments` and NEVER emit an `--exclude-extension` (unknown flag on PG16).
610        let pg16 = pg_dump_invocation(&conn(), false, "160004", &exts, false);
611        assert!(
612            has(&pg16, "--no-comments"),
613            "PG16 has no --exclude-extension → blunt strip"
614        );
615        assert!(
616            !pg16
617                .argv
618                .iter()
619                .any(|a| a.starts_with("--exclude-extension")),
620            "PG16's pg_dump does not know --exclude-extension"
621        );
622
623        // PG17 but NO shadow allowlist (the sidecar/external path): the restore needs the dump's
624        // own CREATE EXTENSION, so stay on `--no-comments` and exclude nothing.
625        let sidecar = pg_dump_invocation(&conn(), false, "170000", &[], false);
626        assert!(has(&sidecar, "--no-comments"));
627        assert!(
628            !sidecar
629                .argv
630                .iter()
631                .any(|a| a.starts_with("--exclude-extension"))
632        );
633
634        // An unparseable banner is conservative: blunt strip, no exclude.
635        let unknown = pg_dump_invocation(&conn(), false, "garbage", &exts, false);
636        assert!(has(&unknown, "--no-comments"));
637        assert!(
638            !unknown
639                .argv
640                .iter()
641                .any(|a| a.starts_with("--exclude-extension"))
642        );
643    }
644
645    #[test]
646    fn shadow_builders_split_trusted_ddl_from_untrusted_apply() {
647        let c = conn();
648        // The TRUSTED maintenance side: psql runs ONE generated statement; ON_ERROR_STOP so a
649        // failed CREATE/ALTER/DROP DATABASE is a non-zero exit (never mistaken for success).
650        let ddl = psql_invocation(&c, "CREATE DATABASE \"app__shadow_1\" OWNER \"owner\"");
651        assert_eq!(ddl.argv[0], "psql");
652        assert!(ddl.argv.iter().any(|a| a == "ON_ERROR_STOP=1"));
653        assert!(
654            ddl.argv
655                .iter()
656                .any(|a| a == "CREATE DATABASE \"app__shadow_1\" OWNER \"owner\"")
657        );
658        // The UNTRUSTED apply side: FULL restore (NEVER --data-only), single-transaction,
659        // exit-on-error, no-owner/no-privileges, targeting the SHADOW by name — and NEVER `-j`
660        // (parallel restore needs a seekable archive; the dump is a pipe).
661        let apply = pg_restore_shadow_invocation(&c, "app__shadow_1");
662        assert_eq!(apply.argv[0], "pg_restore");
663        assert!(apply.argv.iter().any(|a| a == "--single-transaction"));
664        assert!(apply.argv.iter().any(|a| a == "--exit-on-error"));
665        assert!(apply.argv.iter().any(|a| a == "--no-owner"));
666        assert!(apply.argv.iter().any(|a| a == "--no-privileges"));
667        assert!(
668            !apply.argv.iter().any(|a| a == "--data-only"),
669            "the chunked pg shadow path is FULL-restore only (default #1)"
670        );
671        assert!(
672            !apply
673                .argv
674                .iter()
675                .any(|a| a == "-j" || a.starts_with("--jobs")),
676            "NEVER parallel-restore a piped archive"
677        );
678        let pos = apply.argv.iter().position(|a| a == "-d").expect("-d");
679        assert_eq!(
680            apply.argv.get(pos + 1).map(String::as_str),
681            Some("app__shadow_1"),
682            "the shadow db (NOT conn.database) follows -d"
683        );
684        // Both still carry the credential ONLY in the env, never argv.
685        for inv in [&ddl, &apply] {
686            assert!(
687                inv.env
688                    .iter()
689                    .any(|(k, v)| k == "PGPASSWORD" && v == &c.password)
690            );
691            for a in &inv.argv {
692                assert!(
693                    !a.contains(&c.password),
694                    "credential leaked into argv: {a:?}"
695                );
696            }
697        }
698    }
699
700    #[test]
701    fn mysql_shadow_builders_are_tables_only_and_target_the_shadow() {
702        let c = myconn();
703        // Tables-only FULL dump: positional db, NO --databases (so no CREATE DATABASE/USE of the
704        // ORIGINAL name), NO --no-create-info (schema + data), NO --connect-timeout (mysqldump
705        // rejects it).
706        let dump = mysqldump_tables_only_invocation(&c);
707        assert_eq!(dump.argv[0], "mysqldump");
708        assert!(!dump.argv.iter().any(|a| a == "--databases"));
709        assert!(!dump.argv.iter().any(|a| a == "--no-create-info"));
710        assert!(!dump.argv.iter().any(|a| a.starts_with("--connect-timeout")));
711        // Clean under gtid_mode=ON: suppress mysqldump's AUTO `SET @@GLOBAL.GTID_PURGED` +
712        // `SET @@SESSION.SQL_LOG_BIN` injections so the non-superuser shadow apply never hits them.
713        assert!(
714            dump.argv.iter().any(|a| a == "--set-gtid-purged=OFF"),
715            "tables-only dump must suppress gtid/sql_log_bin statements: {:?}",
716            dump.argv
717        );
718        assert!(
719            dump.argv.last().map(String::as_str) == Some("app"),
720            "the db is positional (tables only): {:?}",
721            dump.argv
722        );
723        // The untrusted apply targets the SHADOW by name (positional default db).
724        let apply = mysql_shadow_invocation(&c, "app__shadow_1");
725        assert_eq!(apply.argv[0], "mysql");
726        assert!(apply.argv.iter().any(|a| a == "app__shadow_1"));
727        assert!(!apply.argv.iter().any(|a| a == "app"));
728        // The untrusted apply disables client-side LOAD DATA LOCAL INFILE (defense in depth).
729        assert!(apply.argv.iter().any(|a| a == "--local-infile=0"));
730        // The trusted admin side names NO default db (so CREATE/DROP DATABASE + schema-qualified
731        // RENAME TABLE work) and asks for header-less tab output for the table enumeration.
732        let admin = mysql_admin_invocation(&c);
733        assert_eq!(admin.argv[0], "mysql");
734        assert!(admin.argv.iter().any(|a| a == "-N"));
735        assert!(admin.argv.iter().any(|a| a == "--batch"));
736        assert!(
737            !admin
738                .argv
739                .iter()
740                .any(|a| a == "app" || a == "app__shadow_1"),
741            "the admin connection names no default database: {:?}",
742            admin.argv
743        );
744        // Credential only in MYSQL_PWD, never argv; host explicit (mysqldump ignores MYSQL_HOST).
745        for inv in [&dump, &apply, &admin] {
746            assert!(
747                inv.env
748                    .iter()
749                    .any(|(k, v)| k == "MYSQL_PWD" && v == &c.password)
750            );
751            assert!(inv.argv.iter().any(|a| a == &format!("--host={}", c.host)));
752            for a in &inv.argv {
753                assert!(
754                    !a.contains(&c.password),
755                    "credential leaked into argv: {a:?}"
756                );
757            }
758        }
759    }
760
761    #[test]
762    fn mysql_true_delta_capture_and_replay_builders() {
763        let c = myconn();
764        // BASE capture dump: tables-only + the snapshot-pinned coordinate flags. `--source-data=2`
765        // (COMMENTED CHANGE MASTER) + `--set-gtid-purged=COMMENTED` (COMMENTED GTID_PURGED) so the
766        // driver can parse the exact snapshot GTID + binlog file without polluting the restore.
767        let base = mysqldump_capture_base_invocation(&c);
768        assert_eq!(base.argv[0], "mysqldump");
769        assert!(base.argv.iter().any(|a| a == "--source-data=2"));
770        assert!(base.argv.iter().any(|a| a == "--set-gtid-purged=COMMENTED"));
771        assert!(!base.argv.iter().any(|a| a == "--databases")); // tables-only (shadow-restorable)
772        assert_eq!(base.argv.last().map(String::as_str), Some("app"));
773
774        // CAPTURE: db-SCOPED + REWRITTEN at capture (member is app-only SQL text targeting the
775        // shadow). NOT --raw (which byte-copies the whole-server binlog incl. account-DDL hashes);
776        // `--database` filters on the REWRITTEN name (applied after `--rewrite-db`) — the DISCLOSURE
777        // fix: foreign-db / `mysql`-schema events never enter the member.
778        let cap = mysqlbinlog_capture_invocation(
779            &c,
780            "app",
781            "app__shadow_1",
782            "uuid:1-9",
783            "mysql-bin.000007",
784        );
785        assert_eq!(cap.argv[0], "mysqlbinlog");
786        assert!(cap.argv.iter().any(|a| a == "--read-from-remote-server"));
787        assert!(
788            !cap.argv.iter().any(|a| a == "--raw"),
789            "capture is NON-raw (db-scoped SQL text), never --raw (whole-server disclosure): {:?}",
790            cap.argv
791        );
792        assert!(
793            cap.argv
794                .iter()
795                .any(|a| a == "--rewrite-db=app->app__shadow_1")
796        );
797        assert!(
798            cap.argv.iter().any(|a| a == "--database=app__shadow_1"),
799            "--database must filter on the REWRITTEN (shadow) name: {:?}",
800            cap.argv
801        );
802        assert!(cap.argv.iter().any(|a| a == "--skip-gtids"));
803        assert!(cap.argv.iter().any(|a| a == "--exclude-gtids=uuid:1-9"));
804        assert!(cap.argv.iter().any(|a| a == "--to-last-log"));
805        assert_eq!(
806            cap.argv.last().map(String::as_str),
807            Some("mysql-bin.000007")
808        );
809        assert!(cap.argv.iter().any(|a| a == &format!("--user={}", c.user)));
810
811        // The capture dump + remote capture carry the password ONLY in MYSQL_PWD, never argv.
812        for inv in [&base, &cap] {
813            assert!(
814                inv.env
815                    .iter()
816                    .any(|(k, v)| k == "MYSQL_PWD" && v == &c.password)
817            );
818            for a in &inv.argv {
819                assert!(
820                    !a.contains(&c.password),
821                    "credential leaked into argv: {a:?}"
822                );
823                assert!(!a.contains("://"), "no DSN in argv: {a:?}");
824            }
825        }
826    }
827
828    #[test]
829    fn credential_is_in_the_env_never_in_argv() {
830        let c = conn();
831        for inv in [
832            pg_dump_invocation(&c, true, "160004", &[], false),
833            // PG17 narrowing path too: the generated `--exclude-extension` args carry no credential.
834            pg_dump_invocation(&c, false, "170000", &["vector".to_string()], false),
835            pg_restore_invocation(&c, false),
836        ] {
837            // The password is present in the environment...
838            assert!(
839                inv.env
840                    .iter()
841                    .any(|(k, v)| k == "PGPASSWORD" && v == &c.password),
842                "PGPASSWORD must be set in the environment"
843            );
844            // ...and NEVER appears in argv (which shows in ps/inspect). This is the
845            // non-leak invariant; a regression that put the password on the command
846            // line fails here.
847            for a in &inv.argv {
848                assert!(
849                    !a.contains(&c.password),
850                    "credential leaked into argv: {a:?}"
851                );
852            }
853            // No connection string is assembled at all — no `postgres://` in argv.
854            for a in &inv.argv {
855                assert!(
856                    !a.contains("://"),
857                    "a DSN must never be built into argv: {a:?}"
858                );
859            }
860        }
861    }
862
863    #[test]
864    fn env_carries_the_discrete_connection_and_a_bounded_timeout() {
865        let e = conn().env();
866        let get = |k: &str| e.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone());
867        assert_eq!(get("PGHOST").as_deref(), Some("10.0.0.5"));
868        assert_eq!(get("PGPORT").as_deref(), Some("5432"));
869        assert_eq!(get("PGUSER").as_deref(), Some("svc"));
870        assert_eq!(get("PGDATABASE").as_deref(), Some("app"));
871        // A bounded connect timeout so an unreachable DSN fails fast.
872        assert_eq!(get("PGCONNECT_TIMEOUT").as_deref(), Some("10"));
873    }
874
875    fn myconn() -> MysqlConnection {
876        MysqlConnection {
877            host: "10.0.0.6".to_string(),
878            port: 3306,
879            user: "svc".to_string(),
880            password: "sup3r-s3cret-pw".to_string(),
881            database: "app".to_string(),
882            connect_timeout_secs: 10,
883        }
884    }
885
886    #[test]
887    fn mysql_data_only_toggles_and_credential_stays_in_env() {
888        // data-only diverges: dump uses --no-create-info (no --databases); full uses
889        // --databases; restore names the db only for data-only.
890        let dump_do = mysqldump_invocation(&myconn(), true);
891        let dump_full = mysqldump_invocation(&myconn(), false);
892        assert!(dump_do.argv.iter().any(|a| a == "--no-create-info"));
893        assert!(!dump_full.argv.iter().any(|a| a == "--no-create-info"));
894        assert!(dump_full.argv.iter().any(|a| a == "--databases"));
895        assert!(!dump_do.argv.iter().any(|a| a == "--databases"));
896        // BOTH variants must suppress GTID_PURGED: a gtid_mode=ON server would otherwise emit an
897        // active `SET @@GLOBAL.GTID_PURGED` that fails to restore (overlap) + taints a portable bundle.
898        for d in [&dump_do, &dump_full] {
899            assert!(
900                d.argv.iter().any(|a| a == "--set-gtid-purged=OFF"),
901                "mysqldump must carry --set-gtid-purged=OFF (gtid-safety): {:?}",
902                d.argv
903            );
904        }
905        // mysqldump does NOT register --connect-timeout (it fails with "unknown variable");
906        // it must never appear on a dump invocation, but the mysql restore client DOES
907        // accept it.
908        for d in [&dump_do, &dump_full] {
909            assert!(
910                !d.argv.iter().any(|a| a.starts_with("--connect-timeout")),
911                "mysqldump must not carry --connect-timeout"
912            );
913        }
914
915        let restore_do = mysql_restore_invocation(&myconn(), true);
916        let restore_full = mysql_restore_invocation(&myconn(), false);
917        assert!(
918            restore_full
919                .argv
920                .iter()
921                .any(|a| a.starts_with("--connect-timeout")),
922            "the mysql restore client should carry --connect-timeout"
923        );
924        assert!(restore_do.argv.iter().any(|a| a == "app"));
925        assert!(!restore_full.argv.iter().any(|a| a == "app"));
926
927        // The password is in MYSQL_PWD (env), never argv; no DSN in argv.
928        let c = myconn();
929        for inv in [
930            mysqldump_invocation(&c, true),
931            mysql_restore_invocation(&c, false),
932        ] {
933            assert!(
934                inv.env
935                    .iter()
936                    .any(|(k, v)| k == "MYSQL_PWD" && v == &c.password),
937                "MYSQL_PWD must be set in the environment"
938            );
939            // Host must be explicit in argv: mysqldump ignores MYSQL_HOST and would hit
940            // the local socket (error 2002) without it.
941            assert!(
942                inv.argv.iter().any(|a| a == &format!("--host={}", c.host)),
943                "the connection host must be explicit in argv: {:?}",
944                inv.argv
945            );
946            for a in &inv.argv {
947                assert!(
948                    !a.contains(&c.password),
949                    "credential leaked into argv: {a:?}"
950                );
951                assert!(
952                    !a.contains("://"),
953                    "a DSN must never be built into argv: {a:?}"
954                );
955            }
956        }
957    }
958
959    #[test]
960    fn mysql_env_carries_the_discrete_connection() {
961        let c = myconn();
962        // The ENV carries ONLY the password (the sole secret) — mysqldump ignores
963        // MYSQL_HOST, so host/port/user must NOT be relied on via env.
964        let e = c.env();
965        assert_eq!(e.len(), 1, "env carries only MYSQL_PWD");
966        assert_eq!(e[0].0, "MYSQL_PWD");
967        assert_eq!(e[0].1, "sup3r-s3cret-pw");
968        // Host/port/user travel in argv (so both mysql and mysqldump connect over TCP),
969        // and never the password.
970        let a = c.conn_args();
971        assert!(a.iter().any(|x| x == "--host=10.0.0.6"));
972        assert!(a.iter().any(|x| x == "--port=3306"));
973        assert!(a.iter().any(|x| x == "--user=svc"));
974        assert!(
975            !a.iter().any(|x| x.contains("sup3r-s3cret-pw")),
976            "password never in conn_args"
977        );
978    }
979}