Skip to main content

rivet/config/
resolve.rs

1/// Replaces `${VAR}` patterns with values from `params` (if provided) or environment variables.
2/// Params take precedence over env vars.
3///
4/// **Strict mode (default, SecOps):** if a `${VAR}` reference resolves to neither a
5/// param nor an env var, an error is returned rather than silently substituting an
6/// empty string. A missing `DB_PASS` turning `postgres://u:${DB_PASS}@h/d` into
7/// `postgres://u:@h/d` is an auth-bypass footgun — we fail fast instead.
8///
9/// A literal empty value is still accepted (`export VAR=""`) — only completely
10/// unset variables fail.
11///
12/// Empty placeholders (`${}`) are left as-is for backwards compatibility.
13///
14/// **Value hardening (V6, CWE-89/94, narrowed):** a substituted value is spliced
15/// into the raw config/query text *before* YAML/SQL parse, so a NUL byte (never
16/// legitimate, enables C-string truncation) is rejected. Substitution is
17/// otherwise a documented verbatim splice — newlines/quotes/braces pass through
18/// (escaping is the caller's responsibility); the structural fix for raw-text
19/// param injection (substitute into parsed values) is tracked separately.
20pub fn resolve_vars(
21    input: &str,
22    params: Option<&std::collections::HashMap<String, String>>,
23) -> crate::error::Result<String> {
24    let mut result = input.to_string();
25    let mut search_from = 0;
26    while let Some(rel_start) = result[search_from..].find("${") {
27        let start = search_from + rel_start;
28        let Some(rel_end) = result[start..].find('}') else {
29            break;
30        };
31        let end = start + rel_end;
32        let var_name = &result[start + 2..end];
33
34        let value = if var_name.is_empty() {
35            // Preserve legacy behavior: `${}` expands to the empty string. No secret
36            // is involved, so there's nothing to protect against.
37            String::new()
38        } else if let Some(v) = params.and_then(|p| p.get(var_name)) {
39            v.clone()
40        } else {
41            match std::env::var(var_name) {
42                Ok(v) => v,
43                Err(_) => anyhow::bail!(
44                    "'${{{var_name}}}' in the config is unresolved — set it with \
45                     `--param {var_name}=<value>` (`-p`, which wins over the environment) or export \
46                     the environment variable '{var_name}'. (A missing value would silently become \
47                     an empty string, so rivet refuses.)"
48                ),
49            }
50        };
51
52        // V6 (CWE-89/94, narrowed): the value is spliced into the RAW
53        // config/query text before YAML/SQL parse with no escaping. A NUL byte
54        // is never legitimate in a config/SQL value and enables C-string
55        // truncation tricks, so reject it. Newlines/quotes/braces are NOT
56        // rejected: substitution is a documented verbatim text splice (escaping
57        // is the caller's responsibility — see the `passes_through` test), and
58        // legitimate multi-line `-p` values rely on that. The structural fix for
59        // raw-text param injection is to substitute into parsed values, not raw
60        // text — tracked separately; this guard is the no-cost NUL backstop. The
61        // error names the placeholder but never echoes the value (it may itself
62        // be a secret).
63        if value.contains('\0') {
64            anyhow::bail!(
65                "value for '${{{var_name}}}' contains a NUL byte; refusing to substitute it \
66                 (check the parameter/environment source)"
67            );
68        }
69
70        result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
71        search_from = start + value.len();
72    }
73    Ok(result)
74}
75
76/// Convenience wrapper: resolve `${VAR}` from environment only.
77pub fn resolve_env_vars(input: &str) -> crate::error::Result<String> {
78    resolve_vars(input, None)
79}
80
81/// Return the names of `--param key=value` entries whose `${key}` placeholder
82/// does not appear in `haystack`. Sorted for deterministic warning order.
83///
84/// Used by [`warn_unused_params`]; exposed separately so tests can assert the
85/// set of unused keys without needing to capture log output.
86pub fn find_unused_params(
87    haystack: &str,
88    params: Option<&std::collections::HashMap<String, String>>,
89) -> Vec<String> {
90    let Some(p) = params else {
91        return Vec::new();
92    };
93    let mut unused: Vec<String> = p
94        .keys()
95        .filter(|k| !haystack.contains(&format!("${{{k}}}")))
96        .cloned()
97        .collect();
98    unused.sort();
99    unused
100}
101
102/// F10 (0.7.5 audit): warn loudly when `--param key=value` was passed but
103/// `${key}` never appears anywhere the resolver searched.  A common typo
104/// (`--param maxid=…` vs `${max_id}`) is otherwise silently ignored and the
105/// operator gets unexpected results.
106///
107/// Decoupled from `resolve_vars` because the same params object flows through
108/// the YAML body resolve AND each `ExportConfig::resolve_query` call — emitting
109/// the warning inside `resolve_vars` itself fired it N+1 times per `--param`.
110/// Call this exactly once per CLI invocation, passing the original (un-resolved)
111/// YAML text as the haystack so placeholders are still present.
112pub fn warn_unused_params(
113    haystack: &str,
114    params: Option<&std::collections::HashMap<String, String>>,
115) {
116    for key in find_unused_params(haystack, params) {
117        log::warn!(
118            "--param '{}' was not referenced by any `${{{}}}` placeholder in the config — \
119             check the parameter name (case-sensitive) or remove the unused --param",
120            key,
121            key
122        );
123    }
124}
125
126/// Parse a human-readable file size like "512MB", "1GB", "100KB" into bytes.
127///
128/// Accepted units are `B`, `KB`, `MB`, `GB` (case-insensitive); a bare number
129/// is bytes. A fractional value is allowed (`1.5GB`). Units are IEC-style binary
130/// multiples: `KB` = 1024 bytes, `MB` = 1024 KB, `GB` = 1024 MB.
131pub fn parse_file_size(s: &str) -> crate::error::Result<u64> {
132    let s = s.trim().to_uppercase();
133    let (num, multiplier) = if let Some(n) = s.strip_suffix("GB") {
134        (n.trim(), 1024u64 * 1024 * 1024)
135    } else if let Some(n) = s.strip_suffix("MB") {
136        (n.trim(), 1024u64 * 1024)
137    } else if let Some(n) = s.strip_suffix("KB") {
138        (n.trim(), 1024u64)
139    } else if let Some(n) = s.strip_suffix('B') {
140        (n.trim(), 1u64)
141    } else {
142        (s.as_str(), 1u64)
143    };
144    let value: f64 = num.parse().map_err(|_| {
145        anyhow::anyhow!(
146            "invalid file size: '{}' — expected a number with an optional unit \
147             B/KB/MB/GB (e.g. '512MB', '1.5GB', or a bare byte count like '1048576'); \
148             a fractional value is allowed and units are binary (KB = 1024 bytes)",
149            s
150        )
151    })?;
152    // Reject anything that lands on 0 bytes: the float→u64 cast saturates zero,
153    // NEGATIVE, NaN, AND sub-byte fractions ("0.5B", "0.0005KB") all to 0 — a
154    // 0-byte rotation threshold that splits a part after every row. The guard
155    // must run on the RESULT (post-multiplier byte count), not the pre-multiplier
156    // float: `0.5 <= 0.0` is false, so a pre-multiplier check let 0.5B through as
157    // 0 bytes (#19 bughunt). Fail loud like a non-numeric value.
158    let bytes = (value * multiplier as f64) as u64;
159    if bytes == 0 {
160        anyhow::bail!(
161            "invalid file size: '{}' — must be at least 1 byte; a zero, negative, or \
162             sub-byte rotation threshold would split the output after every row",
163            s
164        );
165    }
166    Ok(bytes)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::collections::HashMap;
173
174    // ── resolve_vars — no substitution ──────────────────────────────────────
175
176    #[test]
177    fn no_placeholders_returned_verbatim() {
178        assert_eq!(resolve_vars("SELECT 1", None).unwrap(), "SELECT 1");
179    }
180
181    #[test]
182    fn empty_string_returned_verbatim() {
183        assert_eq!(resolve_vars("", None).unwrap(), "");
184    }
185
186    // ── resolve_vars — param substitution ───────────────────────────────────
187
188    #[test]
189    fn param_substitutes_placeholder() {
190        let mut p = HashMap::new();
191        p.insert("TABLE".into(), "orders".into());
192        let result = resolve_vars("SELECT * FROM ${TABLE}", Some(&p)).unwrap();
193        assert_eq!(result, "SELECT * FROM orders");
194    }
195
196    #[test]
197    fn param_takes_precedence_over_env() {
198        // Set an env var with the same name but different value.
199        unsafe { std::env::set_var("RIVET_TEST_OVERRIDE_VAR", "from_env") };
200        let mut p = HashMap::new();
201        p.insert("RIVET_TEST_OVERRIDE_VAR".into(), "from_param".into());
202        let result = resolve_vars("${RIVET_TEST_OVERRIDE_VAR}", Some(&p)).unwrap();
203        unsafe { std::env::remove_var("RIVET_TEST_OVERRIDE_VAR") };
204        assert_eq!(result, "from_param");
205    }
206
207    #[test]
208    fn multiple_placeholders_all_substituted() {
209        let mut p = HashMap::new();
210        p.insert("A".into(), "hello".into());
211        p.insert("B".into(), "world".into());
212        let result = resolve_vars("${A} ${B}", Some(&p)).unwrap();
213        assert_eq!(result, "hello world");
214    }
215
216    // ── resolve_vars — V6 param-value injection hardening (NUL-only) ─────────
217    //
218    // A param/env value is spliced into the RAW config/query text before
219    // YAML/SQL parse with no escaping. A NUL byte is never legitimate and
220    // enables C-string truncation, so it is rejected. Newlines/quotes/braces
221    // are NOT rejected — substitution is a documented verbatim splice (see
222    // `resolve_vars_value_with_quotes_newlines_braces_passes_through`) and
223    // legitimate multi-line `-p` values rely on it; the structural fix for
224    // raw-text param injection (substitute into parsed values) is tracked
225    // separately.
226
227    #[test]
228    fn sec_param_value_with_nul_rejected() {
229        let mut p = HashMap::new();
230        p.insert("x".into(), "1\0injected".into());
231        let err = resolve_vars("${x}", Some(&p)).expect_err("a NUL value must be rejected");
232        // Names the placeholder, never echoes the (possibly-secret) value.
233        assert!(err.to_string().contains("x"), "must name the param: {err}");
234        assert!(
235            !err.to_string().contains("injected"),
236            "must not echo the value: {err}"
237        );
238    }
239
240    // (No env-var NUL test: the OS forbids a NUL byte in an environment
241    // variable — `set_var` would reject it — so that channel cannot carry the
242    // payload. The HashMap `-p` param path above is the reachable vector.)
243
244    #[test]
245    fn sec_param_value_newline_passes_through_guard() {
246        // Documented verbatim contract: a multi-line value substitutes as-is.
247        // (Mirrors resolve_vars_value_with_quotes_newlines_braces_passes_through;
248        // the V6 guard rejects only NUL, not structural-looking characters.)
249        let mut p = HashMap::new();
250        p.insert("frag".into(), "a\nb".into());
251        let result = resolve_vars("X=${frag}", Some(&p)).unwrap();
252        assert_eq!(result, "X=a\nb");
253    }
254
255    #[test]
256    fn sec_normal_param_value_substitutes_fine_guard() {
257        let mut p = HashMap::new();
258        p.insert("id_min".into(), "100".into());
259        let result = resolve_vars("WHERE id >= ${id_min}", Some(&p)).unwrap();
260        assert_eq!(result, "WHERE id >= 100");
261    }
262
263    #[test]
264    fn sec_normal_param_value_with_spaces_and_quotes_substitutes_fine_guard() {
265        let mut p = HashMap::new();
266        p.insert("filter".into(), "name = 'o''brien'".into());
267        let result = resolve_vars("WHERE ${filter}", Some(&p)).unwrap();
268        assert_eq!(result, "WHERE name = 'o''brien'");
269    }
270
271    // ── resolve_vars — env var substitution ─────────────────────────────────
272
273    #[test]
274    fn env_var_substituted_when_set() {
275        unsafe { std::env::set_var("RIVET_TEST_RESOLVE_VAR", "secret123") };
276        let result = resolve_vars("pass=${RIVET_TEST_RESOLVE_VAR}", None).unwrap();
277        unsafe { std::env::remove_var("RIVET_TEST_RESOLVE_VAR") };
278        assert_eq!(result, "pass=secret123");
279    }
280
281    #[test]
282    fn missing_env_var_returns_error() {
283        unsafe { std::env::remove_var("RIVET_DEFINITELY_NOT_SET_VAR_XYZ") };
284        let err = resolve_vars("${RIVET_DEFINITELY_NOT_SET_VAR_XYZ}", None).unwrap_err();
285        let msg = err.to_string();
286        assert!(
287            msg.contains("RIVET_DEFINITELY_NOT_SET_VAR_XYZ"),
288            "got: {msg}"
289        );
290    }
291
292    // ── resolve_vars — empty placeholder ────────────────────────────────────
293
294    #[test]
295    fn empty_placeholder_expands_to_empty_string() {
296        let result = resolve_vars("pre${}post", None).unwrap();
297        assert_eq!(result, "prepost");
298    }
299
300    // ── resolve_vars — unclosed placeholder ─────────────────────────────────
301
302    #[test]
303    fn unclosed_placeholder_left_as_is() {
304        let result = resolve_vars("${UNCLOSED", None).unwrap();
305        assert_eq!(result, "${UNCLOSED");
306    }
307
308    // ── find_unused_params — regression: F-NEW after 0.7.5 audit ────────────
309    //
310    // Before splitting the warning out of `resolve_vars`, the unused-param
311    // warning was emitted N+1 times per `--param` (once at YAML resolve,
312    // once per `ExportConfig::resolve_query` call), AND every `--param` was
313    // wrongly flagged unused at the resolve_query stage because the YAML
314    // pass had already substituted the placeholders out. These tests pin
315    // the new behavior: `find_unused_params` flags only genuinely-unused
316    // keys, against an un-resolved (placeholder-bearing) haystack.
317
318    #[test]
319    fn find_unused_params_returns_empty_when_no_params() {
320        assert!(find_unused_params("SELECT 1", None).is_empty());
321    }
322
323    #[test]
324    fn find_unused_params_used_key_not_flagged() {
325        let mut p = HashMap::new();
326        p.insert("max_id".into(), "20".into());
327        let unused = find_unused_params("SELECT * FROM t WHERE id <= ${max_id}", Some(&p));
328        assert!(unused.is_empty(), "got: {unused:?}");
329    }
330
331    #[test]
332    fn find_unused_params_unused_key_flagged_once() {
333        let mut p = HashMap::new();
334        p.insert("typo_id".into(), "20".into());
335        let unused = find_unused_params("SELECT * FROM t WHERE id <= ${max_id}", Some(&p));
336        assert_eq!(unused, vec!["typo_id".to_string()]);
337    }
338
339    #[test]
340    fn find_unused_params_mixed_used_and_unused() {
341        let mut p = HashMap::new();
342        p.insert("col".into(), "id".into());
343        p.insert("typo".into(), "x".into());
344        let unused = find_unused_params("SELECT ${col} FROM t", Some(&p));
345        assert_eq!(unused, vec!["typo".to_string()]);
346    }
347
348    #[test]
349    fn find_unused_params_partial_match_does_not_count() {
350        // A param named `max` is NOT used by a `${max_id}` placeholder —
351        // substring overlap must not satisfy the check.
352        let mut p = HashMap::new();
353        p.insert("max".into(), "20".into());
354        let unused = find_unused_params("SELECT * FROM t WHERE id <= ${max_id}", Some(&p));
355        assert_eq!(unused, vec!["max".to_string()]);
356    }
357
358    // ── resolve_env_vars wrapper ─────────────────────────────────────────────
359
360    #[test]
361    fn resolve_env_vars_reads_env() {
362        unsafe { std::env::set_var("RIVET_TEST_ENV_WRAPPER", "wrapped") };
363        let result = resolve_env_vars("v=${RIVET_TEST_ENV_WRAPPER}").unwrap();
364        unsafe { std::env::remove_var("RIVET_TEST_ENV_WRAPPER") };
365        assert_eq!(result, "v=wrapped");
366    }
367
368    // ── parse_file_size ──────────────────────────────────────────────────────
369
370    #[test]
371    fn parse_1gb() {
372        assert_eq!(parse_file_size("1GB").unwrap(), 1024 * 1024 * 1024);
373    }
374
375    #[test]
376    fn parse_512mb() {
377        assert_eq!(parse_file_size("512MB").unwrap(), 512 * 1024 * 1024);
378    }
379
380    #[test]
381    fn parse_100kb() {
382        assert_eq!(parse_file_size("100KB").unwrap(), 100 * 1024);
383    }
384
385    #[test]
386    fn parse_bytes_suffix() {
387        assert_eq!(parse_file_size("2048B").unwrap(), 2048);
388    }
389
390    #[test]
391    fn parse_no_suffix_treated_as_bytes() {
392        assert_eq!(parse_file_size("4096").unwrap(), 4096);
393    }
394
395    #[test]
396    fn parse_whitespace_trimmed() {
397        assert_eq!(parse_file_size("  256MB  ").unwrap(), 256 * 1024 * 1024);
398    }
399
400    #[test]
401    fn parse_lowercase_accepted() {
402        assert_eq!(parse_file_size("1gb").unwrap(), 1024 * 1024 * 1024);
403    }
404
405    #[test]
406    fn parse_invalid_returns_error() {
407        assert!(parse_file_size("notanumber").is_err());
408    }
409
410    #[test]
411    fn parse_invalid_error_names_accepted_units() {
412        // L25: the error must teach the accepted format, not just name the bad
413        // value — units B/KB/MB/GB, fractional allowed, and KB = 1024 (binary).
414        let err = parse_file_size("banana").unwrap_err();
415        let msg = err.to_string();
416        assert!(msg.contains("B/KB/MB/GB"), "got: {msg}");
417        assert!(msg.contains("fractional"), "got: {msg}");
418        assert!(msg.contains("1024"), "got: {msg}");
419    }
420
421    #[test]
422    fn parse_zero_and_negative_rejected_not_coerced_to_zero() {
423        // #dogfood LOW: `0` / `0B` / `-5MB` used to float→u64-saturate to a
424        // 0-byte rotation threshold (a part after every row), silently. They must
425        // fail loud like `banana`/`1PB` do.
426        for bad in ["0", "0B", "0MB", "-5MB", "-1", "-0.5GB"] {
427            let err = parse_file_size(bad).unwrap_err();
428            assert!(
429                err.to_string().contains("at least 1 byte"),
430                "'{bad}' must be rejected as non-positive, got: {err}"
431            );
432        }
433        // A tiny positive value is still accepted (only a <1-byte result is rejected).
434        assert_eq!(parse_file_size("1B").unwrap(), 1);
435    }
436
437    #[test]
438    fn parse_sub_byte_fraction_rejected_not_truncated_to_zero() {
439        // #19 bughunt: the positivity guard ran on the PRE-multiplier float, so a
440        // sub-byte value ("0.5B", "0.0005KB" = 0.512 bytes, bare "0.9") passed the
441        // `<= 0.0` check and then truncated to 0 bytes — the same silent 0-byte
442        // rotation threshold. Must reject: the check now runs on the byte result.
443        for bad in ["0.5B", "0.9", "0.0005KB", "0.4"] {
444            let err = parse_file_size(bad).unwrap_err();
445            assert!(
446                err.to_string().contains("at least 1 byte"),
447                "'{bad}' truncates to 0 bytes and must be rejected, got: {err}"
448            );
449        }
450        // Just-at / just-over one byte still parses.
451        assert_eq!(parse_file_size("1.9B").unwrap(), 1);
452        assert_eq!(parse_file_size("1.5KB").unwrap(), 1536);
453    }
454}