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}