Skip to main content

anodizer_core/
arch_path_guard.rs

1//! Guard that converts a silent per-architecture output clobber into an
2//! immediate, actionable error.
3//!
4//! Every OS-installer stage (`app_bundles`, `dmgs`, `pkgs`, `msis`, `nsis`)
5//! loops once per build target and writes its artifact to a path derived
6//! from the user's `name` template. If that template omits `{{ .Arch }}`,
7//! two architectures render the *same* output path and the second silently
8//! overwrites the first — and any downstream `use:` consumer (e.g.
9//! `dmgs.use: appbundle`) then wraps the lone survivor twice, producing
10//! per-arch artifacts with wrong-arch payloads. The stage default templates
11//! all carry `{{ .Arch }}`, so this only fires on a bad override; it makes
12//! the mistake impossible to ship silently.
13//!
14//! Construct one [`ArchPathGuard`] per crate — shared across all of that
15//! crate's configs (a stage's config is a `Vec`, so one crate can have many) —
16//! and call [`ArchPathGuard::check`] with each rendered output path. The first
17//! duplicate returns an error naming the offending template and crate. A
18//! per-crate (not per-config) scope is required so two configs of the same
19//! crate that render the same path collide loudly instead of the second
20//! silently clobbering the first.
21
22use std::collections::HashSet;
23use std::path::{Path, PathBuf};
24
25/// Tracks the output paths one crate's installer configs have produced across
26/// every per-architecture artifact loop, erroring on the first collision.
27#[derive(Debug, Default)]
28pub struct ArchPathGuard {
29    seen: HashSet<PathBuf>,
30}
31
32impl ArchPathGuard {
33    /// A fresh guard with no recorded paths. One per crate, shared across all
34    /// of that crate's configs.
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Record `path` for this scope; error if a previous call already
40    /// produced it.
41    ///
42    /// `stage` is the config key (`"dmgs"`), `artifact` the user-facing noun
43    /// (`"image"`) — pluralized with a trailing `s` in the message,
44    /// `name_template` the offending template, `rendered` the rendered output
45    /// name, and `crate_name` the crate being built.
46    pub fn check(
47        &mut self,
48        path: &Path,
49        stage: &str,
50        artifact: &str,
51        name_template: &str,
52        rendered: &str,
53        crate_name: &str,
54    ) -> anyhow::Result<()> {
55        if self.seen.insert(path.to_path_buf()) {
56            return Ok(());
57        }
58        anyhow::bail!(
59            "{stage}: name template '{name_template}' rendered the same {artifact} \
60             '{rendered}' more than once for crate '{crate_name}', so one build target \
61             would silently overwrite another. Add '{{{{ .Arch }}}}' to the `name` \
62             (e.g. \"{{{{ .ProjectName }}}}_{{{{ .Arch }}}}\") — or, when the collision \
63             is between same-arch micro-architecture variants, '{{{{ .Amd64 }}}}' \
64             (e.g. \"{{{{ .ProjectName }}}}_{{{{ .Arch }}}}{{{{ .Amd64 }}}}\") — so each \
65             build target's {artifact} gets a distinct path."
66        );
67    }
68
69    /// Record `path` for this scope when the name comes from a stage's
70    /// *conventional default* filename rather than a user template; error if a
71    /// previous call already produced it.
72    ///
73    /// For distro-conventional packagers (deb/rpm/apk), the default filename's
74    /// arch field must stay bare (`amd64`, never `amd64v3`), so two amd64
75    /// micro-architecture variants of one triple render the same path under the
76    /// default. The message names "the conventional default filename" (no fake
77    /// template echo) and advises a `file_name_template` carrying
78    /// `{{ .Amd64 }}` — the only field that disambiguates same-arch variants.
79    pub fn check_conventional(
80        &mut self,
81        path: &Path,
82        stage: &str,
83        artifact: &str,
84        rendered: &str,
85        crate_name: &str,
86    ) -> anyhow::Result<()> {
87        if self.seen.insert(path.to_path_buf()) {
88            return Ok(());
89        }
90        anyhow::bail!(
91            "{stage}: the conventional default filename rendered the same {artifact} \
92             '{rendered}' more than once for crate '{crate_name}', so one build target \
93             would silently overwrite another. This happens when two same-arch \
94             micro-architecture variants (e.g. a baseline amd64 build and one tuned \
95             with -Ctarget-cpu=x86-64-v3) share the distro-conventional arch field. \
96             Set a `file_name_template` carrying '{{{{ .Amd64 }}}}' \
97             (e.g. \"{{{{ .PackageName }}}}_{{{{ .Version }}}}_{{{{ .Arch }}}}{{{{ .Amd64 }}}}\") \
98             so each variant's {artifact} gets a distinct path."
99        );
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn distinct_paths_pass() {
109        let mut guard = ArchPathGuard::new();
110        guard
111            .check(
112                Path::new("dist/macos/app_amd64.app"),
113                "app_bundles",
114                "bundle",
115                "{{ .ProjectName }}_{{ .Arch }}",
116                "app_amd64.app",
117                "app",
118            )
119            .expect("first path must pass");
120        guard
121            .check(
122                Path::new("dist/macos/app_arm64.app"),
123                "app_bundles",
124                "bundle",
125                "{{ .ProjectName }}_{{ .Arch }}",
126                "app_arm64.app",
127                "app",
128            )
129            .expect("distinct second path must pass");
130    }
131
132    #[test]
133    fn duplicate_path_bails_with_actionable_message() {
134        let mut guard = ArchPathGuard::new();
135        guard
136            .check(
137                Path::new("dist/macos/app.app"),
138                "app_bundles",
139                "bundle",
140                "{{ .ProjectName }}",
141                "app.app",
142                "app",
143            )
144            .expect("first path must pass");
145
146        let err = guard
147            .check(
148                Path::new("dist/macos/app.app"),
149                "app_bundles",
150                "bundle",
151                "{{ .ProjectName }}",
152                "app.app",
153                "app",
154            )
155            .unwrap_err()
156            .to_string();
157
158        assert!(err.contains("app_bundles:"), "{err}");
159        assert!(err.contains("crate 'app'"), "{err}");
160        assert!(err.contains("{{ .Arch }}"), "{err}");
161        assert!(err.contains("bundle gets a distinct path"), "{err}");
162    }
163
164    #[test]
165    fn conventional_default_collision_bails_without_fake_template() {
166        // The conventional-default path must NOT echo a fabricated template and
167        // must advise `{{ .Amd64 }}` (the conventional default already carries
168        // `{{ .Arch }}`, so advising `{{ .Arch }}` would be useless).
169        let mut guard = ArchPathGuard::new();
170        guard
171            .check_conventional(
172                Path::new("dist/linux/myapp_1.0.0_amd64.deb"),
173                "nfpms",
174                "package",
175                "myapp_1.0.0_amd64.deb",
176                "myapp",
177            )
178            .expect("first path must pass");
179
180        let err = guard
181            .check_conventional(
182                Path::new("dist/linux/myapp_1.0.0_amd64.deb"),
183                "nfpms",
184                "package",
185                "myapp_1.0.0_amd64.deb",
186                "myapp",
187            )
188            .unwrap_err()
189            .to_string();
190
191        assert!(err.contains("nfpms:"), "{err}");
192        assert!(err.contains("crate 'myapp'"), "{err}");
193        assert!(err.contains("conventional default filename"), "{err}");
194        assert!(err.contains("{{ .Amd64 }}"), "{err}");
195        assert!(
196            !err.contains("name template '"),
197            "must not echo a fake template: {err}"
198        );
199        assert!(err.contains("package gets a distinct path"), "{err}");
200    }
201
202    #[test]
203    fn separate_scopes_do_not_share_state() {
204        // Two crates (or two config entries) each render the same leaf path;
205        // a per-scope guard must NOT treat the second scope's first write as
206        // a collision.
207        let path = Path::new("dist/macos/app.app");
208        let mut first = ArchPathGuard::new();
209        first
210            .check(path, "dmgs", "image", "{{ .ProjectName }}", "app.dmg", "a")
211            .expect("scope one first write");
212        let mut second = ArchPathGuard::new();
213        second
214            .check(path, "dmgs", "image", "{{ .ProjectName }}", "app.dmg", "b")
215            .expect("scope two first write must pass");
216    }
217}