git-ents.gitmain
⌘K
foforge
commit e010378
feat: parameterize toolchain recipes by target platform

A downloaded toolchain pin could previously only be authored from a machine already running the target platform’s binaries. --platform now drives recipe resolution itself: rustup resolves version and components from the channel manifest alone, and sccache/a new url recipe fetch the archive once at import time solely to compute its sha256 (trust on first use), so any machine can author a hash-pinned toolchain for any platform without holding its bytes.

feat: add strip/dest layout metadata to Component feat: add url toolchain recipe for pinning arbitrary hosted archives feat: add --strip/--dest CLI flags for the url recipe’s layout refactor: extract downloaded_script as a pure, unit-tested function docs: document --platform, the url recipe, and TOFU pinning Assisted-by: Claude:claude-sonnet-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

docs/abstractions.adoc @@ -127,7 +127,8 @@ subsystems. * *Toolchains* — typed trees under `refs/meta/toolchains/*`; a resource effects declare, not a trigger. The repo carries its own execution -environment with provenance. They keep a subcommand only because +environment with provenance, as ~1KB hash-pinned manifests; only the +sandbox touches the bytes. They keep a subcommand only because import/activation logic is nontrivial; if that shrinks, the subcommand dies. * *Members, accounts, comments* — typed trees behind meta-refs (1+2),
docs/spec/checks.adoc @@ -117,10 +117,16 @@ Before running a check that names a toolchain, the worker MUST resolve each distinct toolchain named across the job's checks (<<checks.toolchains>>) to -its typed `bin` tree (`Toolchain::bin`, not the whole document) and extract -it into the Sprite at a hash-keyed directory, skipping the extraction when -that directory already exists — the Sprite's persistent filesystem is the -cache. +its typed `bin` (`Toolchain::bin`, not the whole document) and materialize +it into the Sprite at a hash-keyed directory, skipping the work when that +directory already exists — the Sprite's persistent filesystem is the cache. +An embedded toolchain's tree is extracted from the object database; a +downloaded toolchain's components MUST be fetched, sha256-verified against +their recorded pins, and extracted *inside* the Sprite itself, each per its +recorded layout (leading path segments stripped, landed in its recorded +subdirectory, compression auto-detected), under a cache key derived from +the component hashes plus their layout — the repository never proxies the +bytes it deliberately does not store. A check's command MUST then run with each of its named toolchains' extracted `bin` directory prefixed onto `PATH`, in declaration order, so an earlier toolchain wins a name collision.
docs/spec/cli.adoc @@ -82,24 +82,35 @@ `bin` MUST be non-empty; `license` MUST be a valid SPDX expression; `version` MUST be valid semver; `platform` MUST be a valid target triple. * `import --from <recipe>` — derive the fields from a local toolchain - install, or a hosted release, via a named recipe (`rustup` or `sccache`, - selected further by `--spec <name>`, e.g. `stable` for `rustup`, a release - tag for `sccache`); explicitly passed fields override the recipe. - A recipe capable of pointing at a distributor's own hosted, hash-pinned - archives (rust-lang's dist tarballs, for `rustup`) records those as the - toolchain's `bin` by default, sparing the repository the bytes; `--embed` - forces importing the local install's actual `bin` bytes, as `import` - without `--from` always does. `sccache` has no hash manifest to pin a - hosted download against, so it always imports the downloaded bytes - directly regardless of `--embed`. + install, or a hosted release, via a named recipe (`rustup`, `sccache`, or + `url`, selected further by `--spec <name>`, e.g. `stable` for `rustup`, a + release tag for `sccache`, the archive's URL for `url`); explicitly passed + fields override the recipe. A recipe capable of pointing at a + distributor's own hosted, hash-pinned archives (rust-lang's dist tarballs, + for `rustup`) records those as the toolchain's `bin` by default, sparing + the repository the bytes; `--embed` forces importing the local install's + actual `bin` bytes, as `import` without `--from` always does. +* `import --from <recipe> --platform <triple>` — `--platform` MUST + additionally parameterize the recipe's own resolution, pinning the named + target's hosted archives without the local machine ever holding that + platform's binaries (`rustup` resolves version and components from the + channel manifest alone), and MUST be rejected together with `--embed`. + A recipe whose distributor publishes no hash manifest (`sccache`, `url`) + MUST instead fetch the archive once at import time solely to compute the + sha256 recorded as its pin — trust on first use, with the recipe string + and the ref's commit history as the audit trail; every later fetch + verifies against that pin. `url` pins any archive (`--spec <url>`, + required) with its extraction layout from `--strip`/`--dest`, and + supplies no version, platform, or license of its own. * `list` — render every toolchain on a remote with its `bin` (a tree id, or a component count when hosted externally), version, platform, and license. * `export` — recreate a remote toolchain's `bin` (and `src`, if present) under a local destination, restoring the executable bit and symlinks — - fetching and sha256-verifying externally hosted components first — and - print the version, platform, and license; read-only, per - <<cli.remote-admin>>. + fetching, sha256-verifying, and extracting externally hosted components + first, each per its recorded layout (leading segments stripped, landed in + its recorded subdirectory, compression auto-detected) — and print the + version, platform, and license; read-only, per <<cli.remote-admin>>. * `remove` — delete the toolchain's ref on a remote. --
docs/spec/conformance.adoc @@ -39,6 +39,7 @@ |`cli.comments` |`git-ents/src/main.rs` (`CommentAction`, `run_comment`) | _(manual/CLI UX, no automated test)_ |`cli.login` |`git-ents/src/main.rs` (`login`); `git-ents-server/src/main.rs` (`/login/cli` routes) | _(manual/CLI UX, no automated test)_ |`cli.key-resolution` |`git-ents/src/main.rs` (`fingerprint`, MD5 colon form) | _(manual/CLI UX, no automated test)_ +|`cli.toolchains` |`git-ents/src/main.rs` (`ToolchainAction`, `toolchain_import`); `git-ents/src/registry.rs` (`resolve`, `rustup`, `sccache`, `url_archive`) | _(manual/CLI UX, no automated test)_ |`account.ref` |`git-ents/src/account.rs` |`account::store_then_load_round_trips_the_account`, `the_account_ref_marks_an_account_repo` |`account.genesis` |`git-ents/src/account.rs` (`genesis`) | _(exercised via `members add --account`; no dedicated unit test)_ |`config.ref` |`git-ents-core/src/config.rs` |`config::store_then_load_round_trips_the_config`, `default_when_the_config_ref_is_absent` @@ -46,7 +47,7 @@ |`checks.cache` |`git-effect/src/cache.rs` (`CACHE_NS`, `cache_ref`, `cache_dir`, `restore`, `snapshot`); `git-effect/src/engine.rs` (`with_cache_env`) |`definition::tests::store_then_load_round_trips_cache`, `order_rejects_an_invalid_cache_name`; `engine::tests::with_cache_env_leaves_a_cache_free_command_unchanged`, `with_cache_env_exports_the_restored_directory` _(`restore`/`snapshot` themselves require a live Sprite; not covered by unit tests)_ |`checks.post-receive` |`git-effect/src/engine.rs` (`post_receive`, `enqueue`) | _(hook-level, exercised manually; `enqueue` writes tmp+rename)_ |`checks.worker` |`git-effect/src/engine.rs` (`worker`, `pending_jobs`, `drain_repo`, `process_job`, `derive_composite`) |`engine::tests::pending_jobs_groups_by_repo_and_drops_malformed`, `composite_status_derives_from_its_dependencies` _(dependency-ordered execution requires a live Sprite; `order` is unit-tested in `git-effect`)_ -|`checks.sandbox` |`git-effect/src/engine.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`) | _(requires a live Sprite; not covered by unit tests)_ +|`checks.sandbox` |`git-effect/src/engine.rs` (`ensure_auth`, `ensure_sprite`, `sync_tree`, `resolve_toolchains`, `downloaded_script`, `components_key`, `activate`) |`engine::tests::downloaded_script_extracts_each_component_per_its_layout`, `components_key_includes_the_layout`, `activate_prefixes_path_in_declared_order` _(live-Sprite paths not covered by unit tests)_ |`checks.outcomes` |`git-effect/src/results.rs` (`Status`, `record`/`update_run`); `git-effect/src/engine.rs` (`CHECK_TIMEOUT`, `finalize_error`) |`results::tests::update_run_advances_in_place_rather_than_appending`, `round_trips_an_outcomes_duration_and_recording` |`checks.debug` |`git-ents-server/src/web/debug.rs` (`handshake`, broker); `git-ents/src/main.rs` (`effect debug`) | _(requires a live Sprite; manually verified)_ |`checks.admin-only` |`git-signed-push/src/lib.rs` (`pre_receive`, `EFFECTS_NS_PREFIX`) |`git-ents-server::pre_receive rejects_a_push_to_effects_from_a_self_attested_member`
crates/git-effect/src/engine.rs @@ -656,8 +656,10 @@ sync_downloaded_toolchain(sprite, &key, components)?; // Unlike an embedded toolchain's tree (already flattened to // put executables at its own top level), each component - // archive extracts its own `bin/` alongside `lib/`, so `PATH` - // must point one level deeper. + // extracts per its recorded layout — its own `bin/` top level + // (rustup) or straight into a `bin` dest (a flat archive) — + // landing executables at `<key>/bin` either way, so `PATH` + // points one level deeper. format!("{TOOLCHAINS_DIR}/{key}/bin") } }; @@ -667,13 +669,20 @@ } /// A stable, filesystem-safe cache key for a [`git_toolchain::Bin::Downloaded`] -/// toolchain: its components' sha256s, joined in extraction order — there is -/// no tree oid to key the extraction cache by, since nothing is written to -/// the object database for a downloaded toolchain's `bin`. +/// toolchain: each component's sha256 plus its recorded layout +/// (`strip`/`dest` — the same bytes extracted differently are a different +/// toolchain on disk), joined in extraction order — there is no tree oid to +/// key the extraction cache by, since nothing is written to the object +/// database for a downloaded toolchain's `bin`. fn components_key(components: &[git_toolchain::Component]) -> String { components .iter() - .map(|component| component.sha256.as_str()) + .map(|component| { + format!( + "{}.{}.{}", + component.sha256, component.strip, component.dest + ) + }) .collect::<Vec<_>>() .join("-") } @@ -811,17 +820,7 @@ return Ok(()); } - let mut script = format!("mkdir -p {dir}"); - for component in components { - script.push_str(&format!( - " && curl -fsSL '{url}' -o /tmp/component.tar.gz \ - && [ \"$(sha256sum /tmp/component.tar.gz | cut -d' ' -f1)\" = '{sha256}' ] \ - && tar -xz --strip-components=2 -C {dir} -f /tmp/component.tar.gz \ - && rm -f /tmp/component.tar.gz", - url = component.url, - sha256 = component.sha256, - )); - } + let script = downloaded_script(&dir, components); let status = Command::new("sprite") .args(["exec", "-s", sprite, "--", "sh", "-c", &script]) .status() @@ -835,6 +834,40 @@ } } +/// The `sh` script fetching, verifying, and extracting `components` into +/// `dir` — pure, so the exact extraction semantics the Sprite runs are unit +/// tested against `git_toolchain::export`'s local equivalent (the +/// local/hosted parity anchor). Each component lands in `dir`/its `dest`, +/// stripped of its leading `strip` path segments, compression auto-detected +/// by `tar` (rust-lang ships gzip, zig ships xz). Interpolation is safe by +/// construction: `git_toolchain::import_downloaded` refuses a component +/// whose fields could escape the single quotes. +/// +/// ## Requirements +/// +/// @relation(checks.sandbox) +fn downloaded_script(dir: &str, components: &[git_toolchain::Component]) -> String { + let mut script = format!("mkdir -p {dir}"); + for component in components { + let dest = if component.dest.is_empty() { + dir.to_owned() + } else { + format!("{dir}/{}", component.dest) + }; + script.push_str(&format!( + " && mkdir -p {dest} \ + && curl -fsSL '{url}' -o /tmp/component.archive \ + && [ \"$(sha256sum /tmp/component.archive | cut -d' ' -f1)\" = '{sha256}' ] \ + && tar -x --strip-components={strip} -C {dest} -f /tmp/component.archive \ + && rm -f /tmp/component.archive", + url = component.url, + sha256 = component.sha256, + strip = component.strip, + )); + } + script +} + /// How long a single effect may run before the worker abandons it. A runaway /// effect that outlived this — a hung build, a command blocked on input — is /// killed and recorded `error` rather than wedging the worker (and with it every @@ -1083,6 +1116,58 @@ ); } + // @relation(checks.sandbox, role=Verifies) + #[test] + fn downloaded_script_extracts_each_component_per_its_layout() { + let components = vec![ + git_toolchain::Component { + url: "https://static.rust-lang.org/dist/rustc.tar.gz".to_owned(), + sha256: "aaa".to_owned(), + strip: 2, + dest: String::new(), + }, + git_toolchain::Component { + url: "https://example.com/flat.tar.xz".to_owned(), + sha256: "bbb".to_owned(), + strip: 1, + dest: "bin".to_owned(), + }, + ]; + assert_eq!( + downloaded_script("/toolchains/key", &components), + "mkdir -p /toolchains/key \ + && mkdir -p /toolchains/key \ + && curl -fsSL 'https://static.rust-lang.org/dist/rustc.tar.gz' -o /tmp/component.archive \ + && [ \"$(sha256sum /tmp/component.archive | cut -d' ' -f1)\" = 'aaa' ] \ + && tar -x --strip-components=2 -C /toolchains/key -f /tmp/component.archive \ + && rm -f /tmp/component.archive \ + && mkdir -p /toolchains/key/bin \ + && curl -fsSL 'https://example.com/flat.tar.xz' -o /tmp/component.archive \ + && [ \"$(sha256sum /tmp/component.archive | cut -d' ' -f1)\" = 'bbb' ] \ + && tar -x --strip-components=1 -C /toolchains/key/bin -f /tmp/component.archive \ + && rm -f /tmp/component.archive" + ); + } + + // @relation(checks.sandbox, role=Verifies) + #[test] + fn components_key_includes_the_layout() { + let component = git_toolchain::Component { + url: "https://example.com/a.tar.gz".to_owned(), + sha256: "aaa".to_owned(), + strip: 2, + dest: String::new(), + }; + let mut flat = component.clone(); + flat.strip = 1; + flat.dest = "bin".to_owned(); + assert_eq!(components_key(std::slice::from_ref(&component)), "aaa.2."); + assert_ne!( + components_key(std::slice::from_ref(&component)), + components_key(&[flat]) + ); + } + // @relation(checks.cache, role=Verifies) #[test] fn with_cache_env_leaves_a_cache_free_command_unchanged() {
crates/git-ents/src/main.rs @@ -276,11 +276,12 @@ #[facet(args::named, default)] platform: Option<String>, /// Recipe to derive `bin`/`src`/`license`/`version`/`platform` from - /// instead of supplying them by hand (currently only `rustup`). + /// instead of supplying them by hand (`rustup`, `sccache`, `url`). #[facet(args::named)] from: Option<String>, /// Recipe-specific selector (for `--from rustup`, the toolchain - /// name `rustup` itself knows, e.g. `stable`; defaults to `stable`). + /// name `rustup` itself knows, e.g. `stable`; defaults to `stable`. + /// For `--from url`, the archive URL — required). #[facet(args::named)] spec: Option<String>, /// With `--from`, import the recipe's actual `bin` bytes instead of @@ -288,6 +289,16 @@ /// (see `git_toolchain::Bin::Downloaded`). #[facet(args::named, default)] embed: bool, + /// With `--from url`: leading path segments to strip when the + /// sandbox extracts the archive (default 1, a flat + /// `<pkg>-<version>/…` release tarball). + #[facet(args::named, default)] + strip: Option<u8>, + /// With `--from url`: subdirectory of the toolchain to extract the + /// archive into (default `bin`, putting a flat archive's payload on + /// `PATH`). + #[facet(args::named, default)] + dest: Option<String>, }, /// List the toolchains configured on a remote. List, @@ -540,8 +551,10 @@ from, spec, embed, + strip, + dest, } => toolchain_import( - name, bin, src, license, version, platform, from, spec, embed, remote, + name, bin, src, license, version, platform, from, spec, embed, strip, dest, remote, ), ToolchainAction::List => toolchain_list(remote), ToolchainAction::Recipes => toolchain_recipes(), @@ -556,7 +569,10 @@ /// `remote` and push it. Prompts for any field left unset when run at an /// interactive terminal, unless `from` names a recipe (`registry::resolve`) /// to derive `bin`/`src`/`license`/`version`/`platform` from instead; -/// explicit flags still win over a recipe's values. +/// explicit flags still win over a recipe's values, and `platform` +/// additionally parameterizes the recipe's own resolution — pinning another +/// platform's hosted archives without this machine ever holding its +/// binaries. /// /// ## Requirements /// @@ -572,15 +588,26 @@ from: Option<String>, spec: Option<String>, embed: bool, + strip: Option<u8>, + dest: Option<String>, remote: &str, ) -> Result<(), String> { let name = interactive::text_or(name, "Toolchain name")?; + if from.as_deref() == Some("url") && spec.is_none() { + return Err("the url recipe needs --spec <archive-url>".to_owned()); + } let recipe_desc = from .as_deref() .map(|from| registry::describe(from, spec.as_deref().unwrap_or("stable"))); + let opts = registry::RecipeOptions { + embed, + platform: platform.clone(), + strip, + dest, + }; let recipe = from - .map(|recipe| registry::resolve(&recipe, spec.as_deref().unwrap_or("stable"), embed)) + .map(|recipe| registry::resolve(&recipe, spec.as_deref().unwrap_or("stable"), &opts)) .transpose()?; let bin_plan = match bin { @@ -603,15 +630,15 @@ } else { interactive::optional_text_or(None, "Directory of source to import (optional)")? }; - let license = match license.or_else(|| recipe.as_ref().map(|r| r.license.clone())) { + let license = match license.or_else(|| recipe.as_ref().and_then(|r| r.license.clone())) { Some(license) => license, None => interactive::text_or(None, "License (SPDX expression)")?, }; - let version = match version.or_else(|| recipe.as_ref().map(|r| r.version.clone())) { + let version = match version.or_else(|| recipe.as_ref().and_then(|r| r.version.clone())) { Some(version) => version, None => interactive::text_or(None, "Version (semver)")?, }; - let platform = match platform.or_else(|| recipe.as_ref().map(|r| r.platform.clone())) { + let platform = match platform.or_else(|| recipe.as_ref().and_then(|r| r.platform.clone())) { Some(platform) => platform, None => interactive::text_or(None, "Platform (target triple)")?, };
crates/git-ents/src/registry.rs @@ -27,7 +27,10 @@ } /// What a recipe resolved from a local toolchain install, ready to hand to -/// `git_toolchain::import`/`import_downloaded`. +/// `git_toolchain::import`/`import_downloaded`. A `None` metadata field is +/// one the recipe cannot know (the `url` recipe knows nothing about what an +/// arbitrary archive contains); the CLI's own flag-else-prompt chain covers +/// it. /// /// `_staging`, when `bin` is [`Bin::Dir`] pointing into a temporary /// directory, is kept alive only so the directory survives until the @@ -35,12 +38,31 @@ pub struct Resolved { pub bin: Bin, pub src: Option<PathBuf>, - pub license: String, - pub version: String, - pub platform: String, + pub license: Option<String>, + pub version: Option<String>, + pub platform: Option<String>, _staging: Option<TempDir>, } +/// How [`resolve`] should resolve, beyond the recipe's own `spec` selector. +#[derive(Default)] +pub struct RecipeOptions { + /// Import the recipe's actual local `bin` bytes instead of recording + /// hosted, hash-pinned archives. Incompatible with `platform`. + pub embed: bool, + /// Resolve for this target triple instead of the local machine's — the + /// recipe then never touches local binaries, only the distributor's + /// hosted metadata and archives, so a toolchain for the effect worker's + /// sandbox can be pinned from any machine. + pub platform: Option<String>, + /// `url` recipe only: leading path segments to strip at extraction + /// (default 1, a flat `<pkg>-<version>/…` release tarball). + pub strip: Option<u8>, + /// `url` recipe only: subdirectory of the toolchain to extract into + /// (default `bin`, so a flat archive's payload lands on `PATH`). + pub dest: Option<String>, +} + /// A recipe `git ents toolchain import --from` accepts, described richly /// enough to render on its own via `facet_pretty` (see `git ents toolchain /// recipes`) rather than as a bare name. @@ -70,28 +92,51 @@ RecipeInfo { name: "sccache", spec: "a mozilla/sccache release tag, e.g. v0.8.2, or empty for latest", - summary: "Downloads a prebuilt sccache release for this machine's \ - platform from GitHub and imports it directly (always \ - embedded — the binary is small and GitHub publishes no \ - hash manifest to pin a hosted download against).", + summary: "Resolves a prebuilt sccache release from GitHub; with \ + --platform it records the release archive as a hash pin \ + computed at import time (trust on first use — GitHub \ + publishes no hash manifest), otherwise it downloads this \ + machine's archive and imports the binary directly.", + }, + RecipeInfo { + name: "url", + spec: "an archive URL, e.g. https://ziglang.org/download/.../zig-x86_64-linux-0.15.2.tar.xz", + summary: "Pins any hosted archive as a downloaded toolchain: fetches \ + it once at import time only to compute its sha256 (trust \ + on first use), records url+hash+layout (--strip, --dest), \ + and lets the sandbox fetch the bytes itself. Version, \ + platform, and license must be supplied explicitly.", }, ]; /// Resolve `recipe` against `spec` (a recipe-specific selector, e.g. a /// rustup toolchain name). See [`RECIPES`] for what's known. /// -/// `embed` forces the old behavior of staging and importing `bin`'s actual -/// bytes; by default the recipe instead points at its distributor's own -/// hosted, hash-verified archives (see [`Bin::Components`]), sparing the -/// repository the toolchain's own bytes. +/// `opts.embed` forces the old behavior of staging and importing `bin`'s +/// actual bytes; by default the recipe instead points at its distributor's +/// own hosted, hash-verified archives (see [`Bin::Components`]), sparing the +/// repository the toolchain's own bytes. `opts.platform` resolves for a +/// foreign target without touching local binaries at all, and is therefore +/// rejected together with `embed`. /// /// ## Requirements /// /// @relation(cli.toolchains) -pub fn resolve(recipe: &str, spec: &str, embed: bool) -> Result<Resolved, String> { +pub fn resolve(recipe: &str, spec: &str, opts: &RecipeOptions) -> Result<Resolved, String> { + if opts.embed && opts.platform.is_some() { + return Err( + "--embed imports this machine's bytes; it cannot target another platform".to_owned(), + ); + } + if (opts.strip.is_some() || opts.dest.is_some()) && recipe != "url" { + return Err(format!( + "--strip/--dest are layout hints for the url recipe; {recipe} records its own layout" + )); + } match recipe { - "rustup" => rustup(spec, embed), - "sccache" => sccache(spec), + "rustup" => rustup(spec, opts), + "sccache" => sccache(spec, opts.platform.as_deref()), + "url" => url_archive(spec, opts), other => Err(format!( "unknown toolchain recipe {other:?} (known: {})", RECIPES @@ -144,10 +189,31 @@ /// component is installed, else omitted, regardless of `embed`. Rust's own /// toolchain is dual-licensed `MIT OR Apache-2.0`. /// +/// With `opts.platform`, no local toolchain is consulted at all: the channel +/// manifest for `spec` (`stable`, `nightly`, a version) is the sole source — +/// it names its own version (`[pkg.rustc].version`) and hosts hash-pinned +/// archives for every target, so a toolchain for a foreign platform (the +/// effect worker's sandbox) can be pinned from any machine. `src` is omitted +/// there: there is no local sysroot to point at. +/// /// ## Requirements /// /// @relation(cli.toolchains) -fn rustup(spec: &str, embed: bool) -> Result<Resolved, String> { +fn rustup(spec: &str, opts: &RecipeOptions) -> Result<Resolved, String> { + if let Some(platform) = &opts.platform { + let manifest = fetch_manifest(spec)?; + let version = manifest_version(&manifest) + .ok_or_else(|| format!("channel-rust-{spec}.toml has no [pkg.rustc].version"))?; + return Ok(Resolved { + bin: Bin::Components(manifest_components(&manifest, platform)?), + src: None, + license: Some("MIT OR Apache-2.0".to_owned()), + version: Some(version), + platform: Some(platform.clone()), + _staging: None, + }); + } + let toolchain_arg = format!("+{spec}"); let sysroot = rustc(&toolchain_arg, &["--print", "sysroot"])?; let sysroot = PathBuf::from(sysroot.trim()); @@ -161,14 +227,15 @@ let src = sysroot.join("lib/rustlib/src/rust"); let src = src.is_dir().then_some(src); - let (bin, staging) = if embed { + let (bin, staging) = if opts.embed { let staging = tempfile::tempdir() .map_err(|error| format!("could not create a staging directory: {error}"))?; stage_bin(&sysroot.join("bin"), &sysroot.join("lib"), staging.path())?; (Bin::Dir(staging.path().to_owned()), Some(staging)) } else { + let manifest = fetch_manifest(&channel_for(&version))?; ( - Bin::Components(manifest_components(&version, &platform)?), + Bin::Components(manifest_components(&manifest, &platform)?), None, ) }; @@ -176,9 +243,9 @@ Ok(Resolved { bin, src, - license: "MIT OR Apache-2.0".to_owned(), - version, - platform, + license: Some("MIT OR Apache-2.0".to_owned()), + version: Some(version), + platform: Some(platform), _staging: staging, }) } @@ -190,41 +257,101 @@ /// [`rustup`] follows via its local `rustc`. /// /// Unlike `rustup`, GitHub publishes no manifest of hashes alongside a -/// release to pin a hosted download against, and the archive is a single -/// ~15 MB binary, so this recipe always imports it directly (`Bin::Dir`) -/// rather than offering [`Bin::Components`] — `embed` has nothing to toggle -/// here. +/// release to pin a hosted download against. Without a `platform` override +/// this recipe therefore imports this machine's archive's binary directly +/// (`Bin::Dir`). With `platform`, it instead records the release archive as +/// a downloaded component whose sha256 it computes itself, once, at import +/// time — trust on first use: the trust decision is taken exactly here, is +/// audited via the recipe string on the document and the ref's commit +/// history, and every later fetch (local export, the sandbox) verifies +/// against the pinned hash. The archive is flat +/// (`sccache-<tag>-<target>/sccache`), hence `strip: 1, dest: "bin"`. /// /// ## Requirements /// /// @relation(cli.toolchains) -fn sccache(spec: &str) -> Result<Resolved, String> { +fn sccache(spec: &str, platform: Option<&str>) -> Result<Resolved, String> { let tag = if spec.is_empty() { latest_sccache_tag()? } else { spec.to_owned() }; let version = tag.strip_prefix('v').unwrap_or(&tag).to_owned(); - let target = sccache_target()?; + let target = match platform { + Some(platform) => platform.to_owned(), + None => sccache_target()?.to_owned(), + }; let url = format!( "https://github.com/mozilla/sccache/releases/download/{tag}/sccache-{tag}-{target}.tar.gz" ); let bytes = crate::http_get_bytes(&url)?; + if platform.is_some() { + let sha256 = git_toolchain::sha256_hex(&bytes) + .map_err(|error| format!("could not hash: {error}"))?; + return Ok(Resolved { + bin: Bin::Components(vec![Component { + url, + sha256, + strip: 1, + dest: "bin".to_owned(), + }]), + src: None, + license: Some("MPL-2.0".to_owned()), + version: Some(version), + platform: Some(target), + _staging: None, + }); + } + let staging = tempfile::tempdir() .map_err(|error| format!("could not create a staging directory: {error}"))?; - stage_sccache(&bytes, &tag, target, staging.path())?; + stage_sccache(&bytes, &tag, &target, staging.path())?; Ok(Resolved { bin: Bin::Dir(staging.path().to_owned()), src: None, - license: "MPL-2.0".to_owned(), - version, - platform: target.to_owned(), + license: Some("MPL-2.0".to_owned()), + version: Some(version), + platform: Some(target), _staging: Some(staging), }) } +/// Pin any hosted archive as a one-component downloaded toolchain — +/// `http_archive`, in Bazel terms. `spec` is the archive's URL; it is +/// fetched once, here, only to compute the sha256 every later verification +/// pins against (trust on first use, audited exactly like [`sccache`]'s +/// pin). The layout hints come from `--strip`/`--dest` (default: a flat +/// `<pkg>-<version>/…` tarball whose payload belongs on `PATH`). This recipe +/// knows nothing about what the archive contains, so version, platform, and +/// license all stay `None` for the caller to supply. +/// +/// ## Requirements +/// +/// @relation(cli.toolchains) +fn url_archive(spec: &str, opts: &RecipeOptions) -> Result<Resolved, String> { + if spec.is_empty() { + return Err("the url recipe needs --spec <archive-url>".to_owned()); + } + let bytes = crate::http_get_bytes(spec)?; + let sha256 = + git_toolchain::sha256_hex(&bytes).map_err(|error| format!("could not hash: {error}"))?; + Ok(Resolved { + bin: Bin::Components(vec![Component { + url: spec.to_owned(), + sha256, + strip: opts.strip.unwrap_or(1), + dest: opts.dest.clone().unwrap_or_else(|| "bin".to_owned()), + }]), + src: None, + license: None, + version: None, + platform: None, + _staging: None, + }) +} + /// The latest `mozilla/sccache` release's tag name, from GitHub's "latest /// release" API. fn latest_sccache_tag() -> Result<String, String> { @@ -292,31 +419,52 @@ .map_err(|error| format!("could not set permissions on {}: {error}", dest.display())) } -/// The three components of rust-lang's channel manifest that together make -/// a working toolchain (compiler, cargo, and the target's standard library), -/// resolved for `target` against the manifest for `version` (or the shared -/// `nightly` channel, when `version` names one — rust-lang does not publish -/// a stable per-version manifest name for nightly builds). -fn manifest_components(version: &str, target: &str) -> Result<Vec<Component>, String> { - let channel = if version.contains("nightly") { +/// The manifest name for a version rustc reported: nightly builds collapse +/// to the shared `nightly` channel, since rust-lang publishes no stable +/// per-version manifest name for them. +fn channel_for(version: &str) -> String { + if version.contains("nightly") { "nightly".to_owned() } else { version.to_owned() - }; - let url = format!("https://static.rust-lang.org/dist/channel-rust-{channel}.toml"); - let manifest = crate::http_get(&url)?; + } +} +/// Fetch rust-lang's channel manifest for `channel` (`stable`, `nightly`, or +/// a version) — the one authoritative document naming the channel's version +/// and every target's hash-pinned component archives. +fn fetch_manifest(channel: &str) -> Result<String, String> { + let url = format!("https://static.rust-lang.org/dist/channel-rust-{channel}.toml"); + crate::http_get(&url) +} + +/// The channel's own version, from `[pkg.rustc] version = "1.88.0 (hash +/// date)"` — the first whitespace-separated token, valid semver for stable +/// (`1.88.0`) and nightly (`1.90.0-nightly`) alike. +fn manifest_version(manifest: &str) -> Option<String> { + let raw = manifest_field(manifest, "pkg.rustc", "version")?; + raw.split_whitespace().next().map(str::to_owned) +} + +/// The three components of rust-lang's channel manifest that together make +/// a working toolchain (compiler, cargo, and the target's standard library), +/// resolved for `target`. Every rust-lang dist archive unpacks to +/// `<package>-<version>-<target>/<component>/…`, hence `strip: 2` with no +/// `dest` — the payload carries its own `bin/`/`lib/` top level. +fn manifest_components(manifest: &str, target: &str) -> Result<Vec<Component>, String> { ["rustc", "cargo", "rust-std"] .into_iter() .map(|package| { let section = format!("pkg.{package}.target.{target}"); - let component_url = manifest_field(&manifest, &section, "url") - .ok_or_else(|| format!("{url} has no [{section}].url"))?; - let sha256 = manifest_field(&manifest, &section, "hash") - .ok_or_else(|| format!("{url} has no [{section}].hash"))?; + let component_url = manifest_field(manifest, &section, "url") + .ok_or_else(|| format!("the channel manifest has no [{section}].url"))?; + let sha256 = manifest_field(manifest, &section, "hash") + .ok_or_else(|| format!("the channel manifest has no [{section}].hash"))?; Ok(Component { url: component_url, sha256, + strip: 2, + dest: String::new(), }) }) .collect()
crates/git-toolchain/src/lib.rs @@ -100,15 +100,15 @@ /// [`export`] (or a Sprite, at check-activation time) instead of stored /// in the object database — spares the repository the toolchain's own /// bytes when a stable, content-hashed origin (a distributor's release - /// archives) already exists. Each component is extracted with its outer - /// two path segments (`<package>-<version>-<target>/<component>/`) - /// stripped, the layout rust-lang's (and most other distributors') - /// dist archives use. + /// archives) already exists. Each component records its own archive + /// layout ([`Component::strip`], [`Component::dest`]), so a rust-lang + /// dist archive and a flat single-binary release tarball can coexist in + /// one toolchain. Downloaded(Vec<Component>), } /// One archive making up a [`Bin::Downloaded`] toolchain: fetched from `url` -/// and checked against `sha256` before being extracted. +/// and checked against `sha256` before being extracted per `strip`/`dest`. #[derive(Debug, Clone, PartialEq, Facet)] pub struct Component { /// Where to fetch the archive from. @@ -116,6 +116,17 @@ /// The archive's expected sha256, hex-encoded — checked before /// extraction; a mismatch is refused rather than extracted anyway. pub sha256: String, + /// Leading path segments stripped at extraction (`tar + /// --strip-components`): 2 for rust-lang dist archives + /// (`<package>-<version>-<target>/<component>/…`), 1 for a flat release + /// tarball (`<package>-<version>/<binary>`). + pub strip: u8, + /// Subdirectory under the toolchain's extraction root to extract into: + /// empty for archives already carrying their own `bin/`/`lib/`/… top + /// level (rustup components), `bin` for flat archives whose payload + /// should itself land on `PATH` — keeping `<root>/bin` the one activation + /// convention either way. + pub dest: String, } /// A failure importing, resolving, listing, exporting, or removing a @@ -153,6 +164,11 @@ /// activates nothing on `PATH` is not a toolchain. #[error("a downloaded toolchain must list at least one component")] NoComponents, + /// A [`Component`] carried a field unsafe to interpolate into the shell + /// script that fetches and extracts it: a `dest` that is not empty or a + /// single safe path segment, or a `url`/`sha256` containing a quote. + #[error("invalid component: {0}")] + InvalidComponent(String), /// A [`Bin::Downloaded`] component could not be fetched or extracted. #[error("could not fetch {0}: {1}")] Fetch(String, String), @@ -242,6 +258,9 @@ if components.is_empty() { return Err(Error::NoComponents); } + for component in &components { + validate_component(component)?; + } validate_metadata(license, version, platform)?; let odb = odb_at(repo)?; let collector = PackCollector::default(); @@ -259,6 +278,26 @@ store_toolchain(repo, name, toolchain, &odb) } +/// A [`Component`]'s fields end up interpolated into a shell script (the +/// Sprite-side fetch-and-extract in `git-effect`), so refuse anything that +/// could escape it: `dest` must be empty or one safe path segment, and no +/// field may contain a single quote. +fn validate_component(component: &Component) -> Result<(), Error> { + if !component.dest.is_empty() && !git_store::ref_segment_ok(&component.dest) { + return Err(Error::InvalidComponent(format!( + "dest {:?} is not empty or a single safe path segment", + component.dest + ))); + } + if component.url.contains('\'') || component.sha256.contains('\'') { + return Err(Error::InvalidComponent(format!( + "{:?} contains a quote", + component.url + ))); + } + Ok(()) +} + /// `license` MUST be a valid SPDX license expression, `version` a valid /// semver version, and `platform` a valid target triple — shared by /// [`import`] and [`import_downloaded`]. @@ -347,10 +386,9 @@ /// /// [`Bin::Embedded`] writes its (already self-contained: executables plus a /// sibling `lib/`) tree straight under `dest/bin`. [`Bin::Downloaded`]'s -/// components already carry their own `bin/`/`lib/`/... top-level -/// directories once their outer two path segments are stripped, so they are -/// extracted directly under `dest` instead, landing at the same `dest/bin/…` -/// shape by construction. +/// components are each fetched, verified, and extracted per their own +/// recorded layout ([`Component::strip`], [`Component::dest`]) relative to +/// `dest`, landing at the same `dest/bin/…` shape by construction. pub fn export(repo: &Path, name: &str, dest: &Path) -> Result<Toolchain, Error> { let toolchain = resolve(repo, name)?; let odb = odb_at(repo)?; @@ -729,7 +767,7 @@ actual, )); } - extract_stripped(&archive, dest)?; + extract_component(&archive, component, dest)?; } Ok(()) } @@ -752,8 +790,9 @@ /// Hex-encoded sha256 of `bytes`, via the system `shasum` (macOS) or /// `sha256sum` (Linux) — shells out rather than adding a hashing dependency -/// to this crate. -fn sha256_hex(bytes: &[u8]) -> Result<String, Error> { +/// to this crate. Public so a recipe pinning a hosted archive (trust on +/// first use) computes its hash the same way every later verification does. +pub fn sha256_hex(bytes: &[u8]) -> Result<String, Error> { let (program, args): (&str, &[&str]) = match std::env::consts::OS { "macos" => ("shasum", &["-a", "256"]), _ => ("sha256sum", &[]), @@ -786,13 +825,20 @@ .ok_or_else(|| Error::Fetch(program.to_owned(), "no hash in output".to_owned())) } -/// Extract a gzipped tar `archive` into `dest`, stripping the outer two path -/// segments every rust-lang dist archive (and most other distributors') -/// wraps its payload in (`<package>-<version>-<target>/<component>/`). -fn extract_stripped(archive: &[u8], dest: &Path) -> Result<(), Error> { +/// Extract a tar `archive` (compression auto-detected — gzip, xz, ...) per +/// `component`'s recorded layout: strip its leading [`Component::strip`] +/// path segments and land it in `dest_root`/[`Component::dest`]. +fn extract_component(archive: &[u8], component: &Component, dest_root: &Path) -> Result<(), Error> { + let dest = if component.dest.is_empty() { + dest_root.to_owned() + } else { + dest_root.join(&component.dest) + }; + fs::create_dir_all(&dest).map_err(|error| Error::Io(dest.clone(), error))?; + let strip = format!("--strip-components={}", component.strip); let mut child = Command::new("tar") - .args(["-xz", "--strip-components=2", "-C"]) - .arg(dest) + .args(["-x", &strip, "-C"]) + .arg(&dest) .stdin(Stdio::piped()) .spawn() .map_err(|error| Error::Fetch("tar".to_owned(), error.to_string()))?; @@ -1022,6 +1068,34 @@ Component { url: format!("file://{}", archive.display()), sha256: sha256_hex(&bytes).unwrap(), + strip: 2, + dest: String::new(), + } + } + + /// Build a flat single-binary `.tar.xz` release archive + /// (`<pkg>-<version>/<binary>`), the shape a GitHub release or a zig + /// tarball ships — one leading segment, no `bin/` of its own. + fn build_flat_component(staging: &Path, name: &str, contents: &[u8]) -> Component { + let root = staging.join("pkg-1.0.0"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join(name), contents).unwrap(); + let archive = staging.join("flat.tar.xz"); + let status = Command::new("tar") + .args(["-cJf"]) + .arg(&archive) + .args(["-C"]) + .arg(staging) + .arg("pkg-1.0.0") + .status() + .unwrap(); + assert!(status.success()); + let bytes = fs::read(&archive).unwrap(); + Component { + url: format!("file://{}", archive.display()), + sha256: sha256_hex(&bytes).unwrap(), + strip: 1, + dest: "bin".to_owned(), } } @@ -1053,6 +1127,66 @@ ); } + #[test] + fn export_extracts_a_flat_xz_component_into_its_dest() { + let repo_dir = repo(); + let staging = tempfile::tempdir().unwrap(); + let component = build_flat_component(staging.path(), "tool", b"#!/bin/sh\n"); + + import_downloaded( + repo_dir.path(), + "flat", + vec![component], + None, + "MIT", + VERSION, + PLATFORM, + None, + ) + .unwrap(); + + let dest = tempfile::tempdir().unwrap(); + let dest_path = dest.path().join("out"); + export(repo_dir.path(), "flat", &dest_path).unwrap(); + assert_eq!( + fs::read(dest_path.join("bin/tool")).unwrap(), + b"#!/bin/sh\n" + ); + } + + #[test] + fn import_downloaded_rejects_an_unsafe_component() { + let repo_dir = repo(); + let staging = tempfile::tempdir().unwrap(); + let mut component = build_component(staging.path(), &[("bin/tool", b"#!/bin/sh\n")]); + component.dest = "a/b".to_owned(); + let result = import_downloaded( + repo_dir.path(), + "bad-dest", + vec![component], + None, + "MIT", + VERSION, + PLATFORM, + None, + ); + assert!(matches!(result, Err(Error::InvalidComponent(_)))); + + let mut component = build_component(staging.path(), &[("bin/tool", b"#!/bin/sh\n")]); + component.url = "https://example.com/x' rm -rf'".to_owned(); + let result = import_downloaded( + repo_dir.path(), + "bad-url", + vec![component], + None, + "MIT", + VERSION, + PLATFORM, + None, + ); + assert!(matches!(result, Err(Error::InvalidComponent(_)))); + } + #[test] fn import_downloaded_rejects_an_empty_component_list() { let repo_dir = repo();