From b15cb4d90fa99aa41dfbfb9a20d87d89c64cc5a7 Mon Sep 17 00:00:00 2001 From: piekstra Date: Thu, 8 Oct 2026 12:26:05 -0400 Subject: [PATCH] fix: run secret commands in the project root --- crates/lest-core/src/notify.rs | 2 +- crates/lest-core/src/runner/mod.rs | 2 +- crates/lest-core/src/secrets.rs | 29 +++++++++++++++++++++++------ crates/lest/src/main.rs | 4 ++-- docs/flow-format.md | 4 ++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/lest-core/src/notify.rs b/crates/lest-core/src/notify.rs index be6ac6a..0712928 100644 --- a/crates/lest-core/src/notify.rs +++ b/crates/lest-core/src/notify.rs @@ -88,7 +88,7 @@ pub async fn send(project: &Project, keyring: &dyn Keyring, report: &RunReport) continue; } let label = format!("notify[{i}]"); - let resolver = Resolver::new(project.secrets_config(), keyring); + let resolver = Resolver::new(project, keyring); let secrets = match resolver.resolve_all(&t.secrets) { Ok(s) => s, Err(e) => { diff --git a/crates/lest-core/src/runner/mod.rs b/crates/lest-core/src/runner/mod.rs index 2103f76..61a69a1 100644 --- a/crates/lest-core/src/runner/mod.rs +++ b/crates/lest-core/src/runner/mod.rs @@ -560,7 +560,7 @@ async fn run_flow(engine: &Engine, req: RunRequest, emitter: &Emitter, cancel: R let mut secret_names: Vec = flows.iter().flat_map(|f| f.flow.secrets.iter().cloned()).collect(); secret_names.sort(); secret_names.dedup(); - let resolver = Resolver::new(engine.project.secrets_config(), engine.keyring.as_ref()); + let resolver = Resolver::new(&engine.project, engine.keyring.as_ref()); let secrets = match resolver.resolve_all(&secret_names) { Ok(s) => s, Err(e) => return finish(engine, &emitter, fail(report, e), started), diff --git a/crates/lest-core/src/secrets.rs b/crates/lest-core/src/secrets.rs index fa08067..7c79814 100644 --- a/crates/lest-core/src/secrets.rs +++ b/crates/lest-core/src/secrets.rs @@ -4,9 +4,10 @@ //! value is redacted from every captured stream and report field. use std::collections::BTreeMap; +use std::path::PathBuf; use std::process::Stdio; -use crate::project::{SecretBackend, SecretsConfig}; +use crate::project::{Project, SecretBackend, SecretsConfig}; pub const KEYRING_SERVICE: &str = "lest"; @@ -95,13 +96,19 @@ type EnvLookup<'a> = Box Option + Send + Sync + 'a>; /// Resolves secret names through the configured backends, in order. pub struct Resolver<'a> { config: SecretsConfig, + /// The project root: where a command backend runs. + root: PathBuf, keyring: &'a dyn Keyring, env: EnvLookup<'a>, } impl<'a> Resolver<'a> { - pub fn new(config: SecretsConfig, keyring: &'a dyn Keyring) -> Self { - Resolver { config, keyring, env: Box::new(|k| std::env::var(k).ok()) } + pub fn new(project: &Project, keyring: &'a dyn Keyring) -> Self { + Self::with_config(project.secrets_config(), project.root.clone(), keyring) + } + + fn with_config(config: SecretsConfig, root: PathBuf, keyring: &'a dyn Keyring) -> Self { + Resolver { config, root, keyring, env: Box::new(|k| std::env::var(k).ok()) } } /// Overrides environment lookup (tests). @@ -131,7 +138,12 @@ impl<'a> Resolver<'a> { SecretBackend::Command { command: c } => { tried.push(format!("command `{}`", c.run)); let out = run_with_timeout( - std::process::Command::new("sh").arg("-c").arg(&c.run).env("LEST_SECRET_NAME", name), + std::process::Command::new("sh") + .arg("-c") + .arg(&c.run) + .current_dir(&self.root) + .env("LEST_PROJECT_DIR", &self.root) + .env("LEST_SECRET_NAME", name), std::time::Duration::from_secs(60), ) .map_err(|e| format!("secret command for '{name}': {e}"))?; @@ -288,12 +300,17 @@ mod tests { backends: vec![ SecretBackend::Env { env: EnvBackend::default() }, SecretBackend::Command { - command: CommandBackend { run: "test \"$LEST_SECRET_NAME\" = via_cmd && echo from-command".into() }, + command: CommandBackend { + run: "test -f marker && test \"$LEST_SECRET_NAME\" = via_cmd && echo from-command".into(), + }, }, ], }; let kr = NoKeyring; - let r = Resolver::new(cfg, &kr).with_env(|k| (k == "LEST_SECRET_TOKEN").then(|| "from-env".to_string())); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("marker"), "").unwrap(); + let r = Resolver::with_config(cfg, dir.path().to_path_buf(), &kr) + .with_env(|k| (k == "LEST_SECRET_TOKEN").then(|| "from-env".to_string())); assert_eq!(r.resolve("token").unwrap(), ("from-env".into(), "env")); assert_eq!(r.resolve("via_cmd").unwrap(), ("from-command".into(), "command")); let err = r.resolve("absent").unwrap_err(); diff --git a/crates/lest/src/main.rs b/crates/lest/src/main.rs index cdba827..aae5f2f 100644 --- a/crates/lest/src/main.rs +++ b/crates/lest/src/main.rs @@ -580,7 +580,7 @@ async fn cmd_cleanup(ctx: &Ctx, a: CleanupArgs, style: Style) -> Result { let fallback_dir = project.root.join(&report.flow_path).parent().map(Path::to_path_buf).unwrap_or_else(|| project.root.clone()); let kr = keyring(); - let resolver = Resolver::new(project.secrets_config(), kr.as_ref()); + let resolver = Resolver::new(&project, kr.as_ref()); let marker = regex_redacted(); let mut failed = 0; for c in report.cleanups.iter_mut().filter(left) { @@ -854,7 +854,7 @@ fn cmd_secrets(ctx: &Ctx, command: SecretsCommand) -> Result { needed.entry(s.clone()).or_default().push(f.flow.id.clone()); } } - let resolver = Resolver::new(ctx.project.secrets_config(), &kr); + let resolver = Resolver::new(&ctx.project, &kr); let rows: Vec> = needed .iter() .map(|(name, flows)| { diff --git a/docs/flow-format.md b/docs/flow-format.md index 61cd248..a4cff50 100644 --- a/docs/flow-format.md +++ b/docs/flow-format.md @@ -341,6 +341,10 @@ secrets: - command: { run: 'my-vault read "app/$LEST_SECRET_NAME"' } ``` +A `command` runs with `sh` in the project root, with the secret's name in +`LEST_SECRET_NAME` and the root in `LEST_PROJECT_DIR`, so it can call a +script kept in the project. It prints the value on stdout. + Every resolved value is replaced with `[redacted:]` in captured output, outputs, events and reports. Values shorter than four characters are not redacted, since they would match ordinary text.