anodizer_stage_install_script/lib.rs
1//! The `install-script` stage: emit a deterministic POSIX `install.sh`
2//! (`curl | sh`) release asset.
3//!
4//! The per-platform `os-arch → asset` case table, the `uname -s`/`uname -m`
5//! detection arms, and the supported-platform list are ALL derived from the
6//! release's configured targets by the engine
7//! ([`anodizer_core::installer::render_installer_cases`]) — the same SSOT that
8//! keeps cargo-binstall's `pkg_url` from 404ing. The stage never reads produced
9//! artifacts and never hand-rolls a `uname`/os/arch mapping, so its output is
10//! byte-identical on every determinism shard regardless of which binaries that
11//! shard compiled.
12//!
13//! At run time (`curl -fsSL .../install.sh | sh`) the script detects the host
14//! OS + architecture, maps it to the matching release archive, downloads and
15//! (by default) sha256-verifies it, extracts the binary(ies), and installs
16//! them — defaulting the release version to the latest GitHub release,
17//! overridable via a `VERSION=` environment variable.
18//!
19//! Everything the tool can compute is derived: the repository slug from the
20//! git `origin` remote, the installed binary names from the project name, the
21//! asset names from the configured targets, and the checksums filename and tag
22//! prefix from the flagship crate that builds the project binary. A bare
23//! `install_scripts: {}` produces a working installer with no required input.
24
25use std::collections::HashSet;
26use std::fs;
27
28use anyhow::{Context as _, Result};
29
30use anodizer_core::artifact::{Artifact, ArtifactKind};
31use anodizer_core::config::{ChecksumConfig, CrateConfig, InstallScriptConfig};
32use anodizer_core::context::Context;
33use anodizer_core::installer::{InstallerCases, installer_crate, render_installer_cases};
34use anodizer_core::stage::Stage;
35
36/// The POSIX script skeleton. `@MARKER@` placeholders are replaced with baked
37/// values at render time; the marker syntax cannot collide with shell `${...}`
38/// expansions in the body.
39const SCRIPT_TEMPLATE: &str = include_str!("install.sh.tmpl");
40
41/// Default output filename when a config sets no `filename:`.
42const DEFAULT_FILENAME: &str = "install.sh";
43
44/// Default install directory when a config sets no `install_dir:`.
45const DEFAULT_INSTALL_DIR: &str = "/usr/local/bin";
46
47/// Default download + REST-API base when a config sets no `base_url:`.
48const DEFAULT_BASE_URL: &str = "https://github.com";
49
50/// The literal the engine bakes into the asset/checksums names in place of the
51/// concrete release version, so the generated script resolves the version at
52/// run time (latest release or a `VERSION=` pin) and substitutes it into
53/// `${version}` — one asset table serves every release the script is fetched
54/// from.
55const VERSION_PLACEHOLDER: &str = "${version}";
56
57/// The `install-script` pipeline stage.
58pub struct InstallScriptStage;
59
60impl Stage for InstallScriptStage {
61 fn name(&self) -> &str {
62 "install-script"
63 }
64
65 fn run(&self, ctx: &mut Context) -> Result<()> {
66 let configs = ctx.config.install_scripts.clone();
67 if configs.is_empty() {
68 return Ok(());
69 }
70
71 validate_unique_ids(&configs)?;
72
73 let dry_run = ctx.options.dry_run;
74 let dist = ctx.config.dist.clone();
75 let project_name = ctx.config.project_name.clone();
76 let repo_default = default_repo(ctx);
77
78 // The engine-derived tables (asset arms, uname detection arms,
79 // supported-platform list) plus the checksums filename and tag prefix
80 // are config-INDEPENDENT — they depend only on the release's targets
81 // and the flagship crate — so derive them once for all configs. The
82 // flagship crate (`installer_crate`) is the SSOT for both the checksums
83 // filename and the tag prefix (per-crate, not the global defaults).
84 let crate_cfg = installer_crate(&ctx.config);
85 let derived = match &crate_cfg {
86 Some(c) => Some(derive_engine_tables(ctx, c)?),
87 None => None,
88 };
89
90 let mut seen_filenames = HashSet::new();
91 let mut artifacts: Vec<Artifact> = Vec::new();
92
93 for cfg in &configs {
94 let id = cfg.id.as_deref().unwrap_or("default");
95 let filename = cfg
96 .filename
97 .as_deref()
98 .unwrap_or(DEFAULT_FILENAME)
99 .to_string();
100
101 if let Some(ref skip) = cfg.skip
102 && skip
103 .try_evaluates_to_true(|tmpl| ctx.render_template(tmpl))
104 .with_context(|| "install-script: render skip template")?
105 {
106 ctx.logger("install-script")
107 .verbose(&format!("install-script config '{id}' skipped"));
108 continue;
109 }
110
111 if !seen_filenames.insert(filename.clone()) {
112 anyhow::bail!("install-script: duplicate filename '{}'", filename);
113 }
114
115 // No flagship crate builds a binstallable archive for the project
116 // binary (e.g. a pure-library workspace, or a restricted build that
117 // filtered the flagship crate out): there is nothing for the script
118 // to fetch, so step aside with a status line rather than emit an
119 // installer whose case table is empty.
120 let Some(derived) = &derived else {
121 ctx.logger("install-script").status(&format!(
122 "skipped install script '{filename}' — no crate builds a binstallable \
123 archive for the '{project_name}' binary"
124 ));
125 continue;
126 };
127
128 let repo = cfg
129 .repo
130 .clone()
131 .or_else(|| repo_default.clone())
132 .with_context(|| {
133 format!(
134 "install-script: config '{id}' needs a `repo:` (owner/name) — could not \
135 derive one from the git origin remote"
136 )
137 })?;
138
139 let binaries = cfg
140 .binaries
141 .clone()
142 .filter(|b| !b.is_empty())
143 .unwrap_or_else(|| vec![project_name.clone()]);
144 let base_url = cfg
145 .base_url
146 .as_deref()
147 .unwrap_or(DEFAULT_BASE_URL)
148 .trim_end_matches('/')
149 .to_string();
150 let install_dir = cfg
151 .install_dir
152 .as_deref()
153 .unwrap_or(DEFAULT_INSTALL_DIR)
154 .to_string();
155 let verify_checksum = cfg.verify_checksum.unwrap_or(true);
156 let name = cfg.name.clone().unwrap_or_else(|| project_name.clone());
157
158 let script = render_script(&ScriptParams {
159 repo: &repo,
160 base_url: &base_url,
161 binaries: &binaries,
162 install_dir: &install_dir,
163 verify_checksum,
164 name: &name,
165 description: cfg.description.as_deref().unwrap_or(""),
166 homepage: cfg.homepage.as_deref().unwrap_or(""),
167 filename: &filename,
168 checksums: &derived.checksums_filename,
169 tag_prefix: &derived.tag_prefix,
170 cases: &derived.cases,
171 });
172
173 if dry_run {
174 ctx.logger("install-script")
175 .status(&format!("(dry-run) would build install script {filename}"));
176 continue;
177 }
178
179 let output_path = dist.join(&filename);
180 fs::write(&output_path, script.as_bytes())
181 .with_context(|| format!("install-script: write {}", output_path.display()))?;
182
183 ctx.logger("install-script")
184 .status(&format!("built install script {filename}"));
185
186 artifacts.push(Artifact {
187 kind: ArtifactKind::InstallScript,
188 name: filename,
189 path: output_path,
190 target: None,
191 crate_name: project_name.clone(),
192 metadata: std::collections::HashMap::from([("id".to_string(), id.to_string())]),
193 size: None,
194 });
195 }
196
197 for artifact in artifacts {
198 ctx.artifacts.add(artifact);
199 }
200
201 Ok(())
202 }
203}
204
205/// Build-time environment requirements. The install-script stage only writes a
206/// text file — it spawns no external tool — so it needs none. Present for
207/// symmetry with the other packaging stages and wired into `preflight`.
208pub fn env_requirements(_ctx: &Context) -> Vec<anodizer_core::EnvRequirement> {
209 Vec::new()
210}
211
212/// The engine-derived, config-independent pieces of a generated installer.
213struct DerivedTables {
214 /// The `os-arch → asset`, `uname` detection, and supported-platform case
215 /// tables, with each asset name carrying [`VERSION_PLACEHOLDER`] in place
216 /// of the concrete release version.
217 cases: InstallerCases,
218 /// The checksums filename the checksum stage produces for the flagship
219 /// crate, version-templated to `${version}`.
220 checksums_filename: String,
221 /// The literal tag prefix from the flagship crate's tag template
222 /// (`v{{ Version }}` → `v`; empty when the template is a bare version).
223 tag_prefix: String,
224}
225
226/// Derive the case tables, checksums filename, and tag prefix for the flagship
227/// crate, all rendered with the release version expressed as the shell
228/// `${version}` placeholder so the emitted script is version-agnostic.
229///
230/// The `Version` template var is stamped to [`VERSION_PLACEHOLDER`] for the
231/// duration and restored afterward, so no concrete release version leaks into
232/// the surrounding pipeline's template scope.
233fn derive_engine_tables(ctx: &mut Context, crate_cfg: &CrateConfig) -> Result<DerivedTables> {
234 let prior_version = ctx.template_vars().get("Version").cloned();
235 ctx.template_vars_mut().set("Version", VERSION_PLACEHOLDER);
236
237 let result = (|| -> Result<DerivedTables> {
238 let cases =
239 render_installer_cases(ctx).context("install-script: derive installer case tables")?;
240 let checksums_filename = resolve_checksums_filename(ctx, crate_cfg)?;
241 let tag_prefix = resolve_tag_prefix(ctx, crate_cfg)?;
242 Ok(DerivedTables {
243 cases,
244 checksums_filename,
245 tag_prefix,
246 })
247 })();
248
249 match prior_version {
250 Some(v) => ctx.template_vars_mut().set("Version", &v),
251 None => {
252 ctx.template_vars_mut().unset("Version");
253 }
254 }
255 result
256}
257
258/// Resolve the flagship crate's combined-checksums filename, rendered with the
259/// `${version}` placeholder already stamped on `Version`.
260///
261/// The template string is resolved through
262/// [`ChecksumConfig::resolve_combined_name_template`] — the single source of
263/// truth shared with the checksum stage — so the crate's own
264/// `checksum.name_template` wins, then `defaults.checksum.name_template`, then
265/// the canonical [`ChecksumConfig::DEFAULT_NAME_TEMPLATE`].
266///
267/// The template is rendered under the AMBIENT template scope, deliberately NOT
268/// binding `CrateName`, so the filename is byte-identical to the one the
269/// checksum stage's `write_combined_file` writes (which also renders under
270/// ambient vars). Binding `CrateName` here would produce a different name than
271/// the file actually written, and the installer's checksum fetch would 404.
272fn resolve_checksums_filename(ctx: &mut Context, crate_cfg: &CrateConfig) -> Result<String> {
273 // Resolve the template string first (drops the immutable `ctx.config`
274 // borrow before the mutable `render_template` borrow).
275 let global = ctx
276 .config
277 .defaults
278 .as_ref()
279 .and_then(|d| d.checksum.as_ref());
280 let template =
281 ChecksumConfig::resolve_combined_name_template(crate_cfg.checksum.as_ref(), global)
282 .to_string();
283
284 // Render under the AMBIENT template scope — deliberately NOT binding
285 // `CrateName` — so the filename is byte-identical to the one the checksum
286 // stage's `write_combined_file` writes (which also renders under ambient
287 // vars). `Version` is already stamped to the `${version}` placeholder by
288 // the caller.
289 ctx.render_template(&template)
290 .with_context(|| "install-script: render checksums filename")
291}
292
293/// Resolve the flagship crate's literal tag prefix.
294///
295/// Renders the crate's tag template (a `release.tag` override wins, then the
296/// crate's `tag_template`, then the canonical `v{{ Version }}`) with `Version`
297/// stamped to `${version}`, then strips the trailing `${version}` — so
298/// `v{{ Version }}` → `v`, `release-{{ Version }}` → `release-`, and a bare
299/// `{{ Version }}` → `` (empty). This is the W2 fix: the script's
300/// `tag="${TAG_PREFIX}${version}"` no longer hardcodes a `v` prefix.
301///
302/// The rendered tag MUST end with the version placeholder: a `curl | sh`
303/// installer reconstructs the release tag from a runtime-resolved version and
304/// can only invert a version-SUFFIXED template (any prefix is fine). A
305/// version-infix template (e.g. `{{ Version }}-stable`) is rejected with a
306/// clear error rather than silently baking a broken `tag=` into the script.
307fn resolve_tag_prefix(ctx: &mut Context, crate_cfg: &CrateConfig) -> Result<String> {
308 let template = crate_cfg
309 .release
310 .as_ref()
311 .and_then(|r| r.tag.clone())
312 .filter(|t| !t.is_empty())
313 .unwrap_or_else(|| crate_cfg.resolved_tag_template().to_string());
314
315 let rendered = ctx
316 .render_template(&template)
317 .with_context(|| format!("install-script: render tag template '{template}'"))?;
318 match rendered.strip_suffix(VERSION_PLACEHOLDER) {
319 Some(prefix) => Ok(prefix.to_string()),
320 None => anyhow::bail!(
321 "install-script: tag template '{template}' must end with the version \
322 (e.g. `v{{{{ Version }}}}`), but rendered to '{rendered}', which places \
323 text after the version. A `curl | sh` installer reconstructs the release \
324 tag from a runtime-resolved version and cannot invert a version-infix \
325 template. Use a version-suffixed `release.tag` / crate `tag_template`, \
326 or skip the install-script stage for this project."
327 ),
328 }
329}
330
331/// Parameters passed to [`render_script`].
332struct ScriptParams<'a> {
333 repo: &'a str,
334 base_url: &'a str,
335 binaries: &'a [String],
336 install_dir: &'a str,
337 verify_checksum: bool,
338 name: &'a str,
339 description: &'a str,
340 homepage: &'a str,
341 filename: &'a str,
342 checksums: &'a str,
343 tag_prefix: &'a str,
344 cases: &'a InstallerCases,
345}
346
347/// Render the POSIX install script from baked values. Pure and deterministic:
348/// identical inputs always yield byte-identical output.
349///
350/// Renders in a SINGLE pass ([`single_pass_replace`]) under a categorized escape
351/// policy, so every `@MARKER@` position is substituted exactly once and no
352/// replacement value can be re-scanned as a marker (re-substitution is
353/// structurally impossible). Two DATA categories are escaped for their shell
354/// context — free-text metadata (`@NAME@` via [`shell_dq_escape`],
355/// `@DESCRIPTION@` / `@HOMEPAGE@` / `@FILENAME@` via [`comment_sanitize`]) and
356/// structured identifiers baked into double-quoted assignments (`@REPO@`,
357/// `@BASE_URL@`, `@BINARIES@` via [`shell_dq_escape`]) — so a `"`, `$`,
358/// `` ` ``, `\`, or newline cannot break out and inject shell text. Engine-
359/// rendered shell fragments (the case tables, tag prefix, checksums filename)
360/// and the shell-expandable `@INSTALL_DIR@` pass through verbatim by design.
361fn render_script(params: &ScriptParams) -> String {
362 // Human free-text metadata: escaped for its shell context (@NAME@ lands in
363 // double-quoted strings and a comment; description/homepage are comment-only).
364 let name = shell_dq_escape(params.name);
365 let description = comment_sanitize(params.description);
366 let homepage = comment_sanitize(params.homepage);
367 let filename = comment_sanitize(params.filename);
368 // Structured identifiers baked into double-quoted assignments. A slug / URL /
369 // binary-name never legitimately contains `"`, `$`, backtick, or a newline,
370 // so escaping them is zero-cost defense-in-depth and keeps the escape policy
371 // consistent across every double-quoted DATA value.
372 let repo = shell_dq_escape(params.repo);
373 let base_url = shell_dq_escape(params.base_url);
374 let binaries = shell_dq_escape(¶ms.binaries.join(" "));
375 let verify = if params.verify_checksum {
376 "true"
377 } else {
378 "false"
379 };
380
381 // Markers NOT escaped, BY DESIGN:
382 // @INSTALL_DIR@ — a shell-expandable default: `install_dir: "$HOME/bin"`
383 // and the runtime `${INSTALL_DIR:-…}` override both rely
384 // on the value expanding, so it must pass through live.
385 // @CHECKSUMS@ — an engine-rendered filename that embeds a live
386 // `${version}` expansion (resolved at install time).
387 // @TAG_PREFIX@ / @VERIFY_CHECKSUM@ / @DETECT_*_CASES@ / @ASSET_CASES@ /
388 // @SUPPORTED_PLATFORMS@ — engine-generated shell code / control values,
389 // not user free-text.
390 let map: &[(&str, &str)] = &[
391 ("@REPO@", &repo),
392 ("@BASE_URL@", &base_url),
393 ("@BINARIES@", &binaries),
394 ("@INSTALL_DIR@", params.install_dir),
395 ("@VERIFY_CHECKSUM@", verify),
396 ("@FILENAME@", &filename),
397 ("@CHECKSUMS@", params.checksums),
398 ("@TAG_PREFIX@", params.tag_prefix),
399 ("@DETECT_OS_CASES@", ¶ms.cases.detect_os_cases),
400 ("@DETECT_ARCH_CASES@", ¶ms.cases.detect_arch_cases),
401 ("@ASSET_CASES@", ¶ms.cases.asset_cases),
402 ("@SUPPORTED_PLATFORMS@", ¶ms.cases.supported_platforms),
403 ("@NAME@", &name),
404 ("@DESCRIPTION@", &description),
405 ("@HOMEPAGE@", &homepage),
406 ];
407 single_pass_replace(SCRIPT_TEMPLATE, map)
408}
409
410/// Escape a value baked into a double-quoted POSIX shell string: strip newlines
411/// (they would break out of the string) and backslash-escape the four
412/// characters the shell still interprets inside double quotes.
413fn shell_dq_escape(s: &str) -> String {
414 let one_line: String = s
415 .chars()
416 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
417 .collect();
418 one_line
419 .replace('\\', "\\\\")
420 .replace('"', "\\\"")
421 .replace('$', "\\$")
422 .replace('`', "\\`")
423}
424
425/// Collapse newlines to spaces so a value baked into a `#` comment line cannot
426/// break out of the comment and inject script text.
427fn comment_sanitize(s: &str) -> String {
428 s.chars()
429 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
430 .collect()
431}
432
433/// Replace every `@MARKER@` token in `template` using `map` in ONE pass: each
434/// marker position is substituted exactly once from `map`, so no replacement
435/// value can contain a marker that a later pass would rewrite — re-substitution
436/// is structurally impossible in either direction. An `@...@` run that matches
437/// no known marker is emitted verbatim. UTF-8 safe: all slicing lands on the
438/// ASCII `@` byte or a marker's byte length.
439fn single_pass_replace(template: &str, map: &[(&str, &str)]) -> String {
440 let mut out = String::with_capacity(template.len() + 512);
441 let mut rest = template;
442 while let Some(at) = rest.find('@') {
443 out.push_str(&rest[..at]);
444 let after = &rest[at..];
445 if let Some((marker, val)) = map.iter().find(|(m, _)| after.starts_with(*m)) {
446 out.push_str(val);
447 rest = &after[marker.len()..];
448 } else {
449 out.push('@');
450 rest = &after[1..];
451 }
452 }
453 out.push_str(rest);
454 out
455}
456
457/// Derive the default `owner/name` repo slug from the git `origin` remote,
458/// returning `None` when detection fails (no remote / not a repo / a
459/// non-GitHub host) so a config without an explicit `repo:` surfaces a clear
460/// error instead. The generated script talks only to `github.com` /
461/// `api.github.com` by default, so a GitHub-aware resolver is used
462/// deliberately: a GitLab/Gitea origin fails closed (forcing an explicit
463/// `repo:`) rather than baking a non-GitHub slug into GitHub URLs that 404 at
464/// install time.
465fn default_repo(ctx: &Context) -> Option<String> {
466 let root = ctx
467 .options
468 .project_root
469 .clone()
470 .unwrap_or_else(|| std::path::PathBuf::from("."));
471 anodizer_core::git::resolve_github_slug_in(None, None, &root)
472 .ok()
473 .map(|slug| slug.slug().to_string())
474}
475
476/// Reject duplicate config IDs (mirrors the makeself/nfpm validation).
477fn validate_unique_ids(configs: &[InstallScriptConfig]) -> Result<()> {
478 let mut seen = HashSet::new();
479 for cfg in configs {
480 let id = cfg.id.as_deref().unwrap_or("default");
481 if !seen.insert(id.to_string()) {
482 anyhow::bail!("install-script: duplicate id '{}'", id);
483 }
484 }
485 Ok(())
486}
487
488#[cfg(test)]
489mod tests;