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.
13pub fn resolve_vars(
14    input: &str,
15    params: Option<&std::collections::HashMap<String, String>>,
16) -> crate::error::Result<String> {
17    let mut result = input.to_string();
18    let mut search_from = 0;
19    while let Some(rel_start) = result[search_from..].find("${") {
20        let start = search_from + rel_start;
21        let Some(rel_end) = result[start..].find('}') else {
22            break;
23        };
24        let end = start + rel_end;
25        let var_name = &result[start + 2..end];
26
27        let value = if var_name.is_empty() {
28            // Preserve legacy behavior: `${}` expands to the empty string. No secret
29            // is involved, so there's nothing to protect against.
30            String::new()
31        } else if let Some(v) = params.and_then(|p| p.get(var_name)) {
32            v.clone()
33        } else {
34            match std::env::var(var_name) {
35                Ok(v) => v,
36                Err(_) => anyhow::bail!(
37                    "environment variable '{}' referenced in config is not set \
38                     (a missing secret silently becomes an empty string — refusing)",
39                    var_name
40                ),
41            }
42        };
43
44        result = format!("{}{}{}", &result[..start], value, &result[end + 1..]);
45        search_from = start + value.len();
46    }
47    Ok(result)
48}
49
50/// Convenience wrapper: resolve `${VAR}` from environment only.
51pub fn resolve_env_vars(input: &str) -> crate::error::Result<String> {
52    resolve_vars(input, None)
53}
54
55/// Return the names of `--param key=value` entries whose `${key}` placeholder
56/// does not appear in `haystack`. Sorted for deterministic warning order.
57///
58/// Used by [`warn_unused_params`]; exposed separately so tests can assert the
59/// set of unused keys without needing to capture log output.
60pub fn find_unused_params(
61    haystack: &str,
62    params: Option<&std::collections::HashMap<String, String>>,
63) -> Vec<String> {
64    let Some(p) = params else {
65        return Vec::new();
66    };
67    let mut unused: Vec<String> = p
68        .keys()
69        .filter(|k| !haystack.contains(&format!("${{{k}}}")))
70        .cloned()
71        .collect();
72    unused.sort();
73    unused
74}
75
76/// F10 (0.7.5 audit): warn loudly when `--param key=value` was passed but
77/// `${key}` never appears anywhere the resolver searched.  A common typo
78/// (`--param maxid=…` vs `${max_id}`) is otherwise silently ignored and the
79/// operator gets unexpected results.
80///
81/// Decoupled from `resolve_vars` because the same params object flows through
82/// the YAML body resolve AND each `ExportConfig::resolve_query` call — emitting
83/// the warning inside `resolve_vars` itself fired it N+1 times per `--param`.
84/// Call this exactly once per CLI invocation, passing the original (un-resolved)
85/// YAML text as the haystack so placeholders are still present.
86pub fn warn_unused_params(
87    haystack: &str,
88    params: Option<&std::collections::HashMap<String, String>>,
89) {
90    for key in find_unused_params(haystack, params) {
91        log::warn!(
92            "--param '{}' was not referenced by any `${{{}}}` placeholder in the config — \
93             check the parameter name (case-sensitive) or remove the unused --param",
94            key,
95            key
96        );
97    }
98}
99
100/// Parse a human-readable file size like "512MB", "1GB", "100KB" into bytes.
101pub fn parse_file_size(s: &str) -> crate::error::Result<u64> {
102    let s = s.trim().to_uppercase();
103    let (num, multiplier) = if let Some(n) = s.strip_suffix("GB") {
104        (n.trim(), 1024u64 * 1024 * 1024)
105    } else if let Some(n) = s.strip_suffix("MB") {
106        (n.trim(), 1024u64 * 1024)
107    } else if let Some(n) = s.strip_suffix("KB") {
108        (n.trim(), 1024u64)
109    } else if let Some(n) = s.strip_suffix('B') {
110        (n.trim(), 1u64)
111    } else {
112        (s.as_str(), 1u64)
113    };
114    let value: f64 = num
115        .parse()
116        .map_err(|_| anyhow::anyhow!("invalid file size: '{}'", s))?;
117    Ok((value * multiplier as f64) as u64)
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use std::collections::HashMap;
124
125    // ── resolve_vars — no substitution ──────────────────────────────────────
126
127    #[test]
128    fn no_placeholders_returned_verbatim() {
129        assert_eq!(resolve_vars("SELECT 1", None).unwrap(), "SELECT 1");
130    }
131
132    #[test]
133    fn empty_string_returned_verbatim() {
134        assert_eq!(resolve_vars("", None).unwrap(), "");
135    }
136
137    // ── resolve_vars — param substitution ───────────────────────────────────
138
139    #[test]
140    fn param_substitutes_placeholder() {
141        let mut p = HashMap::new();
142        p.insert("TABLE".into(), "orders".into());
143        let result = resolve_vars("SELECT * FROM ${TABLE}", Some(&p)).unwrap();
144        assert_eq!(result, "SELECT * FROM orders");
145    }
146
147    #[test]
148    fn param_takes_precedence_over_env() {
149        // Set an env var with the same name but different value.
150        unsafe { std::env::set_var("RIVET_TEST_OVERRIDE_VAR", "from_env") };
151        let mut p = HashMap::new();
152        p.insert("RIVET_TEST_OVERRIDE_VAR".into(), "from_param".into());
153        let result = resolve_vars("${RIVET_TEST_OVERRIDE_VAR}", Some(&p)).unwrap();
154        unsafe { std::env::remove_var("RIVET_TEST_OVERRIDE_VAR") };
155        assert_eq!(result, "from_param");
156    }
157
158    #[test]
159    fn multiple_placeholders_all_substituted() {
160        let mut p = HashMap::new();
161        p.insert("A".into(), "hello".into());
162        p.insert("B".into(), "world".into());
163        let result = resolve_vars("${A} ${B}", Some(&p)).unwrap();
164        assert_eq!(result, "hello world");
165    }
166
167    // ── resolve_vars — env var substitution ─────────────────────────────────
168
169    #[test]
170    fn env_var_substituted_when_set() {
171        unsafe { std::env::set_var("RIVET_TEST_RESOLVE_VAR", "secret123") };
172        let result = resolve_vars("pass=${RIVET_TEST_RESOLVE_VAR}", None).unwrap();
173        unsafe { std::env::remove_var("RIVET_TEST_RESOLVE_VAR") };
174        assert_eq!(result, "pass=secret123");
175    }
176
177    #[test]
178    fn missing_env_var_returns_error() {
179        unsafe { std::env::remove_var("RIVET_DEFINITELY_NOT_SET_VAR_XYZ") };
180        let err = resolve_vars("${RIVET_DEFINITELY_NOT_SET_VAR_XYZ}", None).unwrap_err();
181        let msg = err.to_string();
182        assert!(
183            msg.contains("RIVET_DEFINITELY_NOT_SET_VAR_XYZ"),
184            "got: {msg}"
185        );
186    }
187
188    // ── resolve_vars — empty placeholder ────────────────────────────────────
189
190    #[test]
191    fn empty_placeholder_expands_to_empty_string() {
192        let result = resolve_vars("pre${}post", None).unwrap();
193        assert_eq!(result, "prepost");
194    }
195
196    // ── resolve_vars — unclosed placeholder ─────────────────────────────────
197
198    #[test]
199    fn unclosed_placeholder_left_as_is() {
200        let result = resolve_vars("${UNCLOSED", None).unwrap();
201        assert_eq!(result, "${UNCLOSED");
202    }
203
204    // ── find_unused_params — regression: F-NEW after 0.7.5 audit ────────────
205    //
206    // Before splitting the warning out of `resolve_vars`, the unused-param
207    // warning was emitted N+1 times per `--param` (once at YAML resolve,
208    // once per `ExportConfig::resolve_query` call), AND every `--param` was
209    // wrongly flagged unused at the resolve_query stage because the YAML
210    // pass had already substituted the placeholders out. These tests pin
211    // the new behavior: `find_unused_params` flags only genuinely-unused
212    // keys, against an un-resolved (placeholder-bearing) haystack.
213
214    #[test]
215    fn find_unused_params_returns_empty_when_no_params() {
216        assert!(find_unused_params("SELECT 1", None).is_empty());
217    }
218
219    #[test]
220    fn find_unused_params_used_key_not_flagged() {
221        let mut p = HashMap::new();
222        p.insert("max_id".into(), "20".into());
223        let unused = find_unused_params("SELECT * FROM t WHERE id <= ${max_id}", Some(&p));
224        assert!(unused.is_empty(), "got: {unused:?}");
225    }
226
227    #[test]
228    fn find_unused_params_unused_key_flagged_once() {
229        let mut p = HashMap::new();
230        p.insert("typo_id".into(), "20".into());
231        let unused = find_unused_params("SELECT * FROM t WHERE id <= ${max_id}", Some(&p));
232        assert_eq!(unused, vec!["typo_id".to_string()]);
233    }
234
235    #[test]
236    fn find_unused_params_mixed_used_and_unused() {
237        let mut p = HashMap::new();
238        p.insert("col".into(), "id".into());
239        p.insert("typo".into(), "x".into());
240        let unused = find_unused_params("SELECT ${col} FROM t", Some(&p));
241        assert_eq!(unused, vec!["typo".to_string()]);
242    }
243
244    #[test]
245    fn find_unused_params_partial_match_does_not_count() {
246        // A param named `max` is NOT used by a `${max_id}` placeholder —
247        // substring overlap must not satisfy the check.
248        let mut p = HashMap::new();
249        p.insert("max".into(), "20".into());
250        let unused = find_unused_params("SELECT * FROM t WHERE id <= ${max_id}", Some(&p));
251        assert_eq!(unused, vec!["max".to_string()]);
252    }
253
254    // ── resolve_env_vars wrapper ─────────────────────────────────────────────
255
256    #[test]
257    fn resolve_env_vars_reads_env() {
258        unsafe { std::env::set_var("RIVET_TEST_ENV_WRAPPER", "wrapped") };
259        let result = resolve_env_vars("v=${RIVET_TEST_ENV_WRAPPER}").unwrap();
260        unsafe { std::env::remove_var("RIVET_TEST_ENV_WRAPPER") };
261        assert_eq!(result, "v=wrapped");
262    }
263
264    // ── parse_file_size ──────────────────────────────────────────────────────
265
266    #[test]
267    fn parse_1gb() {
268        assert_eq!(parse_file_size("1GB").unwrap(), 1024 * 1024 * 1024);
269    }
270
271    #[test]
272    fn parse_512mb() {
273        assert_eq!(parse_file_size("512MB").unwrap(), 512 * 1024 * 1024);
274    }
275
276    #[test]
277    fn parse_100kb() {
278        assert_eq!(parse_file_size("100KB").unwrap(), 100 * 1024);
279    }
280
281    #[test]
282    fn parse_bytes_suffix() {
283        assert_eq!(parse_file_size("2048B").unwrap(), 2048);
284    }
285
286    #[test]
287    fn parse_no_suffix_treated_as_bytes() {
288        assert_eq!(parse_file_size("4096").unwrap(), 4096);
289    }
290
291    #[test]
292    fn parse_whitespace_trimmed() {
293        assert_eq!(parse_file_size("  256MB  ").unwrap(), 256 * 1024 * 1024);
294    }
295
296    #[test]
297    fn parse_lowercase_accepted() {
298        assert_eq!(parse_file_size("1gb").unwrap(), 1024 * 1024 * 1024);
299    }
300
301    #[test]
302    fn parse_invalid_returns_error() {
303        assert!(parse_file_size("notanumber").is_err());
304    }
305}