Skip to main content

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    /// MySQL server-side `max_execution_time` timeout (ER_QUERY_TIMEOUT / 3024).
56    /// Wraps the driver's terse "maximum statement execution time exceeded" with
57    /// the actionable fix — including the WIDE-table case (a chunk that still
58    /// times out), which the field's `*_version` tables hit and the raw driver
59    /// error gave no guidance for.
60    pub fn mysql(seconds: u64) -> Self {
61        Self {
62            message: format!(
63                "mysql: statement timeout after {seconds}s (max_execution_time from \
64                 tuning.statement_timeout_s) — this query exceeded its time budget (ERROR 3024). \
65                 Split it with `mode: chunked` / `chunk_by_key` so per-chunk queries stay under \
66                 the limit; if a CHUNK still times out on a WIDE table, lower `chunk_size` or use \
67                 `chunk_size_memory_mb:` (width-aware chunking); or raise `tuning.statement_timeout_s`"
68            ),
69        }
70    }
71}
72
73impl std::fmt::Display for StatementDurationTimeout {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(&self.message)
76    }
77}
78
79impl std::error::Error for StatementDurationTimeout {}
80
81/// Summary of a source table relevant to chunked-mode planning. Source-neutral
82/// shape so plan-build can ask either Postgres or MySQL for the same answer.
83///
84/// Populated by `crate::source::postgres::introspect_pg_table_for_chunking` and
85/// `crate::source::mysql::introspect_mysql_table_for_chunking`. Both helpers
86/// rely on catalog stats (`pg_class` / `information_schema.TABLES`) so the
87/// numbers are only as fresh as the last `ANALYZE` / autoanalyse.
88///
89/// # Why this is a data-shape seam, not a trait
90///
91/// The two per-engine introspection functions have identical signatures
92/// (`fn(url, tls, qualified_table) -> Result<TableIntrospection>`) and return
93/// this shared struct. The parallel shape sometimes invites a refactor along
94/// the lines of `trait Introspector { fn introspect_table(...) }` with one
95/// impl per engine — that refactor adds ceremony without reducing duplication,
96/// because the *bodies* share nothing useful: PG queries `pg_class` /
97/// `pg_index` / `pg_attribute` / `pg_type` (PG-specific type names like
98/// `int2`/`int4`/`int8`) via the `postgres` client; MySQL queries
99/// `information_schema.TABLES` / `STATISTICS` with the InnoDB
100/// `AVG_ROW_LENGTH` overflow correction via the `mysql` client. No shared
101/// implementation logic exists to extract into trait-default methods. A
102/// trait would only rename where the engine match happens
103/// (`match config.source.source_type { … }` at the call site → factory
104/// returning `Box<dyn Introspector>`); the match doesn't disappear.
105///
106/// The seam therefore lives at the **data shape**: this struct is the
107/// shared contract, the two free functions are the adapters, the per-call
108/// dispatch is an `enum`-driven `match`. See ADR-0015 for the full
109/// rationale and the architecture-review walks that led here.
110#[derive(Debug, Clone, Default)]
111pub(crate) struct TableIntrospection {
112    /// Name of the single integer-family PK column, if present and safe to
113    /// range-chunk. `None` when the table has no PK, has a composite PK, or
114    /// the PK type is not an integer family (text, uuid, decimal, …).
115    pub single_int_pk: Option<String>,
116    /// Single-column, NOT NULL, **unique** index columns usable as a keyset
117    /// (seek) pagination key — PK first, then other UNIQUE indexes (OPT-4).
118    /// Index-backed and unique by construction, so `ORDER BY key LIMIT n` is a
119    /// bounded index range scan (never a filesort) and `WHERE key > last` never
120    /// skips a duplicate key. Restricted to types the keyset CURSOR can read
121    /// (`extract_last_cursor_value`: integer / float / string / timestamp / date /
122    /// uuid) — `decimal`/`numeric` keys are EXCLUDED here so the planner refuses
123    /// them up front rather than failing mid-run after a partial write (#dogfood).
124    /// Empty when the table has no such key.
125    pub keyset_keys: Vec<String>,
126    /// Best-effort row count: PG `reltuples`, MySQL `TABLE_ROWS`. `0` means
127    /// the table is empty or stats are unavailable.
128    pub row_estimate: i64,
129    /// Heap-size-per-row in bytes. `None` for empty / unanalysed tables.
130    /// Used to convert `chunk_size_memory_mb` into a row count.
131    pub avg_row_bytes: Option<i64>,
132    /// Names of the table's integer-family columns (PG `int2`/`int4`/`int8`,
133    /// MySQL `tinyint`…`bigint`, MSSQL `tinyint`/`smallint`/`int`/`bigint`). An
134    /// explicit `chunk_column:` that is range-`BETWEEN`-sliced MUST be one of
135    /// these: chunking derives integer min/max boundaries, so a non-integer key
136    /// (numeric/decimal/real/float/…) silently DROPS every value that falls
137    /// between two integer window boundaries. Empty when the engine does not
138    /// populate it (e.g. Mongo, which does not SQL-range-chunk).
139    pub int_columns: Vec<String>,
140}
141
142impl TableIntrospection {
143    /// The auto-selected keyset key: the first usable single-column unique
144    /// NOT NULL key (PK preferred). `None` when the table has none.
145    pub fn auto_keyset_key(&self) -> Option<&str> {
146        self.keyset_keys.first().map(String::as_str)
147    }
148
149    /// Whether `col` is a usable keyset key (single-column, unique, NOT NULL,
150    /// index-backed). Used to validate an explicit `chunk_by_key`.
151    pub fn is_usable_keyset_key(&self, col: &str) -> bool {
152        self.keyset_keys.iter().any(|k| k == col)
153    }
154
155    /// Whether `col` is a known integer-family column — the safety precondition
156    /// for range chunking (`chunk_column`), which slices via integer `BETWEEN`
157    /// windows. A non-integer explicit `chunk_column` silently loses fractional
158    /// rows, so the planner refuses it (see `chunked_strategy_from_introspection`).
159    pub fn is_integer_column(&self, col: &str) -> bool {
160        // Case-INSENSITIVE: the config may write `chunk_column: ID` while the
161        // catalog stores `id` (MySQL is case-insensitive for column names; PG
162        // folds unquoted idents to lowercase). A case-sensitive match falsely
163        // refused a valid integer key with a "not an integer-family column" error
164        // (bughunt MED). A guard should not reject on casing.
165        //
166        // ponytail: #8 narrow, documented non-fix. On PostgreSQL a table could in
167        // principle hold BOTH `id` (int) and a quoted `"ID"` (numeric); the config
168        // `chunk_column: ID` would then pass this guard (matching `id`) yet the
169        // export SQL quotes `"ID"` case-sensitively and range-chunks the NUMERIC
170        // one — the #103 loss. A precise guard needs the FULL column list + the
171        // engine's quoting rule, not just the integer names, so it is not fixed
172        // here. It is vanishingly rare (MySQL cannot hold both spellings; PG needs
173        // deliberately quoted mixed-case twins), and WITHOUT the twin a case
174        // mismatch fails loudly at query time ("column ... does not exist"), never
175        // silently. The case-insensitive match's real, common benefit (MySQL)
176        // outweighs guarding this exotic PG shape.
177        self.int_columns.iter().any(|c| c.eq_ignore_ascii_case(col))
178    }
179}
180
181/// Receives schema and batches from a source, one at a time.
182pub trait BatchSink {
183    fn on_schema(&mut self, schema: SchemaRef) -> Result<()>;
184    fn on_batch(&mut self, batch: &RecordBatch) -> Result<()>;
185    /// A source whose key type is richer than its output column can express
186    /// reports its own keyset high-water mark here, as a lossless,
187    /// engine-decodable token. The keyset/parallel runners prefer it over the
188    /// string extracted from the output column — this is how MongoDB pages by a
189    /// non-ObjectId BSON `_id` (int, string, …) whose hex/text rendering in the
190    /// `_id` column would be type-ambiguous on the round-trip. No-op default:
191    /// SQL engines carry their cursor losslessly in the column already.
192    fn set_source_cursor(&mut self, _token: String) {}
193}
194
195/// Read-only inputs for a single export call.
196///
197/// Packs the parameters that used to live as 5 positional args on
198/// `Source::export` into a named struct. `sink` is **not** part of this struct
199/// — it is `&mut` and conceptually the output channel, separate from the
200/// read-only request configuration.
201pub struct ExportRequest<'a> {
202    /// Already-materialized SQL (after `resolve_query`). The driver still wraps
203    /// it with the dialect-specific incremental predicate via
204    /// [`crate::source::query::build_incremental_query`] when `incremental` is set.
205    pub query: &'a str,
206    /// The *unwrapped* base query to resolve catalog-dependent type hints from
207    /// (PostgreSQL `NUMERIC` precision/scale, which the wire protocol omits — the
208    /// driver parses the `FROM` clause and asks `pg_catalog`). Chunked, dense and
209    /// keyset runners wrap `query` in a `SELECT … FROM (<base>) …` subquery that
210    /// hides the source table from the catalog parser, so they pass the original
211    /// base query here. `None` ⇒ resolve from `query` (full/incremental, where it
212    /// is already the unwrapped form). Drivers that read precision from the wire
213    /// (MySQL) ignore this field.
214    pub catalog_hint_query: Option<&'a str>,
215    pub incremental: Option<&'a IncrementalCursorPlan>,
216    pub cursor: Option<&'a CursorState>,
217    pub tuning: &'a SourceTuning,
218    /// Per-column type declarations from `rivet.yaml` (`exports[].columns:`).
219    /// Drivers apply them during schema building so e.g. a `NUMERIC` column
220    /// without declared precision can still be exported as `Decimal128(18,2)`
221    /// when the user has stated the type explicitly.
222    pub column_overrides: &'a ColumnOverrides,
223    /// Keyset (seek) pagination page size (OPT-4). When `Some(n)` *and*
224    /// `incremental` carries the key plan, the driver builds one keyset page
225    /// (`WHERE key > cursor ORDER BY key LIMIT n`) instead of the unbounded
226    /// incremental/snapshot query. The keyset runner drives the outer loop.
227    pub page_limit: Option<usize>,
228    /// The bare source relation this export reads, when it is a `table:`
229    /// shortcut (`SELECT * FROM <ident>`) — the structured read-intent behind the
230    /// SQL string. Computed once via [`crate::sql::strip_select_star_from`], so a
231    /// non-SQL adapter (MongoDB reads a collection) uses it directly instead of
232    /// re-parsing `query`. `None` for a hand-written `query:` / any wrapped or
233    /// filtered form. SQL engines ignore it (they run `query`). See ADR-0027.
234    pub base_relation: Option<&'a str>,
235    /// INCLUSIVE upper bound on the keyset key for a parallel keyset worker's
236    /// range: the page becomes `WHERE key > cursor AND key <= upper` (OPT
237    /// parallel-keyset). Inlined, so it never consumes the cursor bind slot.
238    /// `None` (the default) = the sequential single-worker page, unbounded above.
239    pub upper_bound: Option<&'a str>,
240}
241
242impl<'a> ExportRequest<'a> {
243    /// A request whose `query` is already the **unwrapped base** form, so
244    /// catalog type hints resolve directly from it. Use for snapshot,
245    /// incremental and keyset runners: the driver applies any incremental /
246    /// keyset predicate internally, so the source table stays visible to the
247    /// catalog parser and `catalog_hint_query` is `None`.
248    pub fn unwrapped(
249        query: &'a str,
250        tuning: &'a SourceTuning,
251        column_overrides: &'a ColumnOverrides,
252    ) -> Self {
253        Self {
254            query,
255            catalog_hint_query: None,
256            incremental: None,
257            cursor: None,
258            tuning,
259            column_overrides,
260            page_limit: None,
261            // `query` is the unwrapped base here, so the relation (if this is a
262            // `table:` shortcut) is visible directly in it.
263            base_relation: crate::sql::strip_select_star_from(query),
264            upper_bound: None,
265        }
266    }
267
268    /// A request whose `query` is a `SELECT … FROM (<base>) …` **wrapper** that
269    /// hides the source table (chunked / dense / time-window). `base` — the
270    /// unwrapped query catalog hints resolve from — is a required argument, so a
271    /// wrapping runner cannot silently fall back to the table-hiding wrapper and
272    /// lose PG `NUMERIC` precision (the bug the catalog-hint fix / ADR-0020
273    /// closed). Drivers that read precision from the wire (MySQL) ignore it.
274    pub fn wrapped(
275        query: &'a str,
276        base: &'a str,
277        tuning: &'a SourceTuning,
278        column_overrides: &'a ColumnOverrides,
279    ) -> Self {
280        Self {
281            query,
282            catalog_hint_query: Some(base),
283            incremental: None,
284            cursor: None,
285            tuning,
286            column_overrides,
287            page_limit: None,
288            // `query` is a table-hiding wrapper; the relation lives in `base`.
289            base_relation: crate::sql::strip_select_star_from(base),
290            upper_bound: None,
291        }
292    }
293
294    /// Attach the incremental cursor plan (the driver builds the `WHERE cursor >
295    /// ? ORDER BY` predicate). Pass-through `Option` so mode-polymorphic callers
296    /// can forward `strategy.incremental_plan()` directly.
297    pub fn with_incremental(mut self, plan: Option<&'a IncrementalCursorPlan>) -> Self {
298        self.incremental = plan;
299        self
300    }
301
302    /// Attach the last committed cursor value the next run resumes after.
303    pub fn with_cursor(mut self, cursor: Option<&'a CursorState>) -> Self {
304        self.cursor = cursor;
305        self
306    }
307
308    /// Set the keyset (seek) page size — one bounded `… WHERE key > cursor ORDER
309    /// BY key LIMIT n` page instead of the unbounded query.
310    pub fn with_page_limit(mut self, page_limit: usize) -> Self {
311        self.page_limit = Some(page_limit);
312        self
313    }
314
315    /// Set the INCLUSIVE upper bound on the keyset key — a parallel keyset
316    /// worker's `(cursor, upper]` range. `None` leaves the page unbounded above
317    /// (the sequential single-worker page).
318    pub fn with_upper_bound(mut self, upper: Option<&'a str>) -> Self {
319        self.upper_bound = upper;
320        self
321    }
322}
323
324pub trait Source: Send {
325    /// Execute `request.query` and stream batches into `sink`.
326    fn export(&mut self, request: &ExportRequest<'_>, sink: &mut dyn BatchSink) -> Result<()>;
327
328    fn query_scalar(&mut self, sql: &str) -> Result<Option<String>>;
329
330    /// Return `TypeMapping` for every column in `query` without fetching rows.
331    ///
332    /// Used by `rivet check --type-report` to show the full type provenance
333    /// (source native type → RivetType → Arrow type → fidelity) before export.
334    /// Implementations execute `SELECT * FROM (...) AS _q LIMIT 0` so only
335    /// server-side type metadata is transferred.
336    fn type_mappings(
337        &mut self,
338        query: &str,
339        column_overrides: &ColumnOverrides,
340    ) -> Result<Vec<TypeMapping>>;
341
342    /// Sample a monotonic source-pressure counter for the OPT-2 concurrency
343    /// governor (`pipeline::chunked::exec`).
344    ///
345    /// Higher = more pressure. The governor compares successive samples
346    /// (`cur > prev` ⇒ under pressure) — the same convention the adaptive
347    /// batch-size loop already uses. Returns `None` when the engine can't
348    /// cheaply sample a pressure proxy, in which case the governor holds
349    /// parallelism flat. Default: `None`.
350    fn sample_pressure(&mut self) -> Option<u64> {
351        None
352    }
353
354    /// A best-effort JSON snapshot of the source SERVER's forensic context —
355    /// version + the limits/session settings that shape failures (the
356    /// statement-timeout that surfaces as `ERROR 3024`, the sql_mode/timezone that
357    /// shape text rendering). Captured ONCE at run open onto the failed
358    /// `export_metrics` row (`server_context_json`), so a post-mortem can explain a
359    /// failure without re-querying a possibly-transient server. `None` when the
360    /// engine can't cheaply gather it; never fails the run.
361    fn server_context(&mut self) -> Option<String> {
362        None
363    }
364}
365
366pub fn create_source(config: &SourceConfig) -> Result<Box<dyn Source>> {
367    use crate::config::SourceType;
368    let url = config.resolve_url()?;
369    warn_if_tls_disabled(config);
370    match config.source_type {
371        SourceType::Postgres => Ok(Box::new(postgres::PostgresSource::connect_with_tls(
372            &url,
373            config.tls.as_ref(),
374        )?)),
375        SourceType::Mysql => Ok(Box::new(mysql::MysqlSource::connect_with_tls(
376            &url,
377            config.tls.as_ref(),
378        )?)),
379        SourceType::Mssql => Ok(Box::new(mssql::MssqlSource::connect_with_tls(
380            &url,
381            config.tls.as_ref(),
382        )?)),
383        SourceType::Mongo => Ok(Box::new(mongo::MongoSource::connect(
384            &url,
385            config.tls.as_ref(),
386            config.mongo.as_ref(),
387        )?)),
388    }
389}
390
391/// Pre-allocation per-value size guard, shared by every engine's
392/// `arrow_convert`. The sink-side `check_value_ceiling`
393/// (`pipeline::sink::mod`) scans the *already-built* Arrow batch, so an
394/// oversized cell costs the driver-decode copy **and** the Arrow-build copy
395/// before that guard fires. This check runs at the decode/`Value` stage — after
396/// the unavoidable driver copy, but *before* the value is appended into the
397/// `StringBuilder` / `BinaryBuilder` — so the Arrow allocation never grows to
398/// hold it. Only variable-length values (Utf8 / Binary) can be individually
399/// huge; fixed-width arms (ints/floats/dates) never call this.
400///
401/// `max_value_bytes` is `tuning.max_value_bytes()` (MB → bytes with the
402/// `Some(0)`/`None` ⇒ disabled semantics). The message mirrors the sink guard's
403/// `RIVET_VALUE_TOO_LARGE` so both read identically; the sink guard stays as the
404/// backstop (it also covers meta / enriched columns and is the contract test).
405pub(crate) fn value_within_ceiling(
406    column: &str,
407    len: usize,
408    max_value_bytes: Option<usize>,
409) -> Result<()> {
410    if let Some(limit) = max_value_bytes
411        && len > limit
412    {
413        anyhow::bail!(
414            "RIVET_VALUE_TOO_LARGE: column '{}' has a single value of {:.1} MB, exceeding the \
415             per-value ceiling of {} MB. One oversized cell can OOM the process regardless of \
416             batch size. Raise `tuning.max_value_mb` (or set it to 0 to disable the guard) if \
417             this value is expected.",
418            column,
419            len as f64 / (1024.0 * 1024.0),
420            limit / (1024 * 1024),
421        );
422    }
423    Ok(())
424}
425
426#[cfg(test)]
427mod value_ceiling_tests {
428    use super::value_within_ceiling;
429
430    #[test]
431    fn sec_value_ceiling_pre_alloc_over_limit_errors() {
432        let err = value_within_ceiling("payload", 2 * 1024 * 1024, Some(1024 * 1024)).unwrap_err();
433        let msg = format!("{err:#}");
434        assert!(msg.contains("RIVET_VALUE_TOO_LARGE"), "got: {msg}");
435        assert!(msg.contains("payload"), "names the column: {msg}");
436    }
437
438    #[test]
439    fn sec_value_ceiling_pre_alloc_at_or_under_limit_ok() {
440        assert!(value_within_ceiling("c", 1024 * 1024, Some(1024 * 1024)).is_ok());
441        assert!(value_within_ceiling("c", 0, Some(1024 * 1024)).is_ok());
442    }
443
444    #[test]
445    fn sec_value_ceiling_pre_alloc_disabled_never_errors() {
446        // `None` (set when tuning.max_value_mb is 0 or unset) disables the guard.
447        assert!(value_within_ceiling("c", usize::MAX, None).is_ok());
448    }
449}
450
451/// One-time nudge to enable TLS when the current config connects in plaintext.
452/// Emitted at `warn` level so operators see it even at the default log level.
453/// `create_source` is called multiple times per run (plan/preflight/exec/chunk
454/// workers), so we gate the warning behind a `Once` to fire exactly once per
455/// process rather than 3-4 times in stderr.
456pub(crate) fn warn_if_tls_disabled(config: &SourceConfig) {
457    let enforced = config.tls.as_ref().is_some_and(|t| t.mode.is_enforced());
458    if enforced {
459        return;
460    }
461    // Loopback (localhost / 127.0.0.0/8 / ::1) is the local-dev / docker case:
462    // the bytes never leave the box, so the plaintext warning is just noise on
463    // a newcomer's laptop. Resolve best-effort — if the URL can't be resolved we
464    // fall through and warn (fail-safe). The real CWE-319 signal still fires for
465    // any remote host.
466    if config.resolve_url().is_ok_and(|u| host_is_loopback(&u)) {
467        return;
468    }
469    static WARNED: std::sync::Once = std::sync::Once::new();
470    WARNED.call_once(|| {
471        log::warn!(
472            "source: TLS is not enforced — credentials and result rows cross the network in plaintext. \
473             Add `source.tls.mode: verify-full` (with `ca_file:` if your CA is private) to enable transport security."
474        );
475    });
476}
477
478/// Whether the host in a `scheme://[user[:pass]@]host[:port][/db][?…]`
479/// connection URL is a loopback address (`127.0.0.0/8`, `::1`) or the literal
480/// `localhost`.
481///
482/// Used by [`require_tls_or_loopback`] to decide TLS posture from the host:
483/// loopback is the docker / local-dev case where the bytes never leave the box,
484/// so plaintext is fine; a remote host without TLS leaks credentials and rows.
485///
486/// Fails **closed**: any URL we cannot confidently parse a loopback host out of
487/// is treated as non-loopback, so a parse gap can only ever *tighten* the gate
488/// (refuse a connection), never silently allow plaintext to an unverified host.
489/// The `host[:port][,host:port…]` span of a URL — scheme stripped, path/query
490/// dropped, `user[:pass]@` userinfo removed (rsplit the last `@` so an `@` in a
491/// password stays with the userinfo). Empty when the URL carries no authority.
492pub(crate) fn host_port_span(url: &str) -> &str {
493    let after_scheme = match url.split_once("://") {
494        Some((_, rest)) => rest,
495        None => url,
496    };
497    let authority = after_scheme
498        .split(['/', '?', '#'])
499        .next()
500        .unwrap_or(after_scheme);
501    match authority.rsplit_once('@') {
502        Some((_, hp)) => hp,
503        None => authority,
504    }
505}
506
507pub(crate) fn host_is_loopback(url: &str) -> bool {
508    let host_port = host_port_span(url);
509    // A comma seedlist (`host1:p1,host2:p2` — valid for MongoDB AND multi-host
510    // PostgreSQL) is loopback ONLY if EVERY host is: reading just the first host
511    // let `127.0.0.1:5432,evil.com:5432` dial evil.com in plaintext under the
512    // gate (bug-hunt find). Empty authority ⇒ not loopback (fail closed).
513    !host_port.is_empty() && host_port.split(',').all(one_host_is_loopback)
514}
515
516/// Loopback test for a single `host[:port]` (or bracketed `[ipv6][:port]`).
517fn one_host_is_loopback(host_port: &str) -> bool {
518    // IPv6 literals are bracketed (`[::1]:5432`); the host is the bracketed span,
519    // and any `:` inside is part of the address.
520    let host = if let Some(rest) = host_port.strip_prefix('[') {
521        match rest.split_once(']') {
522            Some((h, _)) => h,
523            None => return false, // unterminated bracket — fail closed
524        }
525    } else {
526        // Bare host or IPv4: the host ends at the (single) port `:`.
527        host_port.split(':').next().unwrap_or(host_port)
528    };
529
530    if host.eq_ignore_ascii_case("localhost") {
531        return true;
532    }
533    // `IpAddr::is_loopback` covers the whole 127.0.0.0/8 block and `::1`.
534    host.parse::<std::net::IpAddr>()
535        .is_ok_and(|ip| ip.is_loopback())
536}
537
538/// Refuse a URL that carries no host authority (`mysql://`, `postgres:///db`)
539/// with a clear parse error, BEFORE any engine-specific setup hint can blanket
540/// it (dogfood LOW: `rivet cdc --source mysql://` reported a binlog-grants
541/// problem for a host that doesn't exist). No URL echo — the userinfo may hold
542/// credentials — and no `user:pass@` pattern in the message (the redactor
543/// mangles it).
544pub(crate) fn require_url_has_host(url: &str) -> Result<()> {
545    if host_port_span(url).is_empty() {
546        anyhow::bail!(
547            "source: invalid URL — no host found. Expected a URL of the form \
548             scheme://host:port/database."
549        );
550    }
551    Ok(())
552}
553
554/// Gate plaintext / trust-any-cert connections by host (CWE-319 / CWE-295).
555///
556/// When no `tls:` block is configured (`tls == None`) **and** the resolved host
557/// is not loopback, refuse the connection *before any network I/O* with a
558/// TLS-required policy error. This stops the per-engine connect helpers from
559/// silently dialing a remote database in cleartext (Postgres/MySQL `NoTls`) or
560/// trusting any server certificate (MSSQL `trust_cert`).
561///
562/// Loopback hosts (docker / local dev) keep today's behaviour — plaintext is
563/// allowed there because the bytes never leave the box. An explicit
564/// `tls: { mode: disable }` is `Some(..)`, so it is the operator's opt-in to
565/// remote plaintext and is **not** refused here.
566pub(crate) fn require_tls_or_loopback(url: &str, tls: Option<&TlsConfig>) -> Result<()> {
567    // An explicit `tls: {..}` (including `mode: disable`) is the operator's
568    // opt-in and is never refused here — including for a host-LESS URL, which a
569    // driver resolves as a LOCAL unix socket (`postgres:///db?host=/var/run/
570    // postgresql`). The host-presence check must therefore live INSIDE the
571    // no-tls branch: hoisting it above (da7abbf) rejected a valid socket URL that
572    // worked on main whenever `tls: { mode: disable }` was set (#16 bughunt).
573    if tls.is_none() {
574        // A URL with NO host at all (`mysql://`, `postgres:///db`) is not a
575        // "remote host" — it is malformed. Prescribing a TLS block there sends the
576        // operator chasing a security setting for a host that doesn't exist.
577        require_url_has_host(url)?;
578    }
579    if tls.is_none() && !host_is_loopback(url) {
580        // The message must name TLS *and* that it is a policy refusal for a
581        // remote host. Emit it at `error` level (→ stderr) as well as returning
582        // it: callers like `doctor` print the `Err` to stdout in their own
583        // `[FAIL]` style and only re-raise a generic summary, so the log line is
584        // what guarantees the TLS-required reason reaches stderr. Deliberately
585        // avoids socket-error vocabulary ("could not connect", "timeout", "os
586        // error") so it is never mistaken for a connect-time failure.
587        let msg = "source: TLS required — refusing to connect to a remote (non-loopback) \
588             host without TLS; credentials and every exported row would cross the network \
589             in cleartext. Add `source.tls: { mode: verify-full }` (with `ca_file:` for a \
590             private CA) to enable transport security, or explicitly opt into remote \
591             plaintext with `source.tls: { mode: disable }` if this network path is \
592             already trusted.";
593        log::error!("{msg}");
594        anyhow::bail!("{msg}");
595    }
596    Ok(())
597}
598
599#[cfg(test)]
600mod tls_gate_tests {
601    use super::{host_is_loopback, host_port_span, require_tls_or_loopback};
602    use crate::config::{TlsConfig, TlsMode};
603
604    #[test]
605    fn loopback_variants_are_loopback() {
606        assert!(host_is_loopback(
607            "postgresql://rivet:rivet@127.0.0.1:5432/rivet"
608        ));
609        assert!(host_is_loopback(
610            "postgresql://rivet:rivet@localhost:5432/rivet"
611        ));
612        assert!(host_is_loopback("mysql://root@127.0.0.1:3306/db"));
613        // Whole 127.0.0.0/8 block is loopback.
614        assert!(host_is_loopback("postgresql://u:p@127.255.0.9/db"));
615        // IPv6 loopback, bracketed with and without a port.
616        assert!(host_is_loopback("postgresql://u:p@[::1]:5432/db"));
617        assert!(host_is_loopback("sqlserver://sa:pw@[::1]/master"));
618        // Case-insensitive host, no port, no db.
619        assert!(host_is_loopback("mysql://root@LOCALHOST"));
620        // An `@` inside the password must not be mistaken for the host boundary.
621        assert!(host_is_loopback("postgresql://u:p@ss@127.0.0.1:5432/db"));
622    }
623
624    #[test]
625    fn roast_seedlist_with_any_remote_host_is_not_loopback() {
626        // Multi-host / seedlist authority (`host1:p1,host2:p2`): the TLS gate must
627        // treat it as loopback ONLY if EVERY host is loopback. Reading just the
628        // first host let `127.0.0.1:5432,evil.com:5432` (a valid PostgreSQL and
629        // MongoDB seedlist) pass the gate and dial evil.com in plaintext
630        // (bug-hunt find; the shared gate reaches every engine, PG supports
631        // multi-host URLs).
632        assert!(!host_is_loopback(
633            "postgresql://u:p@127.0.0.1:5432,evil.com:5432/db"
634        ));
635        assert!(!host_is_loopback(
636            "mongodb://u:p@127.0.0.1:27017,evil.com:27017/db"
637        ));
638        // All-loopback seedlist stays loopback.
639        assert!(host_is_loopback(
640            "mongodb://u:p@127.0.0.1:27017,[::1]:27018/db"
641        ));
642    }
643
644    #[test]
645    fn remote_hosts_are_not_loopback() {
646        assert!(!host_is_loopback(
647            "postgresql://rivet:rivet@10.255.255.1:5432/rivet"
648        ));
649        assert!(!host_is_loopback(
650            "postgresql://u:p@db.example.com:5432/app"
651        ));
652        assert!(!host_is_loopback("mysql://root@192.168.1.10:3306/db"));
653        assert!(!host_is_loopback("sqlserver://sa:pw@10.0.0.5:1433/master"));
654        // Not loopback: an unbracketed IPv6-looking address won't parse here, so
655        // it fails closed (treated as remote).
656        assert!(!host_is_loopback("postgresql://u:p@::1:5432/db"));
657    }
658
659    #[test]
660    fn gate_refuses_remote_plaintext_only() {
661        let remote = "postgresql://rivet:rivet@10.255.255.1:5432/rivet";
662        let loopback = "postgresql://rivet:rivet@127.0.0.1:5432/rivet";
663        let disable = TlsConfig {
664            mode: TlsMode::Disable,
665            ..Default::default()
666        };
667        let verify = TlsConfig {
668            mode: TlsMode::VerifyFull,
669            ..Default::default()
670        };
671
672        // Remote + no tls block → refused.
673        assert!(require_tls_or_loopback(remote, None).is_err());
674        // Loopback + no tls block → allowed (docker / dev path).
675        assert!(require_tls_or_loopback(loopback, None).is_ok());
676        // Explicit `mode: disable` is the remote-plaintext opt-in → allowed.
677        assert!(require_tls_or_loopback(remote, Some(&disable)).is_ok());
678        // Enforced TLS to a remote host → allowed (the connect path uses TLS).
679        assert!(require_tls_or_loopback(remote, Some(&verify)).is_ok());
680    }
681
682    #[test]
683    fn host_port_span_extracts_the_authority() {
684        assert_eq!(host_port_span("mysql://u:p@host:3306/db"), "host:3306");
685        assert_eq!(
686            host_port_span("postgres://127.0.0.1:5432/db"),
687            "127.0.0.1:5432"
688        );
689        // No authority at all.
690        assert_eq!(host_port_span("mysql://"), "");
691        assert_eq!(host_port_span("postgres:///db"), "");
692    }
693
694    #[test]
695    fn hostless_url_is_a_parse_error_not_a_tls_refusal() {
696        // #dogfood LOW: `mysql://` has NO host, yet the gate reported "remote
697        // (non-loopback) host, TLS required" and prescribed a TLS block for a
698        // host that doesn't exist. It must be a clear parse error instead.
699        for u in ["mysql://", "postgres:///db", "sqlserver://"] {
700            let err = require_tls_or_loopback(u, None)
701                .expect_err("a host-less URL must error, not connect");
702            let msg = err.to_string();
703            assert!(
704                msg.contains("no host found"),
705                "host-less URL must be a parse error: {msg}"
706            );
707            assert!(
708                !msg.contains("TLS required"),
709                "host-less URL must NOT prescribe a TLS block: {msg}"
710            );
711        }
712    }
713
714    #[test]
715    fn hostless_socket_url_with_explicit_tls_disable_is_allowed() {
716        // #16 bughunt: a unix-socket URL has no authority host (the socket path
717        // lives in `?host=`), so the host-presence check rejected it. But an
718        // explicit `tls: { mode: disable }` is the operator's opt-in for a LOCAL
719        // connection — it must connect, as it did on main (where the gate was
720        // skipped whenever tls was Some). The check now lives inside the no-tls
721        // branch, so tls=Some(disable) is never refused.
722        let disable = TlsConfig {
723            mode: TlsMode::Disable,
724            ..Default::default()
725        };
726        for u in [
727            "postgres:///rivet?host=/var/run/postgresql",
728            "mysql://",
729            "postgres:///db",
730        ] {
731            require_tls_or_loopback(u, Some(&disable))
732                .expect("an explicit tls: { mode: disable } must not be refused for a socket URL");
733        }
734    }
735}
736
737/// Batch positional-mapping guard: every engine's batch decoder indexes wire
738/// rows by the RESOLVE-time column order (`SELECT *` is positional at the
739/// protocol level), so a DDL slipping between chunk reads (parallel-worker
740/// idle gaps, a chunk retry on a fresh connection) would misalign values
741/// silently. The wire carries column NAMES on all three engines — verify them
742/// against the resolved mapping before decoding each batch and fail loudly
743/// instead. (The sequential paths are already server-serialized: PG holds
744/// ACCESS SHARE across the export transaction, MySQL/InnoDB reads through a
745/// snapshot with instant-DDL row versioning — both measured live; this guard
746/// closes the residual windows.)
747pub(crate) fn verify_wire_columns(expected: &[&str], wire: &[&str]) -> anyhow::Result<()> {
748    if expected.len() != wire.len()
749        || expected
750            .iter()
751            .zip(wire)
752            .any(|(e, w)| !e.eq_ignore_ascii_case(w))
753    {
754        anyhow::bail!(
755            "the source returned columns [{}] but this export resolved [{}] — the table's \
756             schema changed while the export was running (a DDL mid-export). Re-run the \
757             export: a fresh run resolves the new schema.",
758            wire.join(", "),
759            expected.join(", "),
760        );
761    }
762    Ok(())
763}
764
765#[cfg(test)]
766mod wire_guard_tests {
767    use super::verify_wire_columns;
768
769    #[test]
770    fn verify_wire_columns_catches_every_drift_shape() {
771        let ok = verify_wire_columns(&["id", "a", "b"], &["id", "a", "b"]);
772        assert!(ok.is_ok());
773        // case-insensitive (MySQL lowercases, MSSQL preserves)
774        assert!(verify_wire_columns(&["id", "A"], &["ID", "a"]).is_ok());
775        // dropped column
776        assert!(verify_wire_columns(&["id", "a", "b"], &["id", "b"]).is_err());
777        // added column
778        assert!(verify_wire_columns(&["id", "b"], &["id", "b", "c"]).is_err());
779        // same-arity rename/reorder — the shape positional decoding CANNOT see
780        assert!(verify_wire_columns(&["id", "a", "b"], &["id", "b", "a"]).is_err());
781        let err = verify_wire_columns(&["id", "a"], &["id"]).unwrap_err();
782        assert!(err.to_string().contains("schema changed"));
783    }
784}
785
786#[cfg(test)]
787mod introspection_tests {
788    use super::TableIntrospection;
789
790    #[test]
791    fn is_integer_column_is_case_insensitive() {
792        // #bughunt MED: a case-sensitive match falsely refused `chunk_column: ID`
793        // when the catalog stores `id` — a guard must not reject on casing.
794        let intro = TableIntrospection {
795            int_columns: vec!["id".into(), "user_id".into()],
796            ..Default::default()
797        };
798        assert!(intro.is_integer_column("id"));
799        assert!(intro.is_integer_column("ID"));
800        assert!(intro.is_integer_column("User_Id"));
801        assert!(!intro.is_integer_column("name"));
802    }
803}