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