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