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