Skip to main content

anodizer_core/
hooks.rs

1use crate::artifact::Artifact;
2use crate::config::{self, BeforePublishArtifactFilter, HookEntry};
3use crate::log::StageLogger;
4use crate::template::{self, TemplateVars};
5use anyhow::{Context as _, Result};
6use std::process::Command;
7
8/// Redact sensitive environment variable values from output strings.
9///
10/// Auto-discovers secret-looking env vars using the same heuristics as
11/// secret detection: key suffix matching and value prefix matching.
12/// This catches both well-known and user-defined secrets.
13fn redact_secrets(output: &str) -> String {
14    let env: Vec<(String, String)> = std::env::vars().collect();
15    crate::redact::string(output, &env)
16}
17
18/// Render a hook template string through the full Tera engine.
19///
20/// Hard-bails on render failure: a typo like `{{ .Teg }}` in a hook command
21/// would otherwise execute literal `{{ .Teg }}` and produce a confusing
22/// shell error rather than a clear template diagnostic.
23fn render_hook_template(template: &str, vars: &TemplateVars, label: &str) -> Result<String> {
24    template::render(template, vars)
25        .with_context(|| format!("{} hook: render template '{}'", label, template))
26}
27
28/// Cross-cutting inputs for [`run_hooks`] that stay constant across every
29/// hook in a single invocation. Bundled into one struct so call sites read
30/// as named fields instead of a run of positional `bool` / `Option`
31/// arguments. Cheap to pass by value — every field is `Copy`.
32#[derive(Clone, Copy)]
33pub struct HookRunContext<'a> {
34    /// Log each hook command instead of executing it.
35    pub dry_run: bool,
36    /// Sink for status lines and captured command output.
37    pub log: &'a StageLogger,
38    /// When set, hook `cmd` / `dir` / `env` / `if` are Tera-expanded with
39    /// these vars before execution, supporting
40    /// conditionals, filters, and `{{ .Env.VAR }}`. `None` runs the literal
41    /// command with no rendering.
42    pub template_vars: Option<&'a TemplateVars>,
43    /// The active build's per-target `builds[].env` map (already
44    /// rendered/expanded by the build planner). It layers BETWEEN the
45    /// inherited process env (base) and each hook's own `env:` (most
46    /// specific) — hook `env:` entries are appended after the build env,
47    /// so a key present
48    /// in both resolves to the hook value. `None` (the default via
49    /// [`HookRunContext::new`]) at non-build hook sites — they have no
50    /// build env.
51    pub build_env: Option<&'a std::collections::HashMap<String, String>>,
52}
53
54impl<'a> HookRunContext<'a> {
55    /// Context for a non-build hook run (the common case): no per-target
56    /// `build_env`. Build sites construct the struct directly and set
57    /// [`HookRunContext::build_env`].
58    pub fn new(
59        dry_run: bool,
60        log: &'a StageLogger,
61        template_vars: Option<&'a TemplateVars>,
62    ) -> Self {
63        Self {
64            dry_run,
65            log,
66            template_vars,
67            build_env: None,
68        }
69    }
70}
71
72/// Execute a list of shell hook commands.
73///
74/// In dry-run mode, log but do not execute.
75/// Supports both simple string hooks and structured hooks with cmd/dir/env/output.
76///
77/// Note: Rust's `Command` inherits the process environment by default.
78/// Pipeline env vars (VERSION, TAG, etc.) should be set in the process
79/// environment before calling `run_hooks`, which `setup_env()` handles. See
80/// [`HookRunContext`] for how `template_vars` and `build_env` are applied.
81pub fn run_hooks(hooks: &[HookEntry], label: &str, ctx: HookRunContext<'_>) -> Result<()> {
82    let HookRunContext {
83        dry_run,
84        log,
85        template_vars,
86        build_env,
87    } = ctx;
88    for hook in hooks {
89        let (raw_cmd, raw_dir, env, output_flag, if_cond) = match hook {
90            HookEntry::Simple(s) => (s.as_str(), None, None, None, None),
91            HookEntry::Structured(h) => (
92                h.cmd.as_str(),
93                h.dir.as_deref(),
94                h.env.as_ref(),
95                h.output,
96                h.if_condition.as_deref(),
97            ),
98        };
99
100        if let Some(tv) = template_vars {
101            let proceed = config::evaluate_if_condition(if_cond, &format!("{label} hook"), |t| {
102                render_hook_template(t, tv, label)
103            })?;
104            if !proceed {
105                tracing::debug!(
106                    label = label,
107                    cmd = raw_cmd,
108                    "skipping hook: `if` condition evaluated falsy"
109                );
110                continue;
111            }
112        } else if let Some(cond) = if_cond {
113            // Without template_vars there's no way to render — treat the gate
114            // as proceed unless the literal condition is explicitly falsy.
115            // An EMPTY `if:` is the "no gate set" no-op (proceed), matching
116            // `evaluate_if_condition`'s `Some("")` → proceed contract used on
117            // the template path above; only `false`/`0`/`no` skip here.
118            let trimmed = cond.trim();
119            let falsy = matches!(trimmed, "false" | "0" | "no");
120            if falsy {
121                tracing::debug!(
122                    label = label,
123                    cmd = raw_cmd,
124                    "skipping hook: literal `if` condition is falsy"
125                );
126                continue;
127            }
128        }
129
130        let cmd_str = if let Some(tv) = template_vars {
131            render_hook_template(raw_cmd, tv, label)?
132        } else {
133            raw_cmd.to_string()
134        };
135
136        let dir_str = match raw_dir {
137            Some(d) => Some(if let Some(tv) = template_vars {
138                render_hook_template(d, tv, label)?
139            } else {
140                d.to_string()
141            }),
142            None => None,
143        };
144
145        let expanded_env: Option<Vec<(String, String)>> = match env {
146            Some(envs) => {
147                let pairs = if let Some(tv) = template_vars {
148                    config::render_env_entries(envs, |s| render_hook_template(s, tv, label))
149                        .with_context(|| format!("{label} hook: render env entries"))?
150                } else {
151                    config::parse_env_entries(envs)
152                        .with_context(|| format!("{label} hook: parse env entries"))?
153                };
154                Some(pairs)
155            }
156            None => None,
157        };
158
159        if dry_run {
160            log.status(&format!("[dry-run] {} hook: {}", label, cmd_str));
161        } else {
162            log.status(&format!("running {} hook: {}", label, cmd_str));
163            let mut command = Command::new("sh");
164            command.arg("-c").arg(&cmd_str);
165            // Hooks inherit the host env so toolchain env vars (PATH, MSVC
166            // INCLUDE/LIB, RUSTUP_HOME) flow through. Secret leakage is gated
167            // by `redact_secrets` on the output side.
168            if let Some(ref d) = dir_str {
169                command.current_dir(d);
170            }
171            // Precedence: process env (inherited, base) < build env
172            // < hook env. Apply the per-target build env first so a same-key
173            // hook `env:` entry below overrides it (the
174            // `append(buildEnv, hook.Env...)`).
175            if let Some(be) = build_env {
176                for (k, v) in be {
177                    command.env(k, v);
178                }
179            }
180            if let Some(ref envs) = expanded_env {
181                for (k, v) in envs {
182                    command.env(k, v);
183                }
184            }
185            let output = command
186                .output()
187                .with_context(|| format!("failed to spawn {} hook: {}", label, cmd_str))?;
188
189            // Redact secrets from stdout/stderr before any logging so that
190            // sensitive env var values never leak to CI logs or terminals.
191            let redacted_stdout = redact_secrets(&String::from_utf8_lossy(&output.stdout));
192            let redacted_stderr = redact_secrets(&String::from_utf8_lossy(&output.stderr));
193
194            // When output flag is true, print the hook's stdout to the log
195            if output_flag == Some(true) && !redacted_stdout.trim().is_empty() {
196                log.status(&format!("[hook output] {}", redacted_stdout.trim()));
197            }
198
199            // Build a synthetic Output with redacted content for check_output,
200            // so that error messages also have secrets scrubbed.
201            let redacted_output = std::process::Output {
202                status: output.status,
203                stdout: redacted_stdout.into_bytes(),
204                stderr: redacted_stderr.into_bytes(),
205            };
206
207            log.check_output(redacted_output, &format!("{} hook: {}", label, cmd_str))?;
208        }
209    }
210    Ok(())
211}
212
213/// Pipeline stage that runs `config.before_publish.hooks` immediately
214/// before any publisher dispatches.
215///
216/// Sits between the integrity stages (sign / checksum) and the publish
217/// phase (release / publish / blob / snapcraft-publish). A non-zero hook
218/// exit aborts the release before any publisher writes to a registry,
219/// giving operators a last-chance hook for smoke tests, antivirus scans,
220/// or external state staging against the staged dist tree.
221///
222/// Each `hooks[*]` entry runs **once per matching artifact** (the
223/// Pro `before_publish:` semantics). For each entry:
224///
225/// 1. Resolve the entry's `ids:` + `artifacts:` filters and walk
226///    `ctx.artifacts.all()` for matches.
227/// 2. For each match, bind per-artifact template variables
228///    (`ArtifactPath`, `ArtifactName`, `ArtifactExt`, `Os`, `Arch`,
229///    plus `ArtifactID` / `ArtifactKind` for parity with the publisher
230///    template surface) and render `cmd` / `dir` / `env` / `if`.
231/// 3. Execute the rendered hook (or log it under `--dry-run`).
232///
233/// `HookEntry::Simple` (bare string) implies `artifacts: all` and no
234/// `ids:` filter — it runs against every registered artifact. Zero
235/// matching artifacts means the hook runs zero times (the lifecycle
236/// semantics of `before:` / `after:` do not apply here).
237///
238/// Honors `--skip=before-publish`: when set, the pipeline's generic
239/// skip handling fires before `run` is invoked, so this stage never
240/// executes.
241pub struct BeforePublishStage;
242
243impl crate::stage::Stage for BeforePublishStage {
244    fn name(&self) -> &str {
245        "before-publish"
246    }
247
248    fn run(&self, ctx: &mut crate::context::Context) -> Result<()> {
249        let log = ctx.logger("before-publish");
250        let Some(cfg) = ctx.config.before_publish.as_ref() else {
251            return Ok(());
252        };
253        let Some(hooks) = cfg.hooks.as_ref() else {
254            return Ok(());
255        };
256        if hooks.is_empty() {
257            return Ok(());
258        }
259
260        let dry_run = ctx.is_dry_run();
261        let base_vars = ctx.template_vars().clone();
262        let artifacts: Vec<Artifact> = ctx.artifacts.all().to_vec();
263
264        for entry in hooks {
265            run_before_publish_entry(entry, &artifacts, dry_run, &log, &base_vars)?;
266        }
267        Ok(())
268    }
269}
270
271/// Iterate `artifacts` for a single `before_publish[*]` entry, executing
272/// the hook command once per match with per-artifact template variables
273/// bound. Returns `Err` on first hook failure so the pipeline aborts
274/// before any publisher dispatches.
275fn run_before_publish_entry(
276    entry: &HookEntry,
277    artifacts: &[Artifact],
278    dry_run: bool,
279    log: &StageLogger,
280    base_vars: &TemplateVars,
281) -> Result<()> {
282    let (ids_filter, kind_filter) = match entry {
283        HookEntry::Simple(_) => (None, BeforePublishArtifactFilter::All),
284        HookEntry::Structured(h) => (
285            h.ids.as_deref(),
286            h.artifacts.unwrap_or(BeforePublishArtifactFilter::All),
287        ),
288    };
289
290    for artifact in artifacts {
291        if !kind_filter.matches(artifact.kind) {
292            continue;
293        }
294        if let Some(allow_ids) = ids_filter {
295            let id = artifact
296                .metadata
297                .get("id")
298                .map(String::as_str)
299                .unwrap_or("");
300            if !allow_ids.iter().any(|a| a == id) {
301                continue;
302            }
303        }
304
305        let mut vars = base_vars.clone();
306        bind_per_artifact_vars(&mut vars, artifact);
307        // Reuse the existing single-entry runner so dry-run, output capture,
308        // env allow-list, redaction, and `if:` evaluation behave identically
309        // to the lifecycle hook sites — only the per-artifact iteration is
310        // new here.
311        let single = std::slice::from_ref(entry);
312        // before_publish hooks are not build hooks — no per-target build env.
313        run_hooks(
314            single,
315            "before-publish",
316            HookRunContext::new(dry_run, log, Some(&vars)),
317        )?;
318    }
319    Ok(())
320}
321
322/// Bind per-artifact template variables onto `vars` for a single
323/// `before_publish:` iteration. Mirrors the publisher-side template
324/// surface (`crates/cli/src/commands/publisher.rs::build_publisher_command`)
325/// so user templates work identically across `publishers:` and
326/// `before_publish:`.
327fn bind_per_artifact_vars(vars: &mut TemplateVars, artifact: &Artifact) {
328    vars.set("ArtifactPath", &artifact.path.to_string_lossy());
329    vars.set("ArtifactName", artifact.name());
330    vars.set("ArtifactExt", &artifact.ext());
331    vars.set("ArtifactKind", artifact.kind.as_str());
332    vars.set(
333        "ArtifactID",
334        artifact
335            .metadata
336            .get("id")
337            .map(String::as_str)
338            .unwrap_or(""),
339    );
340    if let Some(target) = artifact.target.as_deref() {
341        let (os, arch) = crate::target::map_target(target);
342        vars.set("Os", &os);
343        vars.set("Arch", &arch);
344        vars.set("Target", target);
345    } else {
346        vars.set("Os", "");
347        vars.set("Arch", "");
348        vars.set("Target", "");
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use crate::config::StructuredHook;
356    use crate::log::{StageLogger, Verbosity};
357    use std::collections::HashMap;
358
359    fn test_logger() -> StageLogger {
360        StageLogger::new("test", Verbosity::Normal)
361    }
362
363    fn vars_with_snapshot(is_snapshot: bool) -> TemplateVars {
364        let mut v = TemplateVars::new();
365        v.set("IsSnapshot", if is_snapshot { "true" } else { "false" });
366        v
367    }
368
369    fn structured(cmd: &str, if_cond: Option<&str>) -> HookEntry {
370        HookEntry::Structured(StructuredHook {
371            cmd: cmd.to_string(),
372            if_condition: if_cond.map(str::to_string),
373            ..Default::default()
374        })
375    }
376
377    #[test]
378    fn hook_if_snapshot_template_runs_on_snapshot() {
379        let log = test_logger();
380        let vars = vars_with_snapshot(true);
381        let hooks = vec![structured("true", Some("{{ IsSnapshot }}"))];
382        // dry_run=true → no actual exec; the function still walks the gate.
383        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
384            .expect("snapshot=true must let the hook proceed");
385    }
386
387    #[test]
388    fn hook_if_snapshot_template_skips_when_not_snapshot() {
389        let log = test_logger();
390        let vars = vars_with_snapshot(false);
391        let hooks = vec![structured(
392            "false-cmd-must-be-skipped",
393            Some("{{ IsSnapshot }}"),
394        )];
395        run_hooks(
396            &hooks,
397            "test",
398            HookRunContext::new(false, &log, Some(&vars)),
399        )
400        .expect("falsy `if:` must skip without spawning the cmd");
401    }
402
403    #[test]
404    fn hook_if_literal_true_always_runs() {
405        let log = test_logger();
406        let vars = vars_with_snapshot(false);
407        let hooks = vec![structured("true", Some("true"))];
408        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
409            .expect("`if: true` must proceed");
410    }
411
412    #[test]
413    fn hook_if_empty_literal_is_noop_gate() {
414        let log = test_logger();
415        let vars = vars_with_snapshot(false);
416        let hooks = vec![structured("true", Some(""))];
417        run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
418            .expect("empty `if:` literal must be a no-op (always proceed)");
419    }
420
421    #[test]
422    fn hook_if_empty_literal_no_vars_proceeds() {
423        // The no-`template_vars` branch must treat an empty `if:` as
424        // "no gate set" → proceed, identical to the template path and to
425        // `evaluate_if_condition`'s `Some("")` contract. A dry-run hook that
426        // would loudly fail if executed proves the gate let it through (the
427        // command is logged, never spawned, under dry-run).
428        let log = test_logger();
429        let hooks = vec![structured("true", Some(""))];
430        run_hooks(&hooks, "test", HookRunContext::new(true, &log, None))
431            .expect("empty `if:` with no vars must proceed (no-op gate)");
432    }
433
434    #[test]
435    fn hook_if_falsy_literal_no_vars_skips() {
436        // The complementary pin: an explicitly-falsy literal still skips on
437        // the no-vars path. `false-cmd` would error if spawned; a non-dry-run
438        // call succeeding proves it was skipped, not executed.
439        let log = test_logger();
440        let hooks = vec![structured("false-cmd-must-be-skipped", Some("false"))];
441        run_hooks(&hooks, "test", HookRunContext::new(false, &log, None))
442            .expect("falsy literal `if:` with no vars must skip without spawning");
443    }
444
445    /// Run a single real (non-dry-run) hook that appends `KEY=$KEY` lines to
446    /// `out_file` for each requested key, so the caller can assert what the
447    /// hook actually saw in its process environment.
448    fn run_env_probe_hook(
449        out_file: &std::path::Path,
450        keys: &[&str],
451        hook_env: Option<Vec<String>>,
452        build_env: Option<&HashMap<String, String>>,
453    ) -> Result<()> {
454        let log = test_logger();
455        // sh -c mangles backslashes; feed it a forward-slash path so the redirect target resolves on Windows
456        let out = out_file.display().to_string().replace('\\', "/");
457        let probe = keys
458            .iter()
459            .map(|k| format!("echo {k}=${k} >> {out}"))
460            .collect::<Vec<_>>()
461            .join("; ");
462        let hooks = vec![HookEntry::Structured(StructuredHook {
463            cmd: probe,
464            env: hook_env,
465            ..Default::default()
466        })];
467        let vars = TemplateVars::new();
468        run_hooks(
469            &hooks,
470            "build",
471            HookRunContext {
472                dry_run: false,
473                log: &log,
474                template_vars: Some(&vars),
475                build_env,
476            },
477        )
478    }
479
480    #[test]
481    fn build_env_reaches_build_hook() {
482        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
483        std::fs::create_dir_all(&dir).unwrap();
484        let out = dir.join("reaches.txt");
485        let _ = std::fs::remove_file(&out);
486
487        let mut build_env = HashMap::new();
488        build_env.insert("MY_BUILD_VAR".to_string(), "from-build-env".to_string());
489
490        run_env_probe_hook(&out, &["MY_BUILD_VAR"], None, Some(&build_env)).expect("hook must run");
491
492        let contents = std::fs::read_to_string(&out).unwrap();
493        assert!(
494            contents.contains("MY_BUILD_VAR=from-build-env"),
495            "build env var must reach the hook; got: {contents:?}"
496        );
497        let _ = std::fs::remove_file(&out);
498    }
499
500    #[test]
501    fn hook_env_overrides_build_env_on_key_conflict() {
502        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
503        std::fs::create_dir_all(&dir).unwrap();
504        let out = dir.join("precedence.txt");
505        let _ = std::fs::remove_file(&out);
506
507        let mut build_env = HashMap::new();
508        build_env.insert("SHARED".to_string(), "build-loses".to_string());
509
510        run_env_probe_hook(
511            &out,
512            &["SHARED"],
513            Some(vec!["SHARED=hook-wins".to_string()]),
514            Some(&build_env),
515        )
516        .expect("hook must run");
517
518        let contents = std::fs::read_to_string(&out).unwrap();
519        assert!(
520            contents.contains("SHARED=hook-wins"),
521            "hook env must override build env on key conflict (GR append order); got: {contents:?}"
522        );
523        assert!(
524            !contents.contains("SHARED=build-loses"),
525            "build env value must not survive a hook-env override; got: {contents:?}"
526        );
527        let _ = std::fs::remove_file(&out);
528    }
529
530    #[test]
531    fn absent_build_env_is_unchanged_behavior() {
532        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
533        std::fs::create_dir_all(&dir).unwrap();
534        let out = dir.join("absent.txt");
535        let _ = std::fs::remove_file(&out);
536
537        // No build env at all, and the probed key is unset → empty value, no panic.
538        run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, None).expect("hook must run");
539
540        let contents = std::fs::read_to_string(&out).unwrap();
541        assert!(
542            contents.contains("NOT_SET_ANYWHERE="),
543            "absent build env must leave behavior unchanged; got: {contents:?}"
544        );
545        let _ = std::fs::remove_file(&out);
546    }
547
548    #[test]
549    fn empty_build_env_map_adds_nothing() {
550        let dir = std::env::temp_dir().join(format!("anodizer-be-{}", std::process::id()));
551        std::fs::create_dir_all(&dir).unwrap();
552        let out = dir.join("empty.txt");
553        let _ = std::fs::remove_file(&out);
554
555        let build_env: HashMap<String, String> = HashMap::new();
556        run_env_probe_hook(&out, &["NOT_SET_ANYWHERE"], None, Some(&build_env))
557            .expect("hook must run");
558
559        let contents = std::fs::read_to_string(&out).unwrap();
560        assert!(
561            contents.contains("NOT_SET_ANYWHERE="),
562            "empty build env map must be a no-op; got: {contents:?}"
563        );
564        let _ = std::fs::remove_file(&out);
565    }
566
567    #[test]
568    fn hook_if_render_error_propagates() {
569        let log = test_logger();
570        let vars = vars_with_snapshot(false);
571        let hooks = vec![structured("true", Some("{{ UndefinedSymbol }}"))];
572        let err = run_hooks(&hooks, "test", HookRunContext::new(true, &log, Some(&vars)))
573            .expect_err("unrenderable template must surface as Err");
574        let chain = format!("{err:#}");
575        assert!(
576            chain.contains("template render failed") || chain.contains("UndefinedSymbol"),
577            "expected render-error diagnostic, got: {chain}",
578        );
579    }
580}