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