From 373ef3db9f90932ac7f73e79a09f9da48bb39a67 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:56:24 -0400 Subject: [PATCH 1/4] fix(release): publish complete immutable prereleases by ID --- .github/workflows/release.yml | 37 +---- docs/native-release.md | 17 ++- scripts/fixtures/native-release-gh.py | 103 +++++++++++++ scripts/publish-native-release.py | 200 ++++++++++++++++++++++++++ scripts/test-release-notes.py | 128 ++++++++++++----- 5 files changed, 406 insertions(+), 79 deletions(-) create mode 100644 scripts/fixtures/native-release-gh.py create mode 100644 scripts/publish-native-release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f84b9688..39784677 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: required: true type: string publish: - description: Attach verified assets to a draft or prerelease (create a prerelease if absent) + description: Stage and verify complete assets in a draft, then publish an immutable prerelease required: true default: false type: boolean @@ -398,37 +398,4 @@ jobs: notes="$RUNNER_TEMP/release-body.md" title=$(python3 scripts/release-notes.py title --tag "$RELEASE_TAG") python3 scripts/release-notes.py render --tag "$RELEASE_TAG" --commit "$RELEASE_SHA" --manifests artifacts --out "$notes" - check_page() { - gh release view "$RELEASE_TAG" --json name,body > "$RUNNER_TEMP/release-page.json" - python3 - "$RUNNER_TEMP/release-page.json" "$RUNNER_TEMP/release-page.md" "$title" <<'PYPAGE' - import json, pathlib, sys - page = json.loads(pathlib.Path(sys.argv[1]).read_text()) - if page["name"] != sys.argv[3]: - raise SystemExit("Release title differs from the product name and tag") - pathlib.Path(sys.argv[2]).write_bytes(page["body"].encode()) - PYPAGE - python3 scripts/release-notes.py verify --tag "$RELEASE_TAG" --commit "$RELEASE_SHA" --manifests artifacts --body "$RUNNER_TEMP/release-page.md" - } - if gh release view "$RELEASE_TAG" --json isDraft,isPrerelease,body > "$RUNNER_TEMP/release.json"; then - existing=$(python3 - "$RUNNER_TEMP/release.json" <<'PYEXISTING' - import json, sys - r = json.load(open(sys.argv[1])) - assert r["isDraft"] or r["isPrerelease"], "Refusing to mutate a stable release" - print("page" if "")) self.assertEqual(len(release["assets"]), 3 * len(notes.TARGETS)) - self.assertIn("create", [call[1] for call in calls if call[0] == "release"]) + self.assertTrue(any(call[3] == "repos/hraness/algal/releases" and "POST" in call for call in self.mutations(calls))) + self.assertTrue(release["immutable"]) + self.assertFalse(release["isDraft"]) + uploaded = max(i for i, call in enumerate(calls) if call[0] == "api" and call[3].startswith("https://uploads.github.com/")) + published = next(i for i, call in enumerate(calls) if call[0] == "fixture-patch" and call[1].get("draft") is False) + self.assertLess(uploaded, published) + self.assertTrue(any(call[:3] == ["api", "--hostname", "github.com"] and call[3] == "repos/hraness/algal/releases/42" + for call in calls[uploaded + 1:published])) + self.assertFalse(any(call[0] == "api" and "/releases/tags/" in call[3] for call in calls)) + + def test_uploaded_digest_mismatch_keeps_the_release_draft(self): + result, release, calls = self.run_publish(bad_asset=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn("asset bytes differ", result.stderr) + self.assertTrue(release["isDraft"]) + self.assertFalse(any(call[0] == "fixture-patch" and call[1].get("draft") is False for call in calls)) def test_missing_or_unreleased_section_fails_before_any_release_call(self): for changelog in [CHANGELOG.replace(f"## {TAG} - 2026-10-01", "## v9.9.9"), @@ -309,19 +300,21 @@ def test_owner_draft_without_identity_gets_rendered_page(self): def test_retry_with_matching_page_uploads(self): first, release, _ = self.run_publish() self.assertEqual(first.returncode, 0, first.stderr) - result, release, calls = self.run_publish(release={**release, "assets": []}) + result, release, calls = self.run_publish(release={**release, "isDraft": True, "immutable": False, "assets": [], "assetProofs": {}}) self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse([call for call in calls if call[:2] in (["release", "create"], ["release", "edit"])]) + self.assertFalse(any(call[3] == "repos/hraness/algal/releases" for call in self.mutations(calls))) + patches = [call[1] for call in calls if call[0] == "fixture-patch"] + self.assertEqual(patches, [{"draft": False, "prerelease": True, "make_latest": "false"}]) def test_hand_edited_page_is_refused(self): first, release, _ = self.run_publish() self.assertEqual(first.returncode, 0, first.stderr) - edited = {**release, "assets": [], "body": release["body"].replace("64 KiB", "32 KiB")} + edited = {**release, "isDraft": True, "immutable": False, "assets": [], "body": release["body"].replace("64 KiB", "32 KiB")} result, after, calls = self.run_publish(release=edited) self.assertNotEqual(result.returncode, 0) self.assertIn("differ", result.stderr) self.assertEqual(after["assets"], []) - self.assertFalse([call for call in calls if call[:2] == ["release", "upload"]]) + self.assertFalse(self.mutations(calls)) def test_body_changed_after_publication_fails_the_run(self): result, _, _ = self.run_publish(mangle=True) @@ -333,6 +326,63 @@ def test_stable_release_is_never_mutated(self): result, release, calls = self.run_publish(release=stable) self.assertNotEqual(result.returncode, 0) self.assertEqual(release, stable) + self.assertFalse(self.mutations(calls)) + + def test_published_prerelease_is_never_mutated(self): + for immutable in (False, True): + with self.subTest(immutable=immutable): + published = {"isDraft": False, "isPrerelease": True, "immutable": immutable, "name": "x", "body": "x", "assets": []} + result, release, calls = self.run_publish(release=published) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(release, published) + self.assertFalse(self.mutations(calls)) + + def test_partial_draft_preserves_existing_assets_and_uploads_only_missing(self): + first, release, _ = self.run_publish() + self.assertEqual(first.returncode, 0, first.stderr) + retained = release["assets"][:2] + draft = {**release, "isDraft": True, "immutable": False, "assets": retained, + "assetProofs": {name: release["assetProofs"][name] for name in retained}, + "payloads": {name: release["payloads"][name] for name in retained}} + result, published, calls = self.run_publish(release=draft) + self.assertEqual(result.returncode, 0, result.stderr) + uploads = [call for call in self.mutations(calls) if call[3].startswith("https://uploads.github.com/")] + self.assertEqual(len(uploads), 7) + for name in retained: + self.assertEqual(published["assetProofs"][name], release["assetProofs"][name]) + + def test_matching_published_release_is_read_only_after_uncertain_response(self): + first, release, _ = self.run_publish(extra_env={"FAKE_GH_PUBLISH_UNCERTAIN": "1"}) + self.assertNotEqual(first.returncode, 0) + self.assertTrue(release["immutable"]) + second, after, calls = self.run_publish(release=release) + self.assertEqual(second.returncode, 0, second.stderr) + self.assertEqual(after, release) + self.assertFalse(self.mutations(calls)) + + def test_conflicting_existing_asset_is_preserved_without_mutation(self): + first, release, _ = self.run_publish() + self.assertEqual(first.returncode, 0, first.stderr) + release["isDraft"] = True + release["immutable"] = False + release["assetProofs"][release["assets"][0]]["digest"] = "sha256:" + "0" * 64 + result, after, calls = self.run_publish(release=release) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(after, release) + self.assertFalse(self.mutations(calls)) + + def test_download_digest_mismatch_keeps_the_release_draft(self): + result, release, calls = self.run_publish(extra_env={"FAKE_GH_BAD_DOWNLOAD": "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertTrue(release["isDraft"]) + self.assertFalse(any(call[0] == "fixture-patch" and call[1].get("draft") is False for call in calls)) + + def test_release_inventory_has_a_finite_page_bound(self): + result, release, calls = self.run_publish(extra_env={"FAKE_GH_FULL_PAGES": "1"}) + self.assertNotEqual(result.returncode, 0) + self.assertIsNone(release) + self.assertEqual(sum(call[0] == "api" and "/releases?" in call[3] for call in calls), 10) + self.assertFalse(self.mutations(calls)) if __name__ == "__main__": From 0076a104e12eae1e6bc581069b212b6517a2410a Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:56:26 -0400 Subject: [PATCH 2/4] feat(cli): enable verified automatic native updates --- .github/workflows/release.yml | 2 + CHANGELOG.md | 11 + Cargo.lock | 64 + README.md | 7 + cli.ts | 15 +- crates/algal/Cargo.toml | 5 + crates/algal/build.rs | 25 + crates/algal/src/main.rs | 144 +- crates/algal/src/self_update.rs | 277 +++ crates/algal/src/self_update/unix.rs | 1604 +++++++++++++++++ crates/algal/src/self_update/unix/files.rs | 355 ++++ .../src/self_update/unix/legacy-releases.json | 77 + crates/algal/src/self_update/unix/legacy.rs | 91 + docs/native-release.md | 41 + scripts/install-native.sh | 26 + scripts/package-native.py | 5 + site/install.sh | 19 + src/application-native-memory-update.test.ts | 27 + src/application-native-memory.ts | 5 +- src/cli-golden/help.txt | 1 + src/cli-help.ts | 1 + src/cli-update.test.ts | 17 + 22 files changed, 2813 insertions(+), 6 deletions(-) create mode 100644 crates/algal/src/self_update.rs create mode 100644 crates/algal/src/self_update/unix.rs create mode 100644 crates/algal/src/self_update/unix/files.rs create mode 100644 crates/algal/src/self_update/unix/legacy-releases.json create mode 100644 crates/algal/src/self_update/unix/legacy.rs create mode 100644 src/application-native-memory-update.test.ts create mode 100644 src/cli-update.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 39784677..44d7b602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,7 @@ jobs: - name: Build locked release executable env: CARGO_BUILD_TARGET: ${{ matrix.target }} + ALGAL_RELEASE_BUILD_TAG: ${{ inputs.tag }} run: cargo +1.97.1 build --release --locked -p algal --bin algal - name: Qualify archive and installer env: @@ -165,6 +166,7 @@ jobs: - name: Build and qualify the unsigned executable env: CARGO_BUILD_TARGET: aarch64-apple-darwin + ALGAL_RELEASE_BUILD_TAG: ${{ inputs.tag }} run: | cargo +1.97.1 build --release --locked -p algal --bin algal python3 scripts/test-native-release.py --binary target/aarch64-apple-darwin/release/algal --commit "$GITHUB_SHA" --rustc-version "$(rustc +1.97.1 --version)" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e405400..12131c85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,17 @@ independent of these package versions. ## Unreleased +## v0.2.0-vm.13 - 2026-09-30 + +Verified native installations on macOS and Linux update automatically within +the `vm` preview channel. The Bun runtime keeps its existing update workflow. + +- Add `algal update`, with `check`, `status`, `enable`, and `disable` actions and JSON output. Automatic checks run before product work at most once a day; every running command protects its executable until it finishes. +- Compare the full preview tag and source SHA embedded by the official release build. Archive, checksum and release-record downloads must match the immutable GitHub release. macOS retains Developer ID, notarization, hardened-runtime and timestamp checks. +- Preserve hash-keyed `.algal-releases` records through installation and rollback. Re-running the public installer can enroll a verified `vm.11` copy; explicitly selected versions stay pinned. +- Skip automatic updates in CI, offline verification, memory queries and hash-pinned SDK calls. `--no-update` or `HRANESS_NO_UPDATE=1` skips one invocation. Saved opt-outs survive reinstallation. +- Keep the SDK's existing executable hash pin across process startup and check it while the native command holds its update lock. Source and package Bun installs print manual guidance without replacing their runtime. + ## v0.2.0-vm.12 - 2026-09-30 macOS releases use one Developer ID identity across upgrades, so system diff --git a/Cargo.lock b/Cargo.lock index df692da2..8709b0f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,13 +22,16 @@ name = "algal" version = "0.2.0" dependencies = [ "algal-expr", + "anyhow", "apple-foundation", "clap", "getrandom 0.3.4", "hraness-cli-kit", + "hraness-cli-update", "libc", "reqwest", "rusqlite", + "rustix", "ryu-js", "serde", "serde_json", @@ -95,6 +98,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "apple-foundation" version = "0.2.0" @@ -335,6 +344,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -443,6 +462,19 @@ dependencies = [ "clap", ] +[[package]] +name = "hraness-cli-update" +version = "0.1.0" +source = "git+https://github.com/hraness/cli-update?tag=v0.1.0#e1e44d49b2ed21ca7a59cc0cb4f4a91209edd8ee" +dependencies = [ + "fs2", + "libc", + "semver", + "serde", + "serde_json", + "sha2", +] + [[package]] name = "http" version = "1.5.0" @@ -1063,6 +1095,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde" version = "1.0.229" @@ -1549,6 +1591,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/README.md b/README.md index 1fad2379..1c787778 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ arm64 are on the [releases page](https://github.com/hraness/algal/releases) (`curl -fsSL https://algal.computer/install.sh | sh`), and the Bun runtime runs from this checkout. +From `v0.2.0-vm.13`, verified macOS and Linux installs update automatically +within the `vm` preview channel. `algal update disable` turns automatic updates +off; `algal update` installs a newer verified preview now. Explicit versions, +CI, offline verification and hash-pinned SDK calls stay fixed. The Bun runtime +keeps its existing source or package-manager update workflow. See +[native CLI updates](docs/native-release.md#updates). + ## The idea The bet behind ALGAL is that a computer can accumulate tested ways of acting, diff --git a/cli.ts b/cli.ts index e1c19b34..1d97c181 100644 --- a/cli.ts +++ b/cli.ts @@ -95,6 +95,8 @@ async function recallSpecExecutor(spec: string, dir: string): Promise [--out ] [--source-map ] [--bundle-out ] [--source-root ] compile source; bundle its complete local import closure @@ -1062,6 +1064,17 @@ async function main(): Promise { process.stdout.write(help.text); return help.code; } + if (argv[0] === "update") { + const tokens = argv.slice(1), actions = tokens.filter(token => token !== "--json"); + if (actions.length > 1 || tokens.filter(token => token === "--json").length > 1 + || actions.some(action => !["install", "check", "status", "enable", "disable"].includes(action))) { + usageError("algal update [check|status|enable|disable] [--json]"); + } + const instructions = "This Bun runtime uses its original source or package-manager update workflow. Update the checkout or package through that workflow; it stays on Bun."; + if (tokens.includes("--json")) out({schema: "hraness.cli-update.result.v1", product: "algal", status: "unsupported", policy: "disabled", supported: false, automatic: false, current: PACKAGE_VERSION, latest: null, reason: "Source and package Bun installs update through their original workflow.", instructions}); + else process.stdout.write(`${instructions}\n`); + return 0; + } const { cmd, positional, flags } = parseArgs(argv); const requestedDiagnosticFormat = artifactFlag(flags, "diagnostic-format") ?? (humanErrors(argv) ? "text" : "json"); if (requestedDiagnosticFormat !== "json" && requestedDiagnosticFormat !== "text") { @@ -3948,7 +3961,7 @@ function usageError(msg: string): never { throw new AlgalError("PARSE_FAILED", `usage: ${msg}`); } -void reportAlgalCliRun(PACKAGE_VERSION); +if (process.argv[2] !== "update") void reportAlgalCliRun(PACKAGE_VERSION); main() .then((code) => process.exit(code)) .catch(async (e) => { diff --git a/crates/algal/Cargo.toml b/crates/algal/Cargo.toml index 87b1d049..86256850 100644 --- a/crates/algal/Cargo.toml +++ b/crates/algal/Cargo.toml @@ -10,6 +10,8 @@ description = "ALGAL is a programming language and VM for AI agent programs that workspace = true [dependencies] +anyhow = "1" +hraness-cli-update = { git = "https://github.com/hraness/cli-update", tag = "v0.1.0", version = "=0.1.0" } algal-expr = { path = "../algal-expr" } apple-foundation = { git = "https://github.com/hraness/apple-foundation", tag = "v0.2.0" } clap = { version = "=4.5.48", features = ["derive"] } @@ -24,6 +26,9 @@ serde_json = "=1.0.151" sha2 = "=0.10.9" tokio = { version = "=1.47.1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std", "sync", "time", "signal"] } +[target.'cfg(unix)'.dependencies] +rustix = { version = "1", features = ["fs", "process"] } + [dev-dependencies] tempfile = "=3.23.0" diff --git a/crates/algal/build.rs b/crates/algal/build.rs index 9658fd6a..484de137 100644 --- a/crates/algal/build.rs +++ b/crates/algal/build.rs @@ -110,6 +110,7 @@ fn set(name: &str, value: &str) { } fn main() { + println!("cargo:rerun-if-env-changed=ALGAL_RELEASE_BUILD_TAG"); let manifest = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); let root = manifest.parent().unwrap().parent().unwrap(); watch(&root.join(".git")); @@ -168,4 +169,28 @@ fn main() { set("TARGET", &env::var("TARGET").unwrap_or_default()); set("RUSTC", rustc.trim()); set("TAGS", &tags); + if let Ok(tag) = env::var("ALGAL_RELEASE_BUILD_TAG") { + let prefix = format!("v{}-vm.", env::var("CARGO_PKG_VERSION").unwrap()); + let suffix = tag + .strip_prefix(&prefix) + .expect("official builds need the full vm release tag"); + assert!( + !suffix.is_empty() + && !suffix.starts_with('0') + && suffix.bytes().all(|byte| byte.is_ascii_digit()) + && suffix.parse::().is_ok(), + "release number must be canonical" + ); + assert_eq!( + state, "clean", + "official release builds require clean source" + ); + assert!( + commit.is_some() && tags.split(',').any(|exact| exact == tag), + "official build tag must name the checked-out commit" + ); + set("RELEASE_TAG", &tag); + } else { + set("RELEASE_TAG", ""); + } } diff --git a/crates/algal/src/main.rs b/crates/algal/src/main.rs index 42aee1ca..b38fa68f 100644 --- a/crates/algal/src/main.rs +++ b/crates/algal/src/main.rs @@ -23,6 +23,7 @@ use std::{ }; mod cli_style; +mod self_update; /// Reject-all admission used when no `--policy` record is supplied; read-only /// commands still work, every trusted boundary denies. @@ -76,6 +77,9 @@ impl app_memory::MemoryAdmission for NoAdmission { about = "ALGAL is a programming language and VM for AI agent programs that wait for approval and leave receipts you can replay." )] struct Cli { + /// Skip the automatic release check for this invocation. + #[arg(long, global = true)] + no_update: bool, /// Store directory: manifests, receipts, values, slots, and process state. #[arg(long, global = true, default_value = ".algal")] dir: PathBuf, @@ -149,6 +153,12 @@ struct Execution { #[derive(Subcommand)] enum Commands { + /// Update a verified native install or change automatic-update settings. + Update(self_update::UpdateArgs), + #[command(name = "__install-release", hide = true)] + InstallRelease(self_update::InitialInstall), + #[command(name = "__build-identity", hide = true)] + BuildIdentity, /// Run one civilization epoch: propose plans, admit them, measure, and promote. Civ { /// Use a live executor for the designer instead of the recorded fixture. @@ -1434,6 +1444,9 @@ async fn execute(cli: Cli) -> Result { return Ok(true); } match cli.command { + Commands::Update(_) | Commands::InstallRelease(_) | Commands::BuildIdentity => { + unreachable!("maintenance precedes product work") + } Commands::Demo { command } => { if std::env::args().any(|arg| arg == "--dir" || arg.starts_with("--dir=")) { return Err(Error::invalid( @@ -3667,9 +3680,9 @@ fn read_secret_line(prompt: &str) -> Result { /// Commands a new person reaches for, in the order they run them. Root help /// lists these first; every other visible command follows in its own order. -const FIRST_COMMANDS: [&str; 12] = [ - "demo", "doctor", "run", "check", "verify", "resume", "inspect", "explain", "diff", "example", - "suite", "process", +const FIRST_COMMANDS: [&str; 13] = [ + "demo", "doctor", "update", "run", "check", "verify", "resume", "inspect", "explain", "diff", + "example", "suite", "process", ]; /// Research and maintainer commands: hidden from root help, listed by @@ -3766,7 +3779,8 @@ async fn main() { }; let next = next_step(&matches); let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); - let code = match execute(cli).await { + let mut update_lease = None; + let code = match execute_with_update(cli, &mut update_lease).await { Ok(true) => 0, Ok(false) => 1, Err(error) => { @@ -3794,5 +3808,127 @@ async fn main() { 2 } }; + drop(update_lease); std::process::exit(code); } + +async fn execute_with_update( + cli: Cli, + update_lease: &mut Option, +) -> Result { + let update_error = |error: anyhow::Error| Error::new("UPDATE_FAILED", format!("{error:#}")); + match &cli.command { + Commands::Update(args) => { + return self_update::explicit(args) + .map(|_| true) + .map_err(update_error); + } + Commands::InstallRelease(args) => { + return self_update::initial_install(args) + .map(|_| true) + .map_err(update_error); + } + Commands::BuildIdentity => { + self_update::build_identity(); + return Ok(true); + } + _ => {} + } + *update_lease = + self_update::startup(offline_command(&cli.command), cli.no_update).map_err(update_error)?; + execute(cli).await +} + +fn offline_command(command: &Commands) -> bool { + matches!( + command, + Commands::Verify { .. } + | Commands::Check { .. } + | Commands::Inspect { .. } + | Commands::Explain { .. } + | Commands::Diff { .. } + | Commands::Digest { .. } + | Commands::Replay { .. } + | Commands::Ordering { .. } + | Commands::Memory { .. } + | Commands::Context { .. } + | Commands::CivVerify { .. } + | Commands::Demo { .. } + | Commands::Doctor { .. } + | Commands::Bench { + command: Some(BenchCommand::Verify { .. } | BenchCommand::Inspect { .. }), + .. + } + | Commands::Foundry { + command: Some( + FoundryCommand::Verify { .. } + | FoundryCommand::Inspect { .. } + | FoundryCommand::Pack { .. } + | FoundryCommand::SearchVerify { .. } + | FoundryCommand::SearchInspect { .. } + | FoundryCommand::SearchPack { .. } + | FoundryCommand::ScheduleVerify { .. } + ), + .. + } + | Commands::Process { + command: ProcessCommand::Verify { .. } + | ProcessCommand::VerifyEvidence { .. } + | ProcessCommand::Replay { .. } + | ProcessCommand::Export { .. } + | ProcessCommand::Inspect { .. } + | ProcessCommand::List + | ProcessCommand::Journal { .. } + } + | Commands::Application { + command: ApplicationCommand::VerifyEvaluation { .. } + | ApplicationCommand::VerifyComparison { .. } + | ApplicationCommand::VerifyProposal { .. } + | ApplicationCommand::VerifySelection { .. } + | ApplicationCommand::VerifyExperiment { .. } + | ApplicationCommand::VerifyDrain { .. } + | ApplicationCommand::VerifyMessage { .. } + | ApplicationCommand::VerifyContention { .. }, + .. + } + ) +} + +#[cfg(test)] +mod self_update_tests { + use super::*; + + #[test] + fn replay_and_verification_commands_skip_automatic_network_access() { + for arguments in [ + "doctor", + "verify receipt.json", + "check manifest.json", + "replay receipt.json --with revised.json", + "civ-verify population.json", + "demo verify evidence.json", + "process verify job", + "process replay job --with revised.json", + "process verify-evidence evidence.json", + "bench verify report.json", + "foundry verify report.json", + "foundry search-verify report.json", + "foundry schedule-verify record.json", + "application verify-evaluation input.json", + "application verify-comparison input.json", + "application verify-proposal input.json", + "application verify-selection input.json", + "application verify-experiment input.json", + "application verify-drain input.json", + "application verify-message input.json", + "application verify-contention input.json", + ] { + let cli = + Cli::try_parse_from(std::iter::once("algal").chain(arguments.split_whitespace())) + .unwrap(); + assert!(offline_command(&cli.command), "{arguments}"); + } + let cli = Cli::try_parse_from(["algal", "run", "program.json"]).unwrap(); + assert!(!offline_command(&cli.command)); + } +} diff --git a/crates/algal/src/self_update.rs b/crates/algal/src/self_update.rs new file mode 100644 index 00000000..c0ccf1db --- /dev/null +++ b/crates/algal/src/self_update.rs @@ -0,0 +1,277 @@ +//! Executable maintenance runs before application configuration or provider I/O. +//! Release identity is embedded by the official packaging scripts, never inferred +//! from a receipt, a pathname, the runtime environment, or CARGO's version alone. +use anyhow::{Result, bail}; +use clap::{Args, ValueEnum}; +use hraness_cli_update::{ + ActiveLease, Channel, CommandAction, CurlGithub, Paths, Product, RunningIdentity, + StartupContext, StartupOutcome, UpdateResult, Updater, +}; +use std::path::{Path, PathBuf}; + +#[cfg(unix)] +#[path = "self_update/unix.rs"] +mod native; + +#[derive(Clone, Copy, Debug, ValueEnum)] +pub(crate) enum Action { + Install, + Check, + Status, + Enable, + Disable, +} + +#[derive(Args)] +pub(crate) struct UpdateArgs { + /// Install, check, inspect settings, enable automatic updates, or disable them. + #[arg(value_enum, default_value = "install")] + action: Action, + /// Print the update result as JSON. + #[arg(long)] + json: bool, +} + +#[derive(Args)] +pub(crate) struct InitialInstall { + #[arg(long)] + pub archive: PathBuf, + #[arg(long)] + pub checksum: PathBuf, + #[arg(long)] + pub prefix: PathBuf, + /// An explicitly selected version remains fixed. + #[arg(long)] + pub pinned: bool, +} + +pub(crate) fn product() -> Product { + Product { + id: "algal".into(), repository: "hraness/algal".into(), tag_prefix: "v".into(), + channel: Channel::Prerelease("vm".into()), + running_identity: match option_env!("ALGAL_BUILD_RELEASE_TAG") { + Some(tag) if !tag.is_empty() => RunningIdentity::Release { release_tag: tag, build_sha: Some(env!("ALGAL_BUILD_COMMIT")) }, + _ => RunningIdentity::Source, + }, + executable_name: if cfg!(windows) { "algal.exe" } else { "algal" }.into(), + platform: platform().into(), + required_assets: if cfg!(windows) { + vec!["algal-{version}-{platform}.zip".into(), "algal-{version}-{platform}.zip.sha256".into()] + } else { + vec!["algal-{tag}-{platform}.tar.gz".into(), "algal-{tag}-{platform}.tar.gz.sha256".into(), "algal-{tag}-{platform}.release.json".into()] + }, + require_immutable: true, + manual_instructions: "Re-run ALGAL's verified installer for native releases. Use cargo install for Cargo copies. Bun and source builds keep their original update workflow. Native release updates support macOS and Linux.".into(), + } +} + +fn platform() -> &'static str { + env!("ALGAL_BUILD_TARGET") +} + +pub(crate) fn paths(executable: &Path) -> Result { + let directory = executable + .parent() + .ok_or_else(|| anyhow::anyhow!("executable has no parent"))? + .join(".hraness-cli-update-algal"); + Ok(Paths { + receipt: directory.join("install.json"), + // A source/Cargo user may save an opt-out. Preferences must not create + // the separate managed-install activity authority without a receipt. + state_dir: executable + .parent() + .unwrap() + .join(".hraness-cli-update-algal-preferences"), + }) +} + +fn updater() -> Result { + let executable = std::env::current_exe()?.canonicalize()?; + Ok(Updater::new(product(), paths(&executable)?)?) +} + +fn client() -> Result { + Ok(CurlGithub::new(if cfg!(windows) { + "C:/Windows/System32/curl.exe" + } else { + "/usr/bin/curl" + })?) +} + +#[cfg(not(unix))] +struct UnsupportedInstaller; +#[cfg(not(unix))] +impl hraness_cli_update::Installer for UnsupportedInstaller { + fn install( + &self, + _: &hraness_cli_update::InstallRequest<'_>, + ) -> hraness_cli_update::Result<()> { + Err(hraness_cli_update::Error::new( + hraness_cli_update::ErrorCode::Unsupported, + "This platform has no supported native release updater; use its source workflow.", + )) + } +} + +fn print_result(report: &UpdateResult, json: bool) -> Result<()> { + if json { + println!("{}", report.json()?); + } else { + println!( + "ALGAL updates: {:?} (automatic policy: {:?})", + report.status, report.policy + ); + if let Some(reason) = &report.reason { + println!("{reason}"); + } + if let Some(instructions) = &report.instructions { + println!("{instructions}"); + } + if let Some(current) = &report.current { + println!("Installed: {current}"); + } + if let Some(available) = &report.latest { + println!("Available: {available}"); + } + } + Ok(()) +} + +pub(crate) fn explicit(args: &UpdateArgs) -> Result<()> { + let updater = updater()?; + let action = match args.action { + Action::Install => CommandAction::Install, + Action::Check => CommandAction::Check, + Action::Status => CommandAction::Status, + Action::Enable => CommandAction::Enable, + Action::Disable => CommandAction::Disable, + }; + let source = client()?; + #[cfg(unix)] + let installer = native::NativeInstaller; + #[cfg(not(unix))] + let installer = UnsupportedInstaller; + let report = updater.execute_with_context( + action, + &StartupContext { + exact_version_bound: std::env::var_os("ALGAL_EXPECTED_BINARY_SHA256").is_some(), + ..StartupContext::from_process() + }, + &source, + &installer, + )?; + print_result(&report, args.json) +} + +pub(crate) fn startup(offline: bool, no_update: bool) -> Result> { + let mut context = StartupContext::from_process(); + context.offline = offline; + context.no_update |= no_update; + let expected = std::env::var("ALGAL_EXPECTED_BINARY_SHA256") + .map(Some) + .or_else(|error| match error { + std::env::VarError::NotPresent => Ok(None), + _ => Err(error), + })?; + context.exact_version_bound = expected.is_some(); + #[cfg(unix)] + { + context.no_update |= rustix::process::geteuid().is_root(); + } + let updater = updater()?; + let source = client()?; + #[cfg(unix)] + let installer = native::NativeInstaller; + #[cfg(not(unix))] + let installer = UnsupportedInstaller; + match updater.startup(&context, &source, &installer)? { + StartupOutcome::Continue { lease, .. } => { + if let Some(expected) = expected { + verify_expected_hash(&std::env::current_exe()?, &expected)?; + } + Ok(lease) + } + StartupOutcome::Reenter(reentry) => reentry.run_and_exit(), + } +} + +// The SDK already has this pin. Recheck after native admission holds its lease, +// closing the SDK's hash-check-to-spawn gap without inventing any new pin. +pub(crate) fn verify_expected_hash(executable: &Path, expected: &str) -> Result<()> { + use anyhow::ensure; + use sha2::{Digest, Sha256}; + use std::io::Read; + ensure!( + expected.len() == 64 + && expected + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "Native caller supplied an invalid executable hash" + ); + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let file = options.open(executable)?; + ensure!( + file.metadata()?.is_file() && file.metadata()?.len() <= 256 * 1024 * 1024, + "Pinned executable exceeds its byte limit" + ); + let mut source = file.take(256 * 1024 * 1024 + 1); + let mut hasher = Sha256::new(); + let mut bytes = [0; 65_536]; + let mut total = 0; + loop { + let count = source.read(&mut bytes)?; + if count == 0 { + break; + } + total += count; + ensure!( + total <= 256 * 1024 * 1024, + "Pinned executable grew beyond its byte limit" + ); + hasher.update(&bytes[..count]); + } + ensure!( + format!("{:x}", hasher.finalize()) == expected, + "Pinned native executable changed before command admission" + ); + Ok(()) +} + +pub(crate) fn initial_install(args: &InitialInstall) -> Result<()> { + #[cfg(unix)] + { + native::initial_install(args) + } + #[cfg(not(unix))] + { + let _ = args; + bail!("This platform has no supported native release updater; use its source workflow."); + } +} + +pub(crate) fn build_identity() { + let identity = match product().running_identity { + RunningIdentity::Release { release_tag, .. } => Some(release_tag), + RunningIdentity::Source => None, + }; + println!( + "{}", + serde_json::json!({"schema":"algal.build.v1","version":env!("CARGO_PKG_VERSION"),"releaseTag":identity,"platform":platform(),"buildSha":env!("ALGAL_BUILD_COMMIT"),"build":algal::build_info::embedded()}) + ); +} + +#[cfg(unix)] +pub(crate) fn released_tag() -> Result<&'static str> { + match product().running_identity { + RunningIdentity::Release { release_tag, .. } => Ok(release_tag), + RunningIdentity::Source => { + bail!("This source build cannot enroll as an official release installation.") + } + } +} diff --git a/crates/algal/src/self_update/unix.rs b/crates/algal/src/self_update/unix.rs new file mode 100644 index 00000000..cf4be0c9 --- /dev/null +++ b/crates/algal/src/self_update/unix.rs @@ -0,0 +1,1604 @@ +//! ALGAL's native transaction preserves its release archive and Apple +//! signature contract. Only this process writes installed files or receipts. +use super::{InitialInstall, paths, product, released_tag}; +use anyhow::{Context, Result, bail, ensure}; +use hraness_cli_update::{ + Asset, CurlGithub, InstallReceipt, InstallRequest, InstallationKind, Installer, Product, + Release, run_bounded, +}; +use serde::Deserialize; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +#[path = "unix/files.rs"] +mod files; +#[path = "unix/legacy.rs"] +mod legacy; +use files::{Directory, Stage, digest, read_path}; + +#[cfg(target_os = "macos")] +const MACOS_REQUIREMENT: &str = "=anchor apple generic and identifier \"dev.hraness.algal\" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = \"8AAP53VTW3\""; + +const ARCHIVE_LIMIT: usize = 100_000_000; +const BINARY_LIMIT: usize = 100_000_000; +const CHECKSUM_LIMIT: usize = 1024; +const RECEIPT_LIMIT: usize = 64 * 1024; +const METADATA_LIMIT: usize = 16_384; +// The last release before installer ownership receipts. Compare verified bytes, +// never execute an unknown installed binary to discover its claimed version. +const HISTORICAL_RELEASE: &str = "v0.2.0-vm.11"; +const LEGACY_RELEASE: &str = "v0.2.0-vm.12"; + +pub(super) struct NativeInstaller; + +impl Installer for NativeInstaller { + fn install(&self, request: &InstallRequest<'_>) -> hraness_cli_update::Result<()> { + install_update(request).map_err(|error| { + hraness_cli_update::Error::new( + hraness_cli_update::ErrorCode::Installer, + format!("{error:#}"), + ) + }) + } +} + +struct Published { + release: Release, + archive: Asset, + checksum: Asset, + archive_sha256: String, + checksum_sha256: String, + metadata: Asset, + metadata_sha256: String, +} + +#[derive(Deserialize)] +struct AssetDigest { + id: u64, + name: String, + digest: Option, +} + +#[derive(Deserialize)] +struct DigestList { + assets: Vec, +} + +impl Published { + fn parse(profile: &Product, expected_tag: &str, bytes: &[u8]) -> Result { + let release: Release = + serde_json::from_slice(bytes).context("Read canonical release metadata")?; + release.validate(profile)?; + ensure!( + release.tag_name == expected_tag, + "Canonical release tag differs from the requested version" + ); + let raw: DigestList = serde_json::from_slice(bytes)?; + let names = profile.asset_names(expected_tag)?; + ensure!( + names.len() == 3, + "ALGAL requires an archive, checksum and release metadata" + ); + let archive = release + .asset(&names[0]) + .context("Missing release archive")? + .clone(); + let checksum = release + .asset(&names[1]) + .context("Missing release checksum")? + .clone(); + let metadata = release + .asset(&names[2]) + .context("Missing release metadata")? + .clone(); + ensure!( + archive.size <= ARCHIVE_LIMIT as u64 + && checksum.size <= CHECKSUM_LIMIT as u64 + && metadata.size <= METADATA_LIMIT as u64, + "Release assets exceed the native install size limits" + ); + let published_digest = |asset: &Asset| -> Result { + let rows: Vec<_> = raw + .assets + .iter() + .filter(|row| row.id == asset.id && row.name == asset.name) + .collect(); + ensure!( + rows.len() == 1, + "Canonical release asset digest is ambiguous" + ); + let hash = rows[0] + .digest + .as_deref() + .and_then(|value| value.strip_prefix("sha256:")) + .context("Canonical release is missing its asset SHA-256")?; + ensure!(valid_digest(hash), "Canonical asset SHA-256 is invalid"); + Ok(hash.to_owned()) + }; + let archive_sha256 = published_digest(&archive)?; + let checksum_sha256 = published_digest(&checksum)?; + let metadata_sha256 = published_digest(&metadata)?; + Ok(Self { + release, + archive, + checksum, + archive_sha256, + checksum_sha256, + metadata, + metadata_sha256, + }) + } + + fn fetch(profile: &Product, tag: &str) -> Result { + let version = profile.version(tag)?; + ensure!( + profile.accepts(&version), + "Release is outside ALGAL's vm channel" + ); + let url = format!( + "https://api.github.com/repos/{}/releases/tags/{}", + profile.repository, + tag.replace('+', "%2B") + ); + let mut command = Command::new("/usr/bin/curl"); + // No redirect, credential lookup, curl config, or replaceable authority. + command.args([ + "--disable", + "--silent", + "--show-error", + "--fail", + "--proto", + "=https", + "--tlsv1.2", + "--connect-timeout", + "5", + "--max-time", + "30", + "--max-filesize", + "2097152", + "--header", + "Accept: application/vnd.github+json", + "--user-agent", + "algal-native-update", + "--write-out", + "\n%{http_code}", + "--url", + &url, + ]); + let output = run_bounded( + &mut command, + 2 * 1024 * 1024 + 4, + 8192, + Duration::from_secs(32), + )?; + ensure!( + output.status.success(), + "Could not read canonical GitHub release metadata; installation was not changed" + ); + let bytes = output + .stdout + .strip_suffix(b"\n200") + .context("GitHub did not return an exact HTTP 200 release response")?; + Self::parse(profile, tag, bytes) + } + + fn matches_selected(&self, selected: &Release) -> Result<()> { + ensure!( + self.release.id == selected.id && self.release.tag_name == selected.tag_name, + "Selected release changed during verification" + ); + for asset in [&self.archive, &self.checksum, &self.metadata] { + let selected_asset = selected + .asset(&asset.name) + .context("Selected release asset disappeared")?; + ensure!( + asset.id == selected_asset.id + && asset.size == selected_asset.size + && asset.browser_download_url == selected_asset.browser_download_url, + "Selected release asset changed during verification" + ); + } + Ok(()) + } + + fn download(&self, profile: &Product, stage: &Stage<'_>) -> Result<()> { + let source = CurlGithub::new("/usr/bin/curl")?; + source.download_verified( + profile, + &self.release, + &self.archive, + &self.archive_sha256, + &stage.directory.path.join("archive"), + ARCHIVE_LIMIT, + )?; + source.download_verified( + profile, + &self.release, + &self.checksum, + &self.checksum_sha256, + &stage.directory.path.join("checksum"), + CHECKSUM_LIMIT, + )?; + self.download_metadata(profile, stage)?; + self.verify_staged(stage, true) + } + + fn download_metadata(&self, profile: &Product, stage: &Stage<'_>) -> Result<()> { + CurlGithub::new("/usr/bin/curl")?.download_verified( + profile, + &self.release, + &self.metadata, + &self.metadata_sha256, + &stage.directory.path.join("metadata"), + METADATA_LIMIT, + )?; + Ok(()) + } + + fn import(&self, stage: &Stage<'_>, archive: &Path, checksum: &Path) -> Result<()> { + stage + .directory + .write_new("archive", &read_path(archive, ARCHIVE_LIMIT)?, false)?; + stage + .directory + .write_new("checksum", &read_path(checksum, CHECKSUM_LIMIT)?, false)?; + self.verify_staged(stage, false)?; + self.download_metadata(&product(), stage)?; + self.verify_staged(stage, true) + } + + fn verify_staged(&self, stage: &Stage<'_>, check_metadata: bool) -> Result<()> { + let archive = stage + .directory + .read("archive", ARCHIVE_LIMIT)? + .context("Missing staged archive")?; + let checksum = stage + .directory + .read("checksum", CHECKSUM_LIMIT)? + .context("Missing staged checksum")?; + ensure!( + archive.len() as u64 == self.archive.size && digest(&archive) == self.archive_sha256, + "Release archive differs from its canonical size or SHA-256" + ); + ensure!( + checksum.len() as u64 == self.checksum.size + && digest(&checksum) == self.checksum_sha256, + "Release checksum differs from its canonical size or SHA-256" + ); + verify_checksum(&checksum, &self.archive.name, &self.archive_sha256)?; + if !check_metadata { + return Ok(()); + } + let metadata = stage + .directory + .read("metadata", METADATA_LIMIT)? + .context("Missing release metadata")?; + ensure!( + metadata.len() as u64 == self.metadata.size + && digest(&metadata) == self.metadata_sha256, + "Release metadata differs from its canonical size or SHA-256" + ); + Ok(()) + } + + fn receipt( + &self, + executable: &Path, + binary_sha256: String, + pinned: bool, + build_sha: &str, + ) -> InstallReceipt { + InstallReceipt { + schema: InstallReceipt::SCHEMA.into(), + product: "algal".into(), + repository: "hraness/algal".into(), + kind: InstallationKind::NativeRelease, + executable: executable.into(), + binary_sha256, + release_tag: self.release.tag_name.clone(), + build_sha: Some(build_sha.into()), + release_id: self.release.id, + archive_name: self.archive.name.clone(), + archive_sha256: self.archive_sha256.clone(), + platform: super::platform().into(), + pinned, + } + } +} + +fn valid_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +fn verify_checksum(bytes: &[u8], archive: &str, expected: &str) -> Result<()> { + let text = std::str::from_utf8(bytes)?.trim_end_matches(['\r', '\n']); + let plain = format!("{expected} {archive}"); + let binary = format!("{expected} *{archive}"); + ensure!( + text == plain || text == binary, + "Checksum must name only the exact release archive with its canonical SHA-256" + ); + Ok(()) +} + +// Decode gzip under the pre-existing expanded archive bound. Parse raw USTAR +// headers before interpreting sizes or extensions; PAX, GNU extensions, links +// and directories are not part of the four-file ALGAL release format. +fn expanded_archive(archive: &Path) -> Result> { + let mut command = Command::new("/usr/bin/gzip"); + command + .env_remove("GZIP") + .args(["--decompress", "--stdout", "--"]) + .arg(archive); + let output = run_bounded(&mut command, 101_000_000, 8192, Duration::from_secs(30))?; + ensure!( + output.status.success(), + "Release archive gzip stream is invalid" + ); + Ok(output.stdout) +} + +fn octal(bytes: &[u8]) -> Result { + let value = std::str::from_utf8(bytes)?.trim_matches([' ', '\0']); + ensure!( + !value.is_empty() && value.bytes().all(|b| (b'0'..=b'7').contains(&b)), + "Archive integer is not canonical octal" + ); + Ok(usize::from_str_radix(value, 8)?) +} + +fn tar_name(bytes: &[u8]) -> Result<&str> { + let length = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len()); + ensure!( + bytes[length..].iter().all(|b| *b == 0), + "Archive name has data after its terminator" + ); + Ok(std::str::from_utf8(&bytes[..length])?) +} + +fn archive_members(expanded: &[u8]) -> Result> { + ensure!( + expanded.len() <= 101_000_000 && expanded.len().is_multiple_of(512), + "Expanded release archive byte limit or alignment" + ); + let mut members = std::collections::BTreeMap::new(); + let mut offset = 0; + while offset + 512 <= expanded.len() { + let header = &expanded[offset..offset + 512]; + if header.iter().all(|byte| *byte == 0) { + ensure!( + expanded.len() - offset >= 1024 && expanded[offset..].iter().all(|byte| *byte == 0), + "Archive needs two zero end blocks without trailing data" + ); + ensure!( + members.len() == 4, + "Release archive must contain exactly four files" + ); + return Ok(members); + } + ensure!( + members.len() < 4 + && matches!(header[156], 0 | b'0') + && &header[257..263] == b"ustar\0" + && header[157..257].iter().all(|byte| *byte == 0), + "Unexpected archive entry or extension" + ); + let checksum: usize = header + .iter() + .enumerate() + .map(|(index, byte)| { + if (148..156).contains(&index) { + 32 + } else { + usize::from(*byte) + } + }) + .sum(); + ensure!( + octal(&header[148..156])? == checksum, + "Archive header checksum mismatch" + ); + let size = octal(&header[124..136])?; + ensure!(size <= BINARY_LIMIT, "Archive member byte limit"); + let name = tar_name(&header[..100])?; + let prefix = tar_name(&header[345..500])?; + let name = if prefix.is_empty() { + name.to_owned() + } else { + format!("{prefix}/{name}") + }; + offset += 512; + let end = offset.checked_add(size).context("Archive size overflow")?; + ensure!(end <= expanded.len(), "Truncated archive member"); + ensure!( + members.insert(name, &expanded[offset..end]).is_none(), + "Duplicate archive member" + ); + offset = end.div_ceil(512) * 512; + } + bail!("Archive has no complete end marker") +} + +struct Package { + binary_sha256: String, + commit: String, + metadata: serde_json::Value, + bytes: Vec, +} + +fn unpack(stage: &Stage<'_>, published: &Published) -> Result { + let archive = stage.directory.path.join("archive"); + let prefix = published + .archive + .name + .strip_suffix(".tar.gz") + .context("Expected a tar.gz release archive")?; + let expanded = expanded_archive(&archive)?; + let members = archive_members(&expanded)?; + let wanted: Vec<_> = ["LICENSE", "bin/algal", "release.json", "smoke.py"] + .map(|name| format!("{prefix}/{name}")) + .into(); + ensure!( + members.keys().collect::>() == wanted.iter().collect::>(), + "Release archive paths differ from the exact release layout" + ); + let bytes = members[&format!("{prefix}/release.json")].to_vec(); + ensure!(bytes.len() <= METADATA_LIMIT, "Release metadata byte limit"); + let metadata: serde_json::Value = serde_json::from_slice(&bytes)?; + let fields = [ + "contract", + "tag", + "version", + "commit", + "sourceState", + "target", + "rustc", + "build", + "minimumPlatform", + "binarySha256", + "signed", + "smoke", + ]; + ensure!( + metadata + .as_object() + .is_some_and(|object| object.len() == fields.len() + && object.keys().all(|key| fields.contains(&key.as_str()))), + "Release metadata has unexpected fields" + ); + let version = product().version(&published.release.tag_name)?; + let base = format!("{}.{}.{}", version.major, version.minor, version.patch); + let commit = metadata["commit"] + .as_str() + .context("Missing full release commit")? + .to_owned(); + ensure!( + commit.len() == 40 + && commit + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "Release commit must be a full lowercase SHA" + ); + let build = &metadata["build"]; + let build_fields = [ + "contract", + "version", + "sourceCommit", + "sourceState", + "sourceInputsSha256", + "target", + "rustc", + "exactTagsAtBuild", + ]; + ensure!( + build + .as_object() + .is_some_and(|object| object.len() == build_fields.len() + && object + .keys() + .all(|key| build_fields.contains(&key.as_str()))), + "Release build identity has unexpected fields" + ); + ensure!( + metadata["contract"] == "algal.native-release.v1" + && metadata["tag"] == published.release.tag_name + && metadata["version"] == base + && metadata["target"] == super::platform() + && metadata["sourceState"] == "clean" + && metadata["signed"].is_boolean() + && (!cfg!(target_os = "macos") || metadata["signed"] == true) + && metadata["smoke"].is_object() + && metadata["minimumPlatform"] + .as_str() + .is_some_and(|value| value.len() <= 256), + "Release package identity or signature declaration is invalid" + ); + ensure!( + build["contract"] == "algal.native-build.v1" + && build["version"] == base + && build["sourceCommit"] == commit + && build["sourceState"] == "clean" + && build["target"] == super::platform() + && build["rustc"] == metadata["rustc"] + && build["rustc"] + .as_str() + .is_some_and(|value| value.starts_with("rustc ") && value.len() <= 512) + && build["sourceInputsSha256"] + .as_str() + .is_some_and(valid_digest) + && build["exactTagsAtBuild"] + .as_array() + .is_some_and(|tags| tags.len() <= 64 + && tags + .iter() + .all(|tag| tag.as_str().is_some_and(|value| value.len() <= 128)) + && (published.release.tag_name == LEGACY_RELEASE + || tags.iter().any(|tag| tag == &published.release.tag_name))), + "Release build identity does not bind the full tag, source and target" + ); + let sidecar: serde_json::Value = serde_json::from_slice( + &stage + .directory + .read("metadata", METADATA_LIMIT)? + .context("Missing verified release metadata")?, + )?; + let mut expected_sidecar = metadata.clone(); + expected_sidecar["archive"] = published.archive.name.clone().into(); + expected_sidecar["archiveSha256"] = published.archive_sha256.clone().into(); + ensure!( + sidecar == expected_sidecar, + "Archive release record differs from the verified release sidecar" + ); + let binary = members[&format!("{prefix}/bin/algal")]; + let binary_sha256 = digest(binary); + ensure!( + !binary.is_empty() && metadata["binarySha256"] == binary_sha256, + "Executable differs from the release record" + ); + stage.directory.write_new("algal", binary, true)?; + Ok(Package { + binary_sha256, + commit, + metadata, + bytes, + }) +} + +/// A hash-keyed record can be published before replacing the executable: it +/// cannot label different bytes, and old records remain valid after rollback. +fn publish_package(bin: &Directory, package: &Package) -> Result<()> { + let records = Directory::open(&bin.path.join(".algal-releases"), true, false)?; + let name = format!("{}.json", package.binary_sha256); + match records.read(&name, METADATA_LIMIT)? { + Some(bytes) => ensure!( + bytes == package.bytes, + "Conflicting hash-keyed ALGAL release record" + ), + None => records.write_new(&name, &package.bytes, false)?, + } + Ok(()) +} + +fn verify_candidate(stage: &Stage<'_>, tag: &str, package: &Package) -> Result<()> { + let candidate = stage.directory.path.join("algal"); + #[cfg(target_os = "macos")] + { + let mut command = Command::new("/usr/bin/codesign"); + command + .args([ + "--verify", + "--strict", + "--check-notarization", + "--test-requirement", + MACOS_REQUIREMENT, + ]) + .arg(&candidate); + ensure!( + run_bounded(&mut command, 8192, 8192, Duration::from_secs(30))? + .status + .success(), + "Release does not have ALGAL's required Apple Developer ID signature and notarization" + ); + let mut command = Command::new("/usr/bin/codesign"); + command.args(["--display", "--verbose=4"]).arg(&candidate); + let output = run_bounded(&mut command, 8192, 8192, Duration::from_secs(10))?; + ensure!( + output.status.success(), + "Release signature inspection failed" + ); + let signature = String::from_utf8(output.stderr)? + &String::from_utf8(output.stdout)?; + ensure!( + signature + .lines() + .any(|line| line.starts_with("CodeDirectory ") + && line.contains("flags=") + && line + .split_once('(') + .and_then(|(_, rest)| rest.split_once(')')) + .is_some_and(|(flags, _)| flags.split(',').any(|flag| flag == "runtime"))), + "Release lacks hardened runtime" + ); + ensure!( + signature.lines().any(|line| line + .strip_prefix("Timestamp=") + .is_some_and(|stamp| !stamp.trim().is_empty())), + "Release lacks a secure timestamp" + ); + } + let mut command = Command::new(&candidate); + command.arg("--version"); + let output = run_bounded(&mut command, 4096, 4096, Duration::from_secs(10))?; + ensure!( + output.status.success() + && output.stdout + == format!("algal {}\n", package.metadata["version"].as_str().unwrap()).as_bytes(), + "Release executable reports the wrong base version" + ); + + let mut command = Command::new(&candidate); + command.arg("__build-identity"); + let output = run_bounded(&mut command, 4096, 4096, Duration::from_secs(10))?; + ensure!( + output.status.success(), + "Release executable cannot report its embedded identity" + ); + let identity: serde_json::Value = serde_json::from_slice(&output.stdout)?; + ensure!( + identity["schema"] == "algal.build.v1" + && identity["releaseTag"] == tag + && identity["platform"] == super::platform() + && identity["buildSha"] == package.commit + && identity["build"] == package.metadata["build"], + "Release executable is not an official build for this tag and platform" + ); + Ok(()) +} + +fn eligible_destination(executable: &Path) -> Result<()> { + ensure!( + !rustix::process::geteuid().is_root(), + "Install ALGAL as your ordinary user; root-owned installations do not self-update" + ); + ensure!(executable.is_absolute(), "Install prefix must be absolute"); + for component in executable.components() { + ensure!( + !matches!( + component.as_os_str().to_str(), + Some("Cellar" | ".cargo" | "target" | "node_modules" | ".git") + ), + "Cargo, Homebrew and source installations must use their original update workflow" + ); + } + for parent in executable.ancestors().skip(1) { + ensure!( + !(parent.join(".git").exists() && parent.join("Cargo.toml").exists()), + "Install destination is inside a source checkout; use its source workflow" + ); + } + Ok(()) +} + +fn install_update(request: &InstallRequest<'_>) -> Result<()> { + let target = &request.installation.receipt.executable; + eligible_destination(target)?; + let bin = Directory::open( + target.parent().context("Install target has no parent")?, + false, + false, + )?; + let state = Directory::open( + request + .paths + .receipt + .parent() + .context("Receipt has no parent")?, + false, + true, + )?; + let mut stage = Stage::new(&bin)?; + let published = Published::fetch(request.product, &request.release.tag_name)?; + published.matches_selected(request.release)?; + published.download(request.product, &stage)?; + let package = unpack(&stage, &published)?; + let binary_sha = package.binary_sha256.clone(); + verify_candidate(&stage, &published.release.tag_name, &package)?; + let receipt = published.receipt(target, binary_sha, false, &package.commit); + let old = snapshot(&bin, &state, &stage)?; + request.revalidate()?; + replace( + &bin, + &state, + &mut stage, + &old, + &receipt, + || { + request.revalidate()?; + publish_package(&bin, &package)?; + Ok(()) + }, + || { + request.publish_receipt(&receipt)?; + Ok(()) + }, + ) +} + +struct Previous { + binary_sha256: Option, + receipt: Option>, +} + +fn snapshot(bin: &Directory, state: &Directory, stage: &Stage<'_>) -> Result { + let binary = bin.read("algal", BINARY_LIMIT)?; + if let Some(bytes) = &binary { + stage.directory.write_new("previous", bytes, true)?; + stage.directory.copy_mode("previous", bin, "algal")?; + } + let receipt = state.read("install.json", RECEIPT_LIMIT)?; + if let Some(bytes) = &receipt { + stage.directory.write_new("old-receipt", bytes, false)?; + } + Ok(Previous { + binary_sha256: binary.as_deref().map(digest), + receipt, + }) +} + +/// Keep binary replacement, receipt publication and rollback in the lock owner. +/// If rollback fails, retain the private backup and fail closed on the next run. +fn replace( + bin: &Directory, + state: &Directory, + stage: &mut Stage<'_>, + old: &Previous, + receipt: &InstallReceipt, + revalidate: impl FnOnce() -> Result<()>, + publish: impl FnOnce() -> Result<()>, +) -> Result<()> { + ensure!( + bin.read("algal", BINARY_LIMIT)?.as_deref().map(digest) == old.binary_sha256, + "Installed bytes changed before replacement" + ); + ensure!( + state.read("install.json", RECEIPT_LIMIT)? == old.receipt, + "Install receipt changed before replacement" + ); + ensure!( + stage + .directory + .read("algal", BINARY_LIMIT)? + .as_deref() + .map(digest) + .as_deref() + == Some(&receipt.binary_sha256), + "Staged binary changed after verification" + ); + revalidate()?; + let result: Result<()> = (|| { + stage.directory.rename("algal", bin, "algal")?; + publish()?; + Ok(()) + })(); + if let Err(error) = result { + let rollback: Result<()> = (|| { + let now = bin.read("algal", BINARY_LIMIT)?.as_deref().map(digest); + ensure!( + now == old.binary_sha256 || now.as_deref() == Some(&receipt.binary_sha256), + "Installed binary changed outside the transaction; backup was retained" + ); + if old.binary_sha256.is_some() { + stage.directory.rename("previous", bin, "algal")?; + } else { + bin.remove("algal", false)?; + } + if old.receipt.is_some() { + stage + .directory + .rename("old-receipt", state, "install.json")?; + } else { + state.remove("install.json", false)?; + } + Ok(()) + })(); + if let Err(rollback) = rollback { + stage.preserve = true; + bail!( + "Installation failed: {error:#}; restoration failed: {rollback:#}. Verified backup retained at {}", + stage.directory.path.display() + ); + } + return Err(error).context("Installation failed; original files were restored"); + } + Ok(()) +} + +fn verify_previous( + profile: &Product, + published: &Published, + bin: &Directory, + current: Option<&[u8]>, + receipt: Option<&[u8]>, + explicit_pin: bool, +) -> Result { + let Some(binary) = current else { + ensure!( + receipt.is_none(), + "An install receipt exists without its executable" + ); + return Ok(explicit_pin); + }; + if let Some(bytes) = receipt { + let old: InstallReceipt = + serde_json::from_slice(bytes).context("Read previous native install receipt")?; + ensure!( + old.schema == InstallReceipt::SCHEMA + && old.product == profile.id + && old.repository == profile.repository + && old.kind == InstallationKind::NativeRelease + && old.platform == profile.platform + && old.executable == bin.path.join("algal") + && old.binary_sha256 == digest(binary) + && old.release_id != 0 + && valid_digest(&old.archive_sha256) + && profile + .asset_names(&old.release_tag)? + .contains(&old.archive_name), + "Previous install receipt does not match the installed native release" + ); + ensure!( + !old.pinned || explicit_pin || old.release_tag == published.release.tag_name, + "This install is pinned; set ALGAL_VERSION to explicitly select a replacement version" + ); + return Ok(old.pinned || explicit_pin); + } + // The older release was mutable and unsigned. Only its fixed reviewed byte + // pins may migrate; new candidates still require the normal signing checks. + let current_hash = digest(binary); + if legacy::matches(profile, ¤t_hash, binary.len())? { + return Ok(explicit_pin); + } + // Accept the following signing release only with canonical immutable proof. + let legacy = Published::fetch(profile, LEGACY_RELEASE) + .context("Cannot verify the pre-update installation; use a new ALGAL_INSTALL_PREFIX")?; + let stage = Stage::new(bin)?; + legacy.download(profile, &stage)?; + let old_hash = unpack(&stage, &legacy)?.binary_sha256; + ensure!( + current_hash == old_hash, + "Existing file is not a verified {HISTORICAL_RELEASE} or {LEGACY_RELEASE} release; use its source/package-manager update workflow or select a new ALGAL_INSTALL_PREFIX" + ); + Ok(explicit_pin) +} + +pub(super) fn initial_install(args: &InitialInstall) -> Result<()> { + let tag = released_tag()?; + let profile = product(); + let target = args.prefix.join("bin/algal"); + eligible_destination(&target)?; + let bin = Directory::open( + target.parent().context("Install target has no parent")?, + true, + false, + )?; + let mut stage = Stage::new(&bin)?; + let published = Published::fetch(&profile, tag)?; + published.import(&stage, &args.archive, &args.checksum)?; + let package = unpack(&stage, &published)?; + let binary_sha = package.binary_sha256.clone(); + let running = std::env::current_exe()?.canonicalize()?; + ensure!( + digest(&read_path(&running, BINARY_LIMIT)?) == binary_sha, + "Installer executable differs from the verified release archive" + ); + verify_candidate(&stage, tag, &package)?; + let pinned = enroll_verified( + &profile, + &published, + &bin, + &mut stage, + &target, + &package, + args.pinned, + )?; + println!( + "Installed {} ({}); automatic updates {}", + target.display(), + tag, + if pinned { + "off for this pinned version" + } else { + "enabled by default" + } + ); + Ok(()) +} + +fn enroll_verified( + profile: &Product, + published: &Published, + bin: &Directory, + stage: &mut Stage<'_>, + target: &Path, + package: &Package, + explicit_pin: bool, +) -> Result { + let update_paths = paths(target)?; + let coordination = update_paths + .receipt + .parent() + .context("Receipt has no parent")?; + let state_name = ".hraness-cli-update-algal"; + // Verify an old unmanaged installation before creating the coordination + // authority; a failed ownership check must not disable the old executable. + let current = bin.read("algal", BINARY_LIMIT)?; + let old_receipt = match std::fs::symlink_metadata(coordination) { + Ok(_) => Directory::open(coordination, false, true)?.read("install.json", RECEIPT_LIMIT)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.into()), + }; + let pinned = if old_receipt.is_none() + && current.as_deref().map(digest).as_deref() == Some(&package.binary_sha256) + { + explicit_pin + } else { + verify_previous( + profile, + published, + bin, + current.as_deref(), + old_receipt.as_deref(), + explicit_pin, + )? + }; + let created_state = bin.mkdir(state_name)?; + let state = Directory::open(coordination, false, true)?; + let lock = state.lock()?; + let outcome = (|| { + let old = snapshot(bin, &state, stage)?; + ensure!( + old.binary_sha256 == current.as_deref().map(digest) && old.receipt == old_receipt, + "Installation changed while its release identity was checked; retry installation" + ); + let receipt = published.receipt( + target, + package.binary_sha256.clone(), + pinned, + &package.commit, + ); + replace( + bin, + &state, + stage, + &old, + &receipt, + || { + lock.validate()?; + publish_package(bin, package)?; + Ok(()) + }, + || { + receipt.write_verified(profile, &update_paths.receipt)?; + Ok(()) + }, + ) + })(); + if outcome.is_err() && created_state && !stage.preserve { + // No managed install survived this failed first enrollment. Remove only + // our freshly created empty authority, while still holding its lock. + let _ = state.remove("activity.lock", false); + let _ = bin.remove(state_name, true); + } + outcome?; + Ok(pinned) +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(target_os = "macos")] + fn macos_requirement_is_compilable_inline_source() { + let mut command = Command::new("/usr/bin/csreq"); + command.args(["-r", MACOS_REQUIREMENT, "-t"]); + let output = run_bounded(&mut command, 8192, 8192, Duration::from_secs(10)).unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } + + use super::*; + use hraness_cli_update::{ + ReleaseSource, RunningIdentity, StartupContext, StartupOutcome, Updater, + }; + use std::fs; + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + + static SERIAL: AtomicU64 = AtomicU64::new(0); + + struct Fixture { + root: PathBuf, + } + impl Fixture { + fn new() -> Self { + let base = std::env::temp_dir().canonicalize().unwrap(); + let root = base.join(format!( + "algal-native-test-{}-{}", + std::process::id(), + SERIAL.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + Self { root } + } + fn bin(&self) -> Directory { + Directory::open(&self.root.join("bin"), true, false).unwrap() + } + fn state(&self) -> Directory { + Directory::open(&self.root.join("bin/.hraness-cli-update-algal"), true, true).unwrap() + } + } + impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } + } + + fn metadata() -> serde_json::Value { + let names = product().asset_names("v1.0.0-vm.1").unwrap(); + serde_json::json!({ + "id":42,"tag_name":"v1.0.0-vm.1","draft":false,"prerelease":true,"immutable":true, + "assets": names.iter().enumerate().map(|(index, name)| serde_json::json!({ + "id": index + 1, "name":name, "size":123, + "browser_download_url":format!("https://github.com/hraness/algal/releases/download/v1.0.0-vm.1/{name}"), + "digest":format!("sha256:{}", "a".repeat(64)), + })).collect::>() + }) + } + + fn published() -> Published { + Published::parse( + &product(), + "v1.0.0-vm.1", + &serde_json::to_vec(&metadata()).unwrap(), + ) + .unwrap() + } + + fn package(binary: &[u8]) -> Package { + let commit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let metadata = serde_json::json!({ + "contract":"algal.native-release.v1", "tag":"v1.0.0-vm.1", "version":"1.0.0", + "commit":commit, "sourceState":"clean", "target":super::super::platform(), + "rustc":"rustc fixture", "minimumPlatform":"fixture", "signed":cfg!(target_os = "macos"), + "binarySha256":digest(binary), "smoke":{}, + "build":{ + "contract":"algal.native-build.v1", "version":"1.0.0", "sourceCommit":commit, + "sourceState":"clean", "sourceInputsSha256":"b".repeat(64), + "target":super::super::platform(), "rustc":"rustc fixture", + "exactTagsAtBuild":["v1.0.0-vm.1"] + } + }); + Package { + binary_sha256: digest(binary), + commit: commit.into(), + bytes: serde_json::to_vec(&metadata).unwrap(), + metadata, + } + } + + #[test] + fn canonical_release_proof_requires_immutable_assets_and_digests() { + published(); + let mut variants = vec![]; + let mut value = metadata(); + value["immutable"] = false.into(); + variants.push(value); + let mut value = metadata(); + value["assets"][0]["digest"] = serde_json::Value::Null; + variants.push(value); + let mut value = metadata(); + value["assets"][0]["digest"] = format!("sha256:{}", "A".repeat(64)).into(); + variants.push(value); + let mut value = metadata(); + value["assets"][0]["browser_download_url"] = "https://example.test/algal".into(); + variants.push(value); + let mut value = metadata(); + value["tag_name"] = "v1.0.1-vm.1".into(); + variants.push(value); + for value in variants { + assert!( + Published::parse( + &product(), + "v1.0.0-vm.1", + &serde_json::to_vec(&value).unwrap() + ) + .is_err() + ); + } + } + + #[test] + fn checksum_binds_one_exact_archive() { + let hash = "b".repeat(64); + for text in [ + format!("{hash} exact.tar.gz\n"), + format!("{hash} *exact.tar.gz\r\n"), + ] { + verify_checksum(text.as_bytes(), "exact.tar.gz", &hash).unwrap(); + } + for text in [ + format!("{hash} other.tar.gz\n"), + format!("{hash} exact.tar.gz\n{hash} other.tar.gz\n"), + "b".repeat(64), + ] { + assert!(verify_checksum(text.as_bytes(), "exact.tar.gz", &hash).is_err()); + } + } + + #[test] + fn local_archive_cannot_claim_canonical_release_ownership() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let stage = Stage::new(&bin).unwrap(); + stage + .directory + .write_new("archive", b"foreign archive", false) + .unwrap(); + stage + .directory + .write_new("checksum", b"foreign checksum", false) + .unwrap(); + assert!(published().verify_staged(&stage, true).is_err()); + assert!(fixture.bin().read("algal", BINARY_LIMIT).unwrap().is_none()); + } + + fn make_archive(source: &Path, archive: &Path, members: &[&str]) { + let mut command = Command::new("/usr/bin/tar"); + command + .env("COPYFILE_DISABLE", "1") + .env_remove("TAR_OPTIONS") + .arg("--format=ustar") + .arg("-czf") + .arg(archive) + .arg("-C") + .arg(source) + .args(members); + assert!( + run_bounded(&mut command, 8192, 8192, Duration::from_secs(10)) + .unwrap() + .status + .success() + ); + } + + #[test] + fn archive_refuses_links_extensions_and_extra_members_without_execution() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let stage = Stage::new(&bin).unwrap(); + let source = fixture.root.join("source"); + fs::create_dir(&source).unwrap(); + for name in ["one", "two", "three", "four", "extra"] { + fs::write(source.join(name), b"not executed").unwrap(); + } + let archive = stage.directory.path.join("archive"); + make_archive(&source, &archive, &["one", "two", "three", "four"]); + let expanded = expanded_archive(&archive).unwrap(); + assert_eq!(archive_members(&expanded).unwrap().len(), 4); + let mut extension = expanded.clone(); + extension[156] = b'x'; + assert!(archive_members(&extension).is_err()); + let mut huge = expanded.clone(); + huge[124..136].copy_from_slice(b"77777777777\0"); + assert!(archive_members(&huge).is_err()); + fs::remove_file(&archive).unwrap(); + make_archive(&source, &archive, &["one", "two", "three", "four", "extra"]); + assert!(archive_members(&expanded_archive(&archive).unwrap()).is_err()); + fs::remove_file(&archive).unwrap(); + fs::remove_file(source.join("one")).unwrap(); + symlink("/bin/sh", source.join("one")).unwrap(); + make_archive(&source, &archive, &["one", "two", "three", "four"]); + assert!(archive_members(&expanded_archive(&archive).unwrap()).is_err()); + } + + #[test] + fn release_record_binds_binary_tag_target_build_and_sidecar() { + for invalid in 0..8 { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let stage = Stage::new(&bin).unwrap(); + let mut published = published(); + let prefix = published.archive.name.strip_suffix(".tar.gz").unwrap(); + let source = fixture.root.join("source"); + fs::create_dir_all(source.join(prefix).join("bin")).unwrap(); + let mut record = package(b"candidate bytes").metadata; + match invalid { + 1 => record["tag"] = "v1.0.0-vm.2".into(), + 2 => record["binarySha256"] = "c".repeat(64).into(), + 3 => record["target"] = "wrong-target".into(), + 4 => record["build"]["sourceCommit"] = "d".repeat(40).into(), + 5 => record["build"]["exactTagsAtBuild"] = serde_json::json!([]), + 6 => record["unexpected"] = true.into(), + _ => {} + } + let members: Vec<_> = ["LICENSE", "bin/algal", "release.json", "smoke.py"] + .map(|name| format!("{prefix}/{name}")) + .into(); + for (name, bytes) in members.iter().zip([ + b"fixture license".to_vec(), + b"candidate bytes".to_vec(), + serde_json::to_vec(&record).unwrap(), + b"fixture smoke".to_vec(), + ]) { + fs::write(source.join(name), bytes).unwrap(); + } + let archive = stage.directory.path.join("archive"); + make_archive( + &source, + &archive, + &members.iter().map(String::as_str).collect::>(), + ); + published.archive_sha256 = digest(&fs::read(archive).unwrap()); + let mut sidecar = record; + sidecar["archive"] = published.archive.name.clone().into(); + sidecar["archiveSha256"] = published.archive_sha256.clone().into(); + if invalid == 7 { + sidecar["commit"] = "e".repeat(40).into(); + } + stage + .directory + .write_new("metadata", &serde_json::to_vec(&sidecar).unwrap(), false) + .unwrap(); + let result = unpack(&stage, &published); + if invalid == 0 { + assert_eq!(result.unwrap().binary_sha256, digest(b"candidate bytes")); + assert_eq!( + stage + .directory + .read("algal", BINARY_LIMIT) + .unwrap() + .unwrap(), + b"candidate bytes" + ); + } else { + assert!(result.is_err(), "invalid record variant {invalid}"); + assert!( + stage + .directory + .read("algal", BINARY_LIMIT) + .unwrap() + .is_none() + ); + } + } + } + + #[test] + fn rollback_restores_binary_and_receipt_after_publication_failure() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let state = fixture.state(); + bin.write_new("algal", b"old binary", true).unwrap(); + fs::set_permissions(bin.path.join("algal"), fs::Permissions::from_mode(0o700)).unwrap(); + state + .write_new("install.json", b"old receipt", false) + .unwrap(); + let mut stage = Stage::new(&bin).unwrap(); + stage + .directory + .write_new("algal", b"new binary", true) + .unwrap(); + let old = snapshot(&bin, &state, &stage).unwrap(); + let old_package = package(b"old binary"); + let new_package = package(b"new binary"); + publish_package(&bin, &old_package).unwrap(); + let receipt = published().receipt( + &bin.path.join("algal"), + digest(b"new binary"), + false, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let result = replace( + &bin, + &state, + &mut stage, + &old, + &receipt, + || publish_package(&bin, &new_package), + || { + state.remove("install.json", false)?; + state.write_new("install.json", b"partially published receipt", false)?; + bail!("injected receipt publication failure") + }, + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("original files were restored") + ); + assert_eq!( + bin.read("algal", BINARY_LIMIT).unwrap().unwrap(), + b"old binary" + ); + assert_eq!( + fs::metadata(bin.path.join("algal")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + state.read("install.json", RECEIPT_LIMIT).unwrap().unwrap(), + b"old receipt" + ); + let records = Directory::open(&bin.path.join(".algal-releases"), false, false).unwrap(); + for record in [old_package, new_package] { + assert_eq!( + records + .read(&format!("{}.json", record.binary_sha256), METADATA_LIMIT) + .unwrap() + .unwrap(), + record.bytes + ); + } + } + + #[test] + fn failed_final_revalidation_performs_no_installed_writes() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let state = fixture.state(); + bin.write_new("algal", b"old", true).unwrap(); + state.write_new("install.json", b"old", false).unwrap(); + let mut stage = Stage::new(&bin).unwrap(); + stage.directory.write_new("algal", b"new", true).unwrap(); + let old = snapshot(&bin, &state, &stage).unwrap(); + let receipt = published().receipt( + &bin.path.join("algal"), + digest(b"new"), + false, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + assert!( + replace( + &bin, + &state, + &mut stage, + &old, + &receipt, + || bail!("policy disabled"), + || panic!("publication must not run") + ) + .is_err() + ); + assert_eq!(bin.read("algal", BINARY_LIMIT).unwrap().unwrap(), b"old"); + assert_eq!( + state.read("install.json", RECEIPT_LIMIT).unwrap().unwrap(), + b"old" + ); + } + + #[test] + fn installed_symlinks_and_shared_files_are_never_replaced() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let foreign = fixture.root.join("foreign"); + fs::write(&foreign, b"preserve").unwrap(); + symlink(&foreign, bin.path.join("algal")).unwrap(); + assert!(bin.read("algal", BINARY_LIMIT).is_err()); + fs::remove_file(bin.path.join("algal")).unwrap(); + fs::hard_link(&foreign, bin.path.join("algal")).unwrap(); + assert!(bin.read("algal", BINARY_LIMIT).is_err()); + assert_eq!(fs::read(&foreign).unwrap(), b"preserve"); + } + + #[test] + fn manager_and_source_destinations_are_ineligible() { + for path in [ + "/opt/homebrew/Cellar/algal/1/bin/algal", + "/home/user/.cargo/bin/algal", + "/work/target/release/algal", + ] { + assert!(eligible_destination(Path::new(path)).is_err()); + } + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join(".git")).unwrap(); + fs::write(fixture.root.join("Cargo.toml"), b"").unwrap(); + assert!(eligible_destination(&fixture.root.join("bin/algal")).is_err()); + } + + #[test] + fn initial_enrollment_refuses_an_unpublished_source_build() { + if matches!(product().running_identity, RunningIdentity::Source) { + let args = InitialInstall { + archive: "/missing".into(), + checksum: "/missing".into(), + prefix: "/missing".into(), + pinned: false, + }; + assert!( + initial_install(&args) + .unwrap_err() + .to_string() + .contains("source build") + ); + } + } + + #[test] + fn reinstall_preserves_pins_and_rejects_foreign_receipts() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let published = published(); + let profile = product(); + let binary = b"verified binary"; + let mut receipt = published.receipt( + &bin.path.join("algal"), + digest(binary), + true, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let bytes = serde_json::to_vec(&receipt).unwrap(); + assert!( + verify_previous( + &profile, + &published, + &bin, + Some(binary), + Some(&bytes), + false + ) + .unwrap() + ); + receipt.release_tag = "v0.9.0-vm.1".into(); + receipt.archive_name = profile.asset_names("v0.9.0-vm.1").unwrap()[0].clone(); + let bytes = serde_json::to_vec(&receipt).unwrap(); + assert!( + verify_previous( + &profile, + &published, + &bin, + Some(binary), + Some(&bytes), + false + ) + .is_err() + ); + assert!( + verify_previous(&profile, &published, &bin, Some(binary), Some(&bytes), true).unwrap() + ); + receipt.kind = InstallationKind::Cargo; + assert!( + verify_previous( + &profile, + &published, + &bin, + Some(binary), + Some(&serde_json::to_vec(&receipt).unwrap()), + true + ) + .is_err() + ); + } + + struct NeverNetwork; + impl ReleaseSource for NeverNetwork { + fn releases(&self, _: &Product) -> hraness_cli_update::Result> { + panic!("offline command must not fetch") + } + } + + #[test] + fn initial_install_keeps_preferences_separate_from_ownership() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + bin.write_new("algal", b"source bytes", true).unwrap(); + let target = bin.path.join("algal"); + let profile = product(); + let update_paths = paths(&target).unwrap(); + let updater = + Updater::for_executable(profile.clone(), update_paths.clone(), target.clone()).unwrap(); + updater + .execute( + hraness_cli_update::CommandAction::Disable, + &NeverNetwork, + &NativeInstaller, + ) + .unwrap(); + assert!(update_paths.state_dir.exists()); + assert!(!update_paths.receipt.parent().unwrap().exists()); + bin.remove("algal", false).unwrap(); + let mut stage = Stage::new(&bin).unwrap(); + stage + .directory + .write_new("algal", b"verified native bytes", true) + .unwrap(); + let mut package = package(b"verified native bytes"); + assert!( + !enroll_verified( + &profile, + &published(), + &bin, + &mut stage, + &target, + &package, + false + ) + .unwrap() + ); + let receipt: InstallReceipt = + serde_json::from_slice(&fs::read(&update_paths.receipt).unwrap()).unwrap(); + assert_eq!(receipt.binary_sha256, package.binary_sha256); + assert!( + update_paths + .receipt + .parent() + .unwrap() + .join("activity.lock") + .exists() + ); + let status = updater + .execute( + hraness_cli_update::CommandAction::Status, + &NeverNetwork, + &NativeInstaller, + ) + .unwrap(); + assert_eq!(status.policy, hraness_cli_update::Policy::Disabled); + package.bytes.push(b' '); + assert!(publish_package(&bin, &package).is_err()); + } + + #[test] + fn installer_lock_obeys_the_running_command_sdk_lease() { + let fixture = Fixture::new(); + let bin = fixture.bin(); + let state = fixture.state(); + bin.write_new("algal", b"release bytes", true).unwrap(); + let target = bin.path.join("algal"); + let paths = paths(&target).unwrap(); + let mut profile = product(); + profile.running_identity = RunningIdentity::Release { + release_tag: "v1.0.0-vm.1", + build_sha: Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + }; + let receipt = published().receipt( + &target, + digest(b"release bytes"), + false, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + receipt.write_verified(&profile, &paths.receipt).unwrap(); + let updater = Updater::for_executable(profile, paths, target.clone()).unwrap(); + let mut context = StartupContext::from_process(); + context.args = vec!["proxy".into(), "serve".into()]; + context.no_update = true; + let outcome = updater + .startup(&context, &NeverNetwork, &NativeInstaller) + .unwrap(); + let StartupOutcome::Continue { + lease: Some(lease), .. + } = outcome + else { + panic!("command must retain an active lease") + }; + super::super::verify_expected_hash(&target, &digest(b"release bytes")).unwrap(); + assert!(state.lock().is_err()); + drop(lease); + state.lock().unwrap(); + bin.write_new("replacement", b"new release bytes", true) + .unwrap(); + bin.rename("replacement", &bin, "algal").unwrap(); + assert!(super::super::verify_expected_hash(&target, &digest(b"release bytes")).is_err()); + super::super::verify_expected_hash(&target, &digest(b"new release bytes")).unwrap(); + } +} diff --git a/crates/algal/src/self_update/unix/files.rs b/crates/algal/src/self_update/unix/files.rs new file mode 100644 index 00000000..3e645356 --- /dev/null +++ b/crates/algal/src/self_update/unix/files.rs @@ -0,0 +1,355 @@ +//! Descriptor-relative, in-process writes using the workspace's safe rustix APIs. +use anyhow::{Context, Result, bail, ensure}; +use rustix::fs::{self as sys, AtFlags, FlockOperation, Mode, OFlags}; +use rustix::io::Errno; +use sha2::{Digest, Sha256}; +use std::ffi::OsStr; +use std::fs::{File, Metadata}; +use std::io::{Read, Write}; +use std::os::unix::fs::{MetadataExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static SERIAL: AtomicU64 = AtomicU64::new(0); + +fn name(value: &OsStr) -> Result<&OsStr> { + ensure!( + matches!( + Path::new(value).components().next(), + Some(Component::Normal(_)) + ) && Path::new(value).components().count() == 1, + "Expected one ordinary filename" + ); + Ok(value) +} + +fn check(meta: &Metadata, directory: bool, private: bool) -> Result<()> { + let uid = rustix::process::geteuid().as_raw(); + ensure!( + if directory { + meta.is_dir() + } else { + meta.is_file() + }, + "Installation path has the wrong file type" + ); + let root_sticky = directory && meta.uid() == 0 && meta.mode() & 0o1000 != 0; + ensure!( + (meta.uid() == uid || (directory && !private && meta.uid() == 0)) + && (root_sticky || meta.mode() & 0o022 == 0) + && (!private || meta.mode() & 0o077 == 0) + && (directory || meta.nlink() == 1), + "Installation paths must be owned by this user and not shared or writable by others" + ); + Ok(()) +} + +fn same(a: &Metadata, b: &Metadata) -> bool { + a.dev() == b.dev() && a.ino() == b.ino() +} + +pub(super) struct Directory { + pub path: PathBuf, + file: File, + private: bool, +} +impl Directory { + pub fn open(path: &Path, create: bool, private: bool) -> Result { + ensure!( + path.is_absolute() + && !path + .components() + .any(|c| matches!(c, Component::ParentDir | Component::CurDir)), + "Installation path must be absolute without dot components" + ); + let mut file = File::open("/")?; + check(&file.metadata()?, true, false)?; + let components: Vec<_> = path + .components() + .filter_map(|c| match c { + Component::Normal(value) => Some(value), + _ => None, + }) + .collect(); + ensure!( + !private || !components.is_empty(), + "Filesystem root cannot hold private update state" + ); + for (index, component) in components.iter().enumerate() { + let component = name(component)?; + let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; + let mut opened = sys::openat(&file, component, flags, Mode::empty()); + if create && matches!(opened, Err(Errno::NOENT)) { + match sys::mkdirat(&file, component, Mode::from_raw_mode(0o700)) { + Ok(()) | Err(Errno::EXIST) => {} + Err(error) => return Err(error.into()), + } + opened = sys::openat(&file, component, flags, Mode::empty()); + } + file = File::from(opened.context("Open install directory without symlinks")?); + check( + &file.metadata()?, + true, + private && index + 1 == components.len(), + )?; + } + Ok(Self { + path: path.into(), + file, + private, + }) + } + + pub fn validate(&self) -> Result<()> { + let now = Self::open(&self.path, false, self.private)?; + ensure!( + same(&self.file.metadata()?, &now.file.metadata()?), + "Install directory changed during verification" + ); + Ok(()) + } + + pub fn mkdir(&self, filename: &str) -> Result { + self.validate()?; + match sys::mkdirat( + &self.file, + name(OsStr::new(filename))?, + Mode::from_raw_mode(0o700), + ) { + Ok(()) => { + self.file.sync_all()?; + Ok(true) + } + Err(Errno::EXIST) => Ok(false), + Err(error) => Err(error.into()), + } + } + + fn open_file( + &self, + filename: &OsStr, + flags: OFlags, + mode: Mode, + private: bool, + ) -> Result> { + self.validate()?; + let opened = sys::openat( + &self.file, + name(filename)?, + flags | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK, + mode, + ); + let file = match opened { + Ok(fd) => File::from(fd), + Err(Errno::NOENT) if !flags.contains(OFlags::CREATE) => return Ok(None), + Err(error) => return Err(error.into()), + }; + check(&file.metadata()?, false, private)?; + Ok(Some(file)) + } + + pub fn read(&self, filename: &str, limit: usize) -> Result>> { + let Some(file) = + self.open_file(OsStr::new(filename), OFlags::RDONLY, Mode::empty(), false)? + else { + return Ok(None); + }; + ensure!( + file.metadata()?.len() <= limit as u64, + "Install file exceeds its size limit" + ); + let mut bytes = Vec::new(); + file.take(limit as u64 + 1).read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= limit, + "Install file grew beyond its size limit" + ); + Ok(Some(bytes)) + } + + pub fn write_new(&self, filename: &str, bytes: &[u8], executable: bool) -> Result<()> { + let mut file = self + .open_file( + OsStr::new(filename), + OFlags::WRONLY | OFlags::CREATE | OFlags::EXCL, + Mode::from_raw_mode(0o600), + true, + )? + .context("Create staged file")?; + file.write_all(bytes)?; + if executable { + sys::fchmod(&file, Mode::from_raw_mode(0o755))?; + } + file.sync_all()?; + self.file.sync_all()?; + Ok(()) + } + + pub fn copy_mode(&self, target: &str, source: &Directory, filename: &str) -> Result<()> { + let original = source + .open_file(OsStr::new(filename), OFlags::RDONLY, Mode::empty(), false)? + .context("Original executable disappeared")?; + let staged = self + .open_file(OsStr::new(target), OFlags::RDONLY, Mode::empty(), false)? + .context("Backup executable disappeared")?; + staged.set_permissions(std::fs::Permissions::from_mode( + original.metadata()?.mode() & 0o777, + ))?; + staged.sync_all()?; + Ok(()) + } + + pub fn rename(&self, source: &str, destination: &Self, target: &str) -> Result<()> { + self.validate()?; + destination.validate()?; + sys::renameat( + &self.file, + name(OsStr::new(source))?, + &destination.file, + name(OsStr::new(target))?, + )?; + destination.file.sync_all()?; + self.file.sync_all()?; + Ok(()) + } + + pub fn remove(&self, filename: &str, directory: bool) -> Result<()> { + self.validate()?; + match sys::unlinkat( + &self.file, + name(OsStr::new(filename))?, + if directory { + AtFlags::REMOVEDIR + } else { + AtFlags::empty() + }, + ) { + Ok(()) | Err(Errno::NOENT) => {} + Err(error) => return Err(error.into()), + } + self.file.sync_all()?; + Ok(()) + } + + pub fn lock(&self) -> Result> { + let file = self + .open_file( + OsStr::new("activity.lock"), + OFlags::RDWR | OFlags::CREATE, + Mode::from_raw_mode(0o600), + true, + )? + .context("Create activity lock")?; + if sys::flock(&file, FlockOperation::NonBlockingLockExclusive).is_err() { + bail!("ALGAL is running or another installer is active; retry when it finishes"); + } + let lock = Lock { + directory: self, + file, + owner: std::process::id(), + }; + lock.validate()?; + Ok(lock) + } +} + +pub(super) struct Lock<'a> { + directory: &'a Directory, + file: File, + owner: u32, +} +impl Lock<'_> { + pub fn validate(&self) -> Result<()> { + ensure!( + self.owner == std::process::id(), + "Install lock belongs to another process" + ); + let now = self + .directory + .open_file( + OsStr::new("activity.lock"), + OFlags::RDONLY, + Mode::empty(), + true, + )? + .context("Activity lock disappeared")?; + ensure!( + same(&now.metadata()?, &self.file.metadata()?), + "Activity lock changed during installation" + ); + Ok(()) + } +} +impl Drop for Lock<'_> { + fn drop(&mut self) { + if self.owner == std::process::id() { + let _ = sys::flock(&self.file, FlockOperation::Unlock); + } + } +} + +pub(super) struct Stage<'a> { + pub directory: Directory, + parent: &'a Directory, + name: String, + pub preserve: bool, +} +impl<'a> Stage<'a> { + pub fn new(parent: &'a Directory) -> Result { + for _ in 0..100 { + let name = format!( + ".algal-update-{}-{}", + std::process::id(), + SERIAL.fetch_add(1, Ordering::Relaxed) + ); + if parent.mkdir(&name)? { + return Ok(Self { + directory: Directory::open(&parent.path.join(&name), false, true)?, + parent, + name, + preserve: false, + }); + } + } + bail!("Could not create a fresh private staging directory") + } +} +impl Drop for Stage<'_> { + fn drop(&mut self) { + if self.preserve || self.directory.validate().is_err() { + return; + } + for filename in [ + "archive", + "metadata", + "checksum", + "algal", + "previous", + "old-receipt", + "new-receipt", + ] { + let _ = self.directory.remove(filename, false); + } + let _ = self.parent.remove(&self.name, true); + } +} + +pub(super) fn read_path(path: &Path, limit: usize) -> Result> { + let path = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir()?.join(path) + }; + let directory = Directory::open(path.parent().context("File has no parent")?, false, false)?; + directory + .read( + path.file_name() + .and_then(OsStr::to_str) + .context("File needs a Unicode name")?, + limit, + )? + .context("Install file is missing") +} +pub(super) fn digest(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} diff --git a/crates/algal/src/self_update/unix/legacy-releases.json b/crates/algal/src/self_update/unix/legacy-releases.json new file mode 100644 index 00000000..ac067fb4 --- /dev/null +++ b/crates/algal/src/self_update/unix/legacy-releases.json @@ -0,0 +1,77 @@ +[ + { + "tag": "v0.2.0-vm.11", + "releaseId": 399320510, + "platform": "aarch64-apple-darwin", + "archive": { + "id": 598643811, + "name": "algal-v0.2.0-vm.11-aarch64-apple-darwin.tar.gz", + "size": 5449316, + "sha256": "877cdf10bbb21c5673c17c1024c6389df15089f02da7adabc95d0e8d43be6bee" + }, + "checksum": { + "id": 598643815, + "name": "algal-v0.2.0-vm.11-aarch64-apple-darwin.tar.gz.sha256", + "size": 113, + "sha256": "bef3263cf7b9081e95db2d31df1664b8df4a65ca910eea6c9dd86954d66665e3" + }, + "binarySize": 12439392, + "binarySha256": "1012355edb66b0993f22d3f2711c5ccf8759e6d795470c00f4a031eaf21f425c", + "metadata": { + "id": 598643814, + "name": "algal-v0.2.0-vm.11-aarch64-apple-darwin.release.json", + "size": 1459, + "sha256": "274ec2e22d5f3463da01c2370b3a36c6648816f75428f4c5b24479c9f67cf0ff" + } + }, + { + "tag": "v0.2.0-vm.11", + "releaseId": 399320510, + "platform": "aarch64-unknown-linux-gnu", + "archive": { + "id": 598643812, + "name": "algal-v0.2.0-vm.11-aarch64-unknown-linux-gnu.tar.gz", + "size": 5735506, + "sha256": "a84c24436e571bfe90ea9d5e51ac861741562b2ddf824fd673faf6cb8e82332d" + }, + "checksum": { + "id": 598643833, + "name": "algal-v0.2.0-vm.11-aarch64-unknown-linux-gnu.tar.gz.sha256", + "size": 118, + "sha256": "dd41c0794a3c1246b562ace1bd4992e5de65dc787ce14aaf100b9d1dc2596d7b" + }, + "binarySize": 13281448, + "binarySha256": "c3822089f1e1d737e2a545817dcbba7d45f0d03634f56ad387a6fea25bf4e0b9", + "metadata": { + "id": 598643813, + "name": "algal-v0.2.0-vm.11-aarch64-unknown-linux-gnu.release.json", + "size": 1473, + "sha256": "df8851a68518201e2c845450f62c104fbd2db5df48e544f90645a18d3144729a" + } + }, + { + "tag": "v0.2.0-vm.11", + "releaseId": 399320510, + "platform": "x86_64-unknown-linux-gnu", + "archive": { + "id": 598643842, + "name": "algal-v0.2.0-vm.11-x86_64-unknown-linux-gnu.tar.gz", + "size": 5990602, + "sha256": "406669852e9600b15f568e39b6a7d2f16e2ab64ce7fe1db1e0df33c260703ebb" + }, + "checksum": { + "id": 598643847, + "name": "algal-v0.2.0-vm.11-x86_64-unknown-linux-gnu.tar.gz.sha256", + "size": 117, + "sha256": "a24e818d51e5ac7d6523eb913be86c351fad9ffe89dc9f5d3396a1a4efae53df" + }, + "binarySize": 14948000, + "binarySha256": "e9973c863ba01066b360390b59737d33823f77c616c7a1cd19a4a4b341db0a8d", + "metadata": { + "id": 598643837, + "name": "algal-v0.2.0-vm.11-x86_64-unknown-linux-gnu.release.json", + "size": 1464, + "sha256": "b1b87f838ebe1b39cae083b7f6d9721e7814f190cb87e7db73fb64d57bef5595" + } + } +] diff --git a/crates/algal/src/self_update/unix/legacy.rs b/crates/algal/src/self_update/unix/legacy.rs new file mode 100644 index 00000000..0a744240 --- /dev/null +++ b/crates/algal/src/self_update/unix/legacy.rs @@ -0,0 +1,91 @@ +//! Historical mutable releases are accepted only by reviewed, fixed byte pins. +//! Nothing in this table grants ownership to a different or newer executable. +use super::{ + ARCHIVE_LIMIT, BINARY_LIMIT, CHECKSUM_LIMIT, HISTORICAL_RELEASE, METADATA_LIMIT, valid_digest, +}; +use anyhow::{Result, ensure}; +use hraness_cli_update::Product; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Asset { + id: u64, + name: String, + size: usize, + sha256: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Release { + tag: String, + release_id: u64, + platform: String, + archive: Asset, + checksum: Asset, + metadata: Asset, + binary_size: usize, + binary_sha256: String, +} + +pub(super) fn matches(profile: &Product, hash: &str, size: usize) -> Result { + let releases: Vec = serde_json::from_str(include_str!("legacy-releases.json"))?; + ensure!( + !releases.is_empty() && releases.len() <= 8, + "Historical release pin count" + ); + let mut matched = false; + for release in releases { + let mut target = profile.clone(); + target.platform = release.platform.clone(); + let names = target.asset_names(&release.tag)?; + ensure!( + release.tag == HISTORICAL_RELEASE + && release.release_id > 0 + && release.binary_size > 0 + && release.binary_size <= BINARY_LIMIT + && valid_digest(&release.binary_sha256) + && release.archive.id > 0 + && release.archive.name == names[0] + && release.archive.size > 0 + && release.archive.size <= ARCHIVE_LIMIT + && valid_digest(&release.archive.sha256) + && release.checksum.id > 0 + && release.checksum.name == names[1] + && release.checksum.size > 0 + && release.checksum.size <= CHECKSUM_LIMIT + && valid_digest(&release.checksum.sha256) + && release.metadata.id > 0 + && release.metadata.name == names[2] + && release.metadata.size > 0 + && release.metadata.size <= METADATA_LIMIT + && valid_digest(&release.metadata.sha256), + "Historical release evidence is inconsistent" + ); + matched |= release.platform == profile.platform + && release.binary_sha256 == hash + && release.binary_size == size; + } + Ok(matched) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_requires_the_exact_recorded_platform_hash_and_size() { + let records: Vec = + serde_json::from_str(include_str!("legacy-releases.json")).unwrap(); + for record in records { + let mut profile = crate::self_update::product(); + profile.platform = record.platform; + assert!(matches(&profile, &record.binary_sha256, record.binary_size).unwrap()); + assert!(!matches(&profile, &"0".repeat(64), record.binary_size).unwrap()); + assert!(!matches(&profile, &record.binary_sha256, record.binary_size + 1).unwrap()); + profile.platform = "unknown-platform".into(); + assert!(!matches(&profile, &record.binary_sha256, record.binary_size).unwrap()); + } + } +} diff --git a/docs/native-release.md b/docs/native-release.md index a2aaa7b8..3e910259 100644 --- a/docs/native-release.md +++ b/docs/native-release.md @@ -34,6 +34,47 @@ one exact release, or `ALGAL_INSTALL_PREFIX` to install somewhere other than `~/.local`. The script's source is `site/install.sh`; the site fills in the default release tag from `site/published-release.json`. +## Updates + +From `v0.2.0-vm.13`, verified native installs on macOS and Linux check for a +newer `vm` preview before product work, at most once a day. Automatic updates +are enabled by default. They keep the current platform and preview channel, +and compare the full release tag and build SHA rather than the `0.2.0` package +version shared by the previews. + +```sh +algal update +algal update check --json +algal update status +algal update disable +algal update enable +``` + +The updater verifies the immutable GitHub release, archive and checksum +digests, and the release record stored both inside and beside the archive. +macOS also requires the expected Developer ID, Apple notarization, hardened +runtime and secure timestamp before executing the candidate. Every running +command protects the installed executable until it finishes. Updates wait +for another invocation when a command is active; they do not restart services. +A failed replacement restores the previous executable and install record. +Hash-keyed `.algal-releases` records stay with the bytes they describe. + +CI, offline verification, memory queries and existing SDK executable pins skip +automatic updates. Use `--no-update` or `HRANESS_NO_UPDATE=1` to skip a single +invocation. `ALGAL_VERSION` and manually installed archives pin the chosen +release; an ordinary reinstall preserves that pin and saved update preferences. +Re-run the public installer to enroll a `vm.11` native copy whose bytes match +the fixed historical hashes recorded in the updater. That older release was +mutable and unsigned. A `vm.12` copy can migrate when its canonical release +is immutable. Unknown copies need their original update workflow or a new +`ALGAL_INSTALL_PREFIX`. + +Source and package Bun installations retain their original update workflow. +Their `algal update` command prints manual guidance without network access, +installation changes or a switch to the native runtime. Native updates do +not rebuild the Apple bridge or change a downstream application's executable +pin. + ## Verify and install by hand Download the target's `.tar.gz` and matching `.tar.gz.sha256` from the same diff --git a/scripts/install-native.sh b/scripts/install-native.sh index b0db7250..3f2ce60a 100644 --- a/scripts/install-native.sh +++ b/scripts/install-native.sh @@ -35,6 +35,7 @@ else [ -z "$checksum" ] || { printf '%s\n' '--checksum requires --archive' >&2; exit 2; } fi mkdir -p "$prefix/bin" +prefix=$(CDPATH= cd -- "$prefix" && pwd -P) staging=$(mktemp -d "$prefix/bin/.algal-install.XXXXXX") trap 'rm -rf "$staging"' EXIT trap 'exit 1' HUP INT TERM @@ -47,6 +48,31 @@ else cp "$build_dir/release/algal" "$staging/algal" fi chmod 755 "$staging/algal" +if [ -n "$archive" ]; then + # Older candidates lack this hidden command. New official builds use their + # own verifier and lock owner; a source build never gains release ownership. + identity=$("$staging/algal" __build-identity 2>/dev/null || true) + official=$(printf '%s' "$identity" | python3 -c 'import hashlib,json,pathlib,sys +try: + value=json.load(sys.stdin) + tag=value.get("releaseTag") + executable=pathlib.Path(sys.argv[1]) + digest=hashlib.sha256(executable.read_bytes()).hexdigest() + metadata=json.loads((executable.parent/".algal-releases"/(digest+".json")).read_text()) + print("yes" if value.get("schema")=="algal.build.v1" and isinstance(tag,str) + and metadata.get("tag")==tag and metadata.get("sourceState")=="clean" else "no") +except (OSError, ValueError, TypeError, AttributeError): + print("no")' "$staging/algal") + if [ "$official" = yes ]; then + "$staging/algal" __install-release --archive "$archive" --checksum "$checksum" --prefix "$prefix" --pinned + printf '%s\n' 'Optional Apple bridge: build separately with scripts/build-apple.sh.' + exit 0 + fi +fi +if [ -e "$prefix/bin/.hraness-cli-update-algal" ] || [ -L "$prefix/bin/.hraness-cli-update-algal" ]; then + printf '%s\n' 'This installation uses native updates; use algal update or choose a new prefix.' >&2 + exit 1 +fi "$staging/algal" doctor > "$staging/doctor.json" # Metadata is immutable and addressed by the verified executable bytes. Publish # it first: interruption cannot attach a new release label to the old binary. diff --git a/scripts/package-native.py b/scripts/package-native.py index 060888f6..dee5dc89 100644 --- a/scripts/package-native.py +++ b/scripts/package-native.py @@ -158,6 +158,11 @@ def package(args): report = json.loads(bounded_output([str(frozen), "doctor"], 16_384)) version = report["version"] build = validate_build(report.get("build"), args, version, source_digest) + if not args.allow_dirty: + identity = json.loads(bounded_output([str(frozen), "__build-identity"], 16_384)) + if (identity.get("schema") != "algal.build.v1" or identity.get("releaseTag") != args.tag + or identity.get("buildSha") != args.commit or identity.get("build") != build): + raise ValueError("release executable lacks its exact official tag and build SHA") if args.tag[1:].split("-", 1)[0] != version: raise ValueError("release tag base does not match the binary version") smoke = module.smoke(frozen) diff --git a/site/install.sh b/site/install.sh index 3828dead..2a4ce434 100644 --- a/site/install.sh +++ b/site/install.sh @@ -54,6 +54,7 @@ main() { name="algal-$tag-$target" temporary=$(mktemp -d "${TMPDIR:-/tmp}/algal-install.XXXXXX") + temporary=$(cd "$temporary" && pwd -P) trap 'rm -rf "$temporary"' EXIT trap 'exit 1' HUP INT TERM @@ -122,6 +123,23 @@ main() { had_algal=0 [ -e "$bin/algal" ] && had_algal=1 mkdir -p "$bin" + prefix=$(cd "$prefix" && pwd -P) + bin="$prefix/bin" + + modern=true + case "$tag" in v0.0.*|v0.1.*|v0.2.0-vm.[1-9]|v0.2.0-vm.10|v0.2.0-vm.11|v0.2.0-vm.12) modern=false ;; esac + if [ "$modern" = true ] && [ -z "${ALGAL_DOWNLOAD_BASE:-}" ]; then + # The verified candidate independently verifies the immutable public release + # and performs final writes, rollback, and record publication under its lock. + if [ -n "${ALGAL_VERSION:-}" ]; then + "$staged" __install-release --archive "$temporary/$name.tar.gz" --checksum "$temporary/$name.tar.gz.sha256" --prefix "$prefix" --pinned + else + "$staged" __install-release --archive "$temporary/$name.tar.gz" --checksum "$temporary/$name.tar.gz.sha256" --prefix "$prefix" + fi + else + if [ -e "$bin/.hraness-cli-update-algal" ] || [ -L "$bin/.hraness-cli-update-algal" ]; then + fail "this installation uses native updates; use algal update or choose a new ALGAL_INSTALL_PREFIX" + fi # Keep the release record keyed by the executable's digest, so `algal doctor` # reports which release it came from. Never replace a different record. @@ -142,6 +160,7 @@ main() { cp "$staged" "$bin/.algal-install.$$" chmod 755 "$bin/.algal-install.$$" mv -f "$bin/.algal-install.$$" "$bin/algal" + fi echo "Installed $version at $bin/algal" case ":${PATH:-}:" in diff --git a/src/application-native-memory-update.test.ts b/src/application-native-memory-update.test.ts new file mode 100644 index 00000000..4f18fd89 --- /dev/null +++ b/src/application-native-memory-update.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NativeMemoryQueryEngine } from "./application-native-memory"; + +test.skipIf(process.platform === "win32")("the existing SDK pin reaches the child and automatic updates stay disabled", async () => { + const directory = await mkdtemp(join(tmpdir(), "algal-pinned-update-")); + try { + const executable = join(directory, "fixture"); + const script = '#!/bin/sh\nprintf "%s\\n" "$HRANESS_NO_UPDATE" "$ALGAL_EXPECTED_BINARY_SHA256" > "$0.env"\nprintf "{}\\n"\n'; + await writeFile(executable, script); await chmod(executable, 0o755); + const expectedSha256 = createHash("sha256").update(script).digest("hex"); + const engine = new NativeMemoryQueryEngine({executable, expectedSha256}); + const snapshot = {contract: "algal.memory.v1", facts: []}; + const program = {contract: "algal.query.v1", rules: [], query: {relation: "available", terms: [{var: "x"}]}, limits: {maxWork: 100, maxRounds: 2, maxDerived: 2, maxBindings: 2, maxRows: 2, maxOutputBytes: 4096}}; + // The fixture only records transport settings; it deliberately has no + // logical query implementation and its response is rejected normally. + await engine.query(snapshot, program).catch(() => undefined); + await engine.settle(); + expect(await readFile(`${executable}.env`, "utf8")).toBe(`1\n${expectedSha256}\n`); + await writeFile(executable, script + "# changed bytes\n"); + await expect(engine.query(snapshot, program)).rejects.toThrow("Pinned native memory executable changed"); + await engine.settle(); + } finally { await rm(directory, {recursive: true, force: true}); } +}); diff --git a/src/application-native-memory.ts b/src/application-native-memory.ts index ccb06afb..d5a4d213 100644 --- a/src/application-native-memory.ts +++ b/src/application-native-memory.ts @@ -71,7 +71,10 @@ export class NativeMemoryQueryEngine implements MemoryQueryEngine { const args = [this.executable, "memory", command, join(dir, "snapshot.json"), join(dir, "program.json")]; if (result !== undefined) { await writeFile(join(dir, "result.json"), canonicalize(result), { flag: "wx", mode: 0o600 }); args.push(join(dir, "result.json")); } if (signal?.aborted) return { failure: { kind: "incomplete", status: "cancelled", reason: "cancelled-before-dispatch", work: null } }; - const child = Bun.spawn(args, { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const child = Bun.spawn(args, { + stdin: "ignore", stdout: "pipe", stderr: "pipe", + env: { ...process.env, HRANESS_NO_UPDATE: "1", ALGAL_EXPECTED_BINARY_SHA256: this.expectedSha256 }, + }); let stopReason: "cancelled" | "timeout" | "output-limit" | undefined; let force: ReturnType | undefined; const stop = (reason: typeof stopReason) => { diff --git a/src/cli-golden/help.txt b/src/cli-golden/help.txt index f2e42802..52d6c660 100644 --- a/src/cli-golden/help.txt +++ b/src/cli-golden/help.txt @@ -36,6 +36,7 @@ Store and share Setup auth jev Save a TypeSafe Jev key on this computer doctor Check that ALGAL is ready + update Show this Bun runtime's manual update workflow Options -h, --help Show help. Also: algal --help diff --git a/src/cli-help.ts b/src/cli-help.ts index 8911d7b2..89df3f2c 100644 --- a/src/cli-help.ts +++ b/src/cli-help.ts @@ -59,6 +59,7 @@ const GROUPS: readonly Group[] = [ rows: [ ["auth jev", "Save a TypeSafe Jev key on this computer"], ["doctor", "Check that ALGAL is ready"], + ["update", "Show this Bun runtime's manual update workflow"], ], }, ]; diff --git a/src/cli-update.test.ts b/src/cli-update.test.ts new file mode 100644 index 00000000..b02057ea --- /dev/null +++ b/src/cli-update.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +test("Bun update reports its manual workflow without creating a store or install state", async () => { + const cwd = await mkdtemp(join(tmpdir(), "algal-source-update-")); + try { + const command = Bun.spawn([process.execPath, join(import.meta.dir, "../cli.ts"), "update", "status", "--json"], {cwd, stdout: "pipe", stderr: "pipe"}); + const report = await new Response(command.stdout).json() as Record; + expect(await command.exited).toBe(0); + expect(report.status).toBe("unsupported"); + expect(report.supported).toBe(false); + expect(report.instructions).toContain("stays on Bun"); + expect(await readdir(cwd)).toEqual([]); + } finally { await rm(cwd, {recursive: true, force: true}); } +}); From 05b7f01238dd762f92696e6bfa936ccdc7a49459 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:09:26 -0400 Subject: [PATCH 3/4] test(cli): include updater in first-run help order --- crates/algal/tests/cli_help.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/algal/tests/cli_help.rs b/crates/algal/tests/cli_help.rs index 623dff81..d855bfde 100644 --- a/crates/algal/tests/cli_help.rs +++ b/crates/algal/tests/cli_help.rs @@ -37,10 +37,10 @@ fn root_help_leads_with_first_run_commands_and_hides_research_ones() { .lines() .skip_while(|line| *line != "Commands:") .skip(1) - .take(4) + .take(5) .map(|line| line.split_whitespace().next().unwrap_or("")) .collect(); - assert_eq!(commands, ["demo", "doctor", "run", "check"]); + assert_eq!(commands, ["demo", "doctor", "update", "run", "check"]); for hidden in ["civ", "civ-verify", "bench", "foundry", "acp"] { assert!( !help From 11fed51d58916e69edb92c5c5f6145dd350b3b05 Mon Sep 17 00:00:00 2001 From: 0thernet <894119+0thernet@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:55:17 -0400 Subject: [PATCH 4/4] Reuse checked optimized binary for native CI demos --- .github/workflows/ci.yml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bac1da3..9db7bdb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -459,7 +459,7 @@ jobs: demos: name: Native demos - needs: [changes, native] + needs: [changes, native-parity-build] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 timeout-minutes: 20 @@ -476,17 +476,21 @@ jobs: - run: bun install --frozen-lockfile - uses: actions/download-artifact@v8 with: - name: algal-debug-linux + name: algal-ci-parity-linux path: ${{ runner.temp }} - - name: Restore the debug binary built by the native job - run: mkdir -p target/debug && tar -C target/debug -xf "$RUNNER_TEMP/algal-debug.tar" && target/debug/algal --version - - run: ALGAL_MEMORY_NATIVE="$PWD/target/debug/algal" bun test examples/coding-harness/memory-records.test.ts examples/coding-harness/memory.test.ts - - run: ALGAL_MEMORY_NATIVE="$PWD/target/debug/algal" bun test examples/adaptive-inventory/run.test.ts - - run: bun scripts/vm-demo.ts --native ./target/debug/algal - - run: bun scripts/recovery-demo.ts --native ./target/debug/algal - - run: bun scripts/repair-demo.ts --native ./target/debug/algal - - run: bun scripts/coding-recovery-demo.ts --native ./target/debug/algal - - run: bun scripts/process-evidence-demo.ts --native ./target/debug/algal + # SDK children verify their executable hash on every invocation. Reuse + # the existing optimized binary while retaining debug assertions and + # overflow checks, so hashing full debug symbols cannot consume a demo's + # unchanged process deadline. + - name: Restore the ci-parity binary + run: mkdir -p target/ci-parity && tar -C target/ci-parity -xf "$RUNNER_TEMP/algal-ci-parity.tar" && target/ci-parity/algal --version + - run: ALGAL_MEMORY_NATIVE="$PWD/target/ci-parity/algal" bun test examples/coding-harness/memory-records.test.ts examples/coding-harness/memory.test.ts + - run: ALGAL_MEMORY_NATIVE="$PWD/target/ci-parity/algal" bun test examples/adaptive-inventory/run.test.ts + - run: bun scripts/vm-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/recovery-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/repair-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/coding-recovery-demo.ts --native ./target/ci-parity/algal + - run: bun scripts/process-evidence-demo.ts --native ./target/ci-parity/algal required: name: Required