Skip to main content

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(&params.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@", &params.cases.detect_os_cases),
400        ("@DETECT_ARCH_CASES@", &params.cases.detect_arch_cases),
401        ("@ASSET_CASES@", &params.cases.asset_cases),
402        ("@SUPPORTED_PLATFORMS@", &params.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;