1use super::spec::{self, ParamsSpec};
25use crate::error::{CliError, CliResult};
26use crate::interpolate::{
27 Directive, classify_directive, iter_directives, rewrite, value_to_string,
28};
29use serde_json::Value;
30use std::collections::{BTreeMap, BTreeSet};
31
32pub const PARAMS_KEY: &str = "params";
34
35pub const PARAM_ID: &str = "param";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum BindMode {
41 Strict,
44 Placeholder,
48}
49
50pub type SuppliedParams = BTreeMap<String, Value>;
53
54#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct BoundParams {
58 pub values: BTreeMap<String, Value>,
59 pub secret_names: BTreeSet<String>,
60}
61
62impl BoundParams {
63 pub fn redacted(&self) -> BTreeMap<String, Value> {
67 self.values
68 .iter()
69 .map(|(k, v)| {
70 if self.secret_names.contains(k) {
71 (k.clone(), Value::String("***".into()))
72 } else {
73 (k.clone(), v.clone())
74 }
75 })
76 .collect()
77 }
78
79 pub fn has_secrets(&self) -> bool {
82 !self.secret_names.is_empty()
83 }
84}
85
86pub fn declared(doc: &Value) -> CliResult<ParamsSpec> {
89 let Some(raw) = doc.get(PARAMS_KEY) else {
90 return Ok(ParamsSpec::new());
91 };
92 if raw.is_null() {
93 return Ok(ParamsSpec::new());
94 }
95 let parsed: ParamsSpec = serde_json::from_value(raw.clone())
96 .map_err(|e| CliError::Config(format!("invalid `params:` block: {e}")))?;
97 spec::validate(&parsed)?;
98 Ok(parsed)
99}
100
101pub fn resolve(
107 spec: &ParamsSpec,
108 supplied: &SuppliedParams,
109 mode: BindMode,
110) -> CliResult<BoundParams> {
111 for name in supplied.keys() {
112 if !spec.contains_key(name) {
113 return Err(CliError::UnknownParam {
114 name: name.clone(),
115 known: spec.keys().cloned().collect(),
116 });
117 }
118 }
119
120 let mut bound = BoundParams::default();
121 for (name, p) in spec {
122 let value = match supplied.get(name) {
123 Some(raw) => {
124 reject_directives(name, raw)?;
125 spec::coerce(name, p.kind, raw)?
126 }
127 None => match &p.default {
128 Some(d) => spec::coerce(name, p.kind, d)?,
132 None => match mode {
133 BindMode::Placeholder => p.kind.placeholder(),
134 BindMode::Strict => {
135 return Err(CliError::MissingParam {
136 name: name.clone(),
137 description: p.description.clone(),
138 });
139 }
140 },
141 },
142 };
143 if p.secret {
144 crate::secrets::registry::register(&value_to_string(&value));
147 bound.secret_names.insert(name.clone());
148 }
149 bound.values.insert(name.clone(), value);
150 }
151 Ok(bound)
152}
153
154pub fn bind_document(
161 doc: &mut Value,
162 supplied: &SuppliedParams,
163 mode: BindMode,
164) -> CliResult<BoundParams> {
165 let spec = declared(doc)?;
166 let bound = resolve(&spec, supplied, mode)?;
167
168 let stashed = doc.get_mut(PARAMS_KEY).map(std::mem::take);
172 let result = substitute(doc, &bound.values);
173 if let (Some(block), Some(map)) = (stashed, doc.as_object_mut()) {
174 map.insert(PARAMS_KEY.to_string(), block);
175 }
176 result?;
177 Ok(bound)
178}
179
180fn reject_directives(name: &str, raw: &Value) -> CliResult<()> {
184 if let Value::String(s) = raw
185 && s.contains("${")
186 {
187 return Err(CliError::Config(format!(
188 "param '{name}': value contains an interpolation directive (`${{`). Param values are \
189 literal data — put the directive in the config's `params:` default or in the config \
190 body instead"
191 )));
192 }
193 Ok(())
194}
195
196fn substitute(v: &mut Value, bound: &BTreeMap<String, Value>) -> CliResult<()> {
198 if let Value::String(s) = v {
199 let replaced = match whole_token(s, bound)? {
200 Some(typed) => typed,
201 None => Value::String(rewrite_text(s, bound)?),
202 };
203 *v = replaced;
204 return Ok(());
205 }
206 match v {
207 Value::Array(items) => {
208 for item in items.iter_mut() {
209 substitute(item, bound)?;
210 }
211 }
212 Value::Object(map) => {
213 let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
216 for (key, mut val) in entries {
217 substitute(&mut val, bound)?;
218 map.insert(rewrite_text(&key, bound)?, val);
219 }
220 }
221 _ => {}
222 }
223 Ok(())
224}
225
226fn whole_token(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<Option<Value>> {
230 let mut tokens = iter_directives(s);
231 let Some((token, dir)) = tokens.next() else {
232 return Ok(None);
233 };
234 if tokens.next().is_some() || token != s {
235 return Ok(None);
236 }
237 match dir {
238 Directive::Deferred { id, path } if id == PARAM_ID => {
239 Ok(Some(lookup(path, token, bound)?.clone()))
240 }
241 _ => Ok(None),
242 }
243}
244
245fn rewrite_text(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
248 rewrite(s, |body| match classify_directive(body) {
249 Directive::Deferred { id, path } if id == PARAM_ID => {
250 let token = format!("${{{body}}}");
251 Ok(Some(value_to_string(lookup(path, &token, bound)?)))
252 }
253 _ => Ok(None),
254 })
255}
256
257fn lookup<'a>(path: &str, token: &str, bound: &'a BTreeMap<String, Value>) -> CliResult<&'a Value> {
260 if path.is_empty() {
261 return Err(CliError::Config(format!(
262 "interpolation '{token}' is missing a param name — write `${{param.NAME}}`"
263 )));
264 }
265 if path.contains('.') {
266 return Err(CliError::Config(format!(
267 "interpolation '{token}' is not a valid param reference — params are scalars, so \
268 `${{param.NAME}}` takes a bare name"
269 )));
270 }
271 bound.get(path).ok_or_else(|| CliError::UnknownParamRef {
272 name: path.to_string(),
273 token: token.to_string(),
274 })
275}
276
277pub fn parse_cli_param(arg: &str) -> CliResult<(String, Value)> {
280 let (key, value) = arg.split_once('=').ok_or_else(|| {
281 CliError::Config(format!("invalid --param '{arg}' — expected `name=value`"))
282 })?;
283 let key = key.trim();
284 if key.is_empty() {
285 return Err(CliError::Config(format!(
286 "invalid --param '{arg}' — the name is empty"
287 )));
288 }
289 Ok((key.to_string(), Value::String(value.to_string())))
290}
291
292pub fn collect_cli_params(args: &[String]) -> CliResult<SuppliedParams> {
295 let mut out = SuppliedParams::new();
296 for arg in args {
297 let (k, v) = parse_cli_param(arg)?;
298 if out.insert(k.clone(), v).is_some() {
299 return Err(CliError::Config(format!(
300 "--param '{k}' was given more than once"
301 )));
302 }
303 }
304 Ok(out)
305}
306
307pub fn collect_env_overrides(args: &[String]) -> CliResult<BTreeMap<String, String>> {
311 let mut out = BTreeMap::new();
312 for arg in args {
313 let (name, value) = match arg.split_once('=') {
314 Some((n, v)) => (n.trim().to_string(), v.to_string()),
315 None => {
316 let n = arg.trim().to_string();
317 let v = std::env::var(&n).map_err(|_| {
318 CliError::Config(format!(
319 "--param-env '{n}' has no value and '{n}' is not set in the environment"
320 ))
321 })?;
322 (n, v)
323 }
324 };
325 if name.is_empty() {
326 return Err(CliError::Config(format!(
327 "invalid --param-env '{arg}' — the variable name is empty"
328 )));
329 }
330 if out.insert(name.clone(), value).is_some() {
331 return Err(CliError::Config(format!(
332 "--param-env '{name}' was given more than once"
333 )));
334 }
335 }
336 Ok(out)
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use serde_json::json;
343
344 fn spec_of(yaml: &str) -> ParamsSpec {
345 serde_yaml::from_str(yaml).unwrap()
346 }
347
348 fn supplied(pairs: &[(&str, Value)]) -> SuppliedParams {
349 pairs
350 .iter()
351 .map(|(k, v)| (k.to_string(), v.clone()))
352 .collect()
353 }
354
355 #[test]
356 fn resolves_supplied_default_and_placeholder() {
357 let spec = spec_of(
358 "tenant: { required: true }\n\
359 since: { default: \"1970-01-01\" }\n\
360 page: { type: int, required: true }\n",
361 );
362 let bound = resolve(
363 &spec,
364 &supplied(&[("tenant", json!("acme")), ("page", json!("50"))]),
365 BindMode::Strict,
366 )
367 .unwrap();
368 assert_eq!(bound.values["tenant"], json!("acme"));
369 assert_eq!(bound.values["since"], json!("1970-01-01"));
370 assert_eq!(bound.values["page"], json!(50));
372
373 let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
375 assert_eq!(bound.values["tenant"], json!("<param>"));
376 assert_eq!(bound.values["page"], json!(0));
377 assert_eq!(bound.values["since"], json!("1970-01-01"));
378 }
379
380 #[test]
381 fn missing_required_param_is_a_typed_error() {
382 let spec = spec_of("tenant: { required: true, description: Tenant to sync }\n");
383 match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
384 CliError::MissingParam { name, description } => {
385 assert_eq!(name, "tenant");
386 assert_eq!(description.as_deref(), Some("Tenant to sync"));
387 }
388 other => panic!("expected MissingParam, got {other:?}"),
389 }
390 }
391
392 #[test]
393 fn unknown_supplied_param_is_rejected() {
394 let spec = spec_of("tenant: { required: true }\n");
395 match resolve(
396 &spec,
397 &supplied(&[("tenant", json!("a")), ("tenatn", json!("b"))]),
398 BindMode::Strict,
399 )
400 .unwrap_err()
401 {
402 CliError::UnknownParam { name, known } => {
403 assert_eq!(name, "tenatn");
404 assert_eq!(known, vec!["tenant".to_string()]);
405 }
406 other => panic!("expected UnknownParam, got {other:?}"),
407 }
408 }
409
410 #[test]
411 fn supplied_value_may_not_carry_a_directive() {
412 let spec = spec_of("t: { required: true }\n");
413 let err = resolve(
414 &spec,
415 &supplied(&[("t", json!("${vault:secret/data/db#password}"))]),
416 BindMode::Strict,
417 )
418 .unwrap_err()
419 .to_string();
420 assert!(err.contains("literal data"), "{err}");
421 }
422
423 #[test]
424 fn binds_document_typed_and_textual() {
425 let mut doc = json!({
426 "version": 1,
427 "params": {
428 "tenant": { "required": true },
429 "page": { "type": "int", "default": 500 },
430 "live": { "type": "bool", "default": true }
431 },
432 "pipeline": {
433 "source": {
434 "type": "rest",
435 "config": {
436 "url": "https://api.example.com/${param.tenant}/events",
437 "page_size": "${param.page}",
438 "streaming": "${param.live}"
439 }
440 }
441 }
442 });
443 let bound = bind_document(
444 &mut doc,
445 &supplied(&[("tenant", json!("acme"))]),
446 BindMode::Strict,
447 )
448 .unwrap();
449 let cfg = &doc["pipeline"]["source"]["config"];
450 assert_eq!(cfg["url"], "https://api.example.com/acme/events");
451 assert_eq!(cfg["page_size"], json!(500));
453 assert_eq!(cfg["streaming"], json!(true));
454 assert_eq!(doc["params"]["page"]["default"], json!(500));
456 assert_eq!(bound.values["tenant"], json!("acme"));
457 }
458
459 #[test]
460 fn defaults_inside_the_params_block_are_not_substituted() {
461 let mut doc = json!({
464 "params": { "a": { "default": "${param.a}" } },
465 "pipeline": { "x": "ok" }
466 });
467 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
468 assert_eq!(doc["params"]["a"]["default"], "${param.a}");
469 }
470
471 #[test]
472 fn undeclared_reference_is_rejected() {
473 let mut doc = json!({
474 "params": { "a": { "default": "1" } },
475 "pipeline": { "url": "${param.b}" }
476 });
477 match bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
478 CliError::UnknownParamRef { name, token } => {
479 assert_eq!(name, "b");
480 assert_eq!(token, "${param.b}");
481 }
482 other => panic!("expected UnknownParamRef, got {other:?}"),
483 }
484 }
485
486 #[test]
487 fn reference_without_a_params_block_is_rejected() {
488 let mut doc = json!({ "pipeline": { "url": "${param.x}" } });
491 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
492 assert!(matches!(err, CliError::UnknownParamRef { .. }), "{err:?}");
493 }
494
495 #[test]
496 fn supplying_a_param_with_no_block_is_rejected() {
497 let mut doc = json!({ "pipeline": {} });
498 match bind_document(&mut doc, &supplied(&[("x", json!("1"))]), BindMode::Strict)
499 .unwrap_err()
500 {
501 CliError::UnknownParam { known, .. } => assert!(known.is_empty()),
502 other => panic!("expected UnknownParam, got {other:?}"),
503 }
504 }
505
506 #[test]
507 fn malformed_references_are_rejected() {
508 for bad in ["${param}", "${param.a.b}"] {
509 let mut doc = json!({
510 "params": { "a": { "default": "1" } },
511 "pipeline": { "url": bad }
512 });
513 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict)
514 .unwrap_err()
515 .to_string();
516 assert!(err.contains("param"), "{bad}: {err}");
517 }
518 }
519
520 #[test]
521 fn escaped_token_stays_literal() {
522 let mut doc = json!({
523 "params": { "a": { "default": "v" } },
524 "pipeline": { "note": "$${param.a}" }
525 });
526 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
527 assert_eq!(doc["pipeline"]["note"], "${param.a}");
528 }
529
530 #[test]
531 fn other_namespaces_survive_binding() {
532 let mut doc = json!({
533 "params": { "a": { "default": "v" } },
534 "pipeline": { "url": "${param.a}/${now.date}/${users.id}" }
535 });
536 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
537 assert_eq!(doc["pipeline"]["url"], "v/${now.date}/${users.id}");
538 }
539
540 #[test]
541 fn substitutes_into_keys_and_arrays() {
542 let mut doc = json!({
543 "params": { "h": { "default": "X-Tenant" }, "n": { "type": "int", "default": 2 } },
544 "pipeline": {
545 "headers": { "${param.h}": "v" },
546 "list": ["${param.n}", "n=${param.n}"]
547 }
548 });
549 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
550 assert_eq!(doc["pipeline"]["headers"]["X-Tenant"], "v");
551 assert_eq!(doc["pipeline"]["list"][0], json!(2));
552 assert_eq!(doc["pipeline"]["list"][1], json!("n=2"));
553 }
554
555 #[test]
556 fn secret_params_are_tracked_and_redacted() {
557 let spec = spec_of("token: { required: true, secret: true }\nuser: { default: bob }\n");
558 let bound = resolve(
559 &spec,
560 &supplied(&[("token", json!("s3cret-value-long-enough"))]),
561 BindMode::Strict,
562 )
563 .unwrap();
564 assert!(bound.has_secrets());
565 let red = bound.redacted();
566 assert_eq!(red["token"], json!("***"));
567 assert_eq!(red["user"], json!("bob"));
568 assert_eq!(
570 crate::secrets::registry::redact("token=s3cret-value-long-enough"),
571 "token=***"
572 );
573 }
574
575 #[test]
576 fn invalid_params_block_is_a_config_error() {
577 let doc = json!({ "params": { "a": { "type": "date" } } });
578 let err = declared(&doc).unwrap_err().to_string();
579 assert!(err.contains("`params:` block"), "{err}");
580 assert!(declared(&json!({ "params": null })).unwrap().is_empty());
582 assert!(declared(&json!({})).unwrap().is_empty());
583 }
584
585 #[test]
586 fn cli_param_parsing() {
587 let (k, v) = parse_cli_param("tenant=acme").unwrap();
588 assert_eq!(k, "tenant");
589 assert_eq!(v, json!("acme"));
590 let (_, v) = parse_cli_param("q=a=b").unwrap();
592 assert_eq!(v, json!("a=b"));
593 let (_, v) = parse_cli_param("q=").unwrap();
595 assert_eq!(v, json!(""));
596 assert!(parse_cli_param("noequals").is_err());
597 assert!(parse_cli_param("=v").is_err());
598
599 let map = collect_cli_params(&["a=1".into(), "b=2".into()]).unwrap();
600 assert_eq!(map.len(), 2);
601 let err = collect_cli_params(&["a=1".into(), "a=2".into()])
602 .unwrap_err()
603 .to_string();
604 assert!(err.contains("more than once"), "{err}");
605 }
606
607 #[test]
608 fn env_override_parsing() {
609 let map = collect_env_overrides(&["A=1".into()]).unwrap();
610 assert_eq!(map["A"], "1");
611 unsafe { std::env::set_var("FAUCET_PARAM_ENV_TEST", "from-env") };
613 let map = collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).unwrap();
614 assert_eq!(map["FAUCET_PARAM_ENV_TEST"], "from-env");
615 unsafe { std::env::remove_var("FAUCET_PARAM_ENV_TEST") };
616 assert!(collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).is_err());
617 assert!(collect_env_overrides(&["=1".into()]).is_err());
618 assert!(collect_env_overrides(&["A=1".into(), "A=2".into()]).is_err());
619 }
620
621 #[test]
622 fn placeholder_mode_leaves_a_bindable_document() {
623 let mut doc = json!({
626 "params": { "t": { "required": true }, "n": { "type": "int", "required": true } },
627 "pipeline": { "source": { "config": { "url": "https://x/${param.t}", "n": "${param.n}" } } }
628 });
629 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
630 let cfg = &doc["pipeline"]["source"]["config"];
631 assert_eq!(cfg["url"], "https://x/<param>");
632 assert_eq!(cfg["n"], json!(0));
633 }
634
635 #[test]
636 fn bound_params_default_is_empty() {
637 let b = BoundParams::default();
638 assert!(!b.has_secrets());
639 assert!(b.redacted().is_empty());
640 }
641
642 #[test]
643 fn binding_a_non_object_document_is_a_no_op() {
644 let mut doc = json!(["${param.a}"]);
646 let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
647 assert!(matches!(err, CliError::UnknownParamRef { .. }));
648 let mut doc = json!(7);
649 bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
650 assert_eq!(doc, json!(7));
651 }
652}