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    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                // A default is authored in the config and already went through
129                // env/file/secret resolution, so it is coerced but not
130                // directive-checked.
131                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            // Register before the value can reach any log line, error string, or
145            // API body. `register` no-ops below the registry's minimum length.
146            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
154/// Bind params in an untyped config document, in place.
155///
156/// Reads and validates the document's own `params:` block, resolves each param
157/// against `supplied`, then substitutes `${param.NAME}` everywhere **except**
158/// inside the `params:` block itself (a default is a literal, not a target).
159/// Returns the bound values so the caller can echo/audit them (redacted).
160pub 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    // Lift the declaration block out so defaults are never rewritten, then put
169    // it back byte-identical — the block is part of the config and is persisted
170    // with a registered template.
171    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
180/// Reject an interpolation directive inside a caller-supplied value. Supplied
181/// params are data: allowing `${vault:…}` / `${env:…}` through would let a
182/// caller read the *server's* secrets and environment by way of a param.
183fn 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
196/// Substitute `${param.NAME}` throughout `v`.
197fn 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            // Keys may carry tokens too (a param-named header, say). Rebuild the
214            // map so a rewritten key is honoured — mirrors `interpolate_value`.
215            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
226/// If `s` is *exactly* one `${param.NAME}` token, return that param's value with
227/// its declared type intact. Anything else (extra text, several tokens, an
228/// escaped `$${param.x}`) returns `None` for textual rewriting.
229fn 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
245/// Textual rewrite: every `${param.NAME}` becomes the stringified value; every
246/// other directive survives verbatim for its own resolution stage.
247fn 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
257/// Resolve the `NAME` in `${param.NAME}`. The path must be a bare name — nested
258/// lookups (`${param.a.b}`) are not a thing, since params are scalars.
259fn 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
277/// Parse a `--param key=value` CLI argument. The value is kept as a JSON string;
278/// [`spec::coerce`] converts it to the declared type at bind time.
279pub 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
292/// Collect a `--param name=value` list into a [`SuppliedParams`] map, rejecting
293/// a repeated name (silently keeping the last would be a footgun).
294pub 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
307/// Collect a `--param-env NAME[=VALUE]` list into an env overlay. A bare `NAME`
308/// takes the value from the caller's own environment (so a secret never appears
309/// in the process arguments); `NAME=VALUE` sets it explicitly.
310pub 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        // Coerced to the declared int type even though it arrived as a string.
371        assert_eq!(bound.values["page"], json!(50));
372
373        // Placeholder mode fills the required ones.
374        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        // Whole-scalar tokens keep the declared type.
452        assert_eq!(cfg["page_size"], json!(500));
453        assert_eq!(cfg["streaming"], json!(true));
454        // The declaration block survives untouched.
455        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        // A default that *looks* like a param reference stays literal — the block
462        // is lifted out before substitution.
463        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        // No `params:` at all — a `${param.x}` token must not silently survive
489        // into a connector config.
490        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        // Registered for redaction, so it can never reach a log line in clear.
569        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        // A null block is simply absent.
581        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        // Values may contain '='.
591        let (_, v) = parse_cli_param("q=a=b").unwrap();
592        assert_eq!(v, json!("a=b"));
593        // Empty value is allowed (an intentional blank).
594        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        // Bare NAME reads the caller's environment.
612        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        // The point of Placeholder mode: a config with required params still
624        // parses + expands for structural validation.
625        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        // A scalar / array document has no `params:` key; binding must not panic.
645        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}