Skip to main content

faucet_cli/secrets/
mod.rs

1//! Secrets-manager interpolation for the config layer (#125).
2//!
3//! Resolution runs as the final config-load stage (after env/file and
4//! vars/templates), over the parsed config tree. See the design spec.
5
6pub mod registry;
7
8#[cfg(feature = "secrets-aws-sm")]
9mod aws_sm;
10#[cfg(feature = "secrets-azure-kv")]
11mod azure_kv;
12#[cfg(feature = "secrets-gcp-sm")]
13mod gcp_sm;
14#[cfg(feature = "secrets-vault")]
15mod vault;
16
17use crate::config::PipelineConfig;
18use crate::error::{CliError, CliResult};
19use crate::interpolate::{self, Directive};
20use async_trait::async_trait;
21use futures::stream::{self, StreamExt, TryStreamExt};
22use serde_json::Value;
23use std::collections::{BTreeSet, HashMap};
24use std::sync::Arc;
25
26/// The four secret-manager schemes this layer recognises.
27pub const SECRET_SCHEMES: &[&str] = &["vault", "aws-sm", "gcp-sm", "azure-kv"];
28
29/// A `(scheme, reference)` pair, e.g. `("vault", "secret/data/app#token")`.
30pub type SecretRef = (String, String);
31
32#[async_trait]
33pub trait SecretResolver: Send + Sync {
34    /// Scheme handled, e.g. `"vault"`.
35    fn scheme(&self) -> &'static str;
36    /// Resolve a `path[#field]` reference to its string value.
37    async fn resolve(&self, reference: &str) -> CliResult<String>;
38}
39
40/// Split a `path#field` reference into `(path, Some(field))` or `(path, None)`.
41#[allow(dead_code)] // used by provider modules added in later tasks
42pub(crate) fn split_field(reference: &str) -> (&str, Option<&str>) {
43    match reference.split_once('#') {
44        Some((path, field)) => (path, Some(field)),
45        None => (reference, None),
46    }
47}
48
49/// Extract `field` from a secret body that must parse as a JSON object.
50/// Used by Vault and AWS resolvers for the `#field` selector.
51#[allow(dead_code)] // used by provider modules added in later tasks
52pub(crate) fn extract_field(
53    scheme: &str,
54    reference: &str,
55    body: &str,
56    field: &str,
57) -> CliResult<String> {
58    let json: Value = serde_json::from_str(body).map_err(|_| CliError::SecretNotJson {
59        scheme: scheme.to_owned(),
60        reference: reference.to_owned(),
61    })?;
62    let obj = json.as_object().ok_or_else(|| CliError::SecretNotJson {
63        scheme: scheme.to_owned(),
64        reference: reference.to_owned(),
65    })?;
66    match obj.get(field) {
67        Some(Value::String(s)) => Ok(s.clone()),
68        Some(other) => Ok(other.to_string()),
69        None => Err(CliError::SecretFieldMissing {
70            scheme: scheme.to_owned(),
71            reference: reference.to_owned(),
72            field: field.to_owned(),
73            available: obj.keys().cloned().collect(),
74        }),
75    }
76}
77
78/// Apply `f` to every string leaf in `value`, recursively.
79fn for_each_string<F: FnMut(&str)>(value: &Value, f: &mut F) {
80    match value {
81        Value::String(s) => f(s),
82        Value::Array(a) => a.iter().for_each(|v| for_each_string(v, f)),
83        Value::Object(m) => m.values().for_each(|v| for_each_string(v, f)),
84        _ => {}
85    }
86}
87
88/// Mutate every string leaf in `value`, recursively.
89fn for_each_string_mut<F: FnMut(&mut String) -> CliResult<()>>(
90    value: &mut Value,
91    f: &mut F,
92) -> CliResult<()> {
93    match value {
94        Value::String(s) => f(s),
95        Value::Array(a) => a.iter_mut().try_for_each(|v| for_each_string_mut(v, f)),
96        Value::Object(m) => m.values_mut().try_for_each(|v| for_each_string_mut(v, f)),
97        _ => Ok(()),
98    }
99}
100
101/// Collect every unique secret reference found in a single string.
102fn collect_refs_in_str(s: &str, out: &mut BTreeSet<SecretRef>) {
103    for (_token, dir) in interpolate::iter_directives(s) {
104        if let Directive::LoadTime { prefix, body } = dir
105            && SECRET_SCHEMES.contains(&prefix)
106        {
107            out.insert((prefix.to_owned(), body.to_owned()));
108        }
109    }
110}
111
112/// Collect all unique secret references reachable from a config `Value`.
113pub fn collect_refs(value: &Value, out: &mut BTreeSet<SecretRef>) {
114    for_each_string(value, &mut |s| collect_refs_in_str(s, out));
115}
116
117/// Substitute every secret directive in a `Value` from `cache`. Non-secret
118/// directives (`${users.id}`) pass through verbatim.
119pub fn substitute(value: &mut Value, cache: &HashMap<SecretRef, String>) -> CliResult<()> {
120    for_each_string_mut(value, &mut |s| {
121        let new = interpolate::rewrite(s, |body| match interpolate::classify_directive(body) {
122            Directive::LoadTime { prefix, body: b } if SECRET_SCHEMES.contains(&prefix) => {
123                Ok(Some(
124                    cache
125                        .get(&(prefix.to_owned(), b.to_owned()))
126                        .cloned()
127                        .expect("scan collected every secret ref before fetch"),
128                ))
129            }
130            _ => Ok(None),
131        })?;
132        *s = new;
133        Ok(())
134    })
135}
136
137/// Map of scheme → resolver, built from compiled-in features (or injected in tests).
138#[derive(Default, Clone)]
139pub struct ResolverSet {
140    resolvers: HashMap<&'static str, Arc<dyn SecretResolver>>,
141}
142
143impl ResolverSet {
144    pub fn insert(&mut self, resolver: Arc<dyn SecretResolver>) {
145        self.resolvers.insert(resolver.scheme(), resolver);
146    }
147    fn get(&self, scheme: &str) -> Option<&Arc<dyn SecretResolver>> {
148        self.resolvers.get(scheme)
149    }
150}
151
152/// Construct the resolver for one scheme, or `SecretBackendDisabled` if the
153/// feature was not compiled in. Constructors are cheap (no network / client
154/// init) — heavy clients are built lazily on first `resolve`.
155fn make_resolver(scheme: &str) -> CliResult<Arc<dyn SecretResolver>> {
156    match scheme {
157        #[cfg(feature = "secrets-vault")]
158        "vault" => Ok(Arc::new(vault::VaultResolver::from_env()?)),
159        #[cfg(feature = "secrets-aws-sm")]
160        "aws-sm" => Ok(Arc::new(aws_sm::AwsSmResolver::new())),
161        #[cfg(feature = "secrets-gcp-sm")]
162        "gcp-sm" => Ok(Arc::new(gcp_sm::GcpSmResolver::new())),
163        #[cfg(feature = "secrets-azure-kv")]
164        "azure-kv" => Ok(Arc::new(azure_kv::AzureKvResolver::new())),
165        other => Err(CliError::SecretBackendDisabled {
166            scheme: other.to_owned(),
167        }),
168    }
169}
170
171/// Apply a read-only closure to every config `Value` location (mirrors
172/// `resolve_config_refs`'s traversal).
173fn visit_config_values<F: FnMut(&Value)>(cfg: &PipelineConfig, mut f: F) {
174    // The shared `auth:` catalog and the `vars:` block are first-class config
175    // locations: a secret placed in either must resolve before the auth catalog
176    // is built / vars are consumed (#134).
177    if let Some(auth) = cfg.auth.as_ref() {
178        for spec in auth.values() {
179            f(spec);
180        }
181    }
182    if let Some(vars) = cfg.vars.as_ref() {
183        for v in vars.values() {
184            f(v);
185        }
186    }
187    // The replication snapshot source config is a first-class connector config
188    // (like `pipeline.source.config`); secrets placed there must resolve too.
189    if let Some(r) = cfg.replication.as_ref() {
190        f(&r.snapshot.source.config);
191    }
192    for spec in cfg.pipeline.sources.values() {
193        f(&spec.config);
194    }
195    for spec in cfg.pipeline.sinks.values() {
196        f(&spec.config);
197    }
198    if let Some(spec) = cfg.pipeline.source.as_ref() {
199        f(&spec.config);
200    }
201    if let Some(spec) = cfg.pipeline.sink.as_ref() {
202        f(&spec.config);
203    }
204    for t in cfg.pipeline.transforms.iter() {
205        f(&t.config);
206    }
207    if let Some(s) = cfg.pipeline.state.as_ref() {
208        f(&s.config);
209    }
210    if let Some(d) = cfg.pipeline.dlq.as_ref() {
211        f(&d.sink.config);
212    }
213    for row in cfg.matrix.iter() {
214        if let Some(p) = row.source.as_ref()
215            && let Some(c) = p.config.as_ref()
216        {
217            f(c);
218        }
219        if let Some(p) = row.sink.as_ref()
220            && let Some(c) = p.config.as_ref()
221        {
222            f(c);
223        }
224        if let Some(ts) = row.transforms.as_ref() {
225            for t in ts.iter() {
226                f(&t.config);
227            }
228        }
229        if let Some(s) = row.state.as_ref() {
230            f(&s.config);
231        }
232        if let Some(Some(d)) = row.dlq.as_ref() {
233            f(&d.sink.config);
234        }
235    }
236}
237
238/// Apply a mutating, fallible closure to every config `Value` location.
239fn visit_config_values_mut<F: FnMut(&mut Value) -> CliResult<()>>(
240    cfg: &mut PipelineConfig,
241    mut f: F,
242) -> CliResult<()> {
243    // See `visit_config_values`: the `auth:` catalog and `vars:` block are
244    // walked too so secrets resolve there before they are consumed (#134).
245    if let Some(auth) = cfg.auth.as_mut() {
246        for spec in auth.values_mut() {
247            f(spec)?;
248        }
249    }
250    if let Some(vars) = cfg.vars.as_mut() {
251        for v in vars.values_mut() {
252            f(v)?;
253        }
254    }
255    // The replication snapshot source config is a first-class connector config
256    // (like `pipeline.source.config`); secrets placed there must resolve too.
257    if let Some(r) = cfg.replication.as_mut() {
258        f(&mut r.snapshot.source.config)?;
259    }
260    for spec in cfg.pipeline.sources.values_mut() {
261        f(&mut spec.config)?;
262    }
263    for spec in cfg.pipeline.sinks.values_mut() {
264        f(&mut spec.config)?;
265    }
266    if let Some(spec) = cfg.pipeline.source.as_mut() {
267        f(&mut spec.config)?;
268    }
269    if let Some(spec) = cfg.pipeline.sink.as_mut() {
270        f(&mut spec.config)?;
271    }
272    for t in cfg.pipeline.transforms.iter_mut() {
273        f(&mut t.config)?;
274    }
275    if let Some(s) = cfg.pipeline.state.as_mut() {
276        f(&mut s.config)?;
277    }
278    if let Some(d) = cfg.pipeline.dlq.as_mut() {
279        f(&mut d.sink.config)?;
280    }
281    for row in cfg.matrix.iter_mut() {
282        if let Some(p) = row.source.as_mut()
283            && let Some(c) = p.config.as_mut()
284        {
285            f(c)?;
286        }
287        if let Some(p) = row.sink.as_mut()
288            && let Some(c) = p.config.as_mut()
289        {
290            f(c)?;
291        }
292        if let Some(ts) = row.transforms.as_mut() {
293            for t in ts.iter_mut() {
294                f(&mut t.config)?;
295            }
296        }
297        if let Some(s) = row.state.as_mut() {
298            f(&mut s.config)?;
299        }
300        if let Some(Some(d)) = row.dlq.as_mut() {
301            f(&mut d.sink.config)?;
302        }
303    }
304    Ok(())
305}
306
307/// Collect every unique secret reference across the whole config.
308pub(crate) fn scan_config(cfg: &PipelineConfig) -> BTreeSet<SecretRef> {
309    let mut refs = BTreeSet::new();
310    visit_config_values(cfg, |v| collect_refs(v, &mut refs));
311    refs
312}
313
314/// Parse `path` (tolerating secret directives) and return its unique secret refs.
315pub fn scan_path_refs(
316    path: &std::path::Path,
317    profile: Option<&str>,
318) -> CliResult<BTreeSet<SecretRef>> {
319    scan_path_refs_with(path, profile, &crate::config::RunInputs::default())
320}
321
322/// [`scan_path_refs`] with caller-supplied [`crate::config::RunInputs`] (#444).
323///
324/// The pre-scan is a *structural* load, so it must bind `${param.*}` the same way
325/// the caller's real load will. Without this a bare `faucet validate` on a config
326/// declaring a `required` param failed here — in strict mode — before the caller's
327/// placeholder binding ever ran.
328pub fn scan_path_refs_with(
329    path: &std::path::Path,
330    profile: Option<&str>,
331    inputs: &crate::config::RunInputs,
332) -> CliResult<BTreeSet<SecretRef>> {
333    let cfg = PipelineConfig::from_path_tolerating_secrets_with(path, profile, inputs)?;
334    Ok(scan_config(&cfg))
335}
336
337/// Error with `SecretsRequireAsyncLoad` if any secret directive is present.
338/// Called by the synchronous `from_path` so secrets never silently survive.
339pub fn ensure_no_secret_directives(cfg: &PipelineConfig) -> CliResult<()> {
340    if scan_config(cfg).is_empty() {
341        Ok(())
342    } else {
343        Err(CliError::SecretsRequireAsyncLoad)
344    }
345}
346
347/// Production entry point: resolve all secret directives in `cfg` in place.
348/// Builds resolvers only for the schemes actually referenced.
349pub async fn resolve_secrets(cfg: &mut PipelineConfig) -> CliResult<()> {
350    let refs = scan_config(cfg);
351    if refs.is_empty() {
352        return Ok(());
353    }
354    let mut set = ResolverSet::default();
355    let schemes: BTreeSet<&str> = refs.iter().map(|(s, _)| s.as_str()).collect();
356    for scheme in schemes {
357        set.insert(make_resolver(scheme)?);
358    }
359    resolve_secrets_with(cfg, &set).await
360}
361
362/// Resolve all secret directives using a caller-supplied resolver set (the
363/// seam used by tests to inject fakes).
364pub async fn resolve_secrets_with(cfg: &mut PipelineConfig, set: &ResolverSet) -> CliResult<()> {
365    let refs = scan_config(cfg);
366    if refs.is_empty() {
367        return Ok(());
368    }
369    let cache = fetch_all(&refs, set).await?;
370    visit_config_values_mut(cfg, |v| substitute(v, &cache))
371}
372
373/// Fetch every reference concurrently (bounded), de-duplicated by the result
374/// map, registering each resolved value for redaction.
375async fn fetch_all(
376    refs: &BTreeSet<SecretRef>,
377    set: &ResolverSet,
378) -> CliResult<HashMap<SecretRef, String>> {
379    const MAX_CONCURRENCY: usize = 8;
380    let pairs: Vec<(SecretRef, String)> =
381        stream::iter(refs.iter().cloned())
382            .map(|(scheme, reference)| async move {
383                // Clone the Arc so each concurrent future owns its resolver rather
384                // than borrowing `set` across the await point.
385                let resolver = Arc::clone(set.get(&scheme).ok_or_else(|| {
386                    CliError::SecretBackendDisabled {
387                        scheme: scheme.clone(),
388                    }
389                })?);
390                let value = resolver.resolve(&reference).await?;
391                registry::register(&value);
392                Ok::<(SecretRef, String), CliError>(((scheme, reference), value))
393            })
394            .buffer_unordered(MAX_CONCURRENCY)
395            .try_collect()
396            .await?;
397    Ok(pairs.into_iter().collect())
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use serde_json::json;
404
405    #[test]
406    fn collects_unique_refs_and_ignores_other_directives() {
407        let v = json!({
408            "a": "${vault:secret/data/app#token}",
409            "b": "${aws-sm:prod/db#password}",
410            "c": "${vault:secret/data/app#token}",
411            "d": "${users.id}",
412            "e": "${env:HOME}",
413            "nested": ["${gcp-sm:projects/p/secrets/s/versions/latest}"]
414        });
415        let mut refs = BTreeSet::new();
416        collect_refs(&v, &mut refs);
417        assert_eq!(refs.len(), 3);
418        assert!(refs.contains(&("vault".into(), "secret/data/app#token".into())));
419        assert!(refs.contains(&("aws-sm".into(), "prod/db#password".into())));
420        assert!(refs.contains(&(
421            "gcp-sm".into(),
422            "projects/p/secrets/s/versions/latest".into()
423        )));
424    }
425
426    #[test]
427    fn substitutes_from_cache_and_preserves_runtime_refs() {
428        let mut v = json!({
429            "token": "Bearer ${vault:secret/data/app#token}",
430            "path": "/v1/${users.id}"
431        });
432        let mut cache = HashMap::new();
433        cache.insert(
434            ("vault".into(), "secret/data/app#token".into()),
435            "abc123".into(),
436        );
437        substitute(&mut v, &cache).unwrap();
438        assert_eq!(v["token"], "Bearer abc123");
439        assert_eq!(v["path"], "/v1/${users.id}");
440    }
441
442    #[test]
443    fn extract_field_picks_key_or_errors_with_available() {
444        let body = r#"{"username":"u","password":"p"}"#;
445        assert_eq!(
446            extract_field("aws-sm", "ref", body, "password").unwrap(),
447            "p"
448        );
449        match extract_field("aws-sm", "ref", body, "missing").unwrap_err() {
450            CliError::SecretFieldMissing { available, .. } => {
451                assert!(available.contains(&"username".to_string()));
452            }
453            other => panic!("expected SecretFieldMissing, got {other:?}"),
454        }
455        match extract_field("aws-sm", "ref", "not json", "x").unwrap_err() {
456            CliError::SecretNotJson { .. } => {}
457            other => panic!("expected SecretNotJson, got {other:?}"),
458        }
459    }
460
461    struct FakeResolver {
462        scheme: &'static str,
463        value: String,
464    }
465    #[async_trait]
466    impl SecretResolver for FakeResolver {
467        fn scheme(&self) -> &'static str {
468            self.scheme
469        }
470        async fn resolve(&self, _reference: &str) -> CliResult<String> {
471            Ok(self.value.clone())
472        }
473    }
474
475    #[tokio::test]
476    async fn resolve_secrets_with_substitutes_via_injected_resolvers() {
477        let mut set = ResolverSet::default();
478        set.insert(Arc::new(FakeResolver {
479            scheme: "vault",
480            value: "RESOLVED".into(),
481        }));
482        let cfg_yaml = r#"
483version: 1
484pipeline:
485  source: { type: rest, config: { base_url: https://x, auth: { type: bearer, config: { token: "${vault:secret/data/app#token}" } } } }
486  sink:   { type: jsonl, config: { path: ./o.jsonl } }
487"#;
488        let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
489        resolve_secrets_with(&mut cfg, &set).await.unwrap();
490        let token = &cfg.pipeline.source.as_ref().unwrap().config["auth"]["config"]["token"];
491        assert_eq!(token, "RESOLVED");
492    }
493
494    #[tokio::test]
495    async fn resolve_secrets_resolves_auth_catalog_and_vars_block() {
496        // A secret placed in the shared `auth:` catalog and in the `vars:` block
497        // must be resolved just like one in a connector config (#134). Without
498        // it, `build_auth_catalog` would receive a literal `${vault:…}` token.
499        let mut set = ResolverSet::default();
500        set.insert(Arc::new(FakeResolver {
501            scheme: "vault",
502            value: "RESOLVED".into(),
503        }));
504        let cfg_yaml = r#"
505version: 1
506vars:
507  shared_token: "${vault:secret/data/app#token}"
508auth:
509  idp: { type: static, config: { token: "${vault:secret/data/idp#token}" } }
510pipeline:
511  source: { type: rest, config: { base_url: https://x, auth: { ref: idp } } }
512  sink:   { type: jsonl, config: { path: ./o.jsonl } }
513"#;
514        let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
515        resolve_secrets_with(&mut cfg, &set).await.unwrap();
516
517        let auth_token = &cfg.auth.as_ref().unwrap()["idp"]["config"]["token"];
518        assert_eq!(auth_token, "RESOLVED", "auth-catalog secret should resolve");
519
520        let var_value = &cfg.vars.as_ref().unwrap()["shared_token"];
521        assert_eq!(var_value, "RESOLVED", "vars-block secret should resolve");
522    }
523
524    #[tokio::test]
525    async fn scan_config_collects_refs_from_auth_and_vars() {
526        // The preflight scan (used by `faucet validate`) must report secret
527        // references that live only in the auth catalog or vars block.
528        let cfg_yaml = r#"
529version: 1
530vars:
531  v: "${aws-sm:prod/api#key}"
532auth:
533  idp: { type: static, config: { token: "${vault:secret/data/idp#token}" } }
534pipeline:
535  source: { type: rest, config: { base_url: https://x, auth: { ref: idp } } }
536  sink:   { type: jsonl, config: { path: ./o.jsonl } }
537"#;
538        let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
539        let refs = scan_config(&cfg);
540        assert!(refs.contains(&("vault".into(), "secret/data/idp#token".into())));
541        assert!(refs.contains(&("aws-sm".into(), "prod/api#key".into())));
542    }
543
544    #[tokio::test]
545    async fn resolve_secrets_errors_when_backend_not_built() {
546        let set = ResolverSet::default();
547        let cfg_yaml = r#"
548version: 1
549pipeline:
550  source: { type: rest, config: { url: "${vault:secret/x}" } }
551  sink:   { type: jsonl, config: { path: ./o.jsonl } }
552"#;
553        let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
554        match resolve_secrets_with(&mut cfg, &set).await.unwrap_err() {
555            CliError::SecretBackendDisabled { scheme } => assert_eq!(scheme, "vault"),
556            other => panic!("expected SecretBackendDisabled, got {other:?}"),
557        }
558    }
559
560    #[test]
561    fn make_resolver_rejects_unknown_scheme() {
562        match make_resolver("not-a-scheme") {
563            Err(CliError::SecretBackendDisabled { scheme }) => assert_eq!(scheme, "not-a-scheme"),
564            Err(other) => panic!("expected SecretBackendDisabled, got {other:?}"),
565            Ok(_) => panic!("expected SecretBackendDisabled for an unknown scheme"),
566        }
567    }
568
569    #[cfg(feature = "secrets-aws-sm")]
570    #[test]
571    fn make_resolver_builds_compiled_in_aws_backend() {
572        // The constructor is cheap (no network) — it must yield a resolver whose
573        // scheme matches, proving the feature-gated arm is wired.
574        let r = make_resolver("aws-sm").unwrap();
575        assert_eq!(r.scheme(), "aws-sm");
576    }
577
578    #[tokio::test]
579    async fn resolve_secrets_walks_matrix_row_state_dlq_and_transforms() {
580        // A matrix row whose own state/dlq/transforms configs hold secrets must
581        // have them resolved by the mutating visitor (the per-row branches).
582        let mut set = ResolverSet::default();
583        set.insert(Arc::new(FakeResolver {
584            scheme: "vault",
585            value: "R".into(),
586        }));
587        let cfg_yaml = r#"
588version: 1
589pipeline:
590  source: { type: csv, config: { path: ./in.csv } }
591  sink:   { type: jsonl, config: { path: ./o.jsonl } }
592matrix:
593  - id: row1
594    source: { config: { path: "${vault:secret/src}" } }
595    sink:   { config: { path: "${vault:secret/sink}" } }
596    state:  { type: file, config: { path: "${vault:secret/state}" } }
597    transforms:
598      - type: set
599        config: { field: tag, value: "${vault:secret/tf}" }
600    dlq:
601      sink: { type: jsonl, config: { path: "${vault:secret/dlq}" } }
602"#;
603        let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
604        resolve_secrets_with(&mut cfg, &set).await.unwrap();
605
606        let row = &cfg.matrix[0];
607        assert_eq!(
608            row.source.as_ref().unwrap().config.as_ref().unwrap()["path"],
609            "R"
610        );
611        assert_eq!(
612            row.sink.as_ref().unwrap().config.as_ref().unwrap()["path"],
613            "R"
614        );
615        assert_eq!(row.state.as_ref().unwrap().config["path"], "R");
616        assert_eq!(row.transforms.as_ref().unwrap()[0].config["value"], "R");
617        let dlq = row.dlq.as_ref().unwrap().as_ref().unwrap();
618        assert_eq!(dlq.sink.config["path"], "R");
619    }
620
621    #[test]
622    fn scan_config_collects_refs_from_matrix_row_state_and_dlq() {
623        // The read-only visitor must reach the per-row state/dlq/transforms too.
624        let cfg_yaml = r#"
625version: 1
626pipeline:
627  source: { type: csv, config: { path: ./in.csv } }
628  sink:   { type: jsonl, config: { path: ./o.jsonl } }
629matrix:
630  - id: r
631    state: { type: file, config: { path: "${vault:secret/state}" } }
632    transforms:
633      - type: set
634        config: { field: t, value: "${aws-sm:tf/key}" }
635    dlq:
636      sink: { type: jsonl, config: { path: "${gcp-sm:projects/p/secrets/s/versions/1}" } }
637"#;
638        let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
639        let refs = scan_config(&cfg);
640        assert!(refs.contains(&("vault".into(), "secret/state".into())));
641        assert!(refs.contains(&("aws-sm".into(), "tf/key".into())));
642        assert!(refs.contains(&("gcp-sm".into(), "projects/p/secrets/s/versions/1".into())));
643    }
644
645    #[test]
646    fn scan_config_collects_refs_from_replication_snapshot_source() {
647        // A secret directive in the replication snapshot source config must be
648        // discovered by the scan, just like one in `pipeline.source`.
649        let cfg_yaml = r#"
650version: 1
651pipeline:
652  source: { type: postgres-cdc, config: {} }
653  sink:   { type: jsonl, config: { path: ./o.jsonl } }
654replication:
655  mode: snapshot_then_cdc
656  snapshot:
657    source:
658      type: postgres
659      config: { connection_url: "${vault:secret/data/db#url}", query: "SELECT 1" }
660"#;
661        let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
662        let refs = scan_config(&cfg);
663        assert!(refs.contains(&("vault".into(), "secret/data/db#url".into())));
664    }
665
666    #[tokio::test]
667    async fn resolve_secrets_noop_when_no_directives() {
668        // No secret directives anywhere → resolve_secrets returns Ok without
669        // building any resolver (the empty-refs early return).
670        let cfg_yaml = r#"
671version: 1
672pipeline:
673  source: { type: csv, config: { path: ./in.csv } }
674  sink:   { type: jsonl, config: { path: ./o.jsonl } }
675"#;
676        let mut cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
677        resolve_secrets(&mut cfg).await.unwrap();
678    }
679
680    #[test]
681    fn split_field_splits_on_hash() {
682        assert_eq!(split_field("a/b#c"), ("a/b", Some("c")));
683        assert_eq!(split_field("a/b"), ("a/b", None));
684    }
685
686    #[test]
687    fn ensure_no_secret_directives_passes_when_clean() {
688        let cfg_yaml = r#"
689version: 1
690pipeline:
691  source: { type: csv, config: { path: ./in.csv } }
692  sink:   { type: jsonl, config: { path: ./o.jsonl } }
693"#;
694        let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
695        assert!(ensure_no_secret_directives(&cfg).is_ok());
696    }
697
698    #[test]
699    fn ensure_no_secret_directives_flags_vault() {
700        let cfg_yaml = r#"
701version: 1
702pipeline:
703  source: { type: rest, config: { url: "${vault:secret/x}" } }
704  sink:   { type: jsonl, config: { path: ./o.jsonl } }
705"#;
706        let cfg = PipelineConfig::from_text(cfg_yaml, std::path::Path::new("p.yaml")).unwrap();
707        assert!(matches!(
708            ensure_no_secret_directives(&cfg),
709            Err(CliError::SecretsRequireAsyncLoad)
710        ));
711    }
712
713    #[test]
714    fn path_scan_binds_params_so_a_required_one_does_not_break_the_prescan() {
715        use std::io::Write;
716        let dir = tempfile::tempdir().unwrap();
717        let path = dir.path().join("cfg.yaml");
718        let mut f = std::fs::File::create(&path).unwrap();
719        write!(
720            f,
721            r#"version: 1
722name: prescan
723params:
724  country: {{ type: string, required: true }}
725pipeline:
726  source: {{ type: rest, config: {{ token: "${{vault:secret/data/app#token}}" }} }}
727  sink: {{ type: jsonl, config: {{ path: "./out-${{param.country}}.jsonl" }} }}
728"#
729        )
730        .unwrap();
731
732        // The default (strict) scan is how `faucet validate` used to fail before
733        // it bound params: a `required` param with no value aborts the pre-scan.
734        assert!(scan_path_refs(&path, None).is_err());
735
736        // Bound in placeholder mode — the way `validate` loads it — the scan
737        // reaches the secret reference it exists to report.
738        let inputs = crate::config::RunInputs::placeholders();
739        let refs = scan_path_refs_with(&path, None, &inputs).unwrap();
740        assert_eq!(
741            refs.iter()
742                .map(|(s, r)| (s.as_str(), r.as_str()))
743                .collect::<Vec<_>>(),
744            vec![("vault", "secret/data/app#token")]
745        );
746    }
747}