1pub 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 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 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
76pub fn resolve_env_vars(input: &str) -> crate::error::Result<String> {
78 resolve_vars(input, None)
79}
80
81pub 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
102pub 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
126pub 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 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 #[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 #[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 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 #[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 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 #[test]
245 fn sec_param_value_newline_passes_through_guard() {
246 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 #[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 #[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 #[test]
303 fn unclosed_placeholder_left_as_is() {
304 let result = resolve_vars("${UNCLOSED", None).unwrap();
305 assert_eq!(result, "${UNCLOSED");
306 }
307
308 #[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 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 #[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 #[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 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 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 assert_eq!(parse_file_size("1B").unwrap(), 1);
435 }
436
437 #[test]
438 fn parse_sub_byte_fraction_rejected_not_truncated_to_zero() {
439 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 assert_eq!(parse_file_size("1.9B").unwrap(), 1);
452 assert_eq!(parse_file_size("1.5KB").unwrap(), 1536);
453 }
454}