Skip to main content

rivet/config/
mod.rs

1pub mod cursor;
2mod destination;
3mod export;
4mod format;
5mod lints;
6mod notifications;
7pub mod resolve;
8pub mod schema;
9mod source;
10
11pub use cursor::IncrementalCursorMode;
12pub use destination::*;
13pub use export::*;
14pub use format::*;
15pub use notifications::*;
16#[allow(unused_imports)]
17pub(crate) use resolve::resolve_env_vars;
18pub use resolve::{parse_file_size, resolve_vars};
19pub use schema::generate_config_schema_pretty;
20pub use source::*;
21
22use schemars::JsonSchema;
23use serde::Deserialize;
24
25/// Top-level Rivet configuration root.
26///
27/// Operators write this struct as YAML (typically `rivet.yaml`).  The
28/// `JsonSchema` derive is the source of truth for the `schemas/rivet.schema.json`
29/// artifact and the `rivet schema config` command's output (v0.7.3 P0).
30#[derive(Debug, Deserialize, JsonSchema, Clone)]
31#[serde(deny_unknown_fields)]
32pub struct Config {
33    pub source: SourceConfig,
34    pub exports: Vec<ExportConfig>,
35    #[serde(default)]
36    pub notifications: Option<NotificationsConfig>,
37    #[serde(default)]
38    pub parallel_exports: bool,
39    #[serde(default)]
40    pub parallel_export_processes: bool,
41    /// The warehouse **load** target — consumed by `rivet load` (and `rivet load
42    /// --cdc`), so ONE config drives both the export and the downstream load. The
43    /// extraction commands (`rivet check` / `run` / `apply`) accept and ignore it:
44    /// it shapes the load, not the extract.
45    // Kept as a raw `serde_json::Value` so the extraction path neither depends on
46    // nor validates the load schema — the load module (`crate::load::plan`) parses
47    // it into a typed `LoadSection`.
48    #[serde(default)]
49    pub load: Option<serde_json::Value>,
50}
51
52impl Config {
53    pub fn load(path: &str) -> crate::error::Result<Self> {
54        Self::load_with_params(path, None)
55    }
56
57    pub fn load_with_params(
58        path: &str,
59        params: Option<&std::collections::HashMap<String, String>>,
60    ) -> crate::error::Result<Self> {
61        // F11 (0.7.5 audit): raw `std::io::Error` lost the path on
62        // not-found.  Wrap with the file path + a hint so the operator
63        // can see *which* config the tool could not open.
64        let contents = std::fs::read_to_string(path).map_err(|e| {
65            if e.kind() == std::io::ErrorKind::NotFound {
66                anyhow::anyhow!(
67                    "config file '{}' not found.\n  Hint: check the path, or run `rivet init` to generate one.",
68                    path
69                )
70            } else {
71                anyhow::anyhow!("cannot read config file '{}': {}", path, e)
72            }
73        })?;
74        // Warn about typo'd `--param` keys once per CLI invocation, using the
75        // un-resolved YAML as the haystack so the placeholders are still there.
76        // We pass the raw `contents` (not `resolved`) on purpose: after
77        // resolution the placeholders are gone, and every key would look unused.
78        resolve::warn_unused_params(&contents, params);
79        let resolved = resolve_vars(&contents, params)?;
80        // F12 (0.7.5 audit): YAML parse errors did not name the config
81        // file.  When loading from disk we know the path — thread it
82        // into the parse error.
83        // `.context` (not `anyhow!("…{:#}", e)`, which stringifies into a fresh
84        // error) so a typed `CodedError` raised by validation survives the chain —
85        // `error::error_code` downcasts it for the `[CODE]` prefix. `{e:#}` in
86        // `main` still renders the full "config file '…': <message>" chain.
87        Self::from_yaml(&resolved).map_err(|e| e.context(format!("config file '{}'", path)))
88    }
89
90    pub fn from_yaml(yaml: &str) -> crate::error::Result<Self> {
91        // Intercept the unquoted-`{partition}` footgun before the raw-YAML
92        // pre-scans below (they `from_str::<Value>` too and would re-emit the
93        // same cryptic libyaml message). A document that does not even parse as
94        // a `Value` is malformed; if the failure looks like a flow-mapping
95        // scanner error *and* the source carries an unquoted brace value, point
96        // straight at the quoting fix. On a valid config this `Value` parse
97        // succeeds and the block is skipped, so the success path is unchanged.
98        if let Err(e) = serde_yaml_ng::from_str::<serde_yaml_ng::Value>(yaml) {
99            let m = e.to_string();
100            if let Some(hint) = Self::unquoted_template_brace_hint(yaml, &m) {
101                return Err(anyhow::anyhow!("{e}\n  {hint}"));
102            }
103            // A TAB in indentation is the most common beginner YAML mistake; it
104            // trips libyaml before serde ever sees a field, so it must be caught
105            // here in the raw scan, not in `enhance_parse_error`.
106            if let Some(hint) = Self::tab_indent_hint(yaml, &m) {
107                return Err(anyhow::anyhow!("{e}\n  {hint}"));
108            }
109        }
110        Self::check_misplaced_tuning_fields(yaml)?;
111        Self::check_csv_compression(yaml)?;
112        Self::check_tls_mode_downgrade(yaml)?;
113        let config: Config = serde_yaml_ng::from_str(yaml).map_err(|e| {
114            // A well-formed flow map (`prefix: {partition}`) parses as a YAML
115            // value but serde then rejects it with `invalid type: map, expected
116            // a string`. That is the same unquoted-brace footgun, surfacing one
117            // layer later than the scanner errors caught above — so try the same
118            // hint here before falling back to the generic field-typo enhancer.
119            if let Some(hint) = Self::unquoted_template_brace_hint(yaml, &e.to_string()) {
120                anyhow::anyhow!("{e}\n  {hint}")
121            } else {
122                lints::enhance_parse_error(e)
123            }
124        })?;
125        config.validate()?;
126        Ok(config)
127    }
128
129    /// Detect the unquoted-`{partition}` (or `{date}`, …) template footgun and
130    /// return an actionable quoting hint, or `None` when the error is unrelated.
131    ///
132    /// A YAML value that *starts* a flow mapping — `prefix: {partition}` — is
133    /// the common copy-paste mistake: `{partition}` is the required token for
134    /// `partition_by`, but unquoted it parses as a YAML map (or, with trailing
135    /// text, trips the libyaml scanner). serde then emits a cryptic
136    /// `did not find expected ',' or '}'` / `while parsing a flow mapping` /
137    /// `invalid type: map, expected a string` with no hint that a pair of
138    /// quotes is the fix.
139    ///
140    /// Two guards keep this from firing on unrelated parse errors: the error
141    /// message must carry one of the flow-mapping symptoms, AND the raw source
142    /// must actually contain an unquoted `{…}` value. Both must hold, so a
143    /// valid config (every brace value quoted) never sees the hint, and a
144    /// genuine map-typed field error elsewhere is left alone.
145    /// A YAML document indented with a TAB trips libyaml with `found character
146    /// that cannot start any token`. Point straight at the fix (spaces, not
147    /// tabs) — but only when the cited line really begins with a tab, so an
148    /// unrelated scanner error is left with its original message.
149    fn tab_indent_hint(yaml: &str, err_msg: &str) -> Option<String> {
150        if !err_msg.contains("tab character") {
151            return None;
152        }
153        // serde_yaml_ng reports `found a tab character … at line N column C …`;
154        // the FIRST `line N` is where the offending tab is.
155        let line_no: usize = err_msg
156            .split_once("line ")
157            .and_then(|(_, rest)| rest.split([' ', ',']).next())
158            .and_then(|n| n.parse().ok())?;
159        let line = yaml.lines().nth(line_no.checked_sub(1)?)?;
160        let leading = &line[..line.len() - line.trim_start().len()];
161        leading.contains('\t').then(|| {
162            format!(
163                "line {line_no} is indented with a TAB — YAML requires spaces. Replace the tab(s) with spaces."
164            )
165        })
166    }
167
168    fn unquoted_template_brace_hint(yaml: &str, err_msg: &str) -> Option<String> {
169        const FLOW_SYMPTOMS: &[&str] = &[
170            "did not find expected ',' or '}'",
171            "while parsing a flow mapping",
172            // A bare `key: {token}` parses as a map, then serde rejects the
173            // map where it wanted a scalar — same root cause, later layer.
174            "invalid type: map, expected a string",
175            // `key: {token}/more` runs the flow map into block context.
176            "did not find expected key",
177        ];
178        if !FLOW_SYMPTOMS.iter().any(|s| err_msg.contains(s)) {
179            return None;
180        }
181        if !yaml.lines().any(line_has_unquoted_brace_value) {
182            return None;
183        }
184        Some(
185            "a YAML value containing { } (such as {partition} or {date}) must be quoted, \
186             e.g. prefix: \"exports/{partition}/\""
187                .to_string(),
188        )
189    }
190
191    /// Reject `format: csv` paired with an explicitly-requested compression
192    /// codec (Finding #10). The CSV writer has no compression encoder, so the
193    /// codec is silently dropped on write while the run manifest still records
194    /// it — a degraded, dishonest no-op. We reject loudly at config-validate
195    /// time so `rivet check` / `rivet doctor` catch it before any run.
196    ///
197    /// This is a raw-YAML scan (like [`Self::check_misplaced_tuning_fields`])
198    /// rather than a `validate_export` check on purpose: `ExportConfig.
199    /// compression` is `#[serde(default)]` and `CompressionType::default()` is
200    /// `Zstd`, so a parsed export cannot distinguish "user asked for zstd" from
201    /// "user omitted the field". Only a user who *wrote* `compression:`/
202    /// `compression_profile:` is asking for something the CSV writer cannot
203    /// honour; the bare-`format: csv` default writes uncompressed and is fine.
204    fn check_csv_compression(yaml: &str) -> crate::error::Result<()> {
205        let root: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml)?;
206        let Some(exports) = root.get("exports").and_then(|e| e.as_sequence()) else {
207            return Ok(());
208        };
209        for export in exports {
210            if export.get("format").and_then(|f| f.as_str()) != Some("csv") {
211                continue;
212            }
213            let name = export
214                .get("name")
215                .and_then(|n| n.as_str())
216                .unwrap_or("<unnamed>");
217
218            // Explicit `compression:` codec that the CSV writer cannot apply.
219            // An unrecognised label is left for serde to reject during the real
220            // parse; we only act on a codec we understand and that CSV cannot
221            // honour (everything but `none`).
222            if let Some(codec) = export.get("compression").and_then(|c| c.as_str())
223                && let Some(ct) = CompressionType::from_label(codec)
224                && !format::compression_supported(FormatType::Csv, ct)
225            {
226                anyhow::bail!(
227                    "export '{}': CSV output does not support compression: {}. \
228                     CSV has no compression encoder, so the codec would be silently dropped \
229                     while the manifest records it.\n  \
230                     Hint: use `format: parquet` for compression, or set `compression: none`.",
231                    name,
232                    codec,
233                );
234            }
235
236            // A `compression_profile:` other than `none` resolves to a real
237            // codec too (fast→snappy, balanced/compact→zstd) — same no-op.
238            if let Some(profile) = export.get("compression_profile").and_then(|c| c.as_str())
239                && profile != CompressionProfile::None.label()
240            {
241                anyhow::bail!(
242                    "export '{}': CSV output does not support compression_profile: {} \
243                     (it resolves to a compression codec the CSV writer cannot apply).\n  \
244                     Hint: use `format: parquet` for compression, or set `compression_profile: none`.",
245                    name,
246                    profile,
247                );
248            }
249        }
250        Ok(())
251    }
252
253    /// V13: reject a `source.tls` block that pairs an *explicitly chosen*
254    /// enforced `mode:` with a verification-disabling danger knob
255    /// (`accept_invalid_certs` / `accept_invalid_hostnames`). `mode: verify-full`
256    /// promises chain + hostname verification, but the knob silently downgrades
257    /// it to "trust anything" — a MITM exposure that contradicts the stated
258    /// intent (see `src/source/tls.rs::build_native_tls`, whose comment claims
259    /// this is warned about at config-time but is not).
260    ///
261    /// Like [`Self::check_csv_compression`], this is a raw-YAML scan rather than
262    /// a `validate` check on purpose: `TlsMode` is `#[serde(default)]` and the
263    /// default is `VerifyFull`, so a parsed config cannot distinguish "user
264    /// wrote `mode: verify-full`" (a contradiction to flag) from "user omitted
265    /// `mode:`" (the common dev-container case `tls: { accept_invalid_certs:
266    /// true }` against a loopback self-signed cert — which must keep working).
267    /// Only an *explicit* enforced `mode:` next to a danger knob is the footgun.
268    fn check_tls_mode_downgrade(yaml: &str) -> crate::error::Result<()> {
269        let root: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml)?;
270        let Some(tls) = root.get("source").and_then(|s| s.get("tls")) else {
271            return Ok(());
272        };
273
274        // Only an explicitly written `mode:` is a deliberate, contradicted
275        // choice; an omitted mode is the dev-container default path.
276        let Some(mode) = tls.get("mode").and_then(|m| m.as_str()) else {
277            return Ok(());
278        };
279        // `disable` carries no verification promise to contradict; the danger
280        // knobs are a no-op there. Flag only the enforced modes.
281        if mode == "disable" {
282            return Ok(());
283        }
284
285        let knob = if tls
286            .get("accept_invalid_certs")
287            .and_then(|v| v.as_bool())
288            .unwrap_or(false)
289        {
290            Some("accept_invalid_certs")
291        } else if tls
292            .get("accept_invalid_hostnames")
293            .and_then(|v| v.as_bool())
294            .unwrap_or(false)
295        {
296            Some("accept_invalid_hostnames")
297        } else {
298            None
299        };
300
301        if let Some(knob) = knob {
302            anyhow::bail!(
303                "source.tls: {} disables certificate verification, silently downgrading the \
304                 chosen `mode: {}` to trust-anything (MITM exposure — credentials and rows \
305                 readable/forgeable on the wire).\n  \
306                 Hint: drop the danger knob and trust a private CA with `tls.ca_file: <pem>`; \
307                 only use a danger knob for a loopback self-signed dev container, and then omit \
308                 the explicit `mode:` so the contradiction is gone.",
309                knob,
310                mode,
311            );
312        }
313        Ok(())
314    }
315
316    /// Detect tuning-related fields placed directly under `source:` or an
317    /// `exports[]` entry instead of inside the `tuning:` sub-key. Without this
318    /// check serde silently ignores unknown keys and the user gets unexpected
319    /// defaults (e.g. batch_size=10 000 instead of the intended 1 000).
320    fn check_misplaced_tuning_fields(yaml: &str) -> crate::error::Result<()> {
321        const TUNING_FIELDS: &[&str] = &[
322            "batch_size",
323            "batch_size_memory_mb",
324            "throttle_ms",
325            "statement_timeout_s",
326            "max_retries",
327            "retry_backoff_ms",
328            "lock_timeout_s",
329            "memory_threshold_mb",
330            "profile",
331        ];
332
333        let root: serde_yaml_ng::Value = serde_yaml_ng::from_str(yaml)?;
334
335        if let Some(source) = root.get("source") {
336            let misplaced: Vec<&str> = TUNING_FIELDS
337                .iter()
338                .copied()
339                .filter(|&f| source.get(f).is_some())
340                .collect();
341            if !misplaced.is_empty() {
342                anyhow::bail!(
343                    "source: field(s) [{}] belong under 'source.tuning:', not directly under 'source:'. \
344                     Example:\n  source:\n    tuning:\n      {}: <value>",
345                    misplaced.join(", "),
346                    misplaced[0],
347                );
348            }
349        }
350
351        if let Some(exports) = root.get("exports").and_then(|e| e.as_sequence()) {
352            for (i, export) in exports.iter().enumerate() {
353                let name = export
354                    .get("name")
355                    .and_then(|n| n.as_str())
356                    .unwrap_or("<unnamed>");
357                let misplaced: Vec<&str> = TUNING_FIELDS
358                    .iter()
359                    .copied()
360                    .filter(|&f| export.get(f).is_some())
361                    .collect();
362                if !misplaced.is_empty() {
363                    anyhow::bail!(
364                        "export '{}' (index {}): field(s) [{}] belong under 'exports[].tuning:', \
365                         not directly in the export. Example:\n  exports:\n    - name: {}\n      tuning:\n        {}: <value>",
366                        name,
367                        i,
368                        misplaced.join(", "),
369                        name,
370                        misplaced[0],
371                    );
372                }
373            }
374        }
375
376        Ok(())
377    }
378
379    /// Reject a config before any plan/connect step. The body is split into
380    /// three cohesive validators so each can be read — and unit-tested — on its
381    /// own: the export-list shape, the source connection block, and the
382    /// per-export rules. The end-to-end surface (`Config::from_yaml`) is
383    /// covered by `config/tests/{validation,secops}.rs`; the split additionally
384    /// lets a rule be exercised directly via `validate_export`.
385    fn validate(&self) -> crate::error::Result<()> {
386        self.validate_exports_list()?;
387        self.validate_source_connection()?;
388        for export in &self.exports {
389            self.validate_export(export)?;
390        }
391        self.validate_cdc_resource_conflicts()?;
392        self.validate_non_sql_source_modes()?;
393        Ok(())
394    }
395
396    /// Non-SQL sources (MongoDB) support only `mode: full` today. Chunked /
397    /// incremental / keyset / time-window all build SQL predicates over an
398    /// introspectable columnar schema, and CDC (change streams) is a separate,
399    /// not-yet-implemented seam. Rejecting the other modes here is precisely
400    /// what makes the SQL-only builders' `unreachable!` arms
401    /// (`sql::quote_ident`, `query::cursor_rhs`, …) provably unreachable for a
402    /// document-store source.
403    fn validate_non_sql_source_modes(&self) -> crate::error::Result<()> {
404        if self.source.source_type.is_sql() {
405            return Ok(());
406        }
407        for e in &self.exports {
408            if !matches!(e.mode, ExportMode::Full | ExportMode::Cdc) {
409                crate::config_bail!(
410                    crate::error::codes::CONFIG_SOURCE_MODE_UNSUPPORTED,
411                    "export '{}': source type '{:?}' supports `mode: full` (batch) and \
412                     `mode: cdc` (change streams) (got `mode: {:?}`). MongoDB has no SQL, so \
413                     chunked / incremental / keyset / time-window are not available; every \
414                     document exports as `_id` + a `document` JSON column.",
415                    e.name,
416                    self.source.source_type,
417                    e.mode
418                );
419            }
420            // An impossible combination must be a config error, not a silent
421            // behavior downgrade: the parallel `_id`-range path keeps NO keyset
422            // checkpoint, so `resume: true` was silently ignored — the whole
423            // collection re-read every run with no warning (bug-hunt find).
424            if e.parallel > 1 && self.source.mongo.as_ref().is_some_and(|m| m.resume) {
425                crate::config_bail!(
426                    crate::error::codes::CONFIG_SOURCE_MODE_UNSUPPORTED,
427                    "export '{}': `source.mongo.resume: true` cannot be combined with \
428                     `parallel: {}` — the parallel `_id`-range fan-out keeps no keyset \
429                     checkpoint, so resume would be silently ignored. Drop `parallel` \
430                     to keep cross-run resume, or drop `resume` to keep the fan-out.",
431                    e.name,
432                    e.parallel
433                );
434            }
435        }
436        Ok(())
437    }
438
439    /// CDC stream resources are per-export and their **defaults collide**: two
440    /// PostgreSQL cdc exports without an explicit `slot:` both resolve to
441    /// `rivet_slot` — each export's ack advances `confirmed_flush_lsn` past
442    /// changes the *other* never read (mutual, silent data loss). Two MySQL
443    /// exports both default to `server_id: 4271` — the server kills the older
444    /// replica connection. A shared `checkpoint:` path makes exports overwrite
445    /// each other's resume position on any engine. All three are config bugs a
446    /// naive multi-table CDC config hits by default, so reject them at load, on
447    /// the RESOLVED values (defaults included). SQL Server `capture_instance`
448    /// sharing is deliberately allowed: the change-table poll is read-only and
449    /// resume state lives in the per-export checkpoint.
450    fn validate_cdc_resource_conflicts(&self) -> crate::error::Result<()> {
451        use std::collections::HashMap;
452
453        let mut slots: HashMap<String, &str> = HashMap::new();
454        let mut server_ids: HashMap<u32, &str> = HashMap::new();
455        let mut checkpoints: HashMap<&str, &str> = HashMap::new();
456
457        for e in self.exports.iter().filter(|e| e.mode == ExportMode::Cdc) {
458            let cdc = e.cdc.as_ref();
459            match self.source.source_type {
460                SourceType::Postgres => {
461                    let slot = cdc
462                        .and_then(|c| c.slot.clone())
463                        .unwrap_or_else(|| DEFAULT_PG_SLOT.to_string());
464                    if let Some(prev) = slots.insert(slot.clone(), &e.name) {
465                        crate::config_bail!(
466                            crate::error::codes::CONFIG_CDC_RESOURCE_CONFLICT,
467                            "exports '{prev}' and '{}': same PostgreSQL slot '{slot}' — a slot \
468                             has ONE consumer; each export's ack would advance it past changes \
469                             the other never read (silent data loss). Set a distinct `cdc.slot:` \
470                             per export (the default is '{DEFAULT_PG_SLOT}').",
471                            e.name
472                        );
473                    }
474                }
475                SourceType::Mysql => {
476                    let sid = cdc
477                        .and_then(|c| c.server_id)
478                        .unwrap_or(DEFAULT_MYSQL_SERVER_ID);
479                    if let Some(prev) = server_ids.insert(sid, &e.name) {
480                        crate::config_bail!(
481                            crate::error::codes::CONFIG_CDC_RESOURCE_CONFLICT,
482                            "exports '{prev}' and '{}': same MySQL server_id {sid} — the server \
483                             kills the older replica connection when a new one registers with \
484                             the same id. Set a distinct `cdc.server_id:` per export (the \
485                             default is {DEFAULT_MYSQL_SERVER_ID}).",
486                            e.name
487                        );
488                    }
489                }
490                SourceType::Mssql => {}
491                // MongoDB change streams watch the whole database — no per-export
492                // slot or server_id to collide. Two Mongo CDC exports sharing a
493                // `checkpoint:` path IS still a conflict, caught by the shared
494                // checkpoint check below.
495                SourceType::Mongo => {}
496            }
497            if let Some(ckpt) = cdc.and_then(|c| c.checkpoint.as_deref())
498                && let Some(prev) = checkpoints.insert(ckpt, &e.name)
499            {
500                crate::config_bail!(
501                    crate::error::codes::CONFIG_CDC_RESOURCE_CONFLICT,
502                    "exports '{prev}' and '{}': same checkpoint path '{ckpt}' — each export \
503                     must own its resume position or they overwrite each other's. Set a \
504                     distinct `cdc.checkpoint:` per export.",
505                    e.name
506                );
507            }
508        }
509        Ok(())
510    }
511
512    /// Whole-config shape: at least one export, names unique.
513    fn validate_exports_list(&self) -> crate::error::Result<()> {
514        // An empty `exports:` list is almost always a typo (wrong config file,
515        // dropped anchor, merged doc with the anchor section missing). Running
516        // with zero exports is a silent no-op that looks like success in CI;
517        // reject fast instead. See QA backlog Task 5.1.
518        if self.exports.is_empty() {
519            crate::config_bail!(
520                crate::error::codes::CONFIG_NO_EXPORTS,
521                "exports: at least one export must be defined (got empty list)"
522            );
523        }
524
525        // Duplicate export names break state tracking: `export_state`,
526        // `file_log`, and `chunk_run` are all keyed by `export_name`, so
527        // two configs with the same name silently share cursor/file-log rows.
528        // QA backlog Task 5.1.
529        let mut seen: std::collections::HashSet<&str> =
530            std::collections::HashSet::with_capacity(self.exports.len());
531        for e in &self.exports {
532            if !seen.insert(e.name.as_str()) {
533                crate::config_bail!(
534                    crate::error::codes::CONFIG_DUPLICATE_EXPORT,
535                    "exports: duplicate export name '{}' (each export must have a unique name; state is keyed by name)",
536                    e.name
537                );
538            }
539        }
540        Ok(())
541    }
542
543    /// Source connection block: exactly one connection method, well-formed,
544    /// and the source-level tuning that is shared by every export.
545    fn validate_source_connection(&self) -> crate::error::Result<()> {
546        if let Some(t) = &self.source.tuning
547            && t.batch_size.is_some()
548            && t.batch_size_memory_mb.is_some()
549        {
550            anyhow::bail!(
551                "tuning: batch_size and batch_size_memory_mb are mutually exclusive. \
552                 Prefer batch_size_memory_mb (rivet sizes the batch to a memory budget, \
553                 adapting to row width); set batch_size only to pin an exact row count."
554            );
555        }
556
557        if !self.source.has_url_fields() && !self.source.has_structured_fields() {
558            // First-run footgun: a config that forgot the source block
559            // entirely.  Show the recommended path (`url_env`) up-front;
560            // operators who actually want structured fields know to look
561            // for them.
562            anyhow::bail!(
563                "source: no connection method configured. Add one of:\n  url_env: DATABASE_URL                          (URL from env var — recommended)\n  url: 'postgresql://user:pass@host:5432/db'      (inline — not recommended for committed configs)\n  url_file: /etc/rivet/source.url                 (URL from file — rotation-friendly)\n  host/user/database/...                          (structured fields under `source:`)"
564            );
565        }
566
567        if self.source.has_url_fields() {
568            let url_count = [
569                &self.source.url,
570                &self.source.url_env,
571                &self.source.url_file,
572            ]
573            .iter()
574            .filter(|u| u.is_some())
575            .count();
576            if url_count > 1 {
577                anyhow::bail!(
578                    "source: specify exactly one of 'url', 'url_env', or 'url_file' (got {} set).\n  Hint: pick one — `url_env` is recommended so credentials never enter the YAML.",
579                    url_count
580                );
581            }
582        }
583
584        if self.source.has_url_fields() && self.source.has_structured_fields() {
585            anyhow::bail!(
586                "source: pick either URL-based config (url/url_env/url_file) OR structured fields (host/user/database/port/password_env), not both.\n  Hint: remove whichever block you don't want; mixing the two is ambiguous."
587            );
588        }
589
590        if self.source.has_structured_fields() {
591            if self.source.host.is_none() {
592                anyhow::bail!(
593                    "source: structured config is missing 'host'.\n  Hint: add `host: localhost` (or your DB host) under `source:` in rivet.yaml.\n  Or switch to URL-based config: `url_env: DATABASE_URL`."
594                );
595            }
596            if self.source.user.is_none() {
597                anyhow::bail!(
598                    "source: structured config is missing 'user'.\n  Hint: add `user: <username>` under `source:` in rivet.yaml."
599                );
600            }
601            if self.source.database.is_none() {
602                anyhow::bail!(
603                    "source: structured config is missing 'database'.\n  Hint: add `database: <dbname>` under `source:` in rivet.yaml."
604                );
605            }
606            if self.source.password.is_some() && self.source.password_env.is_some() {
607                anyhow::bail!(
608                    "source: specify 'password' OR 'password_env', not both.\n  Hint: prefer `password_env: DB_PASSWORD` so credentials never enter the YAML."
609                );
610            }
611        }
612        Ok(())
613    }
614
615    /// Per-export rules: effective tuning, query source, `query_file` SecOps,
616    /// destination auth, compression, and the mode/chunk matrix. Takes `&self`
617    /// because effective tuning merges the source-level block.
618    fn validate_export(&self, export: &ExportConfig) -> crate::error::Result<()> {
619        // V5: `name` is keyed into output paths, file logs, and on-disk state,
620        // yet is otherwise free-form. A traversal (`../../etc/x`), absolute or
621        // slash-bearing (`/abs/x`, `sub/dir`), leading-dot, or NUL-bearing name
622        // escapes the intended output tree and corrupts name-keyed state.
623        // Mirror the `query_file` `..`/absolute guard: reject at config-load,
624        // accepting only a filename-safe charset.
625        if !is_filename_safe_name(&export.name) {
626            anyhow::bail!(
627                "export name '{}' is not filename-safe: it must not be absolute, contain \
628                 '/', '\\', '..', a NUL, or start with '.' (the name is used in output paths \
629                 and state keys). Use a plain identifier like `orders` or `daily_events`.",
630                export.name.escape_default(),
631            );
632        }
633
634        // V5 (round-2 audit #5): each `tables:` entry becomes a destination path
635        // SEGMENT in dest_for_table (local `<path>/<table>`, cloud `<prefix>/
636        // <table>/`), so an entry with `..`/`/` escapes the configured tree exactly
637        // like an unsafe export.name — the multi-tenant-wrapper threat model V5/V15
638        // defend. The single `table:` shortcut and export.name are guarded; the
639        // `tables:` list was not. Reject an unsafe segment at config-load, before
640        // dest_for_table (or the pre-write manifest FS probe) ever sees it.
641        for t in export.tables.iter().flatten() {
642            if !is_filename_safe_name(t) {
643                anyhow::bail!(
644                    "export '{}': tables entry '{}' is not filename-safe: it must not contain \
645                     '/', '\\', '..', a NUL, or start with '.' (each table becomes a destination \
646                     path segment).",
647                    export.name,
648                    t.escape_default(),
649                );
650            }
651        }
652
653        // Shape errors in the declared row-hash set — an empty or duplicated
654        // list — fail at config-load, where `rivet check` reports them, rather
655        // than at the first batch.
656        export.meta_columns.row_hash.validate(&export.name)?;
657
658        // A DECLARED column list describes ONE table's shape. A `tables:`
659        // stream captures several tables through one config and they do not
660        // share a column set, so the list would name columns a sibling table
661        // does not project — failing per table, mid-stream, after the
662        // replication slot is already open. Refuse at config-load instead.
663        // `row_hash: true` is unaffected: "every column" is meaningful for
664        // whatever table the row came from.
665        if export.meta_columns.row_hash.declared().is_some() && export.tables.is_some() {
666            anyhow::bail!(
667                "export '{}': meta_columns.row_hash names one table's columns, so it cannot \
668                 apply to a multi-table `tables:` stream. Give each table its own export \
669                 (with `table:`), or use `row_hash: true` to cover every column of each.",
670                export.name
671            );
672        }
673
674        // Round-2 audit #15/#16/#6: partition_by has purely-static rules (mode
675        // compatibility, the `{partition}` token, a filename-safe column name) that
676        // only lived in the run-time expansion step, so `rivet check` gave a false
677        // green and `rivet run` failed later — after a live DB probe, or (mode: cdc)
678        // with a misleading "requires table:". Enforce them at config-load so check
679        // and run agree, mirroring the chunk_dense/chunk_by_days guards.
680        if let Some(col) = export.partition_by.as_deref() {
681            if col.trim().is_empty() {
682                anyhow::bail!("export '{}': partition_by must name a column", export.name);
683            }
684            // #6: the column name becomes the Hive `col=value` path segment, so a
685            // quoted DB column named `../x` would inject a traversal — filename-gate it.
686            if !is_filename_safe_name(col) {
687                anyhow::bail!(
688                    "export '{}': partition_by column '{}' is not filename-safe: it must not \
689                     contain '/', '\\', '..', a NUL, or start with '.' (it becomes a `col=value` \
690                     destination path segment).",
691                    export.name,
692                    col.escape_default(),
693                );
694            }
695            if matches!(export.mode, ExportMode::TimeWindow | ExportMode::Cdc) {
696                anyhow::bail!(
697                    "export '{}': partition_by is not compatible with `mode: {:?}` — it is a batch \
698                     output-layout feature; partition a full/chunked/incremental export instead.",
699                    export.name,
700                    export.mode,
701                );
702            }
703            // Round-5: partition_by writes N per-partition manifests (one per
704            // col=value/ sub-prefix), but `rivet load` (reconcile.rs select_runs)
705            // picks a SINGLE latest-manifest for the export and would load only ONE
706            // partition, silently dropping the rest. Reject the combo at config-load
707            // until the loader is partition-aware.
708            // The `load:` block may be per-export OR top-level (`self.load`, which
709            // applies to EVERY export) — both route this partitioned export through
710            // the loader, which loads a single partition. Guard both: #101, only the
711            // per-export form was checked, so a top-level `load:` (the primary
712            // documented pattern) bypassed the guard and `cleanup_source` then wiped
713            // the unloaded partitions.
714            if export.load.is_some() || self.load.is_some() {
715                anyhow::bail!(
716                    "export '{}': partition_by is not compatible with a `load:` block — a \
717                     partitioned export writes one manifest per partition sub-prefix, but the \
718                     warehouse loader would load only a single partition (and `cleanup_source` \
719                     would then wipe the rest). Load a non-partitioned export, or drop `load:` \
720                     and run `rivet load` per partition.",
721                    export.name,
722                );
723            }
724            if export.chunk_by_key.is_some() {
725                anyhow::bail!(
726                    "export '{}': partition_by is not compatible with chunk_by_key — keyset needs \
727                     the `table:` shortcut to verify the index, but partitioning rewrites the query \
728                     into a subquery. Use a range `chunk_column`, a smaller `partition_granularity`, \
729                     or `mode: full`.",
730                    export.name
731                );
732            }
733            let has_token = export
734                .destination
735                .path
736                .as_deref()
737                .is_some_and(|s| s.contains("{partition}"))
738                || export
739                    .destination
740                    .prefix
741                    .as_deref()
742                    .is_some_and(|s| s.contains("{partition}"));
743            if !has_token {
744                anyhow::bail!(
745                    "export '{}': partition_by requires a '{{partition}}' token in destination.path \
746                     or destination.prefix (otherwise every partition would overwrite the same prefix).",
747                    export.name
748                );
749            }
750        }
751
752        // Round-2 audit #17: chunk_size_memory_mb is documented mutually exclusive
753        // with an explicit chunk_size — build.rs takes the memory budget and
754        // silently drops chunk_size when both are set, mirroring the batch_size pair.
755        if export.chunk_size_memory_mb.is_some() && export.chunk_size != default_chunk_size() {
756            anyhow::bail!(
757                "export '{}': chunk_size and chunk_size_memory_mb are mutually exclusive — \
758                 chunk_size_memory_mb derives the window size from a memory budget, so an explicit \
759                 chunk_size would be silently ignored. Set one or the other.",
760                export.name
761            );
762        }
763
764        let merged =
765            crate::tuning::merge_tuning_config(self.source.tuning.as_ref(), export.tuning.as_ref());
766        if let Some(t) = merged
767            && t.batch_size.is_some()
768            && t.batch_size_memory_mb.is_some()
769        {
770            anyhow::bail!(
771                "export '{}': effective tuning has both batch_size and batch_size_memory_mb (mutually exclusive)",
772                export.name
773            );
774        }
775        if let Some(et) = &export.tuning
776            && et.batch_size.is_some()
777            && et.batch_size_memory_mb.is_some()
778        {
779            anyhow::bail!(
780                "export '{}': tuning.batch_size and tuning.batch_size_memory_mb are mutually exclusive",
781                export.name
782            );
783        }
784
785        // Before the generic exactly-one counting: the `table:`/`tables:` pair
786        // gets its specific message (the generic one doesn't mention `tables`).
787        if export.table.is_some() && export.tables.is_some() {
788            anyhow::bail!(
789                "export '{}': `table:` and `tables:` are mutually exclusive — use \
790                 `tables: [a, b]` for a multi-table stream",
791                export.name
792            );
793        }
794
795        let set_count = [
796            export.query.is_some(),
797            export.query_file.is_some(),
798            export.table.is_some(),
799            // A multi-table CDC stream (`tables:`) is that export's source
800            // specification — the CDC arm below owns its shape rules.
801            export.tables.is_some(),
802        ]
803        .iter()
804        .filter(|b| **b)
805        .count();
806        if set_count == 0 {
807            anyhow::bail!(
808                "export '{}': specify exactly one of 'query', 'query_file', 'table', or 'tables'. \
809                 Use table: <name> for a whole table (enables PK auto-chunking); \
810                 query: \"SELECT …\" for an inline one-liner; \
811                 query_file: <path> for SQL you keep in version control.",
812                export.name
813            );
814        }
815        if set_count > 1 {
816            anyhow::bail!(
817                "export '{}': specify exactly one of 'query', 'query_file', 'table', or 'tables' (got {} set)",
818                export.name,
819                set_count
820            );
821        }
822        // SecOps: syntactic `query_file` checks must run at config-validate
823        // time so `rivet check` / `rivet doctor` catch them before any
824        // plan step. The same checks repeat (with a canonicalize-based
825        // symlink probe) in `ExportConfig::resolve_query` because the
826        // file may have been swapped between validation and read.
827        if let Some(file) = &export.query_file {
828            let p = std::path::Path::new(file);
829            if p.is_absolute() {
830                anyhow::bail!(
831                    "export '{}': query_file must be a relative path: '{}'",
832                    export.name,
833                    file
834                );
835            }
836            if p.components().any(|c| c == std::path::Component::ParentDir) {
837                anyhow::bail!(
838                    "export '{}': query_file path must not contain '..': '{}'",
839                    export.name,
840                    file
841                );
842            }
843        }
844        // V2/V12: a custom cloud `endpoint` is handed straight to the opendal
845        // S3/GCS/Azure builder with no validation, so a committed config can
846        // silently redirect every upload to an attacker host (exfiltration) or
847        // send credentials + rows over cleartext `http://`. The legitimate use
848        // is a local emulator (Minio / Azurite / fake-gcs on `127.0.0.1`), so
849        // accept a loopback host (any scheme), and otherwise accept a remote
850        // endpoint only when the operator has explicitly opted into anonymous
851        // (emulator) mode. Reject every other custom endpoint at config-load.
852        if matches!(
853            export.destination.destination_type,
854            DestinationType::S3 | DestinationType::Gcs | DestinationType::Azure
855        ) && let Some(endpoint) = &export.destination.endpoint
856        {
857            // Loopback emulator (Minio/Azurite/fake-gcs) is the legitimate
858            // local-dev path — accept any scheme. A non-loopback (or
859            // unparseable) custom endpoint is only accepted when the operator
860            // has explicitly opted into anonymous (emulator) mode, where no
861            // credentials are sent. Everything else is rejected.
862            let loopback = endpoint_host(endpoint).is_some_and(|host| is_loopback_host(&host));
863            if !loopback && !export.destination.allow_anonymous {
864                anyhow::bail!(
865                    "export '{}': destination.endpoint '{}' points at a non-loopback host. \
866                     A custom endpoint redirects every upload there — committing one is a \
867                     data-exfiltration / cleartext-credential risk.\n  \
868                     Hint: drop `endpoint:` to use the provider default, point it at a \
869                     loopback emulator (e.g. http://127.0.0.1:9000 with allow_anonymous: true \
870                     for Minio/Azurite), or set `allow_anonymous: true` for an anonymous \
871                     emulator.",
872                    export.name,
873                    endpoint,
874                );
875            }
876        }
877
878        // V15: a `type: local` destination `path` (or `prefix`) is written
879        // verbatim to the filesystem. A `..` component lets a committed config
880        // climb out of the intended output tree (`../../../../tmp/x`) — mirror
881        // the `query_file` traversal guard and reject it at config-load.
882        //
883        // Absolute paths are deliberately *not* rejected: `path: /output` is a
884        // legitimate Docker volume-mount pattern (see `examples/rivet.yaml`) and
885        // an explicit operator choice, not a hidden escape. The `..` climb is
886        // the unambiguous traversal footgun.
887        if export.destination.destination_type == DestinationType::Local {
888            for (field, value) in [
889                ("path", export.destination.path.as_deref()),
890                ("prefix", export.destination.prefix.as_deref()),
891            ] {
892                let Some(value) = value else { continue };
893                if std::path::Path::new(value)
894                    .components()
895                    .any(|c| c == std::path::Component::ParentDir)
896                {
897                    anyhow::bail!(
898                        "export '{}': local destination {} must not contain a '..' component: \
899                         '{}' (a parent-dir climb writes outside the output tree).",
900                        export.name,
901                        field,
902                        value
903                    );
904                }
905            }
906        }
907
908        if export.destination.destination_type == DestinationType::S3 {
909            let ak = export.destination.access_key_env.is_some();
910            let sk = export.destination.secret_key_env.is_some();
911            if ak != sk {
912                anyhow::bail!(
913                    "export '{}': S3 requires both access_key_env and secret_key_env, or neither (use default AWS credential chain)",
914                    export.name
915                );
916            }
917        }
918
919        if export.destination.destination_type == DestinationType::Gcs
920            && export.destination.allow_anonymous
921            && export.destination.credentials_file.is_some()
922        {
923            anyhow::bail!(
924                "export '{}': GCS allow_anonymous cannot be used together with credentials_file",
925                export.name
926            );
927        }
928
929        if export.destination.destination_type == DestinationType::Azure {
930            let has_name = export.destination.account_name.is_some();
931            let has_key = export.destination.account_key_env.is_some();
932            let has_sas = export.destination.sas_token_env.is_some();
933            if export.destination.allow_anonymous {
934                if has_name || has_key || has_sas {
935                    anyhow::bail!(
936                        "export '{}': Azure allow_anonymous cannot be combined with account_name/account_key_env/sas_token_env",
937                        export.name
938                    );
939                }
940            } else if has_key && has_sas {
941                anyhow::bail!(
942                    "export '{}': Azure account_key_env and sas_token_env are mutually exclusive — pick one auth mode",
943                    export.name
944                );
945            } else if !has_name {
946                anyhow::bail!(
947                    "export '{}': Azure requires account_name (plus account_key_env or sas_token_env), or allow_anonymous: true for Azurite",
948                    export.name
949                );
950            } else if !has_key && !has_sas {
951                anyhow::bail!(
952                    "export '{}': Azure requires account_key_env or sas_token_env (or allow_anonymous: true for Azurite)",
953                    export.name
954                );
955            }
956        }
957
958        if let Some(cred_path) = &export.destination.credentials_file
959            && !std::path::Path::new(cred_path).exists()
960        {
961            anyhow::bail!(
962                "export '{}': credentials_file '{}' does not exist",
963                export.name,
964                cred_path
965            );
966        }
967
968        if let Some(ref size_str) = export.max_file_size {
969            parse_file_size(size_str).map_err(|_| {
970                anyhow::anyhow!(
971                    "export '{}': invalid max_file_size '{}'",
972                    export.name,
973                    size_str
974                )
975            })?;
976        }
977
978        if let Some(level) = export.compression_level {
979            match export.compression {
980                CompressionType::Zstd => {
981                    if !(1..=22).contains(&level) {
982                        anyhow::bail!(
983                            "export '{}': zstd compression_level must be 1..22, got {}",
984                            export.name,
985                            level
986                        );
987                    }
988                }
989                CompressionType::Gzip => {
990                    if level > 10 {
991                        anyhow::bail!(
992                            "export '{}': gzip compression_level must be 0..10, got {}",
993                            export.name,
994                            level
995                        );
996                    }
997                }
998                _ => {
999                    anyhow::bail!(
1000                        "export '{}': compression_level is only supported for zstd and gzip",
1001                        export.name
1002                    );
1003                }
1004            }
1005        }
1006
1007        match export.mode {
1008            ExportMode::Incremental => {
1009                if export.cursor_column.is_none() {
1010                    anyhow::bail!(
1011                        "export '{}': incremental mode requires cursor_column",
1012                        export.name
1013                    );
1014                }
1015                match export.incremental_cursor_mode {
1016                    IncrementalCursorMode::Coalesce => {
1017                        if export.cursor_fallback_column.is_none() {
1018                            anyhow::bail!(
1019                                "export '{}': incremental_cursor_mode: coalesce requires cursor_fallback_column",
1020                                export.name
1021                            );
1022                        }
1023                    }
1024                    IncrementalCursorMode::SingleColumn => {
1025                        if export.cursor_fallback_column.is_some() {
1026                            anyhow::bail!(
1027                                "export '{}': cursor_fallback_column is only valid with incremental_cursor_mode: coalesce",
1028                                export.name
1029                            );
1030                        }
1031                    }
1032                }
1033            }
1034            ExportMode::Chunked => {
1035                // `chunk_column` is mandatory unless the user used the `table:`
1036                // shortcut on a Postgres source — in that case it is auto-resolved
1037                // from the table's single-integer PK at plan-build time (see
1038                // `crate::plan::build::resolve_chunk_column`).
1039                // Only fire the "pick a strategy" guidance when NO strategy is set.
1040                // `chunk_by_key` (and chunk_count/chunk_by_days) IS a strategy — with
1041                // `query:` it fails later for a different, accurate reason (it needs
1042                // the `table:` shortcut so the planner can verify the unique index).
1043                // Recommending `chunk_by_key` when it is already set was a dead-end
1044                // loop (dogfood MED).
1045                let has_strategy = export.chunk_column.is_some()
1046                    || export.chunk_by_key.is_some()
1047                    || export.chunk_count.is_some()
1048                    || export.chunk_by_days.is_some();
1049                if !has_strategy && export.table.is_none() {
1050                    anyhow::bail!(
1051                        "export '{}': chunked mode needs a chunking strategy. Pick one:\n  \
1052                         chunk_column: <int col>    range chunks on an integer column (most common)\n  \
1053                         chunk_by_key: <unique col>  keyset pagination when there's no integer PK\n  \
1054                         chunk_count: <N>            split the range into N equal chunks\n  \
1055                         chunk_by_days: <D>          time-bucketed chunks (needs a date/timestamp column)\n  \
1056                         Or use the `table:` shortcut on a single table — rivet auto-resolves the column from the primary key.",
1057                        export.name
1058                    );
1059                }
1060                // chunk_size == 0 would divide the range into zero-width
1061                // slices and (before the saturating fix in generate_chunks)
1062                // either infinite-loop or produce no progress. QA backlog
1063                // Task 5.1.
1064                if export.chunk_size == 0 {
1065                    anyhow::bail!(
1066                        "export '{}': chunked mode requires chunk_size >= 1 (got 0)",
1067                        export.name
1068                    );
1069                }
1070                // parallel == 0 means "spawn zero workers". Claiming tasks
1071                // with no workers stalls the pipeline. QA backlog Task 5.1.
1072                if export.parallel == 0 {
1073                    anyhow::bail!(
1074                        "export '{}': chunked mode requires parallel >= 1 (got 0)",
1075                        export.name
1076                    );
1077                }
1078                if let Some(0) = export.chunk_count {
1079                    crate::config_bail!(
1080                        crate::error::codes::CONFIG_CHUNK_COUNT_INVALID,
1081                        "export '{}': chunk_count must be >= 1",
1082                        export.name
1083                    );
1084                }
1085                if export.chunk_count.is_some() && export.chunk_dense {
1086                    anyhow::bail!(
1087                        "export '{}': chunk_count and chunk_dense are mutually exclusive. \
1088                         Use chunk_count for equal-sized chunks over a sparse key; \
1089                         use chunk_dense only when the key has no gaps.",
1090                        export.name
1091                    );
1092                }
1093                if export.chunk_count.is_some() && export.chunk_by_days.is_some() {
1094                    anyhow::bail!(
1095                        "export '{}': chunk_count and chunk_by_days are mutually exclusive. \
1096                         Use chunk_count: N to split an integer range into N chunks; \
1097                         use chunk_by_days: D to bucket a date/timestamp column by D-day windows.",
1098                        export.name
1099                    );
1100                }
1101            }
1102            ExportMode::TimeWindow => {
1103                if export.time_column.is_none() {
1104                    anyhow::bail!(
1105                        "export '{}': time_window mode requires time_column",
1106                        export.name
1107                    );
1108                }
1109                if export.days_window.is_none() {
1110                    anyhow::bail!(
1111                        "export '{}': time_window mode requires days_window",
1112                        export.name
1113                    );
1114                }
1115            }
1116            ExportMode::Full => {}
1117            ExportMode::Cdc => {
1118                match (&export.table, &export.tables) {
1119                    (None, None) => anyhow::bail!(
1120                        "export '{}': cdc mode requires `table:` (or `tables:` for a \
1121                         multi-table stream)",
1122                        export.name
1123                    ),
1124                    (Some(_), Some(_)) => anyhow::bail!(
1125                        "export '{}': `table:` and `tables:` are mutually exclusive — \
1126                         use `tables: [a, b]` for a multi-table stream",
1127                        export.name
1128                    ),
1129                    (None, Some(ts)) => {
1130                        if ts.is_empty() {
1131                            anyhow::bail!(
1132                                "export '{}': `tables:` must list at least one table",
1133                                export.name
1134                            );
1135                        }
1136                        let mut seen = std::collections::HashSet::new();
1137                        for t in ts {
1138                            if !seen.insert(t.as_str()) {
1139                                anyhow::bail!(
1140                                    "export '{}': duplicate table '{}' in `tables:`",
1141                                    export.name,
1142                                    t
1143                                );
1144                            }
1145                        }
1146                        if self.source.source_type == SourceType::Mssql {
1147                            anyhow::bail!(
1148                                "export '{}': `tables:` is not yet supported for SQL Server — \
1149                                 its capture instances are per-table; use one cdc export per \
1150                                 table (capture_instance each)",
1151                                export.name
1152                            );
1153                        }
1154                    }
1155                    (Some(_), None) => {}
1156                }
1157                if export.query.is_some() || export.query_file.is_some() {
1158                    anyhow::bail!(
1159                        "export '{}': cdc mode reads the transaction log, not a query — \
1160                         remove query/query_file and use `table:`",
1161                        export.name
1162                    );
1163                }
1164            }
1165        }
1166
1167        if export.tables.is_some() && export.mode != ExportMode::Cdc {
1168            anyhow::bail!(
1169                "export '{}': `tables:` is only valid with `mode: cdc` (batch exports \
1170                 are one query/table per export)",
1171                export.name
1172            );
1173        }
1174
1175        if export.chunk_dense && export.mode != ExportMode::Chunked {
1176            anyhow::bail!(
1177                "export '{}': chunk_dense is only valid with mode: chunked",
1178                export.name
1179            );
1180        }
1181
1182        // Round-2 audit #14: the load-bearing chunk knobs are silently dropped
1183        // outside `mode: chunked` — `build_plan` routes Full/Incremental/
1184        // TimeWindow to a single-cursor snapshot that never consults them, so a
1185        // config that sets them but forgets `mode: chunked` degrades to the
1186        // unbounded whole-table snapshot chunking exists to prevent, with no
1187        // error or warn. Gate them the same way chunk_dense/chunk_by_days are.
1188        // (`parallel` is intentionally excluded — the Mongo full/keyset reader
1189        // legitimately fans workers with it, so it is not chunked-only.)
1190        if export.mode != ExportMode::Chunked {
1191            let offending = if export.chunk_column.is_some() {
1192                Some("chunk_column")
1193            } else if export.chunk_by_key.is_some() {
1194                Some("chunk_by_key")
1195            } else if export.chunk_count.is_some() {
1196                Some("chunk_count")
1197            } else if export.chunk_size_memory_mb.is_some() {
1198                Some("chunk_size_memory_mb")
1199            } else if export.chunk_max_attempts.is_some() {
1200                Some("chunk_max_attempts")
1201            } else if export.chunk_checkpoint {
1202                Some("chunk_checkpoint")
1203            } else if export.chunk_size != default_chunk_size() {
1204                Some("chunk_size")
1205            } else {
1206                None
1207            };
1208            if let Some(knob) = offending {
1209                anyhow::bail!(
1210                    "export '{}': `{}` requires `mode: chunked` — it is silently ignored in \
1211                     `mode: {:?}`, which runs a single unbounded snapshot over the whole table \
1212                     instead of chunking (the source-pressure footgun chunked mode prevents).\n  \
1213                     Hint: add `mode: chunked`, or remove the `{}` setting.",
1214                    export.name,
1215                    knob,
1216                    export.mode,
1217                    knob,
1218                );
1219            }
1220        }
1221
1222        if export.cdc.is_some() && export.mode != ExportMode::Cdc {
1223            anyhow::bail!(
1224                "export '{}': a `cdc:` block is only valid with `mode: cdc`",
1225                export.name
1226            );
1227        }
1228
1229        // Table-qualified `columns:` override keys ("table.column"): the named
1230        // table must be one this export captures — a typo must fail at load,
1231        // never silently miss its target. Bare keys stay export-wide.
1232        {
1233            for key in export.columns.keys() {
1234                let Some((tbl, _col)) = key.split_once('.') else {
1235                    continue;
1236                };
1237                if export.query.is_some() || export.query_file.is_some() {
1238                    anyhow::bail!(
1239                        "export '{}': qualified column override '{key}' needs a table-shaped \
1240                         export (`table:`/`tables:`), not a query",
1241                        export.name
1242                    );
1243                }
1244                let bare = |t: &str| t.rsplit('.').next().unwrap_or(t).to_string();
1245                let captures = export
1246                    .table
1247                    .as_deref()
1248                    .map(bare)
1249                    .into_iter()
1250                    .chain(export.tables.iter().flatten().map(|t| bare(t)))
1251                    .any(|t| t == tbl);
1252                if !captures {
1253                    anyhow::bail!(
1254                        "export '{}': column override '{key}' names table '{tbl}', which this \
1255                         export does not capture — fix the table name or use a bare column key \
1256                         to apply it to every captured table",
1257                        export.name
1258                    );
1259                }
1260            }
1261        }
1262
1263        // `initial: snapshot` writes each table's snapshot under the reserved
1264        // sub-prefix `snapshot/` — a table actually NAMED "snapshot" would share
1265        // a prefix with another table's marker. Refuse the collision at load.
1266        if let Some(cdc) = &export.cdc
1267            && cdc.initial == Some(CdcInitialMode::Snapshot)
1268        {
1269            let clashes = |t: &str| t.rsplit('.').next().unwrap_or(t) == "snapshot";
1270            if export.table.as_deref().is_some_and(clashes)
1271                || export.tables.iter().flatten().any(|t| clashes(t))
1272            {
1273                anyhow::bail!(
1274                    "export '{}': a table named 'snapshot' collides with the reserved \
1275                     `snapshot/` sub-prefix that `cdc.initial: snapshot` writes — rename \
1276                     the table or use a separate export without `initial:`",
1277                    export.name
1278                );
1279            }
1280        }
1281
1282        // `initial:` needs a durable anchor BEFORE anything reads. PostgreSQL
1283        // pins server-side (the slot); MySQL / SQL Server have no server-side
1284        // anchor, so the checkpoint file IS the anchor there. Stated against the
1285        // MODE rather than only `snapshot` by name, so a future `initial:` mode
1286        // inherits the requirement instead of silently deferring the failure to
1287        // `ensure_anchor` — which demands a checkpoint on these engines for ANY
1288        // mode, i.e. after the run has already started.
1289        if let Some(cdc) = &export.cdc
1290            && cdc.initial.is_some()
1291            && self.source.source_type != SourceType::Postgres
1292            && cdc.checkpoint.is_none()
1293        {
1294            anyhow::bail!(
1295                "export '{}': `cdc.initial:` on {:?} requires `cdc.checkpoint:` — these \
1296                 engines have no server-side anchor, so the checkpoint file is the anchor, \
1297                 and without it each run re-anchors at the current log position and \
1298                 silently skips every change since the last one",
1299                export.name,
1300                self.source.source_type
1301            );
1302        }
1303
1304        // MongoDB change streams have NO server-side resume anchor (unlike a
1305        // PostgreSQL slot): the checkpoint file IS the anchor. Without it, every
1306        // run re-anchors at "now" and silently loses every change since the last
1307        // run — so `mode: cdc` on mongo requires `cdc.checkpoint:` ALWAYS, not
1308        // only under `initial: snapshot` (bug-hunt find).
1309        if export.mode == ExportMode::Cdc
1310            && self.source.source_type == SourceType::Mongo
1311            && export
1312                .cdc
1313                .as_ref()
1314                .and_then(|c| c.checkpoint.as_ref())
1315                .is_none()
1316        {
1317            anyhow::bail!(
1318                "export '{}': MongoDB `mode: cdc` requires `cdc.checkpoint:` — a change \
1319                 stream has no server-side resume anchor, so without the checkpoint file \
1320                 each run re-anchors at the current time and silently loses every change \
1321                 between runs.",
1322                export.name
1323            );
1324        }
1325
1326        // A collection whose name contains a dot does not route through the
1327        // change-stream capture — its events are silently dropped. Refuse loudly
1328        // rather than report 0-row success forever (bug-hunt find). Batch handles
1329        // dotted names fine, so this is CDC-only.
1330        if export.mode == ExportMode::Cdc && self.source.source_type == SourceType::Mongo {
1331            let dotted = |t: &str| t.contains('.');
1332            if export.table.as_deref().is_some_and(dotted)
1333                || export.tables.iter().flatten().any(|t| dotted(t))
1334            {
1335                anyhow::bail!(
1336                    "export '{}': MongoDB `mode: cdc` does not support a collection name \
1337                     containing a dot — the change-stream router cannot address it and its \
1338                     events would be silently dropped. Rename the collection, or capture it \
1339                     with `mode: full` (batch handles dotted names).",
1340                    export.name
1341                );
1342            }
1343        }
1344
1345        if let Some(days) = export.chunk_by_days {
1346            if export.mode != ExportMode::Chunked {
1347                anyhow::bail!(
1348                    "export '{}': chunk_by_days requires mode: chunked",
1349                    export.name
1350                );
1351            }
1352            if export.chunk_dense {
1353                anyhow::bail!(
1354                    "export '{}': chunk_by_days cannot be combined with chunk_dense",
1355                    export.name
1356                );
1357            }
1358            if days == 0 {
1359                crate::config_bail!(
1360                    crate::error::codes::CONFIG_CHUNK_BY_DAYS_INVALID,
1361                    "export '{}': chunk_by_days must be at least 1",
1362                    export.name
1363                );
1364            }
1365        }
1366        Ok(())
1367    }
1368}
1369
1370/// True when a single YAML line carries a mapping value (text after `key:`)
1371/// that contains a `{` outside of any quotes — the unquoted-template-brace
1372/// shape (`prefix: {partition}`, `path: {date}/out`).
1373///
1374/// Quote-aware so a properly quoted value (`prefix: "exports/{partition}/"`)
1375/// does *not* match, and `$`-prefixed braces (`${VAR}` env placeholders) are
1376/// ignored — they are resolved before the parse and are not the footgun.
1377fn line_has_unquoted_brace_value(line: &str) -> bool {
1378    // Whole-line comments never carry a value — skip before splitting.
1379    if line.trim_start().starts_with('#') {
1380        return false;
1381    }
1382    // Split key from value at the first `": "` / `":\t"` / trailing `:`.
1383    // A YAML plain-key separator is a colon followed by whitespace or EOL.
1384    let bytes = line.as_bytes();
1385    let mut sep = None;
1386    let mut i = 0;
1387    while i < bytes.len() {
1388        if bytes[i] == b':' && (i + 1 == bytes.len() || bytes[i + 1].is_ascii_whitespace()) {
1389            sep = Some(i + 1);
1390            break;
1391        }
1392        i += 1;
1393    }
1394    let Some(value_start) = sep else {
1395        return false;
1396    };
1397    let value = line[value_start..].trim_start();
1398    // A trailing `#` after the value starts an inline comment; an empty or
1399    // comment-only value carries no brace to flag.
1400    if value.is_empty() || value.starts_with('#') {
1401        return false;
1402    }
1403
1404    let mut in_single = false;
1405    let mut in_double = false;
1406    let vbytes = value.as_bytes();
1407    for (j, &c) in vbytes.iter().enumerate() {
1408        match c {
1409            b'\'' if !in_double => in_single = !in_single,
1410            b'"' if !in_single => in_double = !in_double,
1411            b'{' if !in_single && !in_double => {
1412                // Ignore `${...}` env placeholders (resolved pre-parse).
1413                if j > 0 && vbytes[j - 1] == b'$' {
1414                    continue;
1415                }
1416                return true;
1417            }
1418            _ => {}
1419        }
1420    }
1421    false
1422}
1423
1424/// Extract the lower-cased host from a `scheme://host[:port][/path]` endpoint,
1425/// or `None` when it does not look like a URL.
1426///
1427/// SecOps helper for the cloud-`endpoint` exfiltration guard (V2/V12): the host
1428/// decides whether a custom endpoint is a local emulator (loopback) or a remote
1429/// redirect target. We reject every non-loopback custom endpoint regardless of
1430/// scheme (covering both the exfil and the cleartext-`http` gaps), so only the
1431/// host is needed. We hand-parse rather than pull in a URL crate — the inputs
1432/// are operator-typed endpoints, not arbitrary URIs. A bracketed IPv6 literal
1433/// authority (`http://[::1]:9000`) keeps its address so it compares against the
1434/// loopback list.
1435fn endpoint_host(endpoint: &str) -> Option<String> {
1436    let (scheme, rest) = endpoint.split_once("://")?;
1437    if scheme.is_empty() {
1438        return None;
1439    }
1440    // Authority ends at the first `/` (path), `?` (query), or `#` (fragment);
1441    // any `user[:pass]@` userinfo head is dropped (host is after the last `@`).
1442    let authority = rest
1443        .split(['/', '?', '#'])
1444        .next()
1445        .unwrap_or("")
1446        .rsplit('@')
1447        .next()
1448        .unwrap_or("");
1449    let host = if let Some(stripped) = authority.strip_prefix('[') {
1450        // Bracketed IPv6 literal: take up to the closing `]`.
1451        stripped.split(']').next().unwrap_or("")
1452    } else {
1453        // host[:port] — strip the port suffix.
1454        authority.split(':').next().unwrap_or("")
1455    };
1456    if host.is_empty() {
1457        return None;
1458    }
1459    Some(host.to_ascii_lowercase())
1460}
1461
1462/// True when `host` names the local machine — the legitimate cloud-emulator
1463/// target (Minio / Azurite / fake-gcs on `127.0.0.1`). Anything else is a
1464/// remote host and a potential exfiltration redirect.
1465fn is_loopback_host(host: &str) -> bool {
1466    // `localhost` is the only non-IP host that counts as loopback. Everything
1467    // else must PARSE as an IP literal in the loopback range — a lexical
1468    // `starts_with("127.")` would accept attacker-controlled DNS like
1469    // `127.attacker.com` or `127.0.0.1.evil.com` (both resolve off-box), turning
1470    // the credential-exfil gate into a bypass (V2/V12). Parse strictly: only a
1471    // real `127.0.0.0/8` / `::1` address is loopback; a hostname is not.
1472    if host == "localhost" {
1473        return true;
1474    }
1475    // Tolerate a bracketed IPv6 literal (`[::1]`) in case a caller forwards one.
1476    let h = host
1477        .strip_prefix('[')
1478        .and_then(|s| s.strip_suffix(']'))
1479        .unwrap_or(host);
1480    h.parse::<std::net::IpAddr>()
1481        .is_ok_and(|ip| ip.is_loopback())
1482}
1483
1484/// True when `name` is filename-safe: rejects path-traversal (`..`), absolute
1485/// or slash-bearing names (`/`, `\`), a leading `.` (hidden / current-dir), and
1486/// embedded NULs. `ExportConfig.name` is keyed into output paths and on-disk
1487/// state, so a `../../etc/x` or absolute name escapes the output tree (V5).
1488fn is_filename_safe_name(name: &str) -> bool {
1489    !name.is_empty()
1490        && !name.starts_with('.')
1491        && !name.contains('/')
1492        && !name.contains('\\')
1493        && !name.contains("..")
1494        && !name.contains('\0')
1495}
1496
1497#[cfg(test)]
1498mod tests;
1499
1500#[cfg(test)]
1501mod audit_csv_compression {
1502    //! Finding #10: `format: csv` + a compression codec is a silent no-op
1503    //! (the file stays uncompressed but the manifest records the codec). The
1504    //! combo must be rejected at config-validate time. These tests encode the
1505    //! new rule, so reverting the fix turns them red.
1506    use super::*;
1507
1508    fn yaml(format: &str, compression_line: &str) -> String {
1509        format!(
1510            "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
1511             exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: {format}\n\
1512             {compression_line}    destination:\n      type: local\n      path: ./out\n"
1513        )
1514    }
1515
1516    #[test]
1517    fn audit_csv_compression_is_rejected() {
1518        // csv + gzip → rejected, with an actionable message.
1519        let err = Config::from_yaml(&yaml("csv", "    compression: gzip\n")).unwrap_err();
1520        let msg = format!("{err:#}");
1521        assert!(
1522            msg.contains("CSV output does not support compression") && msg.contains("gzip"),
1523            "csv+gzip must be rejected with an actionable message; got: {msg}"
1524        );
1525        assert!(
1526            msg.contains("parquet") && msg.contains("none"),
1527            "message must point to the real options (parquet / none); got: {msg}"
1528        );
1529
1530        // Guard the boundaries: parquet+gzip and csv+none still validate.
1531        Config::from_yaml(&yaml("parquet", "    compression: gzip\n"))
1532            .expect("parquet+gzip must validate");
1533        Config::from_yaml(&yaml("csv", "    compression: none\n")).expect("csv+none must validate");
1534    }
1535
1536    #[test]
1537    fn audit_csv_every_real_codec_is_rejected() {
1538        // Each non-None codec is a silent no-op for CSV — none may slip through.
1539        for codec in ["zstd", "snappy", "gzip", "lz4"] {
1540            let err = Config::from_yaml(&yaml("csv", &format!("    compression: {codec}\n")))
1541                .unwrap_err();
1542            let msg = format!("{err:#}");
1543            assert!(
1544                msg.contains("CSV output does not support compression") && msg.contains(codec),
1545                "csv+{codec} must be rejected; got: {msg}"
1546            );
1547        }
1548    }
1549
1550    #[test]
1551    fn audit_csv_compression_profile_is_rejected() {
1552        // A `compression_profile:` other than `none` resolves to a real codec,
1553        // so it is the same silent no-op for CSV.
1554        for profile in ["fast", "balanced", "compact"] {
1555            let err = Config::from_yaml(&yaml(
1556                "csv",
1557                &format!("    compression_profile: {profile}\n"),
1558            ))
1559            .unwrap_err();
1560            let msg = format!("{err:#}");
1561            assert!(
1562                msg.contains("CSV output does not support compression_profile")
1563                    && msg.contains(profile),
1564                "csv+profile {profile} must be rejected; got: {msg}"
1565            );
1566        }
1567        // profile: none is a no-op request and is fine.
1568        Config::from_yaml(&yaml("csv", "    compression_profile: none\n"))
1569            .expect("csv + compression_profile: none must validate");
1570    }
1571
1572    #[test]
1573    fn audit_csv_default_compression_still_validates() {
1574        // Regression guard: a bare `format: csv` (no explicit codec) must keep
1575        // validating. `CompressionType::default()` is `Zstd`, but the user did
1576        // not *ask* for it — only an explicit codec is a no-op request. This
1577        // pins that the fix scans for explicit intent, not the struct default
1578        // (which would break ~60 existing csv configs).
1579        Config::from_yaml(&yaml("csv", "")).expect("bare format: csv must validate");
1580    }
1581
1582    #[test]
1583    fn audit_compression_supported_predicate() {
1584        // `compression_supported` is re-exported via `pub use format::*`.
1585        // Parquet supports every codec; CSV supports only None.
1586        for ct in [
1587            CompressionType::Zstd,
1588            CompressionType::Snappy,
1589            CompressionType::Gzip,
1590            CompressionType::Lz4,
1591            CompressionType::None,
1592        ] {
1593            assert!(compression_supported(FormatType::Parquet, ct));
1594        }
1595        assert!(compression_supported(
1596            FormatType::Csv,
1597            CompressionType::None
1598        ));
1599        for ct in [
1600            CompressionType::Zstd,
1601            CompressionType::Snappy,
1602            CompressionType::Gzip,
1603            CompressionType::Lz4,
1604        ] {
1605            assert!(
1606                !compression_supported(FormatType::Csv, ct),
1607                "CSV must not claim to support {}",
1608                ct.label()
1609            );
1610        }
1611    }
1612}
1613
1614#[cfg(test)]
1615mod row_hash_config {
1616    //! A DECLARED `meta_columns.row_hash` names ONE table's columns. The
1617    //! failure mode worth guarding is the quiet one: accepting a spec that
1618    //! cannot be honoured and discovering it mid-stream, after a replication
1619    //! slot is already open.
1620    use super::*;
1621
1622    fn yaml(export_body: &str) -> String {
1623        format!(
1624            "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
1625             exports:\n  - name: t\n    format: parquet\n    destination:\n      type: gcs\n      bucket: b\n      prefix: \"t/\"\n{export_body}"
1626        )
1627    }
1628
1629    #[test]
1630    fn single_table_declared_set_is_accepted() {
1631        let cfg = Config::from_yaml(&yaml(
1632            "    table: orders\n    meta_columns:\n      row_hash: [id, status, updated_at]\n",
1633        ))
1634        .expect("a single-table declared row_hash must parse");
1635        assert_eq!(
1636            cfg.exports[0].meta_columns.row_hash.declared(),
1637            Some(["id", "status", "updated_at"].map(String::from).as_slice())
1638        );
1639    }
1640
1641    #[test]
1642    fn multi_table_stream_refuses_a_declared_set() {
1643        let err = Config::from_yaml(&yaml(
1644            "    mode: cdc\n    tables: [orders, customers]\n    cdc:\n      checkpoint: /tmp/ck\n\
1645             \x20   meta_columns:\n      row_hash: [id, status]\n",
1646        ))
1647        .unwrap_err()
1648        .to_string();
1649        assert!(
1650            err.contains("multi-table") && err.contains("row_hash"),
1651            "a `tables:` stream must be refused at config-load, not mid-stream: {err}"
1652        );
1653    }
1654
1655    /// …but `true` is not a per-table claim, so it must still be allowed. This
1656    /// is the whole reason the guard tests `declared()` rather than `enabled()`.
1657    #[test]
1658    fn multi_table_stream_still_accepts_row_hash_true() {
1659        Config::from_yaml(&yaml(
1660            "    mode: cdc\n    tables: [orders, customers]\n    cdc:\n      checkpoint: /tmp/ck\n\
1661             \x20   meta_columns:\n      row_hash: true\n",
1662        ))
1663        .expect("`row_hash: true` covers whatever table the row came from");
1664    }
1665
1666    #[test]
1667    fn empty_column_set_is_refused() {
1668        let err = Config::from_yaml(&yaml(
1669            "    table: orders\n    meta_columns:\n      row_hash: []\n",
1670        ))
1671        .unwrap_err()
1672        .to_string();
1673        assert!(err.contains("attests nothing"), "{err}");
1674    }
1675}
1676
1677#[cfg(test)]
1678mod reserved_load_extension {
1679    //! A downstream first-party loader carries its warehouse target in a
1680    //! top-level `load:` block so ONE config drives export + load. OSS must
1681    //! accept and ignore it — otherwise `check`/`run`/`apply` would reject the
1682    //! single-file config. Reverting the reserved field turns this red.
1683    use super::*;
1684
1685    #[test]
1686    fn reserved_top_level_load_block_is_accepted_and_ignored() {
1687        let yaml = "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
1688             exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n    destination:\n      type: gcs\n      bucket: b\n      prefix: \"t/\"\n\
1689             load:\n  project: p\n  dataset: d\n  cleanup_source: true\n";
1690        let cfg = Config::from_yaml(yaml).expect("config with a reserved `load:` block must parse");
1691        assert!(cfg.load.is_some(), "the load block is captured opaquely");
1692    }
1693}
1694
1695#[cfg(test)]
1696mod audit_unquoted_template_brace {
1697    //! yaml-hint: an unquoted `{partition}` (or `{date}`) in a path/prefix
1698    //! value trips serde_yaml_ng's flow-mapping parser with a cryptic message
1699    //! that gives no clue the brace needs quoting. Since `{partition}` is the
1700    //! required token for `partition_by`, this is a common copy-paste footgun.
1701    //! `Config::from_yaml` augments the parser error with a quoting hint; these
1702    //! tests pin that behavior (and guard that valid configs are untouched).
1703    use super::*;
1704
1705    /// A full, otherwise-valid config whose `prefix:` value is whatever the
1706    /// caller passes verbatim (quoted or not). Only the `prefix:` line varies,
1707    /// so any parse error is attributable to the brace under test.
1708    fn yaml_with_prefix(prefix_value: &str) -> String {
1709        format!(
1710            "source:\n\
1711             \x20 type: postgres\n\
1712             \x20 url: \"postgresql://localhost/test\"\n\
1713             exports:\n\
1714             \x20 - name: t\n\
1715             \x20   query: \"SELECT 1\"\n\
1716             \x20   format: parquet\n\
1717             \x20   partition_by: created_date\n\
1718             \x20   destination:\n\
1719             \x20     type: local\n\
1720             \x20     path: ./out\n\
1721             \x20     prefix: {prefix_value}\n"
1722        )
1723    }
1724
1725    const HINT_FRAGMENT: &str =
1726        "a YAML value containing { } (such as {partition} or {date}) must be quoted";
1727
1728    #[test]
1729    fn top_level_load_with_partition_by_is_rejected() {
1730        // #101: partition_by is incompatible with a `load:` block — the loader
1731        // loads ONE partition and `cleanup_source` then wipes the rest. The
1732        // per-export form was guarded, but a TOP-LEVEL `load:` (the primary
1733        // documented pattern) bypassed it. Both must be rejected.
1734        let yaml = r#"
1735source: { type: postgres, url: "postgresql://rivet:rivet@127.0.0.1:5432/rivet" }
1736exports:
1737  - name: t1
1738    query: "SELECT 1 as id"
1739    format: parquet
1740    partition_by: id
1741    destination: { type: local, path: "./out/{partition}/" }
1742load: { target: bigquery, project: p, dataset: d }
1743"#;
1744        let err = Config::from_yaml(yaml).unwrap_err();
1745        assert!(
1746            err.to_string()
1747                .contains("partition_by is not compatible with a `load:` block"),
1748            "a top-level `load:` + partition_by must be rejected: {err}"
1749        );
1750
1751        // Without the top-level load, the same partitioned export is accepted —
1752        // the guard must not over-fire on a plain partitioned export.
1753        let ok_yaml = r#"
1754source: { type: postgres, url: "postgresql://rivet:rivet@127.0.0.1:5432/rivet" }
1755exports:
1756  - name: t1
1757    query: "SELECT 1 as id"
1758    format: parquet
1759    partition_by: id
1760    destination: { type: local, path: "./out/{partition}/" }
1761"#;
1762        assert!(
1763            Config::from_yaml(ok_yaml).is_ok(),
1764            "partition_by without any load: must stay accepted"
1765        );
1766    }
1767
1768    #[test]
1769    fn bare_partition_token_gets_quoting_hint() {
1770        // `prefix: {partition}` parses as a YAML map, so serde rejects it with
1771        // `invalid type: map, expected a string` — no clue it's a quoting bug.
1772        let err = Config::from_yaml(&yaml_with_prefix("{partition}")).unwrap_err();
1773        let msg = format!("{err:#}");
1774        assert!(
1775            msg.contains(HINT_FRAGMENT),
1776            "bare {{partition}} must carry the quoting hint; got: {msg}"
1777        );
1778        // The original parser detail (type + location) is preserved.
1779        assert!(
1780            msg.contains("invalid type: map") || msg.contains("line"),
1781            "the original parser error must be kept; got: {msg}"
1782        );
1783    }
1784
1785    #[test]
1786    fn trailing_text_after_brace_gets_quoting_hint() {
1787        // `prefix: {date}/{partition}/` runs the flow map into block context:
1788        // serde emits `did not find expected key ... while parsing a block
1789        // mapping`. Same footgun, different libyaml symptom.
1790        let err = Config::from_yaml(&yaml_with_prefix("{date}/{partition}/")).unwrap_err();
1791        let msg = format!("{err:#}");
1792        assert!(
1793            msg.contains(HINT_FRAGMENT),
1794            "{{date}}/{{partition}}/ must carry the quoting hint; got: {msg}"
1795        );
1796    }
1797
1798    #[test]
1799    fn unclosed_brace_gets_quoting_hint() {
1800        // `prefix: {partition` (unclosed) is the canonical flow-mapping scanner
1801        // error: `did not find expected ',' or '}' ... while parsing a flow
1802        // mapping`. The hint must still fire.
1803        let err = Config::from_yaml(&yaml_with_prefix("{partition")).unwrap_err();
1804        let msg = format!("{err:#}");
1805        assert!(
1806            msg.contains(HINT_FRAGMENT),
1807            "unclosed brace must carry the quoting hint; got: {msg}"
1808        );
1809    }
1810
1811    #[test]
1812    fn quoted_brace_value_loads_ok() {
1813        // The fix itself, applied: a properly quoted brace value parses and
1814        // validates. This is the guard that the hint never reaches a valid
1815        // config and the success path is unchanged.
1816        let cfg = Config::from_yaml(&yaml_with_prefix("\"exports/{partition}/\""))
1817            .expect("quoted {partition} prefix must load");
1818        assert_eq!(
1819            cfg.exports[0].destination.prefix.as_deref(),
1820            Some("exports/{partition}/")
1821        );
1822    }
1823
1824    #[test]
1825    fn config_without_braces_is_untouched() {
1826        // No brace anywhere: a plain valid config still loads, and an unrelated
1827        // YAML error elsewhere must not pick up a spurious quoting hint. (No
1828        // partition_by here — a braceless prefix is invalid WITH partition_by,
1829        // which requires a `{partition}` token; this test is about brace
1830        // detection, not partitioning.)
1831        let yaml = "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
1832                    exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n\
1833                    \x20   destination:\n      type: local\n      path: ./out\n      prefix: exports/data/\n";
1834        Config::from_yaml(yaml).expect("a brace-free prefix must load");
1835    }
1836
1837    // ── line_has_unquoted_brace_value() unit coverage ──────────────────────
1838
1839    #[test]
1840    fn unquoted_brace_value_is_detected() {
1841        assert!(line_has_unquoted_brace_value("    prefix: {partition}"));
1842        assert!(line_has_unquoted_brace_value("      path: {date}/out"));
1843        assert!(line_has_unquoted_brace_value("prefix: {partition")); // unclosed
1844    }
1845
1846    #[test]
1847    fn quoted_brace_value_is_not_flagged() {
1848        // Quotes around the value hide the brace from the scanner — not a bug.
1849        assert!(!line_has_unquoted_brace_value(
1850            "    prefix: \"exports/{partition}/\""
1851        ));
1852        assert!(!line_has_unquoted_brace_value("    prefix: 'data/{date}/'"));
1853    }
1854
1855    #[test]
1856    fn env_placeholder_and_plain_values_are_not_flagged() {
1857        // `${VAR}` placeholders are resolved before the parse and are not the
1858        // footgun; plain brace-free values are obviously fine.
1859        assert!(!line_has_unquoted_brace_value("    url: ${DATABASE_URL}"));
1860        assert!(!line_has_unquoted_brace_value("    path: ./out"));
1861        assert!(!line_has_unquoted_brace_value("  # prefix: {partition}")); // comment
1862        assert!(!line_has_unquoted_brace_value("    prefix:")); // no value
1863    }
1864}
1865
1866#[cfg(test)]
1867mod sec_config_validation_regression {
1868    //! Regression edge-cases that pin the *compat boundaries* of the
1869    //! config-validation security fixes — the cases that distinguish a real
1870    //! attack from a legitimate loopback / dev-container / Docker pattern.
1871    //! These complement the RED tests in `sec_config_validation`: the RED
1872    //! tests assert the attack is rejected; these assert the fix stays narrow
1873    //! enough not to break local-dev usage (see CRITICAL COMPAT).
1874    use super::*;
1875
1876    /// A full, otherwise-valid config whose single export's `destination:`
1877    /// block is whatever the caller passes verbatim.
1878    fn yaml_with_destination(dest_block: &str) -> String {
1879        format!(
1880            "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
1881             exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n\
1882             {dest_block}"
1883        )
1884    }
1885
1886    // ── endpoint_host / is_loopback_host helpers ─────────────────────────────
1887
1888    #[test]
1889    fn endpoint_host_parses_forms() {
1890        assert_eq!(
1891            endpoint_host("https://attacker.example.com").as_deref(),
1892            Some("attacker.example.com")
1893        );
1894        // Port and path are stripped from the host.
1895        assert_eq!(
1896            endpoint_host("http://127.0.0.1:10000/devstoreaccount1").as_deref(),
1897            Some("127.0.0.1")
1898        );
1899        // userinfo head is dropped (host is after the last `@`).
1900        assert_eq!(
1901            endpoint_host("http://user:pass@127.0.0.1:9000").as_deref(),
1902            Some("127.0.0.1")
1903        );
1904        // Bracketed IPv6 literal keeps its address.
1905        assert_eq!(endpoint_host("http://[::1]:9000").as_deref(), Some("::1"));
1906        // Not a URL → None (treated as a non-loopback custom endpoint upstream).
1907        assert_eq!(endpoint_host("not-a-url"), None);
1908        assert_eq!(endpoint_host("://nohost"), None);
1909    }
1910
1911    #[test]
1912    fn loopback_host_classification() {
1913        for h in ["127.0.0.1", "127.0.0.53", "localhost", "::1"] {
1914            assert!(is_loopback_host(h), "{h} must be loopback");
1915        }
1916        for h in ["attacker.example.com", "evil.com", "10.0.0.1", "::2"] {
1917            assert!(!is_loopback_host(h), "{h} must be remote");
1918        }
1919    }
1920
1921    // ── V2/V12 endpoint: loopback accepted regardless of allow_anonymous ─────
1922
1923    #[test]
1924    fn loopback_endpoint_without_allow_anonymous_still_accepted() {
1925        // A loopback emulator endpoint with credentials (no allow_anonymous) is
1926        // the Minio-with-keys local-dev pattern and must stay accepted — the
1927        // exfil guard targets *remote* hosts, not localhost.
1928        let cfg = yaml_with_destination(
1929            "    destination:\n      type: s3\n      bucket: b\n      region: us-east-1\n\
1930             \x20     endpoint: http://127.0.0.1:9000\n      access_key_env: AK\n      secret_key_env: SK\n",
1931        );
1932        Config::from_yaml(&cfg).expect("loopback endpoint with creds must stay accepted");
1933    }
1934
1935    #[test]
1936    fn remote_https_endpoint_with_allow_anonymous_is_the_only_remote_escape() {
1937        // The documented escape hatch: an explicit anonymous (emulator) opt-in
1938        // permits a non-loopback endpoint (no credentials are sent). Without
1939        // allow_anonymous the same endpoint is rejected (covered by the RED
1940        // test); with it, accepted.
1941        let cfg = yaml_with_destination(
1942            "    destination:\n      type: gcs\n      bucket: b\n\
1943             \x20     endpoint: https://emulator.example.com\n      allow_anonymous: true\n",
1944        );
1945        Config::from_yaml(&cfg).expect("remote endpoint + allow_anonymous opt-in must be accepted");
1946    }
1947
1948    // ── V15 local path: absolute allowed (Docker mount), `..` rejected ───────
1949
1950    #[test]
1951    fn absolute_local_path_is_allowed() {
1952        // `path: /output` is a legitimate Docker volume-mount pattern
1953        // (examples/rivet.yaml) and must keep validating — only `..` climbs are
1954        // the traversal footgun.
1955        let cfg =
1956            yaml_with_destination("    destination:\n      type: local\n      path: /output\n");
1957        Config::from_yaml(&cfg).expect("absolute local path (Docker mount) must validate");
1958    }
1959
1960    #[test]
1961    fn dotdot_in_local_prefix_is_rejected() {
1962        // `prefix` is guarded the same as `path`.
1963        let cfg = yaml_with_destination(
1964            "    destination:\n      type: local\n      path: ./out\n      prefix: a/../b\n",
1965        );
1966        let err = Config::from_yaml(&cfg).unwrap_err();
1967        let msg = format!("{err:#}");
1968        assert!(
1969            msg.contains("prefix") && msg.contains(".."),
1970            "a '..' in the local prefix must be rejected naming prefix/..; got: {msg}"
1971        );
1972    }
1973
1974    // ── V13 TLS: explicit enforced mode + knob rejected; default-mode kept ───
1975
1976    #[test]
1977    fn tls_danger_knob_without_explicit_mode_still_accepted() {
1978        // The dev-container pattern `tls: { accept_invalid_certs: true }` against
1979        // a loopback self-signed cert (e.g. the MSSQL docker container) omits
1980        // `mode:` — there is no *explicit* mode to contradict, so it must keep
1981        // validating. The RED test rejects only the explicit-mode contradiction.
1982        let yaml = "source:\n  type: mssql\n  url: \"sqlserver://sa:pw@127.0.0.1:1433/db\"\n  \
1983                    tls:\n    accept_invalid_certs: true\n\
1984                    exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n    \
1985                    destination:\n      type: local\n      path: ./out\n";
1986        Config::from_yaml(yaml)
1987            .expect("dev-container default-mode + accept_invalid_certs must stay accepted");
1988    }
1989
1990    #[test]
1991    fn tls_explicit_verify_ca_plus_invalid_hostnames_rejected() {
1992        // The hostname knob is flagged too, against any explicit enforced mode.
1993        let yaml = "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n  \
1994                    tls:\n    mode: verify-ca\n    accept_invalid_hostnames: true\n\
1995                    exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n    \
1996                    destination:\n      type: local\n      path: ./out\n";
1997        let err = Config::from_yaml(yaml).unwrap_err();
1998        let msg = format!("{err:#}");
1999        assert!(
2000            msg.contains("accept_invalid_hostnames") && msg.contains("verify-ca"),
2001            "explicit verify-ca + accept_invalid_hostnames must be rejected; got: {msg}"
2002        );
2003    }
2004
2005    #[test]
2006    fn tls_explicit_disable_with_knob_is_not_flagged() {
2007        // `mode: disable` carries no verification promise to contradict, so the
2008        // danger knob is a no-op there and must not be rejected.
2009        let yaml = "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n  \
2010                    tls:\n    mode: disable\n    accept_invalid_certs: true\n\
2011                    exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n    \
2012                    destination:\n      type: local\n      path: ./out\n";
2013        Config::from_yaml(yaml).expect("mode: disable + knob is a no-op and must validate");
2014    }
2015
2016    // ── V5 name: filename-safe predicate boundaries ──────────────────────────
2017
2018    #[test]
2019    fn filename_safe_name_boundaries() {
2020        for ok in ["t", "orders", "daily_events", "v2-2024", "name.with.dots"] {
2021            assert!(is_filename_safe_name(ok), "{ok:?} must be accepted");
2022        }
2023        for bad in [
2024            "",
2025            "..",
2026            "../x",
2027            "/abs",
2028            "sub/dir",
2029            "back\\slash",
2030            ".hidden",
2031            "with\u{0000}nul",
2032        ] {
2033            assert!(!is_filename_safe_name(bad), "{bad:?} must be rejected");
2034        }
2035    }
2036}
2037
2038#[cfg(test)]
2039mod sec_config_validation {
2040    //! RED security tests for config-load validation gaps (cluster:
2041    //! config-validation). Each asserts the SECURE behavior through the
2042    //! stable `Config::from_yaml` seam: a malicious config that is accepted
2043    //! today must be REJECTED (or, for warn-only knobs, surfaced as an
2044    //! error/loud warning) at config-load. These are expected to FAIL until
2045    //! the corresponding production fix lands.
2046    //!
2047    //! The pattern mirrors the existing `query_file` `..`/absolute-path guard
2048    //! in `validate_export` (see `config/tests/validation.rs`): a syntactic
2049    //! check that runs at config-validate time so `rivet check` / `rivet
2050    //! doctor` catch the problem before any connect/plan/upload step.
2051    use super::*;
2052
2053    /// A full, otherwise-valid config whose single export's `destination:`
2054    /// block is whatever the caller passes verbatim. Only the destination
2055    /// varies, so any rejection is attributable to the destination under test.
2056    fn yaml_with_destination(dest_block: &str) -> String {
2057        format!(
2058            "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
2059             exports:\n  - name: t\n    query: \"SELECT 1\"\n    format: parquet\n\
2060             {dest_block}"
2061        )
2062    }
2063
2064    // ── V2/V12: cloud-endpoint exfiltration + http cleartext ────────────────
2065    //
2066    // `destination.endpoint` is passed straight to the opendal S3/GCS/Azure
2067    // builder with no validation (see `src/destination/{s3,gcs,azure}.rs`),
2068    // so a committed config can silently redirect every export to an
2069    // attacker-controlled host. Two distinct gaps:
2070    //   V2  — a custom *non-loopback* endpoint (data exfiltration target).
2071    //   V12 — an `http://` (plaintext) endpoint (credentials + data on the
2072    //         wire in cleartext).
2073    // The secure behavior is to reject (or require explicit opt-in) at
2074    // config-load. Loopback/emulator endpoints (Minio/Azurite/fake-gcs on
2075    // 127.0.0.1) MUST stay accepted — that path is exercised by the existing
2076    // `gcs_allow_anonymous_parses` test and the guard test below.
2077
2078    #[test]
2079    fn sec_s3_custom_endpoint_rejected() {
2080        // SEC-RED V2: a non-loopback custom S3 endpoint is an exfiltration
2081        // target — every part upload goes to attacker.example.com. Must be
2082        // rejected (or require explicit opt-in) at config-load. Accepted today.
2083        let cfg = yaml_with_destination(
2084            "    destination:\n      type: s3\n      bucket: my-bucket\n      region: us-east-1\n\
2085             \x20     endpoint: https://attacker.example.com\n",
2086        );
2087        let res = Config::from_yaml(&cfg);
2088        assert!(
2089            res.is_err(),
2090            "a non-loopback custom S3 endpoint (https://attacker.example.com) must be \
2091             rejected at config-load (data-exfiltration target); got Ok"
2092        );
2093        let msg = format!("{:#}", res.unwrap_err());
2094        assert!(
2095            msg.contains("endpoint"),
2096            "rejection must name the offending 'endpoint' field; got: {msg}"
2097        );
2098    }
2099
2100    #[test]
2101    fn sec_http_endpoint_rejected() {
2102        // SEC-RED V12: a plaintext http:// endpoint to a *remote* host sends
2103        // credentials and exported rows over the wire in cleartext. Must be
2104        // rejected (or require explicit opt-in) at config-load. Accepted today.
2105        // Use a non-loopback host so this is distinct from the Minio/Azurite
2106        // loopback emulator case (guarded below).
2107        let cfg = yaml_with_destination(
2108            "    destination:\n      type: s3\n      bucket: my-bucket\n      region: us-east-1\n\
2109             \x20     endpoint: http://evil.com\n",
2110        );
2111        let res = Config::from_yaml(&cfg);
2112        assert!(
2113            res.is_err(),
2114            "a plaintext http:// endpoint to a remote host (http://evil.com) must be \
2115             rejected at config-load (cleartext credentials + data); got Ok"
2116        );
2117        let msg = format!("{:#}", res.unwrap_err());
2118        assert!(
2119            msg.contains("endpoint") || msg.to_lowercase().contains("http"),
2120            "rejection must name the endpoint / cleartext problem; got: {msg}"
2121        );
2122    }
2123
2124    #[test]
2125    fn sec_loopback_endpoint_still_accepted_guard() {
2126        // SEC-RED V2/V12 (guard): a loopback emulator endpoint
2127        // (`http://127.0.0.1:9000` Minio, with allow_anonymous) is the
2128        // legitimate local-dev path and MUST stay accepted after the fix.
2129        // This pins that the endpoint rejection targets *remote* hosts, not
2130        // localhost — otherwise the fix breaks every Minio/Azurite/fake-gcs
2131        // integration test (see `gcs_allow_anonymous_parses`).
2132        let cfg = yaml_with_destination(
2133            "    destination:\n      type: s3\n      bucket: my-bucket\n      region: us-east-1\n\
2134             \x20     endpoint: http://127.0.0.1:9000\n      allow_anonymous: true\n",
2135        );
2136        Config::from_yaml(&cfg)
2137            .expect("a loopback emulator endpoint with allow_anonymous must stay accepted");
2138    }
2139
2140    // ── V5: export `name` path traversal ────────────────────────────────────
2141    //
2142    // `ExportConfig.name` is a free-form `String` keyed into state tracking,
2143    // file logs, and (via the destination layout) output paths — yet it is
2144    // never validated. A name like `../../../etc/x`, an absolute `/abs/x`, a
2145    // bare slash, or an embedded NUL can escape the intended output tree.
2146    // Mirror the `query_file` `..`/absolute guard: reject at config-load.
2147
2148    #[test]
2149    fn sec_export_name_traversal_rejected() {
2150        // SEC-RED V5: a traversal / absolute / slash / NUL export name escapes
2151        // the output tree (and corrupts name-keyed state). Must be rejected at
2152        // config-load. Accepted today.
2153        for bad in ["../../../etc/x", "/abs/x", "sub/dir", "with\u{0000}nul"] {
2154            // `name:` is JSON-encoded so embedded slashes / NULs survive the
2155            // YAML parse verbatim and reach validation.
2156            let name_yaml = serde_json::to_string(bad).expect("encode name");
2157            let cfg = format!(
2158                "source:\n  type: postgres\n  url: \"postgresql://localhost/test\"\n\
2159                 exports:\n  - name: {name_yaml}\n    query: \"SELECT 1\"\n    format: parquet\n\
2160                 \x20   destination:\n      type: local\n      path: ./out\n"
2161            );
2162            let res = Config::from_yaml(&cfg);
2163            assert!(
2164                res.is_err(),
2165                "export name {bad:?} (traversal/absolute/slash/NUL) must be rejected at \
2166                 config-load; got Ok"
2167            );
2168            let msg = format!("{:#}", res.unwrap_err());
2169            assert!(
2170                msg.contains("name"),
2171                "rejection of name {bad:?} must name the offending 'name' field; got: {msg}"
2172            );
2173        }
2174    }
2175
2176    #[test]
2177    fn sec_export_name_normal_still_accepted_guard() {
2178        // SEC-RED V5 (guard): a plain, well-formed export name must keep
2179        // loading after the fix. Pins that the traversal check is narrow.
2180        let cfg = yaml_with_destination("    destination:\n      type: local\n      path: ./out\n");
2181        Config::from_yaml(&cfg).expect("a normal export name ('t') must stay accepted");
2182    }
2183
2184    // ── V15: local destination `path` traversal ─────────────────────────────
2185    //
2186    // `destination.path` for a `type: local` export is written verbatim to the
2187    // filesystem. A relative `../../../../tmp/x` or absolute path lets a
2188    // committed config write outside the intended output directory. Must be
2189    // rejected (or at minimum loudly surfaced) at config-load. Accepted today.
2190
2191    #[test]
2192    fn sec_local_dest_path_traversal_rejected() {
2193        // SEC-RED V15: a traversal local-destination path writes outside the
2194        // intended output tree. Must be rejected at config-load. Accepted today.
2195        let cfg = yaml_with_destination(
2196            "    destination:\n      type: local\n      path: ../../../../tmp/x\n",
2197        );
2198        let res = Config::from_yaml(&cfg);
2199        assert!(
2200            res.is_err(),
2201            "a local destination path containing '..' (../../../../tmp/x) must be rejected \
2202             at config-load (writes outside the output tree); got Ok"
2203        );
2204        let msg = format!("{:#}", res.unwrap_err());
2205        assert!(
2206            msg.contains("path") || msg.contains(".."),
2207            "rejection must name the offending 'path' / traversal; got: {msg}"
2208        );
2209    }
2210
2211    // ── V13: dangerous TLS cert-knob combination ─────────────────────────────
2212    //
2213    // `tls: { mode: verify-full, accept_invalid_certs: true }` silently
2214    // *downgrades* the strongest mode to "accept any cert" — `verify-full`
2215    // promises chain + hostname verification, but the danger knob disables
2216    // chain verification (see `src/source/tls.rs::build_native_tls`). The
2217    // comment at `src/source/tls.rs:55-56` claims "Each one emits a warning at
2218    // config-time (see `Config::validate`)" — but `Config::validate` emits no
2219    // such warning today. The secure behavior is a LOUD error (or surfaced
2220    // warning) at config-load. No `Err`/warning is produced today, so this is
2221    // RED.
2222
2223    #[test]
2224    fn sec_accept_invalid_certs_warns() {
2225        // SEC-RED V13: verify-full + accept_invalid_certs: true is a silent
2226        // security downgrade that contradicts the chosen mode. It must be
2227        // loudly surfaced at config-load. The only stable secure seam is an
2228        // `Err` from `Config::from_yaml` (validate returns Ok today, and there
2229        // is no captured-warning seam exposed from here — see notes). Asserting
2230        // `Err` is the strongest secure assertion and is RED against current
2231        // code.
2232        let cfg = yaml_with_destination("    destination:\n      type: local\n      path: ./out\n");
2233        // Splice the TLS block into the source rather than the destination so
2234        // the rest of the config stays valid.
2235        let cfg = cfg.replace(
2236            "  url: \"postgresql://localhost/test\"\n",
2237            "  url: \"postgresql://localhost/test\"\n  tls:\n    mode: verify-full\n    accept_invalid_certs: true\n",
2238        );
2239        let res = Config::from_yaml(&cfg);
2240        assert!(
2241            res.is_err(),
2242            "tls mode: verify-full with accept_invalid_certs: true is a silent security \
2243             downgrade and must be loudly surfaced (error) at config-load; got Ok"
2244        );
2245        let msg = format!("{:#}", res.unwrap_err());
2246        assert!(
2247            msg.contains("accept_invalid_certs") || msg.to_lowercase().contains("verify"),
2248            "the surfaced error must name the dangerous knob / mode contradiction; got: {msg}"
2249        );
2250    }
2251}