Skip to main content

faucet_cli/params/
bind.rs

1//! Binding `${param.NAME}` references to supplied values (#444).
2//!
3//! Binding is a **pre-parse pass over the untyped config document**, run after
4//! `${env:}` / `${file:}` / `${secret:}` interpolation and before the typed
5//! `PipelineConfig` deserialise. Two consequences worth stating, because both
6//! are load-bearing:
7//!
8//! 1. **Structure safety.** Substitution happens per JSON/YAML scalar, exactly
9//!    like [`crate::interpolate::interpolate_value`] — a supplied value holding
10//!    `:`, a newline, or `-` stays the single scalar it replaced and can never
11//!    inject a key or an array element. Downstream, SQL-bound and JSON-safe
12//!    substitution paths (`substitute_context_bind_params` /
13//!    `substitute_context_json` in `faucet_core::util`) are untouched, so the
14//!    existing SQL/JSON-injection guarantees still hold for param-derived text.
15//! 2. **No re-interpolation of caller input.** Env/file/secret directives are
16//!    resolved *before* binding, so a supplied value is never itself scanned for
17//!    directives. Belt and braces, a supplied value containing `${` is rejected
18//!    outright: params are data, not directives.
19//!
20//! When `${param.NAME}` is a scalar's *entire* text the declared type is
21//! preserved (an `int` param lands as a JSON number, not `"5"`); embedded in a
22//! longer string it is stringified, like every other interpolation namespace.
23
24use 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
32/// The config key holding the declaration block.
33pub const PARAMS_KEY: &str = "params";
34
35/// The interpolation namespace params live in (`${param.NAME}`).
36pub const PARAM_ID: &str = "param";
37
38/// What to do with a `required` param the caller did not supply.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum BindMode {
41    /// A missing required param is an error. Used on every path that actually
42    /// runs a pipeline.
43    Strict,
44    /// A missing required param is filled with a type-shaped placeholder. Used
45    /// to structurally validate a config whose values arrive later — template
46    /// registration and `faucet validate` on a parameterized config.
47    Placeholder,
48}
49
50/// Caller-supplied values, keyed by param name. Values arrive either as real
51/// JSON (HTTP) or as strings (`--param k=v`); [`spec::coerce`] normalizes both.
52pub type SuppliedParams = BTreeMap<String, Value>;
53
54/// The result of a bind: every declared param's effective value, plus which of
55/// them are sensitive.
56#[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    /// The bound values with every `secret: true` entry replaced by `"***"` —
64    /// the only form safe to echo into an API response, the audit log, or a run
65    /// record.
66    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    /// Whether any bound param is marked secret (drives the cluster-persistence
80    /// guard in the template-trigger path).
81    pub fn has_secrets(&self) -> bool {
82        !self.secret_names.is_empty()
83    }
84}
85
86/// Read the `params:` block out of an untyped config document, validating it.
87/// A document with no block yields an empty spec.
88pub 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
101/// Resolve every declared param to a value, without touching the document.
102///
103/// Precedence: supplied → `default` → (`Placeholder` mode) type placeholder →
104/// error. Supplied names that are not declared are rejected so a typo can never
105/// be silently ignored.
106pub 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    // A computed param is derived, never supplied — reject a value for one.
121    for (name, p) in spec {
122        if p.computed.is_some() && supplied.contains_key(name) {
123            return Err(CliError::Config(format!(
124                "param '{name}' is `computed` and cannot be supplied a value — it is derived from \
125                 other params"
126            )));
127        }
128    }
129
130    let mut bound = BoundParams::default();
131    for (name, p) in spec {
132        // Computed params are resolved in a second pass (they may reference
133        // other params, including each other), after the supplied/default ones.
134        if p.computed.is_some() {
135            continue;
136        }
137        let value = match supplied.get(name) {
138            Some(raw) => {
139                reject_directives(name, raw)?;
140                spec::coerce(name, p.kind, raw)?
141            }
142            None => match &p.default {
143                // A default is authored in the config and already went through
144                // env/file/secret resolution, so it is coerced but not
145                // directive-checked.
146                Some(d) => spec::coerce(name, p.kind, d)?,
147                None => match mode {
148                    BindMode::Placeholder => p.kind.placeholder(),
149                    BindMode::Strict => {
150                        return Err(CliError::MissingParam {
151                            name: name.clone(),
152                            description: p.description.clone(),
153                        });
154                    }
155                },
156            },
157        };
158        if p.secret {
159            // Register before the value can reach any log line, error string, or
160            // API body. `register` no-ops below the registry's minimum length.
161            crate::secrets::registry::register(&value_to_string(&value));
162            bound.secret_names.insert(name.clone());
163        }
164        bound.values.insert(name.clone(), value);
165    }
166
167    resolve_computed(spec, &mut bound)?;
168    Ok(bound)
169}
170
171/// Resolve every `computed` param, in dependency order, into `bound.values`.
172///
173/// A computed param's expression may reference regular params and other
174/// computed params via `${param.NAME}` and the `${map:NAME|case=value|*=default}`
175/// lookup. We loop, resolving any computed param all of whose referenced params
176/// are already bound, until none remain. If a round makes no progress, the
177/// leftover set either references an undeclared param (→ `UnknownParamRef`) or
178/// forms a cycle (→ `InterpolationCycle`).
179fn resolve_computed(spec: &ParamsSpec, bound: &mut BoundParams) -> CliResult<()> {
180    let computed_names: BTreeSet<String> = spec
181        .iter()
182        .filter(|(_, p)| p.computed.is_some())
183        .map(|(n, _)| n.clone())
184        .collect();
185    let mut remaining: Vec<(String, String)> = spec
186        .iter()
187        .filter_map(|(n, p)| p.computed.as_ref().map(|c| (n.clone(), c.clone())))
188        .collect();
189
190    while !remaining.is_empty() {
191        let mut progressed = false;
192        let mut still = Vec::new();
193        for (name, expr) in remaining {
194            let refs = referenced_params(&expr);
195            if refs.iter().all(|r| bound.values.contains_key(r)) {
196                let value = eval_computed_expr(&name, &expr, &bound.values)?;
197                bound.values.insert(name, Value::String(value));
198                progressed = true;
199            } else {
200                still.push((name, expr));
201            }
202        }
203        if !progressed {
204            // No computed param could resolve. Distinguish an undeclared
205            // reference from a cycle among the remaining computed params.
206            for (name, expr) in &still {
207                for r in referenced_params(expr) {
208                    if !bound.values.contains_key(&r) && !computed_names.contains(&r) {
209                        return Err(CliError::UnknownParamRef {
210                            name: r,
211                            token: format!("computed param '{name}'"),
212                        });
213                    }
214                }
215            }
216            let mut chain: Vec<String> = still.into_iter().map(|(n, _)| n).collect();
217            chain.sort();
218            return Err(CliError::InterpolationCycle { chain });
219        }
220        remaining = still;
221    }
222    Ok(())
223}
224
225/// Param names referenced by a computed expression — the `NAME` in every
226/// `${param.NAME}` and the switch `NAME` in every `${map:NAME|…}`.
227fn referenced_params(expr: &str) -> Vec<String> {
228    let mut out = Vec::new();
229    let mut rest = expr;
230    while let Some(start) = rest.find("${") {
231        let after = &rest[start + 2..];
232        let Some(end) = after.find('}') else { break };
233        let body = &after[..end];
234        if let Some(name) = body.strip_prefix("param.") {
235            out.push(name.trim().to_string());
236        } else if let Some(spec) = body.strip_prefix("map:")
237            && let Some(input) = spec.split('|').next()
238        {
239            out.push(input.trim().to_string());
240        }
241        rest = &after[end + 1..];
242    }
243    out
244}
245
246/// Evaluate a computed expression: substitute `${param.NAME}` (stringified bound
247/// value) and `${map:NAME|case=value|*=default}` (lookup on the bound value of
248/// `NAME`). Any other `${…}` directive is rejected — a computed value is derived
249/// from other params only, never from the environment/secrets.
250fn eval_computed_expr(
251    name: &str,
252    expr: &str,
253    bound: &BTreeMap<String, Value>,
254) -> CliResult<String> {
255    let mut out = String::new();
256    let mut rest = expr;
257    while let Some(start) = rest.find("${") {
258        out.push_str(&rest[..start]);
259        let after = &rest[start + 2..];
260        let end = after.find('}').ok_or_else(|| {
261            CliError::Config(format!(
262                "computed param '{name}': unterminated `${{` in expression '{expr}'"
263            ))
264        })?;
265        let body = &after[..end];
266        out.push_str(&resolve_computed_token(name, body, bound)?);
267        rest = &after[end + 1..];
268    }
269    out.push_str(rest);
270    Ok(out)
271}
272
273/// Resolve one `${…}` body inside a computed expression to its string value.
274fn resolve_computed_token(
275    owner: &str,
276    body: &str,
277    bound: &BTreeMap<String, Value>,
278) -> CliResult<String> {
279    if let Some(pname) = body.strip_prefix("param.") {
280        let pname = pname.trim();
281        let v = bound.get(pname).ok_or_else(|| CliError::UnknownParamRef {
282            name: pname.to_string(),
283            token: format!("computed param '{owner}'"),
284        })?;
285        return Ok(value_to_string(v));
286    }
287    if let Some(spec) = body.strip_prefix("map:") {
288        return resolve_map(owner, spec, bound);
289    }
290    Err(CliError::Config(format!(
291        "computed param '{owner}': `${{{body}}}` is not allowed — a computed expression may only \
292         reference `${{param.NAME}}` or `${{map:NAME|case=value|*=default}}`"
293    )))
294}
295
296/// Resolve a `map:NAME|case=value|…|*=default` body: look up the bound value of
297/// `NAME`, return the value of the matching case, or the `*` default. No match
298/// and no `*` is a typed load-time error.
299fn resolve_map(owner: &str, spec: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
300    let mut parts = spec.split('|');
301    let input_name = parts
302        .next()
303        .map(str::trim)
304        .filter(|s| !s.is_empty())
305        .ok_or_else(|| {
306            CliError::Config(format!(
307                "computed param '{owner}': `${{map:…}}` is missing the switch param name — write \
308                 `${{map:NAME|case=value|*=default}}`"
309            ))
310        })?;
311    let input = bound
312        .get(input_name)
313        .ok_or_else(|| CliError::UnknownParamRef {
314            name: input_name.to_string(),
315            token: format!("computed param '{owner}'"),
316        })?;
317    let input_str = value_to_string(input);
318
319    let mut default: Option<String> = None;
320    let mut matched: Option<String> = None;
321    for pair in parts {
322        let (case, value) = pair.split_once('=').ok_or_else(|| {
323            CliError::Config(format!(
324                "computed param '{owner}': map case '{pair}' is not `case=value`"
325            ))
326        })?;
327        let case = case.trim();
328        if case == "*" {
329            default = Some(value.to_string());
330        } else if case == input_str {
331            matched = Some(value.to_string());
332        }
333    }
334    matched.or(default).ok_or_else(|| {
335        CliError::Config(format!(
336            "computed param '{owner}': map has no case for '{input_name}' = '{input_str}' and no \
337             `*` default"
338        ))
339    })
340}
341
342/// Bind params in an untyped config document, in place.
343///
344/// Reads and validates the document's own `params:` block, resolves each param
345/// against `supplied`, then substitutes `${param.NAME}` everywhere **except**
346/// inside the `params:` block itself (a default is a literal, not a target).
347/// Returns the bound values so the caller can echo/audit them (redacted).
348pub fn bind_document(
349    doc: &mut Value,
350    supplied: &SuppliedParams,
351    mode: BindMode,
352) -> CliResult<BoundParams> {
353    let spec = declared(doc)?;
354    let bound = resolve(&spec, supplied, mode)?;
355
356    // Lift the declaration block out so defaults are never rewritten, then put
357    // it back byte-identical — the block is part of the config and is persisted
358    // with a registered template.
359    let stashed = doc.get_mut(PARAMS_KEY).map(std::mem::take);
360    let result = substitute(doc, &bound.values);
361    if let (Some(block), Some(map)) = (stashed, doc.as_object_mut()) {
362        map.insert(PARAMS_KEY.to_string(), block);
363    }
364    result?;
365    Ok(bound)
366}
367
368/// Reject an interpolation directive inside a caller-supplied value. Supplied
369/// params are data: allowing `${vault:…}` / `${env:…}` through would let a
370/// caller read the *server's* secrets and environment by way of a param.
371fn reject_directives(name: &str, raw: &Value) -> CliResult<()> {
372    if let Value::String(s) = raw
373        && s.contains("${")
374    {
375        return Err(CliError::Config(format!(
376            "param '{name}': value contains an interpolation directive (`${{`). Param values are \
377             literal data — put the directive in the config's `params:` default or in the config \
378             body instead"
379        )));
380    }
381    Ok(())
382}
383
384/// Substitute `${param.NAME}` throughout `v`.
385fn substitute(v: &mut Value, bound: &BTreeMap<String, Value>) -> CliResult<()> {
386    if let Value::String(s) = v {
387        let replaced = match whole_token(s, bound)? {
388            Some(typed) => typed,
389            None => Value::String(rewrite_text(s, bound)?),
390        };
391        *v = replaced;
392        return Ok(());
393    }
394    match v {
395        Value::Array(items) => {
396            for item in items.iter_mut() {
397                substitute(item, bound)?;
398            }
399        }
400        Value::Object(map) => {
401            // Keys may carry tokens too (a param-named header, say). Rebuild the
402            // map so a rewritten key is honoured — mirrors `interpolate_value`.
403            let entries: Vec<(String, Value)> = std::mem::take(map).into_iter().collect();
404            for (key, mut val) in entries {
405                substitute(&mut val, bound)?;
406                map.insert(rewrite_text(&key, bound)?, val);
407            }
408        }
409        _ => {}
410    }
411    Ok(())
412}
413
414/// If `s` is *exactly* one `${param.NAME}` token, return that param's value with
415/// its declared type intact. Anything else (extra text, several tokens, an
416/// escaped `$${param.x}`) returns `None` for textual rewriting.
417fn whole_token(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<Option<Value>> {
418    let mut tokens = iter_directives(s);
419    let Some((token, dir)) = tokens.next() else {
420        return Ok(None);
421    };
422    if tokens.next().is_some() || token != s {
423        return Ok(None);
424    }
425    match dir {
426        Directive::Deferred { id, path } if id == PARAM_ID => {
427            Ok(Some(lookup(path, token, bound)?.clone()))
428        }
429        Directive::LoadTime {
430            prefix: "map",
431            body,
432        } => Ok(Some(Value::String(resolve_map(token, body, bound)?))),
433        _ => Ok(None),
434    }
435}
436
437/// Textual rewrite: every `${param.NAME}` becomes the stringified value; every
438/// other directive survives verbatim for its own resolution stage.
439fn rewrite_text(s: &str, bound: &BTreeMap<String, Value>) -> CliResult<String> {
440    rewrite(s, |body| match classify_directive(body) {
441        Directive::Deferred { id, path } if id == PARAM_ID => {
442            let token = format!("${{{body}}}");
443            Ok(Some(value_to_string(lookup(path, &token, bound)?)))
444        }
445        Directive::LoadTime {
446            prefix: "map",
447            body: map_body,
448        } => {
449            let token = format!("${{{body}}}");
450            Ok(Some(resolve_map(&token, map_body, bound)?))
451        }
452        _ => Ok(None),
453    })
454}
455
456/// Resolve the `NAME` in `${param.NAME}`. The path must be a bare name — nested
457/// lookups (`${param.a.b}`) are not a thing, since params are scalars.
458fn lookup<'a>(path: &str, token: &str, bound: &'a BTreeMap<String, Value>) -> CliResult<&'a Value> {
459    if path.is_empty() {
460        return Err(CliError::Config(format!(
461            "interpolation '{token}' is missing a param name — write `${{param.NAME}}`"
462        )));
463    }
464    if path.contains('.') {
465        return Err(CliError::Config(format!(
466            "interpolation '{token}' is not a valid param reference — params are scalars, so \
467             `${{param.NAME}}` takes a bare name"
468        )));
469    }
470    bound.get(path).ok_or_else(|| CliError::UnknownParamRef {
471        name: path.to_string(),
472        token: token.to_string(),
473    })
474}
475
476/// Parse a `--param key=value` CLI argument. The value is kept as a JSON string;
477/// [`spec::coerce`] converts it to the declared type at bind time.
478pub fn parse_cli_param(arg: &str) -> CliResult<(String, Value)> {
479    let (key, value) = arg.split_once('=').ok_or_else(|| {
480        CliError::Config(format!("invalid --param '{arg}' — expected `name=value`"))
481    })?;
482    let key = key.trim();
483    if key.is_empty() {
484        return Err(CliError::Config(format!(
485            "invalid --param '{arg}' — the name is empty"
486        )));
487    }
488    Ok((key.to_string(), Value::String(value.to_string())))
489}
490
491/// Collect a `--param name=value` list into a [`SuppliedParams`] map, rejecting
492/// a repeated name (silently keeping the last would be a footgun).
493pub fn collect_cli_params(args: &[String]) -> CliResult<SuppliedParams> {
494    let mut out = SuppliedParams::new();
495    for arg in args {
496        let (k, v) = parse_cli_param(arg)?;
497        if out.insert(k.clone(), v).is_some() {
498            return Err(CliError::Config(format!(
499                "--param '{k}' was given more than once"
500            )));
501        }
502    }
503    Ok(out)
504}
505
506/// Collect a `--param-env NAME[=VALUE]` list into an env overlay. A bare `NAME`
507/// takes the value from the caller's own environment (so a secret never appears
508/// in the process arguments); `NAME=VALUE` sets it explicitly.
509pub fn collect_env_overrides(args: &[String]) -> CliResult<BTreeMap<String, String>> {
510    let mut out = BTreeMap::new();
511    for arg in args {
512        let (name, value) = match arg.split_once('=') {
513            Some((n, v)) => (n.trim().to_string(), v.to_string()),
514            None => {
515                let n = arg.trim().to_string();
516                let v = std::env::var(&n).map_err(|_| {
517                    CliError::Config(format!(
518                        "--param-env '{n}' has no value and '{n}' is not set in the environment"
519                    ))
520                })?;
521                (n, v)
522            }
523        };
524        if name.is_empty() {
525            return Err(CliError::Config(format!(
526                "invalid --param-env '{arg}' — the variable name is empty"
527            )));
528        }
529        if out.insert(name.clone(), value).is_some() {
530            return Err(CliError::Config(format!(
531                "--param-env '{name}' was given more than once"
532            )));
533        }
534    }
535    Ok(out)
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use serde_json::json;
542
543    fn spec_of(yaml: &str) -> ParamsSpec {
544        serde_yaml::from_str(yaml).unwrap()
545    }
546
547    fn supplied(pairs: &[(&str, Value)]) -> SuppliedParams {
548        pairs
549            .iter()
550            .map(|(k, v)| (k.to_string(), v.clone()))
551            .collect()
552    }
553
554    #[test]
555    fn resolves_supplied_default_and_placeholder() {
556        let spec = spec_of(
557            "tenant: { required: true }\n\
558             since: { default: \"1970-01-01\" }\n\
559             page: { type: int, required: true }\n",
560        );
561        let bound = resolve(
562            &spec,
563            &supplied(&[("tenant", json!("acme")), ("page", json!("50"))]),
564            BindMode::Strict,
565        )
566        .unwrap();
567        assert_eq!(bound.values["tenant"], json!("acme"));
568        assert_eq!(bound.values["since"], json!("1970-01-01"));
569        // Coerced to the declared int type even though it arrived as a string.
570        assert_eq!(bound.values["page"], json!(50));
571
572        // Placeholder mode fills the required ones.
573        let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
574        assert_eq!(bound.values["tenant"], json!("<param>"));
575        assert_eq!(bound.values["page"], json!(0));
576        assert_eq!(bound.values["since"], json!("1970-01-01"));
577    }
578
579    #[test]
580    fn computed_param_map_default_and_match() {
581        let spec = spec_of(
582            "region: { default: com }\n\
583             accounts_domain: { computed: \"${map:region|ca=zohocloud|*=zoho}\" }\n",
584        );
585        // Default region → `*` default value.
586        let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap();
587        assert_eq!(bound.values["accounts_domain"], json!("zoho"));
588        assert_eq!(bound.values["region"], json!("com"));
589        // region=ca → matching case.
590        let bound = resolve(
591            &spec,
592            &supplied(&[("region", json!("ca"))]),
593            BindMode::Strict,
594        )
595        .unwrap();
596        assert_eq!(bound.values["accounts_domain"], json!("zohocloud"));
597    }
598
599    #[test]
600    fn computed_param_rejected_when_supplied() {
601        let spec = spec_of(
602            "region: { default: com }\n\
603             accounts_domain: { computed: \"${map:region|*=zoho}\" }\n",
604        );
605        let err = resolve(
606            &spec,
607            &supplied(&[("accounts_domain", json!("hacked"))]),
608            BindMode::Strict,
609        )
610        .unwrap_err();
611        assert!(
612            matches!(&err, CliError::Config(m) if m.contains("computed") && m.contains("cannot be supplied")),
613            "got {err:?}"
614        );
615    }
616
617    #[test]
618    fn computed_param_chain_resolves_in_dependency_order() {
619        // b depends on a (also computed); resolution order is derived, not lexical.
620        let spec = spec_of(
621            "env: { default: prod }\n\
622             a: { computed: \"${map:env|prod=live|*=test}\" }\n\
623             b: { computed: \"tier-${param.a}\" }\n",
624        );
625        let bound = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap();
626        assert_eq!(bound.values["a"], json!("live"));
627        assert_eq!(bound.values["b"], json!("tier-live"));
628    }
629
630    #[test]
631    fn computed_param_cycle_is_detected() {
632        let spec = spec_of(
633            "a: { computed: \"${param.b}\" }\n\
634             b: { computed: \"${param.a}\" }\n",
635        );
636        match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
637            CliError::InterpolationCycle { chain } => {
638                assert_eq!(chain, vec!["a".to_string(), "b".to_string()]);
639            }
640            other => panic!("expected InterpolationCycle, got {other:?}"),
641        }
642    }
643
644    #[test]
645    fn computed_param_unknown_reference_is_typed() {
646        let spec = spec_of("a: { computed: \"${param.nope}\" }\n");
647        match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
648            CliError::UnknownParamRef { name, .. } => assert_eq!(name, "nope"),
649            other => panic!("expected UnknownParamRef, got {other:?}"),
650        }
651    }
652
653    #[test]
654    fn map_with_no_match_and_no_default_errors() {
655        let spec = spec_of(
656            "region: { default: xx }\n\
657             d: { computed: \"${map:region|ca=zohocloud}\" }\n",
658        );
659        let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
660        assert!(
661            matches!(&err, CliError::Config(m) if m.contains("no case for") && m.contains("no")),
662            "got {err:?}"
663        );
664    }
665
666    #[test]
667    fn computed_unterminated_brace_errors() {
668        // No closing `}` → referenced_params finds nothing, eval hits the guard.
669        let spec = spec_of("a: { computed: \"x-${param.region\" }\n");
670        let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
671        assert!(
672            matches!(&err, CliError::Config(m) if m.contains("unterminated")),
673            "got {err:?}"
674        );
675    }
676
677    #[test]
678    fn computed_disallowed_directive_errors() {
679        // A computed expression may only reference ${param.*} / ${map:…}.
680        let spec = spec_of("a: { computed: \"${env:SECRET}\" }\n");
681        let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
682        assert!(
683            matches!(&err, CliError::Config(m) if m.contains("may only reference")),
684            "got {err:?}"
685        );
686    }
687
688    #[test]
689    fn map_case_not_key_value_errors() {
690        let spec = spec_of(
691            "region: { default: com }\n\
692             d: { computed: \"${map:region|badcase}\" }\n",
693        );
694        let err = resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
695        assert!(
696            matches!(&err, CliError::Config(m) if m.contains("is not `case=value`")),
697            "got {err:?}"
698        );
699    }
700
701    #[test]
702    fn standalone_map_missing_switch_name_errors() {
703        // `${map:|a=b}` — empty switch name.
704        let mut doc = json!({
705            "pipeline": { "source": { "config": { "h": "${map:|a=b}" } } }
706        });
707        let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
708        assert!(
709            matches!(&err, CliError::Config(m) if m.contains("missing the switch param name")),
710            "got {err:?}"
711        );
712    }
713
714    #[test]
715    fn standalone_map_unknown_input_errors() {
716        let mut doc = json!({
717            "pipeline": { "source": { "config": { "h": "${map:nope|*=x}" } } }
718        });
719        let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
720        assert!(
721            matches!(&err, CliError::UnknownParamRef { name, .. } if name == "nope"),
722            "got {err:?}"
723        );
724    }
725
726    #[test]
727    fn whole_token_map_resolves_with_declared_type() {
728        // The entire value is a single `${map:…}` token → whole_token path.
729        let mut doc = json!({
730            "params": { "region": { "default": "com" } },
731            "pipeline": { "source": { "config": {
732                "domain": "${map:region|ca=zohocloud|*=zoho}"
733            } } }
734        });
735        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
736        assert_eq!(doc["pipeline"]["source"]["config"]["domain"], json!("zoho"));
737    }
738
739    #[test]
740    fn standalone_map_token_resolves_in_document() {
741        // `${map:…}` used directly in a config value (not via a computed param).
742        let mut doc = json!({
743            "params": { "region": { "default": "ca" } },
744            "pipeline": { "source": { "config": {
745                "host": "accounts.${map:region|ca=zohocloud|*=zoho}.${param.region}"
746            } } }
747        });
748        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
749        assert_eq!(
750            doc["pipeline"]["source"]["config"]["host"],
751            json!("accounts.zohocloud.ca")
752        );
753    }
754
755    #[test]
756    fn bind_document_substitutes_computed_param() {
757        let mut doc = json!({
758            "params": {
759                "region": { "default": "com" },
760                "accounts_domain": { "computed": "${map:region|ca=zohocloud|*=zoho}" }
761            },
762            "pipeline": { "source": { "config": {
763                "base_url": "https://accounts.${param.accounts_domain}.${param.region}"
764            } } }
765        });
766        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
767        assert_eq!(
768            doc["pipeline"]["source"]["config"]["base_url"],
769            json!("https://accounts.zoho.com")
770        );
771        // The declaration block is preserved byte-identical (computed expr intact).
772        assert_eq!(
773            doc["params"]["accounts_domain"]["computed"],
774            json!("${map:region|ca=zohocloud|*=zoho}")
775        );
776    }
777
778    #[test]
779    fn missing_required_param_is_a_typed_error() {
780        let spec = spec_of("tenant: { required: true, description: Tenant to sync }\n");
781        match resolve(&spec, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
782            CliError::MissingParam { name, description } => {
783                assert_eq!(name, "tenant");
784                assert_eq!(description.as_deref(), Some("Tenant to sync"));
785            }
786            other => panic!("expected MissingParam, got {other:?}"),
787        }
788    }
789
790    #[test]
791    fn unknown_supplied_param_is_rejected() {
792        let spec = spec_of("tenant: { required: true }\n");
793        match resolve(
794            &spec,
795            &supplied(&[("tenant", json!("a")), ("tenatn", json!("b"))]),
796            BindMode::Strict,
797        )
798        .unwrap_err()
799        {
800            CliError::UnknownParam { name, known } => {
801                assert_eq!(name, "tenatn");
802                assert_eq!(known, vec!["tenant".to_string()]);
803            }
804            other => panic!("expected UnknownParam, got {other:?}"),
805        }
806    }
807
808    #[test]
809    fn supplied_value_may_not_carry_a_directive() {
810        let spec = spec_of("t: { required: true }\n");
811        let err = resolve(
812            &spec,
813            &supplied(&[("t", json!("${vault:secret/data/db#password}"))]),
814            BindMode::Strict,
815        )
816        .unwrap_err()
817        .to_string();
818        assert!(err.contains("literal data"), "{err}");
819    }
820
821    #[test]
822    fn binds_document_typed_and_textual() {
823        let mut doc = json!({
824            "version": 1,
825            "params": {
826                "tenant": { "required": true },
827                "page": { "type": "int", "default": 500 },
828                "live": { "type": "bool", "default": true }
829            },
830            "pipeline": {
831                "source": {
832                    "type": "rest",
833                    "config": {
834                        "url": "https://api.example.com/${param.tenant}/events",
835                        "page_size": "${param.page}",
836                        "streaming": "${param.live}"
837                    }
838                }
839            }
840        });
841        let bound = bind_document(
842            &mut doc,
843            &supplied(&[("tenant", json!("acme"))]),
844            BindMode::Strict,
845        )
846        .unwrap();
847        let cfg = &doc["pipeline"]["source"]["config"];
848        assert_eq!(cfg["url"], "https://api.example.com/acme/events");
849        // Whole-scalar tokens keep the declared type.
850        assert_eq!(cfg["page_size"], json!(500));
851        assert_eq!(cfg["streaming"], json!(true));
852        // The declaration block survives untouched.
853        assert_eq!(doc["params"]["page"]["default"], json!(500));
854        assert_eq!(bound.values["tenant"], json!("acme"));
855    }
856
857    #[test]
858    fn defaults_inside_the_params_block_are_not_substituted() {
859        // A default that *looks* like a param reference stays literal — the block
860        // is lifted out before substitution.
861        let mut doc = json!({
862            "params": { "a": { "default": "${param.a}" } },
863            "pipeline": { "x": "ok" }
864        });
865        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
866        assert_eq!(doc["params"]["a"]["default"], "${param.a}");
867    }
868
869    #[test]
870    fn undeclared_reference_is_rejected() {
871        let mut doc = json!({
872            "params": { "a": { "default": "1" } },
873            "pipeline": { "url": "${param.b}" }
874        });
875        match bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err() {
876            CliError::UnknownParamRef { name, token } => {
877                assert_eq!(name, "b");
878                assert_eq!(token, "${param.b}");
879            }
880            other => panic!("expected UnknownParamRef, got {other:?}"),
881        }
882    }
883
884    #[test]
885    fn reference_without_a_params_block_is_rejected() {
886        // No `params:` at all — a `${param.x}` token must not silently survive
887        // into a connector config.
888        let mut doc = json!({ "pipeline": { "url": "${param.x}" } });
889        let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
890        assert!(matches!(err, CliError::UnknownParamRef { .. }), "{err:?}");
891    }
892
893    #[test]
894    fn supplying_a_param_with_no_block_is_rejected() {
895        let mut doc = json!({ "pipeline": {} });
896        match bind_document(&mut doc, &supplied(&[("x", json!("1"))]), BindMode::Strict)
897            .unwrap_err()
898        {
899            CliError::UnknownParam { known, .. } => assert!(known.is_empty()),
900            other => panic!("expected UnknownParam, got {other:?}"),
901        }
902    }
903
904    #[test]
905    fn malformed_references_are_rejected() {
906        for bad in ["${param}", "${param.a.b}"] {
907            let mut doc = json!({
908                "params": { "a": { "default": "1" } },
909                "pipeline": { "url": bad }
910            });
911            let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict)
912                .unwrap_err()
913                .to_string();
914            assert!(err.contains("param"), "{bad}: {err}");
915        }
916    }
917
918    #[test]
919    fn escaped_token_stays_literal() {
920        let mut doc = json!({
921            "params": { "a": { "default": "v" } },
922            "pipeline": { "note": "$${param.a}" }
923        });
924        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
925        assert_eq!(doc["pipeline"]["note"], "${param.a}");
926    }
927
928    #[test]
929    fn other_namespaces_survive_binding() {
930        let mut doc = json!({
931            "params": { "a": { "default": "v" } },
932            "pipeline": { "url": "${param.a}/${now.date}/${users.id}" }
933        });
934        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
935        assert_eq!(doc["pipeline"]["url"], "v/${now.date}/${users.id}");
936    }
937
938    #[test]
939    fn substitutes_into_keys_and_arrays() {
940        let mut doc = json!({
941            "params": { "h": { "default": "X-Tenant" }, "n": { "type": "int", "default": 2 } },
942            "pipeline": {
943                "headers": { "${param.h}": "v" },
944                "list": ["${param.n}", "n=${param.n}"]
945            }
946        });
947        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
948        assert_eq!(doc["pipeline"]["headers"]["X-Tenant"], "v");
949        assert_eq!(doc["pipeline"]["list"][0], json!(2));
950        assert_eq!(doc["pipeline"]["list"][1], json!("n=2"));
951    }
952
953    #[test]
954    fn secret_params_are_tracked_and_redacted() {
955        let spec = spec_of("token: { required: true, secret: true }\nuser: { default: bob }\n");
956        let bound = resolve(
957            &spec,
958            &supplied(&[("token", json!("s3cret-value-long-enough"))]),
959            BindMode::Strict,
960        )
961        .unwrap();
962        assert!(bound.has_secrets());
963        let red = bound.redacted();
964        assert_eq!(red["token"], json!("***"));
965        assert_eq!(red["user"], json!("bob"));
966        // Registered for redaction, so it can never reach a log line in clear.
967        assert_eq!(
968            crate::secrets::registry::redact("token=s3cret-value-long-enough"),
969            "token=***"
970        );
971    }
972
973    #[test]
974    fn invalid_params_block_is_a_config_error() {
975        let doc = json!({ "params": { "a": { "type": "date" } } });
976        let err = declared(&doc).unwrap_err().to_string();
977        assert!(err.contains("`params:` block"), "{err}");
978        // A null block is simply absent.
979        assert!(declared(&json!({ "params": null })).unwrap().is_empty());
980        assert!(declared(&json!({})).unwrap().is_empty());
981    }
982
983    #[test]
984    fn cli_param_parsing() {
985        let (k, v) = parse_cli_param("tenant=acme").unwrap();
986        assert_eq!(k, "tenant");
987        assert_eq!(v, json!("acme"));
988        // Values may contain '='.
989        let (_, v) = parse_cli_param("q=a=b").unwrap();
990        assert_eq!(v, json!("a=b"));
991        // Empty value is allowed (an intentional blank).
992        let (_, v) = parse_cli_param("q=").unwrap();
993        assert_eq!(v, json!(""));
994        assert!(parse_cli_param("noequals").is_err());
995        assert!(parse_cli_param("=v").is_err());
996
997        let map = collect_cli_params(&["a=1".into(), "b=2".into()]).unwrap();
998        assert_eq!(map.len(), 2);
999        let err = collect_cli_params(&["a=1".into(), "a=2".into()])
1000            .unwrap_err()
1001            .to_string();
1002        assert!(err.contains("more than once"), "{err}");
1003    }
1004
1005    #[test]
1006    fn env_override_parsing() {
1007        let map = collect_env_overrides(&["A=1".into()]).unwrap();
1008        assert_eq!(map["A"], "1");
1009        // Bare NAME reads the caller's environment.
1010        unsafe { std::env::set_var("FAUCET_PARAM_ENV_TEST", "from-env") };
1011        let map = collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).unwrap();
1012        assert_eq!(map["FAUCET_PARAM_ENV_TEST"], "from-env");
1013        unsafe { std::env::remove_var("FAUCET_PARAM_ENV_TEST") };
1014        assert!(collect_env_overrides(&["FAUCET_PARAM_ENV_TEST".into()]).is_err());
1015        assert!(collect_env_overrides(&["=1".into()]).is_err());
1016        assert!(collect_env_overrides(&["A=1".into(), "A=2".into()]).is_err());
1017    }
1018
1019    #[test]
1020    fn placeholder_mode_leaves_a_bindable_document() {
1021        // The point of Placeholder mode: a config with required params still
1022        // parses + expands for structural validation.
1023        let mut doc = json!({
1024            "params": { "t": { "required": true }, "n": { "type": "int", "required": true } },
1025            "pipeline": { "source": { "config": { "url": "https://x/${param.t}", "n": "${param.n}" } } }
1026        });
1027        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Placeholder).unwrap();
1028        let cfg = &doc["pipeline"]["source"]["config"];
1029        assert_eq!(cfg["url"], "https://x/<param>");
1030        assert_eq!(cfg["n"], json!(0));
1031    }
1032
1033    #[test]
1034    fn bound_params_default_is_empty() {
1035        let b = BoundParams::default();
1036        assert!(!b.has_secrets());
1037        assert!(b.redacted().is_empty());
1038    }
1039
1040    #[test]
1041    fn binding_a_non_object_document_is_a_no_op() {
1042        // A scalar / array document has no `params:` key; binding must not panic.
1043        let mut doc = json!(["${param.a}"]);
1044        let err = bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap_err();
1045        assert!(matches!(err, CliError::UnknownParamRef { .. }));
1046        let mut doc = json!(7);
1047        bind_document(&mut doc, &SuppliedParams::new(), BindMode::Strict).unwrap();
1048        assert_eq!(doc, json!(7));
1049    }
1050}