rivet/source/mod.rs
1pub(crate) mod batch_controller;
2pub(crate) mod cdc;
3pub mod mongo;
4pub mod mssql;
5pub mod mysql;
6pub(crate) mod pg_numeric_wire;
7pub mod postgres;
8pub(crate) mod query;
9pub(crate) mod tls;
10pub(crate) mod value_checksum;
11
12use arrow::datatypes::SchemaRef;
13use arrow::record_batch::RecordBatch;
14
15use crate::config::{SourceConfig, TlsConfig};
16use crate::error::Result;
17use crate::plan::IncrementalCursorPlan;
18use crate::tuning::SourceTuning;
19use crate::types::{ColumnOverrides, CursorState, TypeMapping};
20
21/// A statement-DURATION timeout that **rivet itself** raised — distinct from a
22/// driver-native timeout that carries a structured code (PG 57014, MySQL 3024).
23///
24/// The MSSQL engine has no server-side statement-duration `SET`, so rivet
25/// enforces `tuning.statement_timeout_s` client-side and raises this when the
26/// budget is exceeded (see [`mssql`]). Before this type the retry classifier's
27/// permanence hinged on substring-matching rivet's OWN prose ("statement
28/// timeout after …"); a reworded message would silently flip the error back to
29/// *transient*, and the identical query would be retried until it burned the
30/// budget N times (measured: 3×300 s = 20 min for 0 rows). Carrying a typed
31/// marker means [`crate::pipeline::retry::classify_error`] downcasts the TYPE,
32/// so permanence survives any change to the human-facing wording. The string
33/// branches in the classifier remain a fallback for genuinely driver-native
34/// timeout messages we do not control.
35#[derive(Debug)]
36pub struct StatementDurationTimeout {
37 /// Full actionable message shown to the operator. The classifier keys off
38 /// the TYPE, not this text — it exists only for Display.
39 message: String,
40}
41
42impl StatementDurationTimeout {
43 /// MSSQL client-side statement-duration timeout (no server-side `SET`).
44 pub fn mssql(seconds: u64) -> Self {
45 Self {
46 message: format!(
47 "mssql: statement timeout after {seconds}s (tuning.statement_timeout_s) — \
48 this query cannot finish within the budget; split it with `mode: chunked` \
49 (per-chunk statements stay under the limit) or raise \
50 `tuning.statement_timeout_s`"
51 ),
52 }
53 }
54}
55
56impl std::fmt::Display for StatementDurationTimeout {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str(&self.message)
59 }
60}
61
62impl std::error::Error for StatementDurationTimeout {}
63
64/// Summary of a source table relevant to chunked-mode planning. Source-neutral
65/// shape so plan-build can ask either Postgres or MySQL for the same answer.
66///
67/// Populated by `crate::source::postgres::introspect_pg_table_for_chunking` and
68/// `crate::source::mysql::introspect_mysql_table_for_chunking`. Both helpers
69/// rely on catalog stats (`pg_class` / `information_schema.TABLES`) so the
70/// numbers are only as fresh as the last `ANALYZE` / autoanalyse.
71///
72/// # Why this is a data-shape seam, not a trait
73///
74/// The two per-engine introspection functions have identical signatures
75/// (`fn(url, tls, qualified_table) -> Result<TableIntrospection>`) and return
76/// this shared struct. The parallel shape sometimes invites a refactor along
77/// the lines of `trait Introspector { fn introspect_table(...) }` with one
78/// impl per engine — that refactor adds ceremony without reducing duplication,
79/// because the *bodies* share nothing useful: PG queries `pg_class` /
80/// `pg_index` / `pg_attribute` / `pg_type` (PG-specific type names like
81/// `int2`/`int4`/`int8`) via the `postgres` client; MySQL queries
82/// `information_schema.TABLES` / `STATISTICS` with the InnoDB
83/// `AVG_ROW_LENGTH` overflow correction via the `mysql` client. No shared
84/// implementation logic exists to extract into trait-default methods. A
85/// trait would only rename where the engine match happens
86/// (`match config.source.source_type { … }` at the call site → factory
87/// returning `Box<dyn Introspector>`); the match doesn't disappear.
88///
89/// The seam therefore lives at the **data shape**: this struct is the
90/// shared contract, the two free functions are the adapters, the per-call
91/// dispatch is an `enum`-driven `match`. See ADR-0015 for the full
92/// rationale and the architecture-review walks that led here.
93#[derive(Debug, Clone, Default)]
94pub(crate) struct TableIntrospection {
95 /// Name of the single integer-family PK column, if present and safe to
96 /// range-chunk. `None` when the table has no PK, has a composite PK, or
97 /// the PK type is not an integer family (text, uuid, decimal, …).
98 pub single_int_pk: Option<String>,
99 /// Single-column, NOT NULL, **unique** index columns usable as a keyset
100 /// (seek) pagination key — PK first (any type), then other UNIQUE indexes
101 /// (OPT-4). Index-backed and unique by construction, so `ORDER BY key
102 /// LIMIT n` is a bounded index range scan (never a filesort) and
103 /// `WHERE key > last` never skips rows with a duplicate key. Empty when the
104 /// table has no such key.
105 pub keyset_keys: Vec<String>,
106 /// Best-effort row count: PG `reltuples`, MySQL `TABLE_ROWS`. `0` means
107 /// the table is empty or stats are unavailable.
108 pub row_estimate: i64,
109 /// Heap-size-per-row in bytes. `None` for empty / unanalysed tables.
110 /// Used to convert `chunk_size_memory_mb` into a row count.
111 pub avg_row_bytes: Option<i64>,
112}
113
114impl TableIntrospection {
115 /// The auto-selected keyset key: the first usable single-column unique
116 /// NOT NULL key (PK preferred). `None` when the table has none.
117 pub fn auto_keyset_key(&self) -> Option<&str> {
118 self.keyset_keys.first().map(String::as_str)
119 }
120
121 /// Whether `col` is a usable keyset key (single-column, unique, NOT NULL,
122 /// index-backed). Used to validate an explicit `chunk_by_key`.
123 pub fn is_usable_keyset_key(&self, col: &str) -> bool {
124 self.keyset_keys.iter().any(|k| k == col)
125 }
126}
127
128/// Receives schema and batches from a source, one at a time.
129pub trait BatchSink {
130 fn on_schema(&mut self, schema: SchemaRef) -> Result<()>;
131 fn on_batch(&mut self, batch: &RecordBatch) -> Result<()>;
132 /// A source whose key type is richer than its output column can express
133 /// reports its own keyset high-water mark here, as a lossless,
134 /// engine-decodable token. The keyset/parallel runners prefer it over the
135 /// string extracted from the output column — this is how MongoDB pages by a
136 /// non-ObjectId BSON `_id` (int, string, …) whose hex/text rendering in the
137 /// `_id` column would be type-ambiguous on the round-trip. No-op default:
138 /// SQL engines carry their cursor losslessly in the column already.
139 fn set_source_cursor(&mut self, _token: String) {}
140}
141
142/// Read-only inputs for a single export call.
143///
144/// Packs the parameters that used to live as 5 positional args on
145/// `Source::export` into a named struct. `sink` is **not** part of this struct
146/// — it is `&mut` and conceptually the output channel, separate from the
147/// read-only request configuration.
148pub struct ExportRequest<'a> {
149 /// Already-materialized SQL (after `resolve_query`). The driver still wraps
150 /// it with the dialect-specific incremental predicate via
151 /// [`crate::source::query::build_incremental_query`] when `incremental` is set.
152 pub query: &'a str,
153 /// The *unwrapped* base query to resolve catalog-dependent type hints from
154 /// (PostgreSQL `NUMERIC` precision/scale, which the wire protocol omits — the
155 /// driver parses the `FROM` clause and asks `pg_catalog`). Chunked, dense and
156 /// keyset runners wrap `query` in a `SELECT … FROM (<base>) …` subquery that
157 /// hides the source table from the catalog parser, so they pass the original
158 /// base query here. `None` ⇒ resolve from `query` (full/incremental, where it
159 /// is already the unwrapped form). Drivers that read precision from the wire
160 /// (MySQL) ignore this field.
161 pub catalog_hint_query: Option<&'a str>,
162 pub incremental: Option<&'a IncrementalCursorPlan>,
163 pub cursor: Option<&'a CursorState>,
164 pub tuning: &'a SourceTuning,
165 /// Per-column type declarations from `rivet.yaml` (`exports[].columns:`).
166 /// Drivers apply them during schema building so e.g. a `NUMERIC` column
167 /// without declared precision can still be exported as `Decimal128(18,2)`
168 /// when the user has stated the type explicitly.
169 pub column_overrides: &'a ColumnOverrides,
170 /// Keyset (seek) pagination page size (OPT-4). When `Some(n)` *and*
171 /// `incremental` carries the key plan, the driver builds one keyset page
172 /// (`WHERE key > cursor ORDER BY key LIMIT n`) instead of the unbounded
173 /// incremental/snapshot query. The keyset runner drives the outer loop.
174 pub page_limit: Option<usize>,
175 /// The bare source relation this export reads, when it is a `table:`
176 /// shortcut (`SELECT * FROM <ident>`) — the structured read-intent behind the
177 /// SQL string. Computed once via [`crate::sql::strip_select_star_from`], so a
178 /// non-SQL adapter (MongoDB reads a collection) uses it directly instead of
179 /// re-parsing `query`. `None` for a hand-written `query:` / any wrapped or
180 /// filtered form. SQL engines ignore it (they run `query`). See ADR-0027.
181 pub base_relation: Option<&'a str>,
182}
183
184impl<'a> ExportRequest<'a> {
185 /// A request whose `query` is already the **unwrapped base** form, so
186 /// catalog type hints resolve directly from it. Use for snapshot,
187 /// incremental and keyset runners: the driver applies any incremental /
188 /// keyset predicate internally, so the source table stays visible to the
189 /// catalog parser and `catalog_hint_query` is `None`.
190 pub fn unwrapped(
191 query: &'a str,
192 tuning: &'a SourceTuning,
193 column_overrides: &'a ColumnOverrides,
194 ) -> Self {
195 Self {
196 query,
197 catalog_hint_query: None,
198 incremental: None,
199 cursor: None,
200 tuning,
201 column_overrides,
202 page_limit: None,
203 // `query` is the unwrapped base here, so the relation (if this is a
204 // `table:` shortcut) is visible directly in it.
205 base_relation: crate::sql::strip_select_star_from(query),
206 }
207 }
208
209 /// A request whose `query` is a `SELECT … FROM (<base>) …` **wrapper** that
210 /// hides the source table (chunked / dense / time-window). `base` — the
211 /// unwrapped query catalog hints resolve from — is a required argument, so a
212 /// wrapping runner cannot silently fall back to the table-hiding wrapper and
213 /// lose PG `NUMERIC` precision (the bug the catalog-hint fix / ADR-0020
214 /// closed). Drivers that read precision from the wire (MySQL) ignore it.
215 pub fn wrapped(
216 query: &'a str,
217 base: &'a str,
218 tuning: &'a SourceTuning,
219 column_overrides: &'a ColumnOverrides,
220 ) -> Self {
221 Self {
222 query,
223 catalog_hint_query: Some(base),
224 incremental: None,
225 cursor: None,
226 tuning,
227 column_overrides,
228 page_limit: None,
229 // `query` is a table-hiding wrapper; the relation lives in `base`.
230 base_relation: crate::sql::strip_select_star_from(base),
231 }
232 }
233
234 /// Attach the incremental cursor plan (the driver builds the `WHERE cursor >
235 /// ? ORDER BY` predicate). Pass-through `Option` so mode-polymorphic callers
236 /// can forward `strategy.incremental_plan()` directly.
237 pub fn with_incremental(mut self, plan: Option<&'a IncrementalCursorPlan>) -> Self {
238 self.incremental = plan;
239 self
240 }
241
242 /// Attach the last committed cursor value the next run resumes after.
243 pub fn with_cursor(mut self, cursor: Option<&'a CursorState>) -> Self {
244 self.cursor = cursor;
245 self
246 }
247
248 /// Set the keyset (seek) page size — one bounded `… WHERE key > cursor ORDER
249 /// BY key LIMIT n` page instead of the unbounded query.
250 pub fn with_page_limit(mut self, page_limit: usize) -> Self {
251 self.page_limit = Some(page_limit);
252 self
253 }
254}
255
256pub trait Source: Send {
257 /// Execute `request.query` and stream batches into `sink`.
258 fn export(&mut self, request: &ExportRequest<'_>, sink: &mut dyn BatchSink) -> Result<()>;
259
260 fn query_scalar(&mut self, sql: &str) -> Result<Option<String>>;
261
262 /// Return `TypeMapping` for every column in `query` without fetching rows.
263 ///
264 /// Used by `rivet check --type-report` to show the full type provenance
265 /// (source native type → RivetType → Arrow type → fidelity) before export.
266 /// Implementations execute `SELECT * FROM (...) AS _q LIMIT 0` so only
267 /// server-side type metadata is transferred.
268 fn type_mappings(
269 &mut self,
270 query: &str,
271 column_overrides: &ColumnOverrides,
272 ) -> Result<Vec<TypeMapping>>;
273
274 /// Sample a monotonic source-pressure counter for the OPT-2 concurrency
275 /// governor (`pipeline::chunked::exec`).
276 ///
277 /// Higher = more pressure. The governor compares successive samples
278 /// (`cur > prev` ⇒ under pressure) — the same convention the adaptive
279 /// batch-size loop already uses. Returns `None` when the engine can't
280 /// cheaply sample a pressure proxy, in which case the governor holds
281 /// parallelism flat. Default: `None`.
282 fn sample_pressure(&mut self) -> Option<u64> {
283 None
284 }
285}
286
287pub fn create_source(config: &SourceConfig) -> Result<Box<dyn Source>> {
288 use crate::config::SourceType;
289 let url = config.resolve_url()?;
290 warn_if_tls_disabled(config);
291 match config.source_type {
292 SourceType::Postgres => Ok(Box::new(postgres::PostgresSource::connect_with_tls(
293 &url,
294 config.tls.as_ref(),
295 )?)),
296 SourceType::Mysql => Ok(Box::new(mysql::MysqlSource::connect_with_tls(
297 &url,
298 config.tls.as_ref(),
299 )?)),
300 SourceType::Mssql => Ok(Box::new(mssql::MssqlSource::connect_with_tls(
301 &url,
302 config.tls.as_ref(),
303 )?)),
304 SourceType::Mongo => Ok(Box::new(mongo::MongoSource::connect(
305 &url,
306 config.tls.as_ref(),
307 config.mongo.as_ref(),
308 )?)),
309 }
310}
311
312/// Pre-allocation per-value size guard, shared by every engine's
313/// `arrow_convert`. The sink-side `check_value_ceiling`
314/// (`pipeline::sink::mod`) scans the *already-built* Arrow batch, so an
315/// oversized cell costs the driver-decode copy **and** the Arrow-build copy
316/// before that guard fires. This check runs at the decode/`Value` stage — after
317/// the unavoidable driver copy, but *before* the value is appended into the
318/// `StringBuilder` / `BinaryBuilder` — so the Arrow allocation never grows to
319/// hold it. Only variable-length values (Utf8 / Binary) can be individually
320/// huge; fixed-width arms (ints/floats/dates) never call this.
321///
322/// `max_value_bytes` is `tuning.max_value_bytes()` (MB → bytes with the
323/// `Some(0)`/`None` ⇒ disabled semantics). The message mirrors the sink guard's
324/// `RIVET_VALUE_TOO_LARGE` so both read identically; the sink guard stays as the
325/// backstop (it also covers meta / enriched columns and is the contract test).
326pub(crate) fn value_within_ceiling(
327 column: &str,
328 len: usize,
329 max_value_bytes: Option<usize>,
330) -> Result<()> {
331 if let Some(limit) = max_value_bytes
332 && len > limit
333 {
334 anyhow::bail!(
335 "RIVET_VALUE_TOO_LARGE: column '{}' has a single value of {:.1} MB, exceeding the \
336 per-value ceiling of {} MB. One oversized cell can OOM the process regardless of \
337 batch size. Raise `tuning.max_value_mb` (or set it to 0 to disable the guard) if \
338 this value is expected.",
339 column,
340 len as f64 / (1024.0 * 1024.0),
341 limit / (1024 * 1024),
342 );
343 }
344 Ok(())
345}
346
347#[cfg(test)]
348mod value_ceiling_tests {
349 use super::value_within_ceiling;
350
351 #[test]
352 fn sec_value_ceiling_pre_alloc_over_limit_errors() {
353 let err = value_within_ceiling("payload", 2 * 1024 * 1024, Some(1024 * 1024)).unwrap_err();
354 let msg = format!("{err:#}");
355 assert!(msg.contains("RIVET_VALUE_TOO_LARGE"), "got: {msg}");
356 assert!(msg.contains("payload"), "names the column: {msg}");
357 }
358
359 #[test]
360 fn sec_value_ceiling_pre_alloc_at_or_under_limit_ok() {
361 assert!(value_within_ceiling("c", 1024 * 1024, Some(1024 * 1024)).is_ok());
362 assert!(value_within_ceiling("c", 0, Some(1024 * 1024)).is_ok());
363 }
364
365 #[test]
366 fn sec_value_ceiling_pre_alloc_disabled_never_errors() {
367 // `None` (set when tuning.max_value_mb is 0 or unset) disables the guard.
368 assert!(value_within_ceiling("c", usize::MAX, None).is_ok());
369 }
370}
371
372/// One-time nudge to enable TLS when the current config connects in plaintext.
373/// Emitted at `warn` level so operators see it even at the default log level.
374/// `create_source` is called multiple times per run (plan/preflight/exec/chunk
375/// workers), so we gate the warning behind a `Once` to fire exactly once per
376/// process rather than 3-4 times in stderr.
377pub(crate) fn warn_if_tls_disabled(config: &SourceConfig) {
378 let enforced = config.tls.as_ref().is_some_and(|t| t.mode.is_enforced());
379 if enforced {
380 return;
381 }
382 // Loopback (localhost / 127.0.0.0/8 / ::1) is the local-dev / docker case:
383 // the bytes never leave the box, so the plaintext warning is just noise on
384 // a newcomer's laptop. Resolve best-effort — if the URL can't be resolved we
385 // fall through and warn (fail-safe). The real CWE-319 signal still fires for
386 // any remote host.
387 if config.resolve_url().is_ok_and(|u| host_is_loopback(&u)) {
388 return;
389 }
390 static WARNED: std::sync::Once = std::sync::Once::new();
391 WARNED.call_once(|| {
392 log::warn!(
393 "source: TLS is not enforced — credentials and result rows cross the network in plaintext. \
394 Add `source.tls.mode: verify-full` (with `ca_file:` if your CA is private) to enable transport security."
395 );
396 });
397}
398
399/// Whether the host in a `scheme://[user[:pass]@]host[:port][/db][?…]`
400/// connection URL is a loopback address (`127.0.0.0/8`, `::1`) or the literal
401/// `localhost`.
402///
403/// Used by [`require_tls_or_loopback`] to decide TLS posture from the host:
404/// loopback is the docker / local-dev case where the bytes never leave the box,
405/// so plaintext is fine; a remote host without TLS leaks credentials and rows.
406///
407/// Fails **closed**: any URL we cannot confidently parse a loopback host out of
408/// is treated as non-loopback, so a parse gap can only ever *tighten* the gate
409/// (refuse a connection), never silently allow plaintext to an unverified host.
410pub(crate) fn host_is_loopback(url: &str) -> bool {
411 // Strip the scheme (`postgresql://`, `mysql://`, `sqlserver://`, …).
412 let after_scheme = match url.split_once("://") {
413 Some((_, rest)) => rest,
414 None => url,
415 };
416 // Authority ends at the first `/`, `?` or `#`.
417 let authority = after_scheme
418 .split(['/', '?', '#'])
419 .next()
420 .unwrap_or(after_scheme);
421 // Drop `user[:pass]@` — rsplit the last `@` so an `@` inside a password is
422 // tolerated (it belongs to the userinfo, not the host).
423 let host_port = match authority.rsplit_once('@') {
424 Some((_, hp)) => hp,
425 None => authority,
426 };
427 // A comma seedlist (`host1:p1,host2:p2` — valid for MongoDB AND multi-host
428 // PostgreSQL) is loopback ONLY if EVERY host is: reading just the first host
429 // let `127.0.0.1:5432,evil.com:5432` dial evil.com in plaintext under the
430 // gate (bug-hunt find). Empty authority ⇒ not loopback (fail closed).
431 !host_port.is_empty() && host_port.split(',').all(one_host_is_loopback)
432}
433
434/// Loopback test for a single `host[:port]` (or bracketed `[ipv6][:port]`).
435fn one_host_is_loopback(host_port: &str) -> bool {
436 // IPv6 literals are bracketed (`[::1]:5432`); the host is the bracketed span,
437 // and any `:` inside is part of the address.
438 let host = if let Some(rest) = host_port.strip_prefix('[') {
439 match rest.split_once(']') {
440 Some((h, _)) => h,
441 None => return false, // unterminated bracket — fail closed
442 }
443 } else {
444 // Bare host or IPv4: the host ends at the (single) port `:`.
445 host_port.split(':').next().unwrap_or(host_port)
446 };
447
448 if host.eq_ignore_ascii_case("localhost") {
449 return true;
450 }
451 // `IpAddr::is_loopback` covers the whole 127.0.0.0/8 block and `::1`.
452 host.parse::<std::net::IpAddr>()
453 .is_ok_and(|ip| ip.is_loopback())
454}
455
456/// Gate plaintext / trust-any-cert connections by host (CWE-319 / CWE-295).
457///
458/// When no `tls:` block is configured (`tls == None`) **and** the resolved host
459/// is not loopback, refuse the connection *before any network I/O* with a
460/// TLS-required policy error. This stops the per-engine connect helpers from
461/// silently dialing a remote database in cleartext (Postgres/MySQL `NoTls`) or
462/// trusting any server certificate (MSSQL `trust_cert`).
463///
464/// Loopback hosts (docker / local dev) keep today's behaviour — plaintext is
465/// allowed there because the bytes never leave the box. An explicit
466/// `tls: { mode: disable }` is `Some(..)`, so it is the operator's opt-in to
467/// remote plaintext and is **not** refused here.
468pub(crate) fn require_tls_or_loopback(url: &str, tls: Option<&TlsConfig>) -> Result<()> {
469 if tls.is_none() && !host_is_loopback(url) {
470 // The message must name TLS *and* that it is a policy refusal for a
471 // remote host. Emit it at `error` level (→ stderr) as well as returning
472 // it: callers like `doctor` print the `Err` to stdout in their own
473 // `[FAIL]` style and only re-raise a generic summary, so the log line is
474 // what guarantees the TLS-required reason reaches stderr. Deliberately
475 // avoids socket-error vocabulary ("could not connect", "timeout", "os
476 // error") so it is never mistaken for a connect-time failure.
477 let msg = "source: TLS required — refusing to connect to a remote (non-loopback) \
478 host without TLS; credentials and every exported row would cross the network \
479 in cleartext. Add `source.tls: { mode: verify-full }` (with `ca_file:` for a \
480 private CA) to enable transport security, or explicitly opt into remote \
481 plaintext with `source.tls: { mode: disable }` if this network path is \
482 already trusted.";
483 log::error!("{msg}");
484 anyhow::bail!("{msg}");
485 }
486 Ok(())
487}
488
489#[cfg(test)]
490mod tls_gate_tests {
491 use super::{host_is_loopback, require_tls_or_loopback};
492 use crate::config::{TlsConfig, TlsMode};
493
494 #[test]
495 fn loopback_variants_are_loopback() {
496 assert!(host_is_loopback(
497 "postgresql://rivet:rivet@127.0.0.1:5432/rivet"
498 ));
499 assert!(host_is_loopback(
500 "postgresql://rivet:rivet@localhost:5432/rivet"
501 ));
502 assert!(host_is_loopback("mysql://root@127.0.0.1:3306/db"));
503 // Whole 127.0.0.0/8 block is loopback.
504 assert!(host_is_loopback("postgresql://u:p@127.255.0.9/db"));
505 // IPv6 loopback, bracketed with and without a port.
506 assert!(host_is_loopback("postgresql://u:p@[::1]:5432/db"));
507 assert!(host_is_loopback("sqlserver://sa:pw@[::1]/master"));
508 // Case-insensitive host, no port, no db.
509 assert!(host_is_loopback("mysql://root@LOCALHOST"));
510 // An `@` inside the password must not be mistaken for the host boundary.
511 assert!(host_is_loopback("postgresql://u:p@ss@127.0.0.1:5432/db"));
512 }
513
514 #[test]
515 fn roast_seedlist_with_any_remote_host_is_not_loopback() {
516 // Multi-host / seedlist authority (`host1:p1,host2:p2`): the TLS gate must
517 // treat it as loopback ONLY if EVERY host is loopback. Reading just the
518 // first host let `127.0.0.1:5432,evil.com:5432` (a valid PostgreSQL and
519 // MongoDB seedlist) pass the gate and dial evil.com in plaintext
520 // (bug-hunt find; the shared gate reaches every engine, PG supports
521 // multi-host URLs).
522 assert!(!host_is_loopback(
523 "postgresql://u:p@127.0.0.1:5432,evil.com:5432/db"
524 ));
525 assert!(!host_is_loopback(
526 "mongodb://u:p@127.0.0.1:27017,evil.com:27017/db"
527 ));
528 // All-loopback seedlist stays loopback.
529 assert!(host_is_loopback(
530 "mongodb://u:p@127.0.0.1:27017,[::1]:27018/db"
531 ));
532 }
533
534 #[test]
535 fn remote_hosts_are_not_loopback() {
536 assert!(!host_is_loopback(
537 "postgresql://rivet:rivet@10.255.255.1:5432/rivet"
538 ));
539 assert!(!host_is_loopback(
540 "postgresql://u:p@db.example.com:5432/app"
541 ));
542 assert!(!host_is_loopback("mysql://root@192.168.1.10:3306/db"));
543 assert!(!host_is_loopback("sqlserver://sa:pw@10.0.0.5:1433/master"));
544 // Not loopback: an unbracketed IPv6-looking address won't parse here, so
545 // it fails closed (treated as remote).
546 assert!(!host_is_loopback("postgresql://u:p@::1:5432/db"));
547 }
548
549 #[test]
550 fn gate_refuses_remote_plaintext_only() {
551 let remote = "postgresql://rivet:rivet@10.255.255.1:5432/rivet";
552 let loopback = "postgresql://rivet:rivet@127.0.0.1:5432/rivet";
553 let disable = TlsConfig {
554 mode: TlsMode::Disable,
555 ..Default::default()
556 };
557 let verify = TlsConfig {
558 mode: TlsMode::VerifyFull,
559 ..Default::default()
560 };
561
562 // Remote + no tls block → refused.
563 assert!(require_tls_or_loopback(remote, None).is_err());
564 // Loopback + no tls block → allowed (docker / dev path).
565 assert!(require_tls_or_loopback(loopback, None).is_ok());
566 // Explicit `mode: disable` is the remote-plaintext opt-in → allowed.
567 assert!(require_tls_or_loopback(remote, Some(&disable)).is_ok());
568 // Enforced TLS to a remote host → allowed (the connect path uses TLS).
569 assert!(require_tls_or_loopback(remote, Some(&verify)).is_ok());
570 }
571}
572
573/// Batch positional-mapping guard: every engine's batch decoder indexes wire
574/// rows by the RESOLVE-time column order (`SELECT *` is positional at the
575/// protocol level), so a DDL slipping between chunk reads (parallel-worker
576/// idle gaps, a chunk retry on a fresh connection) would misalign values
577/// silently. The wire carries column NAMES on all three engines — verify them
578/// against the resolved mapping before decoding each batch and fail loudly
579/// instead. (The sequential paths are already server-serialized: PG holds
580/// ACCESS SHARE across the export transaction, MySQL/InnoDB reads through a
581/// snapshot with instant-DDL row versioning — both measured live; this guard
582/// closes the residual windows.)
583pub(crate) fn verify_wire_columns(expected: &[&str], wire: &[&str]) -> anyhow::Result<()> {
584 if expected.len() != wire.len()
585 || expected
586 .iter()
587 .zip(wire)
588 .any(|(e, w)| !e.eq_ignore_ascii_case(w))
589 {
590 anyhow::bail!(
591 "the source returned columns [{}] but this export resolved [{}] — the table's \
592 schema changed while the export was running (a DDL mid-export). Re-run the \
593 export: a fresh run resolves the new schema.",
594 wire.join(", "),
595 expected.join(", "),
596 );
597 }
598 Ok(())
599}
600
601#[cfg(test)]
602mod wire_guard_tests {
603 use super::verify_wire_columns;
604
605 #[test]
606 fn verify_wire_columns_catches_every_drift_shape() {
607 let ok = verify_wire_columns(&["id", "a", "b"], &["id", "a", "b"]);
608 assert!(ok.is_ok());
609 // case-insensitive (MySQL lowercases, MSSQL preserves)
610 assert!(verify_wire_columns(&["id", "A"], &["ID", "a"]).is_ok());
611 // dropped column
612 assert!(verify_wire_columns(&["id", "a", "b"], &["id", "b"]).is_err());
613 // added column
614 assert!(verify_wire_columns(&["id", "b"], &["id", "b", "c"]).is_err());
615 // same-arity rename/reorder — the shape positional decoding CANNOT see
616 assert!(verify_wire_columns(&["id", "a", "b"], &["id", "b", "a"]).is_err());
617 let err = verify_wire_columns(&["id", "a"], &["id"]).unwrap_err();
618 assert!(err.to_string().contains("schema changed"));
619 }
620}