1pub 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
26pub const SECRET_SCHEMES: &[&str] = &["vault", "aws-sm", "gcp-sm", "azure-kv"];
28
29pub type SecretRef = (String, String);
31
32#[async_trait]
33pub trait SecretResolver: Send + Sync {
34 fn scheme(&self) -> &'static str;
36 async fn resolve(&self, reference: &str) -> CliResult<String>;
38}
39
40#[allow(dead_code)] pub(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#[allow(dead_code)] pub(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
78fn 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
88fn 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
101fn 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
112pub fn collect_refs(value: &Value, out: &mut BTreeSet<SecretRef>) {
114 for_each_string(value, &mut |s| collect_refs_in_str(s, out));
115}
116
117pub 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#[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
152fn 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
171fn visit_config_values<F: FnMut(&Value)>(cfg: &PipelineConfig, mut f: F) {
174 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 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
238fn visit_config_values_mut<F: FnMut(&mut Value) -> CliResult<()>>(
240 cfg: &mut PipelineConfig,
241 mut f: F,
242) -> CliResult<()> {
243 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 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
307pub(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
314pub 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
322pub 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
337pub 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
347pub 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
362pub 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
373async 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 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 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 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 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 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 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 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 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 assert!(scan_path_refs(&path, None).is_err());
735
736 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}