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