From 85fb6ffd936d2d37c4759707475673dc61bc1a17 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 19 Oct 2026 12:53:55 -0400 Subject: [PATCH] fix(release): let candidate launchers select the Compose backend hack-next (the channel manager) and the bundled hack-v5 entrypoint exported HACK_RUNTIME_BACKEND=native unconditionally, and hack-next also forced the version's private HACK_HOME. Testers could not run the supported Compose backend through the channel; the M3 HACK-1124 rerun had to call the bundled hack-cli directly with a private HACK_HOME, which also hid the global Caddy/CoreDNS services and local CA. Unset or empty HACK_RUNTIME_BACKEND still selects native with the private homes. An explicit HACK_RUNTIME_BACKEND=compose now runs the verified bundle's hack-cli on Compose with the caller's own HACK_HOME and HACK_GLOBAL_CONFIG_PATH, and removes native selectors. Any other value refuses before verification or spawn. The manager runs hack-cli directly for Compose because retained bundles' hack-v5 always selects native. Both launchers set HACK_CANDIDATE_ENTRYPOINT, and `update` refuses when it is set, so a Compose launch keeps the candidate self-update refusal that the native backend marker used to provide. The released next.3-next.5 manager (e2e2bcba) is added as a reviewed predecessor with its fixture, so existing channels get backend selection through upgrade-manager. Refs HACK-1275 Co-Authored-By: Claude Opus 5.5 --- docs/guides/candidate-install.md | 65 +- docs/guides/native-candidate.md | 10 +- docs/guides/v5-dogfooding.md | 10 +- scripts/hack-v5.sh | 33 +- scripts/install-prerelease.py | 33 +- src/commands/update.ts | 9 +- .../fixtures/prerelease-manager-storage-v5.py | 971 ++++++++++++++++++ tests/native-candidate-launcher.test.ts | 187 +++- tests/python/test_prerelease_install.py | 97 +- tests/update-command.test.ts | 35 + 10 files changed, 1381 insertions(+), 69 deletions(-) create mode 100644 tests/fixtures/prerelease-manager-storage-v5.py diff --git a/docs/guides/candidate-install.md b/docs/guides/candidate-install.md index 23b77109d..522f6f6b6 100644 --- a/docs/guides/candidate-install.md +++ b/docs/guides/candidate-install.md @@ -30,8 +30,10 @@ latest-version lookup or automatic updater. By default installation creates a private, current-user-owned mode-`0700` `~/.hack-next` directory. Each immutable version directory contains a complete bundle, its own `native-home`, and its own `cli-home`. An installation receipt binds -the bundle hashes and both homes' filesystem identities. Stable Homebrew `hack`, -its links, and `~/.hack` are untouched. No shell profile or PATH is edited. +the bundle hashes and both homes' filesystem identities. Installation leaves stable +Homebrew `hack`, its links, and `~/.hack` untouched; only an explicit +[Compose test](#test-both-backends) uses your own Hack home. No shell profile or +PATH is edited. Run the candidate explicitly: @@ -41,9 +43,13 @@ Run the candidate explicitly: python3 "$HOME/.hack-next/manager.py" --root "$HOME/.hack-next" status ``` -`hack-next` selects the adjacent verified native executor and the selected version's -private native and CLI homes. These settings override inherited installation -selectors, including `HACK_GLOBAL_CONFIG_PATH`. Explicit native project adaptation, +By default `hack-next` uses the native backend. It selects the adjacent verified +native executor and the selected version's private native and CLI homes. These +settings override inherited installation selectors, including `HACK_NATIVE_BINARY`, +`HACK_NATIVE_HOME`, `HACK_HOME` and `HACK_GLOBAL_CONFIG_PATH`. To run the same +verified candidate on the Compose backend, set `HACK_RUNTIME_BACKEND=compose`; see +[Test both backends](#test-both-backends). Any other non-empty value refuses before +the candidate starts. Explicit native project adaptation, dependency, AWS profile, routing, and source-sharing settings still reach the CLI. Arguments, standard input and output, exit codes, and signals reach the selected candidate. Concurrent observation and stop commands can accompany a foreground @@ -63,7 +69,54 @@ Provider setup and actual application acceptance remain separate steps. Installa does not install the provider, boot a VM, enroll a project, move volumes, or activate system DNS or trust. Follow [native candidate preparation](native-candidate.md) using the selected bundle and its `native-home`. Candidate `hack-next update` -retains the existing refusal; use the channel manager's explicit upgrade command. +retains the existing refusal on both backends; use the channel manager's explicit +upgrade command. + +## Test both backends + +The same selected candidate can run on either backend. You do not need to call the +bundled `hack-cli` directly or create a private `HACK_HOME` to test Compose. + +| Backend | Command | Hack home | Routing, DNS and TLS | +| --- | --- | --- | --- | +| Native (default) | `"$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | The version's private `cli-home` and `native-home` | The candidate's native routing, set up through [native candidate preparation](native-candidate.md) | +| Compose | `HACK_RUNTIME_BACKEND=compose "$HOME/.hack-next/bin/hack-next" up --path /absolute/project` | Your own `HACK_HOME` (default `~/.hack`) and `HACK_GLOBAL_CONFIG_PATH` | Your existing global Caddy, CoreDNS and local CA | + +With `HACK_RUNTIME_BACKEND=compose`, `hack-next` verifies the selected bundle as +usual and then runs its `hack-cli` on the Compose backend. It keeps your Hack home, +so `https://.hack` routes, container DNS and CA trust use the global +services that `hack global install` already set up. It removes `HACK_NATIVE_BINARY` +and `HACK_NATIVE_HOME` so native selections cannot reach Compose children. The +candidate's private homes are not used. Compose `up` still uses the configurable +Compose startup budget; a cold image build can need a larger +`HACK_COMPOSE_STARTUP_TIMEOUT_MS`. + +Compose testing shares state with stable Hack: + +- The candidate reads and writes the same project registry, global configuration + and project `.hack/` files as stable `hack`. Stable v4 uses an older registry + lock protocol, so do not run stable `hack` and the candidate against the same + home at the same time. Stop one before you start the other. +- `hack-next` does not set up the shared global services for you. Install or + change them with stable `hack global` commands. +- To test Compose in isolation instead, set `HACK_HOME` to a separate private + directory. That home has no global services, so routing, container DNS and CA + trust are not tested. + +Leave `HACK_RUNTIME_BACKEND` unset, or set it to `native`, to return to the native +default. A shell that exported `HACK_RUNTIME_BACKEND=compose` for other work also +selects Compose for `hack-next`; unset it before native testing. + +Backend selection needs the current channel manager. The manager released with +`5.0.0-next.3` through `5.0.0-next.5` always selects native, and ignores +`HACK_RUNTIME_BACKEND=compose`. Run the reviewed `upgrade-manager` operation below +once to receive backend selection. This works for every retained bundle, including +`5.0.0-next.5`. Candidate CLIs from `5.0.0-next.5` and earlier refuse `update` only +on the native backend; do not run `hack-next update` on Compose with those bundles. + +The bundled `hack-v5` entrypoint follows the same rule for manual bundles: +unset selects native and requires `HACK_NATIVE_HOME`; `compose` needs no native +home and uses your own Hack home. ## Upgrade and return to a retained candidate diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index b5f309b4c..4f3f0f71c 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -110,8 +110,14 @@ export HACK_NATIVE_HOME=/absolute/private/candidate-home ``` Keep the bundle together and invoke this entrypoint by its full path (do not copy -or symlink only the script). It selects the adjacent executor even if inherited -backend variables select another binary. It preserves arguments, exit codes and +or symlink only the script). With `HACK_RUNTIME_BACKEND` unset (or `native`) it +selects the native backend and the adjacent executor, even if an inherited +`HACK_NATIVE_BINARY` selects another binary. An explicit +`HACK_RUNTIME_BACKEND=compose` runs the same bundled CLI on the Compose backend +with your own Hack home and existing global routing services. It needs no +`HACK_NATIVE_HOME` and removes the native selectors. Other values refuse with exit +code 64. The [candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md#test-both-backends) +describes Compose testing and its shared-state limits. It preserves arguments, exit codes and signals through `exec`, and never replaces the installed `hack`. Project-specific native adaptation, dependency and routing selections remain explicit; this entrypoint does not prepare a provider or migrate existing projects. An unversioned local diff --git a/docs/guides/v5-dogfooding.md b/docs/guides/v5-dogfooding.md index bae09b2e1..461b72311 100644 --- a/docs/guides/v5-dogfooding.md +++ b/docs/guides/v5-dogfooding.md @@ -9,8 +9,9 @@ compatibility, lower resource use or whole-product readiness. The [version-pinned candidate installer](candidate-install.md) provides `hack-next` alongside stable `hack`. It does not replace Homebrew Hack, edit PATH, convert -projects, move volumes, enable DNS or trust, or start a VM. Candidate homes and -stable state are separate. Do not alias `hack` to `hack-next` as a rollout step. +projects, move volumes, enable DNS or trust, or start a VM. On the default native +backend, candidate homes and stable state are separate; an explicit Compose test +uses your own Hack home. Do not alias `hack` to `hack-next` as a rollout step. Each candidate upgrade starts fresh native and CLI homes. Retained bundles keep their original homes; selecting another version does not migrate application data. @@ -35,6 +36,11 @@ These commands do not establish that an arbitrary v4 project can run natively. the [candidate guide](native-candidate.md), with admitted storage and an explicitly approved source share and network policy. A refused guard is a result to inspect, not permission to change host security, erase evidence or raise resource limits. + Test the backend the project will use. `hack-next` runs native by default; + `HACK_RUNTIME_BACKEND=compose hack-next` runs the same candidate on Compose with + your own Hack home, so global routing, DNS and CA trust are checked too. See + [Test both backends](candidate-install.md#test-both-backends), including why + stable `hack` must not run against the same home at the same time. 3. Exercise the project's supported command form: foreground start, readiness, status, finite logs, noninteractive exec, retaining stop and restart. Exercise one-off run only where admitted. For an authored project, check generated public diff --git a/scripts/hack-v5.sh b/scripts/hack-v5.sh index beda8d690..56089d824 100644 --- a/scripts/hack-v5.sh +++ b/scripts/hack-v5.sh @@ -1,15 +1,36 @@ #!/bin/sh -# Relocatable frontend for an explicitly selected native candidate home. +# Relocatable frontend for an explicitly selected candidate bundle. +# +# Backend selection: an unset or empty HACK_RUNTIME_BACKEND selects the native +# runtime with this bundle's adjacent executor and the explicit private +# HACK_NATIVE_HOME. An explicit HACK_RUNTIME_BACKEND=compose runs this bundle's +# CLI on the Compose backend with the caller's own Hack home (HACK_HOME, or +# ~/.hack when unset); native selectors are removed so they cannot leak into +# Compose children. Any other value refuses rather than guessing. set -eu bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) -case "${HACK_NATIVE_HOME:-}" in - /*) ;; - *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; +backend=${HACK_RUNTIME_BACKEND:-native} +case "$backend" in + native|compose) ;; + *) echo "HACK_RUNTIME_BACKEND must be native or compose (unset selects native)." >&2; exit 64 ;; esac +if [ "$backend" = native ]; then + case "${HACK_NATIVE_HOME:-}" in + /*) ;; + *) echo "Set HACK_NATIVE_HOME to an existing private candidate home (absolute path)." >&2; exit 64 ;; + esac +fi if [ ! -x "$bundle/hack-cli" ] || [ ! -x "$bundle/hack-native" ]; then echo "Incomplete candidate bundle: hack-cli and hack-native are required." >&2 exit 69 fi -export HACK_RUNTIME_BACKEND=native -export HACK_NATIVE_BINARY="$bundle/hack-native" +# Candidate CLIs refuse self-update on either backend. +export HACK_CANDIDATE_ENTRYPOINT=hack-v5 +if [ "$backend" = native ]; then + export HACK_RUNTIME_BACKEND=native + export HACK_NATIVE_BINARY="$bundle/hack-native" +else + unset HACK_NATIVE_BINARY HACK_NATIVE_HOME + export HACK_RUNTIME_BACKEND=compose +fi exec "$bundle/hack-cli" "$@" diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index dafdab645..f7cb0cf66 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -47,7 +47,8 @@ REPOSITORY = "hack-dance/hack" DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"} -# Reviewed flat-layout, shared-MCP, compiler, batched-signature, and verified-launcher managers. +# Reviewed flat-layout, shared-MCP, compiler, batched-signature, verified-launcher and +# storage-witness managers (the last, e2e2bcba..., shipped with 5.0.0-next.3 through next.5). # Keep this an explicit allowlist; a matching user-written receipt is not provenance. MANAGER_PREDECESSORS = frozenset({ "b459ffc4f227482119b48e357c91e4607aa4f7d1f88c7ccf5b7d684f66f2c0cd", @@ -55,7 +56,10 @@ "c7562953361b6b37141e126df31edefabc05e4441e3c6e0a38b1c3cda6b645da", "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c", "d39e77623be1876567e9db66c468e8da96067aeac0d88089df28efc11f850ef8", + "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa", }) +# Launch backends. Unset or empty selects native; anything else refuses. +BACKENDS = frozenset(("native", "compose")) MANAGER_UPGRADE = ".manager-upgrade.json" @@ -772,9 +776,24 @@ def verify_version(self, version, checksum): "Candidate home identity changed.") return directory - def environment(self, version): + def environment(self, version, backend="native"): + """Child environment for one launch. + + Native pins this version's executor and both private homes, overriding + inherited installation selectors. Compose is an explicit caller choice: + it keeps the caller's HACK_HOME and HACK_GLOBAL_CONFIG_PATH so the + existing global Caddy/CoreDNS and local CA apply, and removes native + selectors. Either way the CLI knows it is a candidate and refuses + self-update. + """ directory = self.root / "versions" / version environment = dict(os.environ) + environment["HACK_CANDIDATE_ENTRYPOINT"] = "hack-next" + if backend == "compose": + environment.pop("HACK_NATIVE_BINARY", None) + environment.pop("HACK_NATIVE_HOME", None) + environment["HACK_RUNTIME_BACKEND"] = "compose" + return environment environment.pop("HACK_GLOBAL_CONFIG_PATH", None) environment.update({"HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), @@ -891,6 +910,9 @@ def install(self, version, archive=None, checksum=None, upgrade=False): remove_created_stage(stage, created) def run(self, arguments): + backend = os.environ.get("HACK_RUNTIME_BACKEND") or "native" + require(backend in BACKENDS, + "HACK_RUNTIME_BACKEND must be native or compose (unset selects native).") version = self.state["selected"] require(version is not None, "No candidate selected. Use your ordinary stable hack command.") directory = self.verify_version(version, self.state["installed"][version]) @@ -898,8 +920,11 @@ def run(self, arguments): # Shared launcher locks permit ps/down alongside foreground up, while an # exclusive selection switch refuses any in-flight launcher. Background # runtime state is gated by the owning executor on later switches. - child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], - env=self.environment(version)) + # Compose runs the verified CLI directly: retained bundles' hack-v5 + # entrypoints always select native and would ignore the choice. + entrypoint = "bundle/hack-cli" if backend == "compose" else "bundle/hack-v5" + child = subprocess.Popen([str(directory / entrypoint), *arguments], + env=self.environment(version, backend)) handlers = {} def forward(signum, _frame): child.send_signal(signum) diff --git a/src/commands/update.ts b/src/commands/update.ts index 1c8fa1dd2..6b5ce8669 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -68,8 +68,13 @@ const handleUpdate: CommandHandlerFor = async ({ ctx, args, }): Promise => { - // The native launcher runs hack-cli; PATH fallback can select the stable install. - if (process.env.HACK_RUNTIME_BACKEND === "native") { + // Candidate launchers run hack-cli; PATH fallback can select the stable install. + // `hack-next` and `hack-v5` mark their children on both backends, because an + // explicit Compose selection no longer carries the native backend marker. + if ( + process.env.HACK_RUNTIME_BACKEND === "native" || + (process.env.HACK_CANDIDATE_ENTRYPOINT ?? "") !== "" + ) { return writeResult({ json: args.options.json === true, result: { diff --git a/tests/fixtures/prerelease-manager-storage-v5.py b/tests/fixtures/prerelease-manager-storage-v5.py new file mode 100644 index 000000000..dafdab645 --- /dev/null +++ b/tests/fixtures/prerelease-manager-storage-v5.py @@ -0,0 +1,971 @@ +#!/usr/bin/env python3 +"""Explicit, side-by-side native prerelease selection (Python 3.9+, standard library). + +Selection changes commit through one atomic receipt. Explicit manager upgrades +use a recoverable journal. Bundles and homes are retained by version; this manager +never migrates runtime state or edits shell PATH. +""" + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import signal +import stat +import subprocess +import sys +import uuid + + +VERSION = re.compile(r"5\.0\.0-next\.([1-9][0-9]*)\Z") +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +REVISION = re.compile(r"[0-9a-f]{40}\Z") +PAYLOAD = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", + "provider-pins.json", "README.md", "prerelease.json")) +LEGACY_COMPILER_PAYLOAD = frozenset(("hack-config-compiler", "hack.project.schema.json")) +COMPILER_PAYLOAD = LEGACY_COMPILER_PAYLOAD | {"hack.local.schema.json"} +# Guest-only Linux ARM64 witness for authored persistent mounts and its byte +# manifest. The CLI resolves both as siblings of hack-native; older bundles omit both. +STORAGE_TOOL = "hack-storage-root-witness" +STORAGE_TOOL_MANIFEST = "native-storage-tool.json" +STORAGE_TOOL_PAYLOAD = frozenset((STORAGE_TOOL, STORAGE_TOOL_MANIFEST)) +STORAGE_TOOL_KEYS = {"version", "kind", "platform", "architecture", "file", "sha256", "bytes", + "sourceRevision"} +EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", + "hack-config-compiler")) +BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} +MCP_FILES = {"adapter": "hack-mcp-adapter", "owner": "hack-mcp-owner", + "backend": "hack-mcp-backend"} +MCP_MEMBER = re.compile(r"mcp/([0-9a-f]{64})/(manifest\.json|hack-mcp-adapter|hack-mcp-owner|hack-mcp-backend)\Z") +MAX_ARCHIVE = 512 * 1024 * 1024 +METADATA_KEYS = {"schema", "version", "tag", "source_revision", "platform"} +REPOSITORY = "hack-dance/hack" +DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", + "objects.githubusercontent.com"} +# Reviewed flat-layout, shared-MCP, compiler, batched-signature, and verified-launcher managers. +# Keep this an explicit allowlist; a matching user-written receipt is not provenance. +MANAGER_PREDECESSORS = frozenset({ + "b459ffc4f227482119b48e357c91e4607aa4f7d1f88c7ccf5b7d684f66f2c0cd", + "b7c49e3fec6b06790e833db1d2dcb441d2223c283b792713be46826aa2eef877", + "c7562953361b6b37141e126df31edefabc05e4441e3c6e0a38b1c3cda6b645da", + "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c", + "d39e77623be1876567e9db66c468e8da96067aeac0d88089df28efc11f850ef8", +}) +MANAGER_UPGRADE = ".manager-upgrade.json" + + +class Refusal(Exception): + """Untrusted, changed or ambiguous input: preserve the current selection.""" + + +def require(condition, message): + if not condition: + raise Refusal(message) + + +def version_number(value): + match = VERSION.fullmatch(value) if isinstance(value, str) else None + require(match is not None, "Version must be 5.0.0-next.N (positive N, no leading zero).") + return int(match.group(1)) + + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def json_bytes(value): + return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode("utf-8") + + +def parse_json(raw): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON field.") + result[key] = value + return result + try: + value = json.loads(raw, object_pairs_hook=unique) + except (ValueError, UnicodeError) as error: + raise Refusal("Malformed JSON.") from error + require(isinstance(value, dict), "Expected a JSON object.") + return value + + +def metadata(raw, version): + value = parse_json(raw) + require(set(value) == METADATA_KEYS, "Unexpected prerelease metadata fields.") + require(value["schema"] == "hack.prerelease/v1" and value["version"] == version + and value["tag"] == "v" + version and value["platform"] == "darwin-arm64" + and isinstance(value["source_revision"], str) + and REVISION.fullmatch(value["source_revision"]), "Prerelease identity mismatch.") + return value + + +def canonical(path): + path = Path(path) + require(path.is_absolute() and path == path.resolve(), + "Use a canonical absolute path without symlinks or traversal.") + return path + + +def owned(path, directory=False, mode=None): + info = path.lstat() + expected = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode) + require(expected and info.st_uid == os.getuid(), "Foreign or aliased path: " + str(path)) + if not directory: + require(info.st_nlink == 1, "Hard-linked file: " + str(path)) + if mode is not None: + require(stat.S_IMODE(info.st_mode) == mode, "Unsafe mode: " + str(path)) + return info + + +def private_json(path): + owned(path, mode=0o600) + require(path.stat().st_size <= 1024 * 1024, "Oversized installation receipt.") + return parse_json(path.read_bytes()) + + +def sync_directory(path): + descriptor = os.open(str(path), os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_file(path, contents, mode=0o600): + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), mode) + target.write(contents) + target.flush() + os.fsync(target.fileno()) + + +def atomic_file(path, contents): + temporary = path.parent / (".selection-" + uuid.uuid4().hex) + try: + write_file(temporary, contents) + os.replace(str(temporary), str(path)) + sync_directory(path.parent) + finally: + if temporary.exists(): + temporary.unlink() + + +def atomic_json(path, value): + atomic_file(path, json_bytes(value)) + + +def launcher_bytes(): + return ("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd -P)\n" + "exec /usr/bin/python3 -I -S \"$root/manager.py\" --root \"$root\" run -- \"$@\"\n").encode() + + +def checksums(raw, expected): + try: + lines = raw.decode("ascii").splitlines() + except UnicodeError as error: + raise Refusal("Malformed SHA256SUMS.") from error + result = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9./-]+)", line) + require(match is not None, "Malformed SHA256SUMS entry.") + checksum, name = match.groups() + require(name in expected and name not in result, "Duplicate or foreign checksum entry.") + result[name] = checksum + require(set(result) == set(expected), "Incomplete checksum manifest.") + return result + + +def payload_inventory(names): + """Accept complete optional compiler, storage tool and MCP groups beside the original payload.""" + names = set(names) + require(BUNDLE_FILES <= names, "Incomplete candidate bundle.") + compiler = names & COMPILER_PAYLOAD + require(not compiler or compiler in (LEGACY_COMPILER_PAYLOAD, COMPILER_PAYLOAD), + "Incomplete config compiler payload.") + storage = names & STORAGE_TOOL_PAYLOAD + require(not storage or storage == STORAGE_TOOL_PAYLOAD, "Incomplete storage tool payload.") + extra = names - BUNDLE_FILES - COMPILER_PAYLOAD - STORAGE_TOOL_PAYLOAD + if extra: + matches = [MCP_MEMBER.fullmatch(name) for name in extra] + require(all(matches), "Foreign candidate MCP payload.") + identities = {match.group(1) for match in matches} + require(len(identities) == 1, "Candidate requires one MCP bundle identity.") + prefix = "mcp/" + next(iter(identities)) + "/" + require(extra == {prefix + name for name in (*MCP_FILES.values(), "manifest.json")}, + "Incomplete candidate MCP payload.") + return names - {"SHA256SUMS"} + + +def bundle_inventory(bundle): + names = {entry.name for entry in bundle.iterdir()} + if "mcp" in names: + owned(bundle / "mcp", directory=True, mode=0o700) + identities = list((bundle / "mcp").iterdir()) + require(len(identities) == 1 and SHA256.fullmatch(identities[0].name), + "Candidate requires one MCP bundle identity.") + directory = identities[0] + owned(directory, directory=True, mode=0o700) + names.remove("mcp") + names.update("mcp/" + directory.name + "/" + entry.name for entry in directory.iterdir()) + return payload_inventory(names) + + +def payload_mode(name): + if MCP_MEMBER.fullmatch(name): + return 0o400 if name.endswith("/manifest.json") else 0o500 + if name == STORAGE_TOOL: + return 0o500 + return 0o755 if name in EXECUTABLES else 0o600 + + +def verify_storage_tool(bundle, payload, verified_digests, identity): + """Bind the witness manifest to bytes hashed in this verification and to provenance. + + Runtime admission parses the same fields and refuses any mismatch; checking here + keeps an unusable pair from being selected. The ELF itself never runs on macOS. + """ + if STORAGE_TOOL not in payload: + return + path = bundle / STORAGE_TOOL_MANIFEST + require(path.stat().st_size <= 4096, "Oversized storage tool manifest.") + manifest = parse_json(path.read_bytes()) + require(set(manifest) == STORAGE_TOOL_KEYS, "Invalid storage tool manifest fields.") + require(type(manifest["version"]) is int and manifest["version"] == 1 + and manifest["kind"] == "native-storage-root-witness" + and manifest["platform"] == "linux" and manifest["architecture"] == "arm64" + and manifest["file"] == STORAGE_TOOL, "Incompatible storage tool manifest.") + size = (bundle / STORAGE_TOOL).stat().st_size + require(type(manifest["bytes"]) is int and 64 <= manifest["bytes"] <= 2 * 1024 * 1024 + and manifest["bytes"] == size + and isinstance(manifest["sha256"], str) and SHA256.fullmatch(manifest["sha256"]) + and manifest["sha256"] == verified_digests[STORAGE_TOOL], + "Storage tool fingerprint mismatch.") + require(manifest["sourceRevision"] == identity["source_revision"], + "Storage tool source revision mismatch.") + + +def verify_mcp_bundle(bundle, payload, verified_digests): + nested = sorted(name for name in payload if MCP_MEMBER.fullmatch(name)) + if not nested: + return + prefix = str(Path(nested[0]).parent) + path = bundle / prefix / "manifest.json" + require(path.stat().st_size <= 8192, "Oversized MCP manifest.") + manifest = parse_json(path.read_bytes()) + require(set(manifest) == {"schemaVersion", "startupProtocol", "wireProtocol", "platform", + "architecture", "files", "bundleId"}, "Invalid MCP manifest fields.") + require(all(type(manifest[field]) in (int, float) + for field in ("schemaVersion", "startupProtocol", "wireProtocol")) + and manifest["schemaVersion"] == 1 and manifest["startupProtocol"] == 2 + and manifest["wireProtocol"] == 1 and manifest["platform"] == "darwin" + and manifest["architecture"] == "arm64", "Incompatible MCP protocol or host.") + files = manifest["files"] + require(isinstance(files, dict) and set(files) == set(MCP_FILES), "Invalid MCP asset inventory.") + canonical_files = {} + for role, name in MCP_FILES.items(): + entry = files[role] + require(isinstance(entry, dict) and set(entry) == {"sha256", "bytes"} + and isinstance(entry["sha256"], str) and SHA256.fullmatch(entry["sha256"]) + and type(entry["bytes"]) is int and 0 < entry["bytes"] <= 256 * 1024 * 1024, + "Invalid MCP asset fingerprint.") + asset = bundle / prefix / name + # Compare the nested identity against bytes hashed in this verification, + # not just the outer manifest's claims. Never retain this across calls. + require(asset.stat().st_size == entry["bytes"] + and verified_digests[prefix + "/" + name] == entry["sha256"], + "MCP asset fingerprint mismatch.") + canonical_files[role] = {"sha256": entry["sha256"], "bytes": entry["bytes"]} + body = {"schemaVersion": 1, "startupProtocol": 2, "wireProtocol": 1, + "platform": "darwin", "architecture": "arm64", "files": canonical_files} + identity = hashlib.sha256(json.dumps(body, separators=(",", ":")).encode()).hexdigest() + require(manifest["bundleId"] == identity and Path(prefix).name == identity, + "MCP bundle identity mismatch.") + + +def verify_bundle(bundle, version): + owned(bundle, directory=True, mode=0o700) + payload = bundle_inventory(bundle) + for name in payload | {"SHA256SUMS"}: + owned(bundle / name, mode=payload_mode(name)) + require((bundle / "SHA256SUMS").stat().st_size <= 64 * 1024 + and (bundle / "prerelease.json").stat().st_size <= 64 * 1024, + "Oversized bundle metadata or checksum manifest.") + manifest = checksums((bundle / "SHA256SUMS").read_bytes(), payload) + verified_digests = {} + for name, checksum in manifest.items(): + actual = digest(bundle / name) + require(actual == checksum, "Candidate checksum mismatch: " + name) + verified_digests[name] = actual + identity = metadata((bundle / "prerelease.json").read_bytes(), version) + verify_mcp_bundle(bundle, payload, verified_digests) + verify_storage_tool(bundle, payload, verified_digests, identity) + return identity, manifest + + +def verify_signatures(bundle): + # Linux guest ELFs (hack-relay-guest, the storage witness) carry no macOS code + # signature; checksums and, for the witness, its bound manifest pin them. + names = ["hack-native", "hack-cli"] + payload = bundle_inventory(bundle) + if "hack-config-compiler" in payload: + names.append("hack-config-compiler") + names.extend(sorted(name for name in payload + if MCP_MEMBER.fullmatch(name) and not name.endswith("/manifest.json"))) + # codesign verifies every supplied path and fails if any is invalid. Keep all + # executables and strict validation in one bounded, output-redacted process. + try: + result = subprocess.run(["/usr/bin/codesign", "--verify", "--strict", + *(str(bundle / name) for name in names)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Cannot verify candidate code signature.") from error + require(result.returncode == 0, "Candidate code signature failed.") + + +def extract_archive(archive, checksum, destination, version, release_metadata=None): + import tarfile + + owned(archive) + require(SHA256.fullmatch(checksum) is not None, "Expected archive SHA-256 is required.") + require(archive.stat().st_size <= MAX_ARCHIVE and digest(archive) == checksum, + "Archive checksum or size mismatch.") + try: + with tarfile.open(str(archive), "r:gz") as source: + entries = [] + seen = set() + total = 0 + for entry in source: + require(len(entries) < len(BUNDLE_FILES) + len(COMPILER_PAYLOAD) + + len(STORAGE_TOOL_PAYLOAD) + 4, "Incomplete or duplicate archive entries.") + require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD | STORAGE_TOOL_PAYLOAD + or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen, + "Foreign, duplicate or traversing archive entry.") + require(entry.isfile() and not entry.issparse() and entry.size > 0 + and entry.size <= MAX_ARCHIVE, "Only bounded regular archive files are accepted.") + seen.add(entry.name) + total += entry.size + require(total <= MAX_ARCHIVE, "Oversized archive.") + entries.append(entry) + payload = payload_inventory(seen) + by_name = {entry.name: entry for entry in entries} + require(by_name["prerelease.json"].size <= 64 * 1024 + and by_name["SHA256SUMS"].size <= 64 * 1024, + "Oversized archive metadata or checksum manifest.") + with source.extractfile(by_name["prerelease.json"]) as incoming: + identity = metadata(incoming.read(), version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + with source.extractfile(by_name["SHA256SUMS"]) as incoming: + manifest = checksums(incoming.read(), payload) + # Preflight hashes before writing any payload. Revalidation after + # copying also catches a source changed between these two passes. + for entry in entries: + name = entry.name + if name not in payload: + continue + value = hashlib.sha256() + with source.extractfile(entry) as incoming: + for chunk in iter(lambda: incoming.read(1024 * 1024), b""): + value.update(chunk) + require(value.hexdigest() == manifest[name], "Candidate checksum mismatch: " + name) + destination.mkdir(mode=0o700) + nested = {name for name in payload if MCP_MEMBER.fullmatch(name)} + if nested: + prefix = Path(next(iter(nested))).parent + (destination / "mcp").mkdir(mode=0o700) + (destination / prefix).mkdir(mode=0o700) + for entry in entries: + incoming = source.extractfile(entry) + require(incoming is not None, "Unreadable archive payload.") + with incoming: + descriptor = os.open(str(destination / entry.name), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + payload_mode(entry.name)) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), payload_mode(entry.name)) + remaining = entry.size + while remaining: + chunk = incoming.read(min(1024 * 1024, remaining)) + require(bool(chunk), "Truncated archive payload.") + target.write(chunk) + remaining -= len(chunk) + target.flush() + os.fsync(target.fileno()) + except (tarfile.TarError, EOFError) as error: + raise Refusal("Invalid candidate archive.") from error + identity, manifest = verify_bundle(destination, version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + verify_signatures(destination) + sync_directory(destination) + return identity, manifest + + +def secure_url(url): + import urllib.parse + + parsed = urllib.parse.urlsplit(url) + require(parsed.scheme == "https" and parsed.hostname in DOWNLOAD_HOSTS + and parsed.port in (None, 443) and not parsed.username and not parsed.password + and not parsed.fragment, "Refusing nonofficial or non-HTTPS download.") + + +def official_redirect_handler(): + # Local launches do not need the TLS/network/archive import graph. Load the + # same restricted redirect handler only when an official download is needed. + import urllib.request + + class OfficialRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + secure_url(url) + return super().redirect_request(request, response, code, message, headers, url) + + return OfficialRedirect() + + +def download(url, target, limit): + """Write one complete official response, or refuse. + + A short body, a stream interrupted by the transport, or bytes past the budget + refuse. The caller owns `target` and its staging directory; this function never + publishes or selects what it wrote. + """ + import http.client + import urllib.request + + secure_url(url) + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json", + "User-Agent": "hack-prerelease-installer"}) + opener = urllib.request.build_opener(official_redirect_handler()) + with opener.open(request, timeout=30) as response: + secure_url(response.geturl()) + # http.client returns a short body as an ordinary end of stream, so the + # declared length is checked here rather than trusted to the transport. + declared = response.headers.get("Content-Length") + require(declared is None or isinstance(declared, str) and re.fullmatch(r"[0-9]{1,12}", declared), + "Malformed release download length.") + expected = None if declared is None else int(declared) + require(expected is None or expected <= limit, "Release download exceeds its size budget.") + descriptor = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as output: + size = 0 + while True: + try: + chunk = response.read(1024 * 1024) + except (http.client.HTTPException, OSError) as error: + raise Refusal("Release download was interrupted.") from error + if not chunk: + break + size += len(chunk) + require(size <= limit, "Release download exceeds its size budget.") + require(expected is None or size <= expected, + "Release download exceeds its declared length.") + output.write(chunk) + require(expected is None or size == expected, "Release download is incomplete.") + output.flush() + os.fsync(output.fileno()) + + +def official_archive(stage, version): + tag = "v" + version + api_path = stage / "release-api.json" + download("https://api.github.com/repos/" + REPOSITORY + "/releases/tags/" + tag, + api_path, 2 * 1024 * 1024) + release = parse_json(api_path.read_bytes()) + require(release.get("tag_name") == tag and release.get("prerelease") is True + and release.get("draft") is False, "Expected a published, pinned official prerelease.") + archive_name = "hack-" + version + "-darwin-arm64-native.tar.gz" + wanted = {archive_name, "SHA256SUMS", "prerelease.json"} + assets = release.get("assets") + require(isinstance(assets, list), "Missing release assets.") + selected = {} + for asset in assets: + require(isinstance(asset, dict), "Malformed release asset.") + name = asset.get("name") + require(isinstance(name, str), "Malformed release asset name.") + if name in wanted: + require(name not in selected, "Duplicate release asset.") + expected = "https://github.com/" + REPOSITORY + "/releases/download/" + tag + "/" + name + require(asset.get("browser_download_url") == expected, "Foreign release asset URL.") + selected[name] = expected + require(set(selected) == wanted, "Incomplete official prerelease assets.") + download(selected["prerelease.json"], stage / "release-metadata.json", 64 * 1024) + identity = metadata((stage / "release-metadata.json").read_bytes(), version) + download("https://api.github.com/repos/" + REPOSITORY + "/git/ref/tags/" + tag, + stage / "release-tag.json", 64 * 1024) + reference = parse_json((stage / "release-tag.json").read_bytes()) + commit = reference.get("object") + require(reference.get("ref") == "refs/tags/" + tag and isinstance(commit, dict) + and commit.get("type") == "commit" and commit.get("sha") == identity["source_revision"], + "Official prerelease tag does not match its source revision.") + download(selected["SHA256SUMS"], stage / "release-checksums", 64 * 1024) + checksum = checksums((stage / "release-checksums").read_bytes(), {archive_name})[archive_name] + archive = stage / archive_name + download(selected[archive_name], archive, MAX_ARCHIVE) + return archive, checksum, identity + + +def remove_created_stage(stage, created): + """Remove the one install stage whose identity this call recorded at creation. + + A missing, replaced or foreign path is left for inspection. Other stages, + including a manager upgrade's journaled stage, are never touched. + """ + import shutil + + try: + info = stage.lstat() + except FileNotFoundError: + return + if (stat.S_ISDIR(info.st_mode) and info.st_uid == os.getuid() + and (info.st_dev, info.st_ino) == (created.st_dev, created.st_ino)): + shutil.rmtree(str(stage)) + sync_directory(stage.parent) + + +class Channel: + """Owned private layout; the selection file records immutable receipt hashes.""" + + def __init__(self, root): + self.root = canonical(root) + self.state = None + + def initialize(self): + if not self.root.exists(): + owned(self.root.parent, directory=True) + self.root.mkdir(mode=0o700) + owned(self.root, directory=True, mode=0o700) + if (self.root / ".channel.json").exists(): + return + require(not list(self.root.iterdir()), "Refusing to adopt a nonempty installation root.") + (self.root / "bin").mkdir(mode=0o700) + (self.root / "versions").mkdir(mode=0o700) + write_file(self.root / "manager.py", Path(__file__).read_bytes()) + write_file(self.root / "bin/hack-next", launcher_bytes(), 0o755) + root_info = self.root.stat() + write_file(self.root / ".channel.json", json_bytes({ + "schema": "hack.prerelease-install/v1", "root": str(self.root), "uid": os.getuid(), + "device": root_info.st_dev, "inode": root_info.st_ino, + "manager_sha256": digest(self.root / "manager.py"), + "launcher_sha256": digest(self.root / "bin/hack-next")})) + write_file(self.root / ".selection.json", json_bytes({ + "schema": "hack.prerelease-selection/v1", "installed": {}, + "selected": None, "previous": None})) + write_file(self.root / ".manager.lock", b"") + sync_directory(self.root) + + @contextlib.contextmanager + def lock(self, shared=False, manager_upgrade=False): + owned(self.root, directory=True, mode=0o700) + owned(self.root / ".manager.lock", mode=0o600) + descriptor = os.open(str(self.root / ".manager.lock"), os.O_RDWR | os.O_NOFOLLOW) + with os.fdopen(descriptor, "rb") as handle: + try: + fcntl.flock(handle, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + except BlockingIOError as error: + raise Refusal("Another candidate manager or launcher is active.") from error + self.validate(manager_upgrade=manager_upgrade) + yield + + def validate(self, manager_upgrade=False): + root_info = owned(self.root, directory=True, mode=0o700) + pending = os.path.lexists(self.root / MANAGER_UPGRADE) + require(not pending or manager_upgrade, + "Manager upgrade is pending; rerun upgrade-manager with the same reviewed installer.") + transition = self.read_manager_upgrade() if pending else None + marker = private_json(self.root / ".channel.json") + require(set(marker) == {"schema", "root", "uid", "device", "inode", + "manager_sha256", "launcher_sha256"} + and marker["schema"] == "hack.prerelease-install/v1" + and marker["root"] == str(self.root) and marker["uid"] == os.getuid() + and marker["device"] == root_info.st_dev and marker["inode"] == root_info.st_ino, + "Foreign or malformed installation receipt.") + owned(self.root / "manager.py", mode=0o600) + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + manager_hash = digest(self.root / "manager.py") + require((manager_hash == marker["manager_sha256"] or transition is not None + and manager_hash == transition["to_sha256"] + and marker["manager_sha256"] == transition["from_sha256"]) + and digest(self.root / "bin/hack-next") == marker["launcher_sha256"], + "Installation manager or launcher changed.") + require({p.name for p in (self.root / "bin").iterdir()} == {"hack-next"}, + "Foreign launcher entry.") + state = private_json(self.root / ".selection.json") + require(set(state) == {"schema", "installed", "selected", "previous"} + and state["schema"] == "hack.prerelease-selection/v1" + and isinstance(state["installed"], dict), "Malformed selection receipt.") + installed = state["installed"] + require(len(installed) <= 256, "Too many candidate versions; inspection required.") + for version, checksum in installed.items(): + version_number(version) + require(isinstance(checksum, str) and SHA256.fullmatch(checksum), "Malformed receipt hash.") + self.verify_version(version, checksum) + for key in ("selected", "previous"): + require(state[key] is None or isinstance(state[key], str) and state[key] in installed, + "Unknown selected or previous candidate.") + require(state["selected"] is None or state["selected"] != state["previous"], + "Malformed candidate history.") + owned(self.root / "versions", directory=True, mode=0o700) + for entry in (self.root / "versions").iterdir(): + if entry.name not in installed: + version_number(entry.name) + # Rename can precede selection by a power-loss boundary. Validate + # the retained directory, but never select/adopt it automatically. + private_json(entry / ".receipt.json") + self.verify_version(entry.name, digest(entry / ".receipt.json")) + allowed = {".channel.json", ".selection.json", ".manager.lock", "manager.py", "bin", "versions"} + if transition is not None: + allowed.add(MANAGER_UPGRADE) + for entry in self.root.iterdir(): + if entry.name in allowed: + continue + if re.fullmatch(r"\.stage-[0-9a-f]{32}", entry.name): + owned(entry, directory=True, mode=0o700) + continue + if re.fullmatch(r"\.selection-[0-9a-f]{32}", entry.name): + owned(entry, mode=0o600) + continue + raise Refusal("Foreign installation entry: " + entry.name) + self.state = state + + def read_manager_upgrade(self): + """Verify both sides of the only supported two-file transition before recovery. + + The journal closes ordinary launch admission before either file changes. + Recovery may finish this exact transition, never adopt changed receipts or + select software. The staged originals remain available for inspection. + """ + plan = private_json(self.root / MANAGER_UPGRADE) + require(set(plan) == {"schema", "stage", "from_sha256", "to_sha256", + "channel_sha256", "selection_sha256"} + and plan["schema"] == "hack.prerelease-manager-upgrade/v1" + and isinstance(plan["stage"], str) + and re.fullmatch(r"\.stage-[0-9a-f]{32}", plan["stage"]), + "Malformed manager upgrade journal.") + require(all(isinstance(plan[key], str) and SHA256.fullmatch(plan[key]) + for key in ("from_sha256", "to_sha256", "channel_sha256", "selection_sha256")), + "Malformed manager upgrade digest.") + require(plan["from_sha256"] in MANAGER_PREDECESSORS + and plan["to_sha256"] == digest(Path(__file__)), + "Manager recovery requires the same reviewed installer and a known predecessor.") + stage = self.root / plan["stage"] + owned(stage, directory=True, mode=0o700) + require({p.name for p in stage.iterdir()} == { + "manager-before.py", "manager-after.py", "channel-before.json", "channel-after.json"}, + "Foreign manager upgrade staging entry.") + for entry in stage.iterdir(): + owned(entry, mode=0o600) + before = private_json(stage / "channel-before.json") + after = private_json(stage / "channel-after.json") + require(digest(stage / "manager-before.py") == plan["from_sha256"] + and digest(stage / "manager-after.py") == plan["to_sha256"] + and digest(stage / "channel-before.json") == plan["channel_sha256"] + and before.get("manager_sha256") == plan["from_sha256"] + and after == dict(before, manager_sha256=plan["to_sha256"]), + "Manager upgrade staging changed.") + owned(self.root / "manager.py", mode=0o600) + private_json(self.root / ".channel.json") + private_json(self.root / ".selection.json") + old_receipt = (stage / "channel-before.json").read_bytes() + new_receipt = (stage / "channel-after.json").read_bytes() + current_receipt = (self.root / ".channel.json").read_bytes() + current_hash = digest(self.root / "manager.py") + require((current_hash == plan["from_sha256"] and current_receipt == old_receipt) + or (current_hash == plan["to_sha256"] and current_receipt in (old_receipt, new_receipt)), + "Manager upgrade publication state changed.") + require(digest(self.root / ".selection.json") == plan["selection_sha256"], + "Selection changed during manager upgrade; inspection required.") + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + require((self.root / "bin/hack-next").read_bytes() == launcher_bytes(), + "Custom launcher cannot be upgraded.") + return plan + + def upgrade_manager(self): + """Explicitly replace a known manager, with recoverable fail-closed publication.""" + self.validate(manager_upgrade=True) + pending = os.path.lexists(self.root / MANAGER_UPGRADE) + source = canonical(Path(__file__).absolute()) + owned(source) + target = source.read_bytes() + target_hash = hashlib.sha256(target).hexdigest() + if not pending: + current_hash = digest(self.root / "manager.py") + if current_hash == target_hash: + return + require(current_hash in MANAGER_PREDECESSORS, + "Unknown or customized manager; explicit upgrade supports only reviewed predecessors.") + require((self.root / "bin/hack-next").read_bytes() == launcher_bytes(), + "Custom launcher cannot be upgraded.") + # All retained versions share this manager. Keep the existing executor + # ownership checks, including status/down/status, for each of them. + for version in self.state["installed"]: + self.require_quiescent(version, manager_upgrade=True) + self.validate(manager_upgrade=True) + if not pending: + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + before = (self.root / ".channel.json").read_bytes() + after = dict(parse_json(before), manager_sha256=target_hash) + write_file(stage / "manager-before.py", (self.root / "manager.py").read_bytes()) + write_file(stage / "manager-after.py", target) + write_file(stage / "channel-before.json", before) + write_file(stage / "channel-after.json", json_bytes(after)) + plan = {"schema": "hack.prerelease-manager-upgrade/v1", "stage": stage.name, + "from_sha256": current_hash, "to_sha256": target_hash, + "channel_sha256": hashlib.sha256(before).hexdigest(), + "selection_sha256": digest(self.root / ".selection.json")} + write_file(stage / "journal.json", json_bytes(plan)) + sync_directory(stage) + self.validate() + os.rename(str(stage / "journal.json"), str(self.root / MANAGER_UPGRADE)) + sync_directory(stage) + sync_directory(self.root) + self.validate(manager_upgrade=True) + plan = self.read_manager_upgrade() + stage = self.root / plan["stage"] + if digest(self.root / "manager.py") != target_hash: + atomic_file(self.root / "manager.py", (stage / "manager-after.py").read_bytes()) + self.validate(manager_upgrade=True) + if (self.root / ".channel.json").read_bytes() != (stage / "channel-after.json").read_bytes(): + atomic_file(self.root / ".channel.json", (stage / "channel-after.json").read_bytes()) + self.validate(manager_upgrade=True) + (self.root / MANAGER_UPGRADE).unlink() + sync_directory(self.root) + self.validate() + + def verify_version(self, version, checksum): + directory = self.root / "versions" / version + owned(directory, directory=True, mode=0o700) + require({p.name for p in directory.iterdir()} == {"bundle", "native-home", "cli-home", ".receipt.json"}, + "Incomplete or foreign version installation.") + receipt_path = directory / ".receipt.json" + receipt = private_json(receipt_path) + require(digest(receipt_path) == checksum, "Candidate installation receipt changed.") + require(set(receipt) == {"schema", "metadata", "archive_sha256", "checksums", "homes"} + and receipt["schema"] == "hack.prerelease-version/v1" + and isinstance(receipt["archive_sha256"], str) + and SHA256.fullmatch(receipt["archive_sha256"]), "Malformed version receipt.") + identity, manifest = verify_bundle(directory / "bundle", version) + require(receipt["metadata"] == identity and receipt["checksums"] == manifest, + "Candidate identity or checksum receipt changed.") + require(isinstance(receipt["homes"], dict) and set(receipt["homes"]) == {"native-home", "cli-home"}, + "Malformed candidate home receipt.") + for name in ("native-home", "cli-home"): + info = owned(directory / name, directory=True, mode=0o700) + require(receipt["homes"][name] == {"device": info.st_dev, "inode": info.st_ino}, + "Candidate home identity changed.") + return directory + + def environment(self, version): + directory = self.root / "versions" / version + environment = dict(os.environ) + environment.pop("HACK_GLOBAL_CONFIG_PATH", None) + environment.update({"HACK_RUNTIME_BACKEND": "native", + "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), + "HACK_NATIVE_HOME": str(directory / "native-home"), + "HACK_HOME": str(directory / "cli-home")}) + return environment + + def require_quiescent(self, version, manager_upgrade=False): + if version is None: + return + self.validate(manager_upgrade=manager_upgrade) + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + for action in ("status", "down", "status"): + command = [str(directory / "bundle/hack-native"), "--candidate-root", + str(directory / "native-home"), "runtime", action, "--json"] + try: + result = subprocess.run(command, env=self.environment(version), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Candidate quiescence is unavailable; selection preserved.") from error + require(result.returncode == 0 and len(result.stdout) <= 1024 * 1024, + "Candidate quiescence check failed; failure never means idle.") + status = parse_json(result.stdout) + phase = status.get("phase") + require(phase in ("uninitialized", "stopped") and status.get("process_alive") is False, + "Candidate runtime is active or ambiguous; stop it through its supported CLI first.") + if phase == "uninitialized": + require(not list((directory / "native-home").iterdir()), + "Uninitialized home contains state; inspect it through its owning CLI.") + self.validate(manager_upgrade=manager_upgrade) + + def select(self, version, installed=None): + self.require_quiescent(self.state["selected"]) + if version is not None: + self.require_quiescent(version) + # Recheck receipts at the effect boundary, then commit exactly one pointer. + self.validate() + state = dict(self.state) + if installed is not None: + state["installed"] = installed + if version != state["selected"]: + state["previous"] = state["selected"] + state["selected"] = version + atomic_json(self.root / ".selection.json", state) + self.state = state + + def install(self, version, archive=None, checksum=None, upgrade=False): + version_number(version) + current = self.state["selected"] + if version in self.state["installed"]: + require(version == current, "Version is already installed; use rollback to select it.") + if archive is not None: + archive = canonical(archive) + owned(archive) + require(archive is None or digest(archive) == checksum + == private_json(self.root / "versions" / version / ".receipt.json")["archive_sha256"], + "Installed version differs from the supplied archive.") + return + require(upgrade or current is None, "A candidate is selected; use upgrade with a newer version.") + require(current is None or version_number(version) > version_number(current), + "Upgrade must increase the prerelease version; use rollback for an installed version.") + self.require_quiescent(current) + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + created = stage.lstat() + try: + if archive is None: + archive, checksum, release_metadata = official_archive(stage, version) + else: + archive = canonical(archive) + release_metadata = None + installation = stage / "installation" + installation.mkdir(mode=0o700) + identity, manifest = extract_archive(archive, checksum, installation / "bundle", version, + release_metadata) + # The retained launcher always executes its recorded manager. Never select + # a new layout that an older manager cannot subsequently validate/rollback. + # Manager replacement is a separate explicit, recoverable operation. + require(set(manifest) <= PAYLOAD + or digest(self.root / "manager.py") == digest(Path(__file__)), + "Optional candidate payloads require this channel's retained manager to match the installer. " + "Run upgrade-manager with this reviewed installer, or use a fresh --root; " + "the existing channel and its selection are unchanged.") + homes = {} + for name in ("native-home", "cli-home"): + (installation / name).mkdir(mode=0o700) + info = (installation / name).stat() + homes[name] = {"device": info.st_dev, "inode": info.st_ino} + write_file(installation / ".receipt.json", json_bytes({ + "schema": "hack.prerelease-version/v1", "metadata": identity, + "archive_sha256": checksum, "checksums": manifest, "homes": homes})) + sync_directory(installation) + # Final refusal happens before publication. A power loss after rename retains + # an uncommitted directory for inspection, while the old selection stays valid. + self.require_quiescent(current) + self.validate() + destination = self.root / "versions" / version + require(not destination.exists(), "Refusing to overwrite a candidate version.") + os.rename(str(installation), str(destination)) + sync_directory(destination.parent) + installed = dict(self.state["installed"]) + installed[version] = digest(destination / ".receipt.json") + state = dict(self.state, installed=installed, selected=version, previous=current) + atomic_json(self.root / ".selection.json", state) + self.state = state + finally: + # Downloads, partial extraction and an unpublished installation live only + # in this call's stage. Cleanup never decides the outcome: validate() + # tolerates a stage that could not be removed, and the selection above + # is either committed or unchanged. + with contextlib.suppress(OSError): + remove_created_stage(stage, created) + + def run(self, arguments): + version = self.state["selected"] + require(version is not None, "No candidate selected. Use your ordinary stable hack command.") + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + # Shared launcher locks permit ps/down alongside foreground up, while an + # exclusive selection switch refuses any in-flight launcher. Background + # runtime state is gated by the owning executor on later switches. + child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], + env=self.environment(version)) + handlers = {} + def forward(signum, _frame): + child.send_signal(signum) + try: + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + handlers[signum] = signal.signal(signum, forward) + code = child.wait() + return code if code >= 0 else 128 - code + finally: + for signum, handler in handlers.items(): + signal.signal(signum, handler) + + +def parser(): + arguments = argparse.ArgumentParser(description=__doc__) + arguments.add_argument("--root", type=Path, default=Path.home() / ".hack-next", + help="private canonical installation root (default ~/.hack-next)") + commands = arguments.add_subparsers(dest="command", required=True) + for command in ("install", "upgrade"): + install = commands.add_parser(command) + install.add_argument("--version", required=True) + install.add_argument("--archive", type=Path, help="explicit local archive instead of GitHub") + install.add_argument("--sha256", help="required expected digest for --archive") + rollback = commands.add_parser("rollback") + rollback.add_argument("--version", help="retained version; default previous selection") + commands.add_parser("stable", help="deselect candidate; retain all bundles and homes") + commands.add_parser("status") + commands.add_parser("upgrade-manager", help="explicitly upgrade a known channel manager, or finish its interrupted upgrade") + run = commands.add_parser("run") + run.add_argument("arguments", nargs=argparse.REMAINDER) + return arguments + + +def main(argv=None): + args = parser().parse_args(argv) + os.umask(0o077) + require(platform.system() == "Darwin" and platform.machine() == "arm64", + "Native prereleases support Apple Silicon macOS only.") + channel = Channel(args.root) + if args.command in ("install", "upgrade"): + version_number(args.version) + require(bool(args.archive) == bool(args.sha256), "--archive and --sha256 must be supplied together.") + channel.initialize() + with channel.lock(shared=args.command == "run", manager_upgrade=args.command == "upgrade-manager"): + if args.command in ("install", "upgrade"): + channel.install(args.version, args.archive, args.sha256, args.command == "upgrade") + elif args.command == "upgrade-manager": + channel.upgrade_manager() + elif args.command == "rollback": + version = args.version or channel.state["previous"] + require(version is not None and version in channel.state["installed"], "No retained rollback version.") + channel.select(version) + elif args.command == "stable": + channel.select(None) + elif args.command == "run": + arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments + return channel.run(arguments) + print(json.dumps({"selected": channel.state["selected"], "previous": channel.state["previous"], + "installed": sorted(channel.state["installed"], key=version_number), + "launcher": str(channel.root / "bin/hack-next")})) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (Refusal, OSError, ValueError) as error: + print("hack-next: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/tests/native-candidate-launcher.test.ts b/tests/native-candidate-launcher.test.ts index 8bf40e18e..38a0b8b2b 100644 --- a/tests/native-candidate-launcher.test.ts +++ b/tests/native-candidate-launcher.test.ts @@ -1,55 +1,150 @@ -import { expect, test } from "bun:test"; +import { afterEach, beforeEach, expect, test } from "bun:test"; import { chmod, copyFile, mkdtemp, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; -test("candidate launcher selects its adjacent executor and preserves arguments and exit", async () => { - const root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); - try { - await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); - await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); - await chmod(join(root, "hack-native"), 0o755); - await Bun.write( - join(root, "hack-cli"), - '#!/bin/sh\nprintf "%s\\n" "$HACK_RUNTIME_BACKEND" "$HACK_NATIVE_BINARY" "$HACK_NATIVE_HOME" "$@"\nexit 7\n' - ); - await chmod(join(root, "hack-cli"), 0o755); - const env = { - ...process.env, +/** + * The bundled `hack-v5` entrypoint runs against a stub `hack-cli` that only + * prints the selection it received, so no candidate software, Docker engine or + * real HOME is reached. + */ +const PRINTED = [ + "HACK_RUNTIME_BACKEND", + "HACK_NATIVE_BINARY", + "HACK_NATIVE_HOME", + "HACK_HOME", + "HACK_CANDIDATE_ENTRYPOINT", +] as const; +const STUB_CLI = `#!/bin/sh\n${PRINTED.map( + (name) => `printf "%s\\n" "\${${name}-}"` +).join("\n")}\nprintf "%s\\n" "$@"\nexit 7\n`; + +let root: string; + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "hack candidate "))); + await copyFile("scripts/hack-v5.sh", join(root, "hack-v5")); + await Bun.write(join(root, "hack-native"), "#!/bin/sh\nexit 0\n"); + await chmod(join(root, "hack-native"), 0o755); + await Bun.write(join(root, "hack-cli"), STUB_CLI); + await chmod(join(root, "hack-cli"), 0o755); +}); + +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +function launcherEnv(env: Readonly>) { + return privateCliEnv({ + root, + env: { HACK_NATIVE_HOME: join(root, "private home"), - HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_BINARY: "/wrong/executor", - }; - const child = Bun.spawn( - ["sh", join(root, "hack-v5"), "exec", "a b", "--", "$literal"], - { - env, - stdout: "pipe", - stderr: "pipe", - } - ); - expect(await new Response(child.stdout).text()).toBe( - `native\n${root}/hack-native\n${root}/private home\nexec\na b\n--\n$literal\n` + HACK_HOME: join(root, "caller hack home"), + ...env, + }, + }); +} + +async function launch( + env: Readonly>, + args: readonly string[] = ["exec", "a b", "--", "$literal"] +) { + const child = Bun.spawn(["sh", join(root, "hack-v5"), ...args], { + env: launcherEnv(env), + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: await new Response(child.stdout).text(), + stderr: await new Response(child.stderr).text(), + exitCode: await child.exited, + }; +} + +test.each([ + ["unset", undefined], + ["empty", ""], + ["native", "native"], +])("candidate launcher selects native and its adjacent executor when the backend is %s", async (_label, backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }); + expect(result.stdout).toBe( + [ + "native", + `${root}/hack-native`, + `${root}/private home`, + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); +}); + +test("explicit Compose selection is respected with the caller's Hack home and no native selectors", async () => { + const result = await launch({ HACK_RUNTIME_BACKEND: "compose" }); + expect(result.stdout).toBe( + [ + "compose", + "", + "", + `${root}/caller hack home`, + "hack-v5", + "exec", + "a b", + "--", + "$literal", + "", + ].join("\n") + ); + expect(result.exitCode).toBe(7); + // Compose needs no native home, so an absent or relative one does not refuse. + for (const home of [undefined, "relative"]) { + const noHome = await launch( + { HACK_RUNTIME_BACKEND: "compose", HACK_NATIVE_HOME: home }, + ["ps"] ); - expect(await child.exited).toBe(7); - for (const home of ["", "relative"]) { - const refused = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env: { ...env, HACK_NATIVE_HOME: home }, - stdout: "pipe", - stderr: "pipe", - }); - expect(await refused.exited).toBe(64); - expect(await new Response(refused.stdout).text()).toBe(""); - } - await rm(join(root, "hack-native")); - const incomplete = Bun.spawn(["sh", join(root, "hack-v5"), "up"], { - env, - stdout: "pipe", - stderr: "pipe", - }); - expect(await incomplete.exited).toBe(69); - expect(await new Response(incomplete.stdout).text()).toBe(""); - } finally { - await rm(root, { recursive: true, force: true }); + expect(noHome.exitCode).toBe(7); + expect(noHome.stdout.split("\n").slice(0, 3)).toEqual([ + "compose", + "", + "", + ]); } }); + +test.each([ + "docker", + "Native", + "native ", +])("unknown backend %p refuses before running the CLI", async (backend) => { + const result = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(result.exitCode).toBe(64); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "HACK_RUNTIME_BACKEND must be native or compose" + ); +}); + +test("native selection refuses without an absolute private home", async () => { + for (const home of [undefined, "", "relative"]) { + const refused = await launch({ HACK_NATIVE_HOME: home }, ["up"]); + expect(refused.exitCode).toBe(64); + expect(refused.stdout).toBe(""); + } +}); + +test.each([ + undefined, + "compose", +])("incomplete bundle refuses on backend %p", async (backend) => { + await rm(join(root, "hack-native")); + const incomplete = await launch({ HACK_RUNTIME_BACKEND: backend }, ["up"]); + expect(incomplete.exitCode).toBe(69); + expect(incomplete.stdout).toBe(""); +}); diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 8d41a0c1f..f767cee17 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -135,11 +135,13 @@ def setUp(self): self.run_patch = mock.patch.object(installer.subprocess, "run", side_effect=self.process) self.run_patch.start() self.addCleanup(self.run_patch.stop) + # A manually exported native selection naming a foreign executor and home: + # the launcher must still pin its own. Backend selection has its own tests. self.env_patch = mock.patch.dict(os.environ, { "HOME": str(self.stable_home), "HACK_HOME": str(self.stable_home / ".hack"), "HACK_GLOBAL_CONFIG_PATH": str(self.stable_home / ".hack/config.json"), "HACK_NATIVE_HOME": str(self.stable_home / "foreign-native"), - "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "docker", + "HACK_NATIVE_BINARY": str(self.brew / "hack"), "HACK_RUNTIME_BACKEND": "native", "HACK_NATIVE_ADAPTATION": "/private/project/adaptation.json", "HACK_NATIVE_DEPENDENCIES": "/private/project/dependencies.json", "HACK_NATIVE_AWS_PROFILE": "fixture-qa", @@ -1334,6 +1336,8 @@ def test_manager_and_selected_receipt_changes_refuse_status(self): def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launchers(self): self.install() + # No backend selection: the channel default is native with private homes. + os.environ.pop("HACK_RUNTIME_BACKEND") child = mock.Mock() child.wait.return_value = 23 with self.channel.lock(shared=True): @@ -1347,6 +1351,7 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche self.assertEqual(environment["HACK_NATIVE_HOME"], str(self.channel.root / "versions/5.0.0-next.1/native-home")) self.assertEqual(environment["HACK_NATIVE_BINARY"], str(self.channel.root / "versions/5.0.0-next.1/bundle/hack-native")) self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_DEPENDENCIES", "HACK_NATIVE_AWS_PROFILE", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): self.assertEqual(environment[name], os.environ[name]) @@ -1358,6 +1363,96 @@ def test_launcher_pins_private_homes_arguments_exit_code_and_allows_peer_launche with self.channel.lock(shared=True), mock.patch.object(installer.subprocess, "Popen", return_value=child): self.assertEqual(self.channel.run(["ps"]), 143) + def launch(self, channel=None): + """Run the selected candidate through `channel` with a stand-in child process.""" + channel = channel or self.channel + child = mock.Mock() + child.wait.return_value = 0 + # Every manager imports the same standard-library subprocess module. + with channel.lock(shared=True), \ + mock.patch.object(installer.subprocess, "Popen", return_value=child) as spawn: + self.assertEqual(channel.run(["ps", "--path", "a path"]), 0) + self.assertEqual(spawn.call_args.args[0][1:], ["ps", "--path", "a path"]) + return Path(spawn.call_args.args[0][0]), spawn.call_args.kwargs["env"] + + def test_launcher_respects_explicit_backend_and_keeps_native_default(self): + self.install() + version = self.channel.root / "versions/5.0.0-next.1" + for selection in (None, "", "native"): + with self.subTest(backend=selection), mock.patch.dict(os.environ): + if selection is None: + os.environ.pop("HACK_RUNTIME_BACKEND") + else: + os.environ["HACK_RUNTIME_BACKEND"] = selection + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_NATIVE_BINARY"], str(version / "bundle/hack-native")) + self.assertEqual(environment["HACK_NATIVE_HOME"], str(version / "native-home")) + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertNotIn("HACK_GLOBAL_CONFIG_PATH", environment) + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + command, environment = self.launch() + # Compose runs the same verified CLI with the caller's own Hack home, so the + # existing global Caddy/CoreDNS and local CA apply; native selectors are removed. + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_CANDIDATE_ENTRYPOINT"], "hack-next") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + self.assertEqual(environment["HACK_GLOBAL_CONFIG_PATH"], str(self.stable_home / ".hack/config.json")) + self.assertNotIn("HACK_NATIVE_BINARY", environment) + self.assertNotIn("HACK_NATIVE_HOME", environment) + for name in ("HACK_NATIVE_ADAPTATION", "HACK_NATIVE_HTTPS_PORT", "HACK_NATIVE_SHARED_SOURCE"): + self.assertEqual(environment[name], os.environ[name]) + # Unknown selections refuse before verification or spawn; inherited `docker` is + # not silently replaced by either backend. + for selection in ("docker", "Native", "native "): + with self.subTest(backend=selection), \ + mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": selection}), \ + mock.patch.object(installer.subprocess, "Popen") as spawn, \ + mock.patch.object(installer, "verify_signatures") as signatures: + with self.channel.lock(shared=True): + with self.assertRaisesRegex(installer.Refusal, "must be native or compose"): + self.channel.run(["up"]) + spawn.assert_not_called() + signatures.assert_not_called() + + def test_released_next5_manager_needs_upgrade_manager_for_compose_selection(self): + source = SOURCE.parent.parent / "tests/fixtures/prerelease-manager-storage-v5.py" + predecessor = "e2e2bcbacf58b21d1086047c94727e5e814776384439700adcf5aabe5c49c2fa" + self.assertEqual(installer.digest(source), predecessor) + self.assertIn(predecessor, installer.MANAGER_PREDECESSORS) + spec = importlib.util.spec_from_file_location("storage_v5_predecessor", source) + previous = importlib.util.module_from_spec(spec) + spec.loader.exec_module(previous) + self.channel = previous.Channel(self.root / "storage-v5") + self.channel.initialize() + archive, checksum = self.archive("5.0.0-next.1", with_current_payload) + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + version = self.channel.root / "versions/5.0.0-next.1" + for home in ("native-home", "cli-home"): + (version / home / "marker").write_text(home) + before = self.retained_snapshot() + with mock.patch.dict(os.environ, {"HACK_RUNTIME_BACKEND": "compose"}): + # The released manager always selects native and its private homes. + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "native") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.channel = self.upgrade_manager() + self.assertEqual((self.channel.root / "manager.py").read_bytes(), SOURCE.read_bytes()) + self.assertEqual(self.retained_snapshot(), before) + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-cli") + self.assertEqual(environment["HACK_RUNTIME_BACKEND"], "compose") + self.assertEqual(environment["HACK_HOME"], str(self.stable_home / ".hack")) + os.environ.pop("HACK_RUNTIME_BACKEND") + command, environment = self.launch() + self.assertEqual(command, version / "bundle/hack-v5") + self.assertEqual(environment["HACK_HOME"], str(version / "cli-home")) + self.assertEqual(self.retained_snapshot(), before) + def test_host_and_local_archive_cli_contract(self): for system, machine in (("Linux", "aarch64"), ("Darwin", "x86_64")): with mock.patch.object(installer.platform, "system", return_value=system), \ diff --git a/tests/update-command.test.ts b/tests/update-command.test.ts index cf684e170..56f65decd 100644 --- a/tests/update-command.test.ts +++ b/tests/update-command.test.ts @@ -20,6 +20,7 @@ const ENV_KEYS = [ "HACK_NATIVE_HOME", "HACK_HOME", "HACK_ASSETS_DIR", + "HACK_CANDIDATE_ENTRYPOINT", ] as const; const STABLE_BYTES = "unrelated standalone stable Hack sentinel\n"; const refuseNetwork: typeof globalThis.fetch = Object.assign( @@ -50,6 +51,7 @@ beforeEach(async () => { process.env.HACK_RUNTIME_BACKEND = "native"; process.env.HACK_NATIVE_BINARY = join(root, "candidate", "hack-native"); process.env.HACK_NATIVE_HOME = join(root, "candidate-home"); + Reflect.deleteProperty(process.env, "HACK_CANDIDATE_ENTRYPOINT"); }); afterEach(async () => { @@ -146,6 +148,39 @@ test("native update --check --tag also refuses with a human diagnostic", async ( expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); }); +test.each([ + ["hack-next", "compose"], + ["hack-v5", "compose"], + ["hack-next", undefined], +])("candidate launcher %s on backend %p refuses before lookup or fetch", async (entrypoint, backend) => { + // An explicit Compose selection through a candidate launcher drops the native + // marker; the launcher's own marker must still keep update off the stable install. + process.env.HACK_CANDIDATE_ENTRYPOINT = entrypoint; + Reflect.deleteProperty(process.env, "HACK_NATIVE_BINARY"); + Reflect.deleteProperty(process.env, "HACK_NATIVE_HOME"); + if (backend === undefined) { + Reflect.deleteProperty(process.env, "HACK_RUNTIME_BACKEND"); + } else { + process.env.HACK_RUNTIME_BACKEND = backend; + } + const before = await stableIdentity(); + const fetch = spyOn(globalThis, "fetch").mockImplementation(refuseNetwork); + const lookup = spyOn(Bun, "which").mockImplementation(() => { + throw new Error("unexpected stable binary lookup"); + }); + const result = await capturedRun(["update", "--yes", "--json"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toEqual({ + ok: false, + error: + "Refusing to self-update the opt-in native candidate. Install a separately reviewed candidate bundle to update it.", + }); + expect(fetch).not.toHaveBeenCalled(); + expect(lookup).not.toHaveBeenCalled(); + expect(await readFile(stable, "utf8")).toBe(STABLE_BYTES); + expect(await stableIdentity()).toEqual(before); +}); + test.each([ "compose", undefined,