From 0ac2b814692100715ccbc8f20afe9077c3c83b8b Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:18:23 +0000 Subject: [PATCH] fix(upgrade): qualify v4 migration and recover interrupted installs --- docs/guides/prereleases.md | 30 +- docs/guides/upgrading-from-v4.md | 81 +++ docs/reference/update-publication.mmd | 34 + docs/reference/update-publication.svg | 66 ++ docs/reference/v5-model-coverage.md | 2 +- scripts/build-release.ts | 56 +- scripts/install-prerelease.ts | 40 +- scripts/installer/bootstrap.ts | 4 +- scripts/installer/channel.ts | 124 +++- scripts/installer/download.ts | 141 +---- scripts/test-shard-plan.ts | 4 + src/commands/update.ts | 16 +- src/lib/release-install.ts | 274 ++++++++ src/lib/release-integrity.ts | 75 +++ src/lib/release-staging.ts | 102 +++ .../lib/release-transfer-budget.ts | 0 src/lib/release-transfer.ts | 280 +++++++++ src/lib/release-version.ts | 17 + src/lib/self-update.ts | 306 +++------ tests/fixtures/update-publication-child.ts | 27 + tests/fixtures/update-recovery-cli.ts | 25 + tests/fixtures/v4-upgrade-cli.ts | 54 ++ tests/helpers/v4-upgrade-fixture.ts | 414 ++++++++++++ tests/python-free-download.test.ts | 15 +- tests/python-free-installer.test.ts | 120 +++- tests/upgrade-installer.test.ts | 589 ++++++++++++++++++ tests/v4-upgrade.test.ts | 281 +++++++++ 27 files changed, 2795 insertions(+), 382 deletions(-) create mode 100644 docs/guides/upgrading-from-v4.md create mode 100644 docs/reference/update-publication.mmd create mode 100644 docs/reference/update-publication.svg create mode 100644 src/lib/release-install.ts create mode 100644 src/lib/release-integrity.ts create mode 100644 src/lib/release-staging.ts rename scripts/installer/transfer-budget.ts => src/lib/release-transfer-budget.ts (100%) create mode 100644 src/lib/release-transfer.ts create mode 100644 src/lib/release-version.ts create mode 100644 tests/fixtures/update-publication-child.ts create mode 100644 tests/fixtures/update-recovery-cli.ts create mode 100644 tests/fixtures/v4-upgrade-cli.ts create mode 100644 tests/helpers/v4-upgrade-fixture.ts create mode 100644 tests/upgrade-installer.test.ts create mode 100644 tests/v4-upgrade.test.ts diff --git a/docs/guides/prereleases.md b/docs/guides/prereleases.md index 7430a11ee..8d2eba59f 100644 --- a/docs/guides/prereleases.md +++ b/docs/guides/prereleases.md @@ -221,10 +221,14 @@ later bundle that adds a file therefore needs the same `upgrade-manager` step first. Upgrading with that newer installer against such a channel also refuses until you run it, and leaves the channel unchanged. -A truncated, interrupted or oversized official download is a refused installation. -The installer removes only the staging directory that attempt created. It leaves -the current selection, retained bundles and homes unchanged. Retry the same version -after the network problem is resolved. +Compiled-manager downloads share one total deadline and an inactivity limit. Connection +interruptions and HTTP 502/503/504 receive up to three fresh attempts with bounded +backoff. Checksums, signatures, URL/size refusals and uncertain cancellation never +retry. There is no persistent byte-range resume: a rerun starts from byte zero. +A refused installation preserves the selection, retained bundles and homes. +The installer removes its stage; a later install under the exclusive manager lock +also reclaims abandoned stages with the expected private ownership and name. +Unsafe entries refuse before reclamation. An upgrade retains the previous bundle and home. Selection changes require the owning candidate CLI to confirm quiescence. A running runtime, timeout, malformed @@ -238,6 +242,24 @@ or application data. Returning to stable selects the existing stable executable and its original Docker state. Preserve candidate data for a later retry rather than trying to make a v4 executable read v5 receipts. +### Uninstall without deleting candidate data + +`hack-next --manager uninstall` previews removal without changing files or stopping +applications. `hack-next --manager uninstall --apply` requires every retained +version to confirm quiescence. It moves the complete channel, including its homes, +to a private sibling and prints a rollback manifest. Stable Hack, its home and your +shell PATH are unchanged. No candidate data is deleted. + +To undo it, run the retained `manager` executable with the original `--root` and +`uninstall --rollback-manifest /path/to/manifest`. Rollback checks the retained +channel identity and refuses to replace an occupied original path. Keep the +manifest and retained directory together until you choose to dispose of them. + +For stable CLI installations, `bun run uninstall:local` from a checkout removes +only `$HOME/.hack/bin/hack`; it can be repeated and retains user data. See +[upgrading from v4](upgrading-from-v4.md) for the stable updater's integrity and +backup boundaries. + Before accepting a candidate for a project, verify normal start, status, logs, exec, run, restart and retaining shutdown, then exercise its browser sign-in, data-backed pages and assets. Record the bundle version and source commit with diff --git a/docs/guides/upgrading-from-v4.md b/docs/guides/upgrading-from-v4.md new file mode 100644 index 000000000..6ae02b88d --- /dev/null +++ b/docs/guides/upgrading-from-v4.md @@ -0,0 +1,81 @@ +# Upgrading a v4 installation + +Keep your existing home, registry, project checkout and Git worktrees. Install the +new CLI, then run the diagnostic and explicit env migration for each checkout: + +```sh +hack doctor +hack doctor --migrate-env-config +``` + +Migration refuses to change tracked env files without explicit consent. Review +those files and rerun with `--accept-tracked-env-changes` when you want to proceed. +Machine-local values move into local YAML; secrets remain encrypted. Keep project +keys: the migrated values still need them. In particular, a legacy encrypted +store may reuse `.hack.secret.key`; that key remains a live input after migration. +See [env migration](../env.md#legacy-repos-and-migration) for scope and recovery details. + +Verify your normal env-aware command after migration. A linked worktree has its +own declared env inputs; check it separately. Running migration again preserves +an already migrated project. + +If Doctor reports a global encrypted store whose key is lost, recover the key +from your own backup if any project still needs its values. `hack secrets +retire-store` refuses healthy or still-needed stores. It previews the decision +and requires explicit confirmation to move an unused unreadable store aside. +An unchecked registry entry needs the additional `--allow-unchecked` consent. +Retirement retains ciphertext; it does not recover missing values. See the +[encrypted store guidance](../env.md) before confirming. + +## Download and installation recovery + +`hack update --check` is read-only. `hack update --yes` downloads the selected +stable release into private scratch before changing the installation. Downloads +have size, inactivity and total limits. Connection failures and HTTP 502/503/504 +receive up to three fresh attempts within the same deadline. Rerunning starts +from byte zero; partial bytes are never trusted or adopted. + +The stable updater checks the GitHub asset SHA-256 when supplied, every file in +the archive's `SHA256SUMS` and the archive layout. It checks the version on the +executable staged beside the installed CLI, before publication, so the download +directory can be on a `noexec` mount. These checks detect corruption and mismatched payloads. Publisher +identity continues to depend on the official GitHub HTTPS origin; an inner +checksum is not a cryptographic publisher signature. + +The updater stages the complete asset tree, preserving existing extra assets, +and publishes the matching compiler before replacing the CLI last. A caught +publication failure restores previous files. A successful update prints a private +backup manifest with paths and hashes, alongside the previous CLI, compiler and +assets. Keep that backup until you have verified the new installation. A hard +kill or filesystem failure during the multi-directory swap may require restoring +those retained files; this is not a power-loss-atomic transaction. + +The printed manifest lives at +`/.hack-update-backup-XXXXXX/manifest.json`. Completed backups are +retained until you choose to remove them: after checking the new installation, +you can delete that whole backup directory to reclaim its disk space. They are +not automatically pruned. Abandoned executable stages with the updater's matching +private ownership receipt are reclaimed under its lock on the next update; +unrecognised directories are retained. + +Offline download failure leaves the installation unchanged. Installers in older +v4 executables retain their old behavior: these updater improvements apply once +you run a build containing them. New release archives retain the v4 expected +`hack-VERSION-release/{hack,assets,binaries}` layout. + +For standalone native prereleases, use the +[prerelease recovery and uninstall contract](prereleases.md). Native is optional; +Compose users use their existing stable installation and project state. + +## Regression coverage + +`tests/v4-upgrade.test.ts` compiles the current CLI with a test-only network fence +and uses historical bytes, synthetic values, private HOME, real temporary Git +repositories and a linked worktree. It checks Doctor's read-only path, tracked +consent, resolution before/after, every changed file, live keys, retirement, +repeat migration and value-free command output/journals. The generic fixture +also exposes command and snapshot helpers for future assistant flows. + +The upgrade and installer files run in both normal Linux and macOS shard pools. +Installer tests use loopback servers and injected faults; they do not qualify +published downloads or native runtime applications. diff --git a/docs/reference/update-publication.mmd b/docs/reference/update-publication.mmd new file mode 100644 index 000000000..ed9a1706f --- /dev/null +++ b/docs/reference/update-publication.mmd @@ -0,0 +1,34 @@ +--- +title: CLI update · verification and recovery +--- +stateDiagram-v2 + direction TB + [*] --> Current + Current --> Downloading : confirmed update + Downloading --> Verified : layout and hashes pass + Downloading --> Refused : transfer or integrity refusal + Verified --> AssetsPublished : staged version passes + AssetsPublished --> CompilerPublished : matching compiler + CompilerPublished --> Installed : CLI rename last + Verified --> Restored : caught failure + AssetsPublished --> Restored : caught failure + CompilerPublished --> Restored : caught failure + Installed --> Restored : caught sync failure + AssetsPublished --> Uncertain : hard kill + CompilerPublished --> Uncertain : hard kill + Restored --> Current : old files restored + Refused --> Current : install unchanged + Uncertain --> Current : retained backup recovery + note right of Downloading + Fresh retries share one deadline. + Uncertain cancellation refuses retry. + end note + note right of Installed + Previous files and value-free manifest retained. + No power-loss atomicity across directories. + end note + class Current idle + class Downloading,AssetsPublished,CompilerPublished live + class Verified,Installed,Restored ok + class Refused fail + class Uncertain wait diff --git a/docs/reference/update-publication.svg b/docs/reference/update-publication.svg new file mode 100644 index 000000000..7eea9e9cf --- /dev/null +++ b/docs/reference/update-publication.svg @@ -0,0 +1,66 @@ +CLI update · verification and recoveryconfirmed updatelayout and hashes passtransfer or integrity refusalstaged version passesmatching compilerCLI rename lastcaught failurecaught failurecaught failurecaught sync failurehard killhard killold files restoredinstall unchangedretained backup recoveryCurrentVerifiedRefusedAssetsPublishedCompilerPublishedRestoredUncertainDownloadingInstalledFresh retries share one deadline.Uncertain cancellation refuses retry.Previous files and value-free manifest retained.No power-loss atomicity across directories. \ No newline at end of file diff --git a/docs/reference/v5-model-coverage.md b/docs/reference/v5-model-coverage.md index aeaa28ce1..390bba575 100644 --- a/docs/reference/v5-model-coverage.md +++ b/docs/reference/v5-model-coverage.md @@ -59,7 +59,7 @@ must be attached to the eventual exact integration revision. | One pool, many project roots: share-set changes, boot mounts, per-project share selection, removal of a referenced share | multi-root-share | `provider::share_update` (journaled stopped-pool add/remove, record before owner), `project_share::{check_request, check_boot, validate_set}`, `OwnedGuest::project_share_for`; `share_update` and `project_share` unit tests | Real provider boot with an updated mount record, virtio-fs device limits and live multi-project graphs on a host VM. Cross-project cleanup is abstracted to one run's own resources. | | Native image builds: unleased build versus engine stop, crash mid-build, storage recreation, foreign or forged images, reclamation versus retained graph references | native-build-retention | `provider::image_build` (shared build fence, per-key lock, incarnation/boot/engine recheck and label check at adoption, dead-attempt recording) and `image_build::reclaim` (owned labels, current receipts, retained graph state scan, engine non-force removal); `image_build` regressions | One key and namespace; a build client orphaned by a killed `hack-native` is not modeled (the client inherits the build fence and per-key lock, so both stay held until it exits: `image_build::tests::a_killed_coordinator_leaves_its_client_fenced`); real BuildKit, engine removal semantics and guest storage are host evidence. | | MCP cold start, cancellation and duplicate launches | mcp-startup, mcp-startup-cancellation | Shared MCP bundle identity, startup deduplication and cancellation regressions | Fresh supported client sessions, full workflow coverage and measured overhead (WU16/WU20/WU28). | -| Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/install-prerelease.py`, prerelease-install/Python installer tests, candidate-install contract | Add a bounded selection/lock/crash/compatibility model before primary-driver promotion; real official-artifact upgrade/rollback still required. | +| Software install/upgrade/rollback, active launcher versus selection, compatibility | **Coverage gap:** no maintained model here is an installer/selection refinement | `scripts/installer/channel.ts`, `src/lib/release-install.ts`, `tests/v4-upgrade.test.ts`, `tests/upgrade-installer.test.ts`, Python-free installer/download tests, candidate-install contract | Historical-byte compiled-CLI cases cover key-present/lost v4 homes, real Git/worktrees, consent, migration, resolution, retirement and sentinel-free output. Four injected publication faults cover stable caught-error rollback; transfer fixtures cover retry, deadlines and uncertain cancellation. No power-loss atomicity, hostile external writer, published artifact or real native runtime proof. Add a bounded selection/lock/crash/compatibility model before primary-driver promotion. | | Primary-driver promotion and rollback to stable | **Coverage gap:** staged readiness policy is not an implemented state machine proof | Reviewed release manifest, per-project opt-in, explicit stable/candidate ownership records | No active-project switch until exact candidate/artifact and rollback/data compatibility are qualified. | | Post-Compose bridge attachment: endpoint placeholder persists or resolves to owned/foreign ID (HACK-2141) | **Coverage gap:** no maintained model of external Docker endpoint attachment timing | `waitForNativeComposeNetworkMembers` retries only owner-issued member-ID refusal, re-acquiring complete proof; `native-compose-ownership.test.ts` covers the three-ID/one-placeholder listing, resolution, persistence, foreign member, cancellation, deadline and nonretryable errors. `native-compose-endpoint-settlement-command.test.ts` drives source CLI `up` through readiness and the prepared guard independently, with a placeholder that resolves on the next query; strict/saved policy is unchanged | Two-second monotonic retry window plus one bounded probe; Docker read consistency and eventual attachment remain external assumptions. `native-config-process-policy` is the live isolated regression; repeated CI runs remain separate evidence. Other effect-adjacent reads still fail closed immediately: the routed-run guard, strict `afterReadiness` re-read, one-off run pre/post-removal checks, saved exec guards and other saved non-observe commands, saved stop/down reads, and file-owner pre/post-effect/readiness checks in `native-compose-file-command.ts`. A detaching-endpoint placeholder was not observed in this investigation; those paths remain known gaps. | | Compose detached startup: short job exits before observation; old completed container replaced between samples | **Coverage gap:** no maintained model of external Docker observation consistency | `waitForComposeStartupState` corroborates identities and classifies only the latest snapshot within the remaining start budget. `compose-startup-state.test.ts` covers sub-second interleavings, replacement, failure and deadline; `project-up-startup-state.test.ts` replays the Compose/Docker boundary. `compose-short-oneshot` checks real repeated replacement and sub-second exit zero | Assumes Docker listings eventually describe current containers; two matching identity listings cannot prevent replacement after the final read. Unit bounds are one job, two identities and a 1 s virtual budget. Real-project acceptance and Docker CI remain separate gates. | diff --git a/scripts/build-release.ts b/scripts/build-release.ts index 63b95b680..e5720b206 100644 --- a/scripts/build-release.ts +++ b/scripts/build-release.ts @@ -176,18 +176,11 @@ async function main({ args }: { readonly args: BuildArgs }): Promise { await Bun.write(slimInstallScriptPath, renderCodexSlimInstallScript()); await chmodExecutable({ path: slimInstallScriptPath }); - const checksumPath = resolve(releaseDir, "SHA256SUMS"); - await Bun.write(checksumPath, await renderChecksums({ root: releaseDir })); - - const tarballName = `hack-${version}-${target.platform}-${target.arch}.tar.gz`; - const tarballPath = resolve(releaseRoot, tarballName); - const tarExit = await run({ - cmd: ["tar", "-czf", tarballPath, "-C", releaseRoot, releaseDirName], - cwd: repoRoot, + const tarballPath = await writeReleaseArchive({ + releaseRoot, + version, + target, }); - if (tarExit !== 0) { - return tarExit; - } const downloadScriptName = `hack-${version}-install.sh`; const downloadScriptPath = resolve(releaseRoot, downloadScriptName); @@ -715,6 +708,44 @@ function resolveTarget(): ReleaseTarget | null { return { platform, arch }; } +/** Preserve the archive contract used by installed v4 updaters. Tests exercise + * this exact producer with synthetic executable/assets bytes. */ +export async function writeReleaseArchive(opts: { + readonly releaseRoot: string; + readonly version: string; + readonly target: ReleaseTarget; +}): Promise { + const directoryName = `hack-${opts.version}-release`; + const directory = resolve(opts.releaseRoot, directoryName); + await Bun.write( + resolve(directory, "SHA256SUMS"), + await renderChecksums({ root: directory }) + ); + const archive = resolve( + opts.releaseRoot, + `hack-${opts.version}-${opts.target.platform}-${opts.target.arch}.tar.gz` + ); + const child = Bun.spawn( + ["tar", "-czf", archive, "-C", opts.releaseRoot, directoryName], + { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + } + ); + const [code] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (code !== 0) { + throw new Error("Unable to create checksummed release archive."); + } + return archive; +} + async function renderChecksums({ root, }: { @@ -725,6 +756,9 @@ async function renderChecksums({ for (const file of files) { const rel = relative(root, file); + if (rel === "SHA256SUMS") { + continue; + } const data = await Bun.file(file).arrayBuffer(); const hasher = new Bun.CryptoHasher("sha256"); hasher.update(new Uint8Array(data)); diff --git a/scripts/install-prerelease.ts b/scripts/install-prerelease.ts index 043c02804..b23e5b2ad 100644 --- a/scripts/install-prerelease.ts +++ b/scripts/install-prerelease.ts @@ -1,7 +1,7 @@ #!/usr/bin/env bun import { homedir } from "node:os"; import { join } from "node:path"; -import { Channel } from "./installer/channel.ts"; +import { Channel, rollbackUninstall } from "./installer/channel.ts"; import { candidateVersion, compareCandidateVersions, @@ -20,7 +20,7 @@ export async function main(inputArgv: string[]) { } if (argv.length === 1 && ["--help", "-h"].includes(argv[0] ?? "")) { console.log( - "hack-next --manager install|upgrade --version 5.MINOR.PATCH-next.N [--archive /path --sha256 digest]\nhack-next --manager status|stable|rollback [--version VERSION]|upgrade-manager\nStandalone manager: [--root /canonical/path] ; hack-next runs the selected CLI." + "hack-next --manager install|upgrade --version 5.MINOR.PATCH-next.N [--archive /path --sha256 digest]\nhack-next --manager status|stable|rollback [--version VERSION]|upgrade-manager\nhack-next --manager uninstall [--apply | --rollback-manifest /path]\nStandalone manager: [--root /canonical/path] ; hack-next runs the selected CLI." ); return 0; } @@ -28,6 +28,12 @@ export async function main(inputArgv: string[]) { process.platform === "darwin" && process.arch === "arm64", "Native prereleases support Apple Silicon macOS only." ); + process.umask(0o077); + // Also applies to uninstall rollback, whose original root no longer exists. + requireThat( + import.meta.path.startsWith("/$bunfs/"), + "Build the standalone manager before installing; source is not an installable artifact." + ); const command = argv[0]; requireThat( command && @@ -39,12 +45,30 @@ export async function main(inputArgv: string[]) { "status", "upgrade-manager", "run", + "uninstall", ].includes(command), "Expected install, upgrade, rollback, stable, status, upgrade-manager or run. Use --help." ); const options: Record = Object.create(null); const rest = argv.slice(1); - if (command !== "run") { + if (command === "uninstall") { + requireThat( + rest.length === 0 || + (rest.length === 1 && rest[0] === "--apply") || + (rest.length === 2 && + rest[0] === "--rollback-manifest" && + Boolean(rest[1])), + "Use uninstall, uninstall --apply or uninstall --rollback-manifest /path." + ); + if (rest[0] === "--rollback-manifest") { + console.log( + JSON.stringify( + rollbackUninstall(root, rest[1] as string, process.execPath) + ) + ); + return 0; + } + } else if (command !== "run") { const allowed = command === "install" || command === "upgrade" ? ["--version", "--archive", "--sha256"] @@ -73,12 +97,6 @@ export async function main(inputArgv: string[]) { "--archive and --sha256 must be supplied together." ); } - process.umask(0o077); - // Source invocation is development-only; releases always copy the compiled executable. - requireThat( - import.meta.path.startsWith("/$bunfs/"), - "Build the standalone manager before installing; source is not an installable artifact." - ); const channel = new Channel(root, process.execPath); if (command === "install" || command === "upgrade") { channel.initialize(); @@ -88,6 +106,10 @@ export async function main(inputArgv: string[]) { command === "upgrade-manager" ); try { + if (command === "uninstall") { + console.log(JSON.stringify(channel.uninstall(rest[0] === "--apply"))); + return 0; + } if (command === "install" || command === "upgrade") { await channel.install( version as string, diff --git a/scripts/installer/bootstrap.ts b/scripts/installer/bootstrap.ts index e241d127c..c71fce572 100644 --- a/scripts/installer/bootstrap.ts +++ b/scripts/installer/bootstrap.ts @@ -1,8 +1,8 @@ -import { candidateVersion, requireThat, SHA256 } from "./integrity.ts"; import { TRANSFER_INACTIVITY_MS, TRANSFER_TOTAL_MS, -} from "./transfer-budget.ts"; +} from "../../src/lib/release-transfer-budget.ts"; +import { candidateVersion, requireThat, SHA256 } from "./integrity.ts"; /** Curl's low-speed window bounds sustained inactivity; max-time independently caps progress. */ export function curlTransferFlags({ inactivitySeconds = TRANSFER_INACTIVITY_MS / 1000, diff --git a/scripts/installer/channel.ts b/scripts/installer/channel.ts index 528db1d9a..53c42a26d 100644 --- a/scripts/installer/channel.ts +++ b/scripts/installer/channel.ts @@ -300,7 +300,7 @@ export class Channel { this.initializationPreflight(); } /** Kernel flock is shared for launches, exclusive for mutations, and never waits. */ - lock(shared = false, managerUpgrade = false) { + lock(shared = false, managerUpgrade = false, receiptRoot = this.root) { owned(this.root, true, 0o700); this.refuseLegacy(); const before = owned(this.path(".manager.lock"), false, 0o600); @@ -334,7 +334,7 @@ export class Channel { after.ino === opened.ino, "Manager lock changed." ); - this.validate(managerUpgrade); + this.validate(managerUpgrade, receiptRoot); } catch (error) { closeSync(fd); library.close(); @@ -345,7 +345,8 @@ export class Channel { library.close(); }; } - validate(managerUpgrade = false) { + /** receiptRoot differs only while validating an uninstalled retained sibling. */ + validate(managerUpgrade = false, receiptRoot = this.root) { const info = owned(this.root, true, 0o700); this.refuseLegacy(); const pending = lexists(this.path(JOURNAL)); @@ -366,7 +367,7 @@ export class Channel { ]); requireThat( marker.schema === "hack.prerelease-install/v2" && - marker.root === this.root && + marker.root === receiptRoot && marker.uid === process.getuid?.() && marker.device === info.dev && marker.inode === info.ino, @@ -599,6 +600,47 @@ export class Channel { atomicJson(this.path(".selection.json"), state, this.hooks.checkpoint); this.state = state; } + /** Read-only by default; applying retains the complete channel and user homes for rollback. */ + uninstall(apply = false) { + this.validate(); + const backup = join( + dirname(this.root), + `.${basename(this.root)}.uninstalled-${nonce()}` + ); + const info = owned(this.root, true, 0o700); + const plan = { + schema: "hack.prerelease-uninstall/v1", + from: this.root, + to: backup, + device: info.dev, + inode: info.ino, + manager_sha256: digest(this.path("manager")), + versions: Object.keys(this.state.installed).sort(), + manifest: `${backup}.json`, + }; + if (!apply) { + return { ...plan, applied: false }; + } + for (const version of plan.versions) { + this.requireQuiescent(version); + } + this.validate(); + const current = owned(this.root, true, 0o700); + requireThat( + current.dev === info.dev && current.ino === info.ino, + "Candidate root changed; uninstall refused." + ); + writeFile(plan.manifest, jsonBytes(plan)); + syncDirectory(dirname(this.root)); + this.hooks.checkpoint?.("uninstall-manifest"); + if (!publishDirectoryExclusive(this.root, backup)) { + unlinkSync(plan.manifest); + syncDirectory(dirname(this.root)); + throw new Error("Uninstall destination exists; channel preserved."); + } + syncDirectory(dirname(this.root)); + return { ...plan, applied: true }; + } async install( version: string, archive?: string, @@ -648,6 +690,17 @@ export class Channel { "Retained manager differs from this installer; use the retained manager or a separate --root. Replacement requires a future reviewed compiled predecessor digest; none is currently approved." ); this.requireQuiescent(current); + // The exclusive manager lock excludes another installer. A killed download's + // private stage is disposable; published versions and user homes are not. + for (const name of readdirSync(this.root)) { + if (!STAGE.test(name)) { + continue; + } + const abandoned = this.path(name); + owned(abandoned, true, 0o700); + rmSync(abandoned, { recursive: true }); + syncDirectory(this.root); + } const stage = this.path(`.stage-${nonce()}`); mkdirSync(stage, { mode: 0o700 }); const created = lstatSync(stage); @@ -962,3 +1015,66 @@ export class Channel { } import { parseJson as parseJsonStatus } from "./integrity.ts"; + +/** Restore only the exact retained channel; an occupied original root is never replaced. */ +export function rollbackUninstall( + root: string, + manifestPath: string, + source: string +) { + canonical(root); + canonical(manifestPath); + canonical(source); + const plan = privateJson(manifestPath); + exact(plan, [ + "schema", + "from", + "to", + "device", + "inode", + "manager_sha256", + "versions", + "manifest", + ]); + requireThat( + plan.schema === "hack.prerelease-uninstall/v1" && + plan.from === root && + plan.manifest === manifestPath && + typeof plan.to === "string", + "Invalid uninstall manifest." + ); + const backup = canonical(plan.to as string); + requireThat( + dirname(backup) === dirname(root) && + basename(backup).startsWith(`.${basename(root)}.uninstalled-`) && + /^[a-f0-9]{32}$/.test(basename(backup).slice(-32)) && + manifestPath === `${backup}.json`, + "Uninstall manifest target is not its retained sibling." + ); + const retained = new Channel(backup, source); + const release = retained.lock(false, false, root); + try { + const info = owned(backup, true, 0o700); + const marker = privateJson(join(backup, ".channel.json")); + requireThat( + info.dev === plan.device && + info.ino === plan.inode && + marker.root === root && + marker.device === info.dev && + marker.inode === info.ino && + marker.uid === process.getuid?.() && + digest(join(backup, "manager")) === plan.manager_sha256 && + digest(source) === plan.manager_sha256, + "Retained channel identity changed; rollback refused." + ); + requireThat( + publishDirectoryExclusive(backup, root), + "Original channel path is occupied; nothing replaced." + ); + syncDirectory(dirname(root)); + new Channel(root, join(root, "manager")).validate(); + return { restored: root, manifest: manifestPath }; + } finally { + release(); + } +} diff --git a/scripts/installer/download.ts b/scripts/installer/download.ts index 9ada61be1..b84014237 100644 --- a/scripts/installer/download.ts +++ b/scripts/installer/download.ts @@ -1,13 +1,10 @@ -import { - closeSync, - constants, - fchmodSync, - fsyncSync, - openSync, - readFileSync, - writeSync, -} from "node:fs"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { + type ReleaseFetch, + transferRelease, +} from "../../src/lib/release-transfer.ts"; +import { TransferBudget } from "../../src/lib/release-transfer-budget.ts"; import { checksums, MAX_ARCHIVE, @@ -16,7 +13,6 @@ import { parseJson, requireThat, } from "./integrity.ts"; -import { TransferBudget } from "./transfer-budget.ts"; const HOSTS = new Set([ "api.github.com", @@ -42,131 +38,24 @@ export async function download( raw: string, target: string, limit: number, - sharedBudget?: TransferBudget + sharedBudget?: TransferBudget, + request?: ReleaseFetch ) { const budget = sharedBudget ?? new TransferBudget(); try { - return await downloadWithinBudget(raw, target, limit, budget); + return await transferRelease({ + url: raw, + path: target, + maxBytes: limit, + budget, + request, + }); } finally { if (!sharedBudget) { budget.close(); } } } -async function downloadWithinBudget( - raw: string, - target: string, - limit: number, - budget: TransferBudget -) { - let url = secureUrl(raw); - let response: Response | undefined; - let reader: ReadableStreamDefaultReader | undefined; - let fd: number | undefined; - let primaryFailure: unknown; - try { - for (let redirects = 0; redirects <= 5; redirects++) { - response = await budget.wait(() => - fetch(url, { - redirect: "manual", - signal: budget.signal, - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "hack-prerelease-installer", - "Accept-Encoding": "identity", - }, - }) - ); - budget.progress(); - if (![301, 302, 303, 307, 308].includes(response.status)) { - break; - } - const location = response.headers.get("location"); - requireThat( - location && redirects < 5, - "Missing redirect or redirect budget exceeded." - ); - const next = secureUrl(new URL(location, url).href); - const body = response.body; - response = undefined; // The cancellation below is the sole cleanup attempt for this response. - if (body) { - await budget.settleCancellation(() => body.cancel()); - } - url = next; - } - requireThat(response?.ok && response.body, "Release download failed."); - const declared = response.headers.get("content-length"); - requireThat( - declared === null || /^[0-9]{1,12}$/.test(declared), - "Malformed release download length." - ); - const expected = declared === null ? undefined : Number(declared); - requireThat( - expected === undefined || expected <= limit, - "Release download exceeds its size budget." - ); - fd = openSync( - target, - constants.O_WRONLY | - constants.O_CREAT | - constants.O_EXCL | - constants.O_NOFOLLOW, - 0o600 - ); - reader = response.body.getReader(); - fchmodSync(fd, 0o600); - let size = 0; - for (;;) { - const activeReader = reader; - const incoming = await budget.wait(() => activeReader.read()); - if (incoming.done) { - break; - } - const chunk = incoming.value; - if (chunk.length > 0) { - budget.progress(); - } - size += chunk.length; - requireThat( - size <= limit && (expected === undefined || size <= expected), - "Release download exceeds its size budget or declared length." - ); - let offset = 0; - while (offset < chunk.length) { - offset += writeSync(fd, chunk, offset, chunk.length - offset); - } - } - requireThat( - expected === undefined || size === expected, - "Release download is incomplete." - ); - fsyncSync(fd); - } catch (error) { - primaryFailure = error; - throw error; - } finally { - try { - if (reader) { - const activeReader = reader; - await budget.settleCancellation( - () => activeReader.cancel(), - primaryFailure - ); - } else if (response?.body) { - const body = response.body; - await budget.settleCancellation(() => body.cancel(), primaryFailure); - } - } finally { - try { - reader?.releaseLock(); - } finally { - if (fd !== undefined) { - closeSync(fd); - } - } - } - } -} /** The five official downloads share one monotonic total budget, plus bounded cancellation grace. */ export async function officialArchive( stage: string, diff --git a/scripts/test-shard-plan.ts b/scripts/test-shard-plan.ts index 327817661..979032b36 100644 --- a/scripts/test-shard-plan.ts +++ b/scripts/test-shard-plan.ts @@ -513,6 +513,10 @@ export const MACOS_POOL_FILES: Readonly> = {}; * added to the Linux pool as well (HACK-2142). */ export const BOTH_POOL_FILES: Readonly> = { + "tests/v4-upgrade.test.ts": + "historical upgrade must preserve keys and values on Linux and macOS", + "tests/upgrade-installer.test.ts": + "download recovery and installation publication must work on both hosts", "tests/daemon-systemd.test.ts": "the systemd user service tests run only on Linux (linuxTest)", "tests/daemon-systemd-cli.test.ts": diff --git a/src/commands/update.ts b/src/commands/update.ts index 86d640883..00358f1eb 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -1,12 +1,11 @@ import { access, rm } from "node:fs/promises"; -import { basename, dirname, resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { confirm, isCancel } from "@clack/prompts"; import type { CommandHandlerFor } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { optJson } from "../cli/options.ts"; import { resolveGlobalHackDir } from "../lib/config-paths.ts"; -import { ensureDir } from "../lib/fs.ts"; import { canPrompt } from "../lib/interactivity.ts"; import { ensureBundledMutagenInstalled, @@ -161,7 +160,7 @@ const handleUpdate: CommandHandlerFor = async ({ json: args.options.json === true, result: { ok: false, - error: `Missing release asset for ${target.platform}/${target.arch} (v${latestVersion})`, + error: `Release assets incomplete; retry later (missing ${target.platform}/${target.arch} for v${latestVersion}).`, }, }); } @@ -214,12 +213,10 @@ const handleUpdate: CommandHandlerFor = async ({ }); } - await ensureDir(dirname(bin.path)); - await ensureDir(assetsDir); - const extracted = await downloadAndExtractRelease({ url: asset.url, version: latestVersion, + sha256: asset.sha256, }); if (!extracted.ok) { return writeResult({ @@ -228,8 +225,10 @@ const handleUpdate: CommandHandlerFor = async ({ }); } + let backupManifest: string | undefined; try { const install = await installExtractedRelease({ + version: latestVersion, extractedReleaseDir: extracted.extractedReleaseDir, binaryPath: bin.path, assetsDir, @@ -240,6 +239,7 @@ const handleUpdate: CommandHandlerFor = async ({ result: { ok: false, error: install.error }, }); } + backupManifest = install.backupManifest; } finally { // Best-effort cleanup; avoid failing the update after a successful install. await rm(extracted.tmpDir, { recursive: true, force: true }).catch( @@ -261,8 +261,9 @@ const handleUpdate: CommandHandlerFor = async ({ binaryPath: bin.path, assetsDir, mutagen: mutagenProvision, + backupManifest, }, - human: `Updated to v${latestVersion}.`, + human: `Updated to v${latestVersion}. Backup manifest: ${backupManifest}`, }); }; @@ -437,6 +438,7 @@ type UpdateOutput = readonly binaryPath: string; readonly assetsDir: string; readonly mutagen?: MutagenProvisionResult; + readonly backupManifest?: string; } | { readonly ok: false; diff --git a/src/lib/release-install.ts b/src/lib/release-install.ts new file mode 100644 index 000000000..0433a03cc --- /dev/null +++ b/src/lib/release-install.ts @@ -0,0 +1,274 @@ +import { createHash } from "node:crypto"; +import type { Stats } from "node:fs"; +import { + chmod, + copyFile, + cp, + lstat, + mkdir, + mkdtemp, + open, + readdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { withProjectsRegistryLock } from "./projects-registry-lock.ts"; +import { markReleaseStage, reclaimReleaseStages } from "./release-staging.ts"; +import { verifyStagedReleaseVersion } from "./release-version.ts"; + +async function present(path: string) { + return await lstat(path).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") { + return null; + } + throw error; + }); +} +async function assertTree(path: string) { + const info = await lstat(path); + if (info.isDirectory()) { + for (const name of await readdir(path)) { + await assertTree(join(path, name)); + } + } else if (!info.isFile()) { + throw new Error( + `Install tree contains a link or special file at ${path}; installation unchanged.` + ); + } +} +async function syncDirectory(path: string) { + const file = await open(path, "r"); + try { + await file.sync(); + } finally { + await file.close(); + } +} +async function syncTree(path: string): Promise { + const info = await lstat(path); + if (info.isDirectory()) { + for (const name of await readdir(path)) { + await syncTree(join(path, name)); + } + } + await syncDirectory(path); +} +async function assertIdentity(path: string, expected: Stats | null) { + const current = await present(path); + if (current?.dev !== expected?.dev || current?.ino !== expected?.ino) { + throw new Error( + "Installation identity changed; refusing to replace or remove it." + ); + } +} + +/** Stage whole asset trees, publish the binary last, and retain a value-free backup manifest. */ +export async function publishRelease(opts: { + readonly version: string; + readonly extractedReleaseDir: string; + readonly binaryPath: string; + readonly assetsDir: string; + /** Fault injection only; never selected by environment or CLI input. */ + readonly checkpoint?: (phase: string) => void; +}) { + await mkdir(dirname(opts.binaryPath), { recursive: true }); + await mkdir(dirname(opts.assetsDir), { recursive: true }); + return await withProjectsRegistryLock({ + lockPath: `${opts.binaryPath}.update.lock`, + label: "CLI update", + run: async () => { + await reclaimReleaseStages(opts.binaryPath); + const oldBinary = await present(opts.binaryPath); + const oldAssets = await present(opts.assetsDir); + const compilerPath = join( + dirname(opts.binaryPath), + "hack-config-compiler" + ); + const oldCompiler = await present(compilerPath); + const compilerSource = join( + opts.extractedReleaseDir, + "hack-config-compiler" + ); + const hasCompiler = Boolean(await present(compilerSource)); + if ( + (oldBinary && !oldBinary.isFile()) || + (oldCompiler && !oldCompiler.isFile()) || + (oldAssets && !oldAssets.isDirectory()) + ) { + throw new Error( + "Refused non-regular installation target; installation unchanged." + ); + } + if (oldAssets) { + await assertTree(opts.assetsDir); + } + await assertTree(opts.extractedReleaseDir); + const backup = await mkdtemp( + join(dirname(opts.assetsDir), ".hack-update-backup-") + ); + await chmod(backup, 0o700); + const assetsStage = join(backup, "new-assets"); + const oldAssetsPath = join(backup, "assets"); + const previousBinary = join(backup, "hack"); + const binaryStage = await mkdtemp( + join(dirname(opts.binaryPath), ".hack-update-stage-") + ); + await chmod(binaryStage, 0o700); + let assetsMoved = false; + let assetsPublished = false; + let binaryPublished = false; + let compilerPublished = false; + let complete = false; + let assetsIdentity: Stats | null = null; + let binaryIdentity: Stats | null = null; + let compilerIdentity: Stats | null = null; + try { + await markReleaseStage(binaryStage, opts.binaryPath); + await mkdir(assetsStage, { mode: 0o700 }); + if (oldAssets) { + await cp(opts.assetsDir, assetsStage, { recursive: true }); + } + for (const name of ["assets", "binaries"]) { + const source = join(opts.extractedReleaseDir, name); + if (await present(source)) { + await cp( + source, + name === "assets" ? assetsStage : join(assetsStage, "binaries"), + { recursive: true } + ); + } + } + if (oldBinary) { + await copyFile(opts.binaryPath, previousBinary); + } + if (oldCompiler) { + await copyFile(compilerPath, join(backup, "hack-config-compiler")); + } + if (hasCompiler) { + await copyFile( + compilerSource, + join(binaryStage, "hack-config-compiler") + ); + await chmod(join(binaryStage, "hack-config-compiler"), 0o755); + } + const next = join(binaryStage, "hack"); + await copyFile(join(opts.extractedReleaseDir, "hack"), next); + await chmod(next, 0o755); + const file = await open(next, "r"); + try { + await file.sync(); + } finally { + await file.close(); + } + const hash = (bytes: Buffer) => + createHash("sha256").update(bytes).digest("hex"); + const manifest = { + schema: "hack.update-backup/v1", + binary: opts.binaryPath, + assets: opts.assetsDir, + previousBinary: oldBinary ? previousBinary : null, + previousAssets: oldAssets ? oldAssetsPath : null, + previousSha256: oldBinary + ? hash(await readFile(previousBinary)) + : null, + installedSha256: hash(await readFile(next)), + compiler: hasCompiler ? compilerPath : null, + previousCompiler: oldCompiler + ? join(backup, "hack-config-compiler") + : null, + previousCompilerSha256: oldCompiler + ? hash(await readFile(join(backup, "hack-config-compiler"))) + : null, + }; + await writeFile( + join(backup, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o600 } + ); + await syncTree(backup); + await syncTree(binaryStage); + await verifyStagedReleaseVersion(next, opts.version); + opts.checkpoint?.("staged"); + assetsIdentity = await lstat(assetsStage); + binaryIdentity = await lstat(next); + if (hasCompiler) { + compilerIdentity = await lstat( + join(binaryStage, "hack-config-compiler") + ); + } + await assertIdentity(opts.assetsDir, oldAssets); + if (oldAssets) { + await rename(opts.assetsDir, oldAssetsPath); + assetsMoved = true; + } + await rename(assetsStage, opts.assetsDir); + assetsPublished = true; + opts.checkpoint?.("assets-published"); + if (hasCompiler) { + await assertIdentity(compilerPath, oldCompiler); + await rename(join(binaryStage, "hack-config-compiler"), compilerPath); + compilerPublished = true; + } + opts.checkpoint?.("compiler-published"); + await assertIdentity(opts.binaryPath, oldBinary); + await rename(next, opts.binaryPath); + binaryPublished = true; + await syncDirectory(dirname(opts.binaryPath)); + await syncDirectory(dirname(opts.assetsDir)); + opts.checkpoint?.("binary-published"); + complete = true; + return { backupManifest: join(backup, "manifest.json") }; + } catch (error) { + try { + if (binaryPublished) { + await assertIdentity(opts.binaryPath, binaryIdentity); + if (oldBinary) { + const rollback = join(binaryStage, "rollback"); + await copyFile(previousBinary, rollback); + await chmod(rollback, oldBinary.mode & 0o777); + await rename(rollback, opts.binaryPath); + } else { + await rm(opts.binaryPath); + } + } + if (compilerPublished) { + await assertIdentity(compilerPath, compilerIdentity); + if (oldCompiler) { + const rollback = join(binaryStage, "rollback-compiler"); + await copyFile(join(backup, "hack-config-compiler"), rollback); + await chmod(rollback, oldCompiler.mode & 0o777); + await rename(rollback, compilerPath); + } else { + await rm(compilerPath); + } + } + if (assetsPublished) { + await assertIdentity(opts.assetsDir, assetsIdentity); + await rm(opts.assetsDir, { recursive: true }); + } + if (assetsMoved) { + await assertIdentity(opts.assetsDir, null); + await rename(oldAssetsPath, opts.assetsDir); + } + await syncDirectory(dirname(opts.binaryPath)); + await syncDirectory(dirname(opts.assetsDir)); + } catch { + // Keep the complete old copies and manifest if rollback itself is uncertain. + complete = true; + throw new Error( + `Update rollback could not be confirmed. Keep the backup manifest at ${join(backup, "manifest.json")} and inspect before retrying.` + ); + } + throw error; + } finally { + await rm(binaryStage, { recursive: true, force: true }); + if (!complete) { + await rm(backup, { recursive: true, force: true }); + } + } + }, + }); +} diff --git a/src/lib/release-integrity.ts b/src/lib/release-integrity.ts new file mode 100644 index 000000000..df1d5f335 --- /dev/null +++ b/src/lib/release-integrity.ts @@ -0,0 +1,75 @@ +import { createHash } from "node:crypto"; +import { lstat, readdir, readFile } from "node:fs/promises"; +import { join, relative } from "node:path"; + +const CHECKSUM_LINE = /^([a-f0-9]{64}) {2}([A-Za-z0-9._/-]+)$/; +/** Every release payload file must be named once by the inner release checksum list. */ +export async function verifyReleaseDirectory(root: string) { + const lines = (await readFile(join(root, "SHA256SUMS"), "utf8")) + .trim() + .split("\n"); + const expected = new Map(); + for (const line of lines) { + const match = CHECKSUM_LINE.exec(line); + const name = match?.[2]; + if ( + !(match?.[1] && name) || + name.startsWith("/") || + name.split("/").some((part) => !part || part === "." || part === "..") || + name === "SHA256SUMS" || + expected.has(name) + ) { + throw new Error( + "Invalid release checksum inventory; installation unchanged." + ); + } + expected.set(name, match[1]); + } + if (!expected.has("hack")) { + throw new Error( + "Release checksum inventory omits hack; installation unchanged." + ); + } + const found = new Set(); + const walk = async (path: string) => { + for (const item of await readdir(path)) { + const file = join(path, item); + const info = await lstat(file); + if (info.isDirectory()) { + await walk(file); + } else if (info.isFile()) { + const name = relative(root, file); + if (name === "SHA256SUMS") { + continue; + } + const hash = createHash("sha256"); + const reader = Bun.file(file).stream().getReader(); + try { + for (;;) { + const chunk = await reader.read(); + if (chunk.done) { + break; + } + hash.update(chunk.value); + } + } finally { + reader.releaseLock(); + } + if (expected.get(name) !== hash.digest("hex")) { + throw new Error( + "Release payload checksum mismatch; installation unchanged." + ); + } + found.add(name); + } else { + throw new Error( + "Release contains a link or special file; installation unchanged." + ); + } + } + }; + await walk(root); + if (found.size !== expected.size) { + throw new Error("Release payload is incomplete; installation unchanged."); + } +} diff --git a/src/lib/release-staging.ts b/src/lib/release-staging.ts new file mode 100644 index 000000000..67fe87689 --- /dev/null +++ b/src/lib/release-staging.ts @@ -0,0 +1,102 @@ +import { + lstat, + open, + readdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { isRecord } from "./guards.ts"; + +const STAGE = /^\.hack-update-stage-[A-Za-z0-9]{6}$/; +const ENTRIES = new Set([ + "stage.json", + "hack", + "hack-config-compiler", + "rollback", + "rollback-compiler", +]); +const SCHEMA = "hack.update-stage/v1"; +/** Mark only a newly created directory containing regenerable executables. */ +export async function markReleaseStage(stage: string, binary: string) { + const info = await lstat(stage); + await writeFile( + join(stage, "stage.json"), + JSON.stringify({ + schema: SCHEMA, + binary, + device: info.dev, + inode: info.ino, + uid: process.getuid?.(), + }), + { mode: 0o600, flag: "wx" } + ); + const file = await open(join(stage, "stage.json"), "r"); + try { + await file.sync(); + } finally { + await file.close(); + } +} +/** Under the update lock, reclaim only recorded private executable stages. + * Complete backup manifests and unrecognised directories are never adopted/deleted. */ +export async function reclaimReleaseStages(binary: string) { + const parent = dirname(binary); + for (const name of await readdir(parent)) { + if (!STAGE.test(name)) { + continue; + } + const stage = join(parent, name); + const info = await lstat(stage); + if ( + !info.isDirectory() || + info.uid !== process.getuid?.() || + (info.mode & 0o7777) !== 0o700 + ) { + continue; + } + const path = join(stage, "stage.json"); + const receipt = await lstat(path).catch(() => null); + if ( + !receipt?.isFile() || + receipt.uid !== info.uid || + (receipt.mode & 0o7777) !== 0o600 || + receipt.size > 4096 + ) { + continue; + } + const raw: unknown = await readFile(path, "utf8").then((text) => { + try { + return JSON.parse(text) as unknown; + } catch { + return null; + } + }); + if ( + !isRecord(raw) || + raw.schema !== SCHEMA || + raw.binary !== binary || + raw.device !== info.dev || + raw.inode !== info.ino || + raw.uid !== info.uid + ) { + continue; + } + let recognized = true; + for (const entry of await readdir(stage)) { + const file = await lstat(join(stage, entry)); + if (!(ENTRIES.has(entry) && file.isFile()) || file.uid !== info.uid) { + recognized = false; + } + } + if (!recognized) { + continue; + } + const current = await lstat(stage); + if (current.dev !== info.dev || current.ino !== info.ino) { + throw new Error("Update stage identity changed; cleanup refused."); + } + await rm(stage, { recursive: true }); + } +} diff --git a/scripts/installer/transfer-budget.ts b/src/lib/release-transfer-budget.ts similarity index 100% rename from scripts/installer/transfer-budget.ts rename to src/lib/release-transfer-budget.ts diff --git a/src/lib/release-transfer.ts b/src/lib/release-transfer.ts new file mode 100644 index 000000000..237f65dc6 --- /dev/null +++ b/src/lib/release-transfer.ts @@ -0,0 +1,280 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { lstat, open, unlink } from "node:fs/promises"; +import { setTimeout as delay } from "node:timers/promises"; +import { TransferBudget } from "./release-transfer-budget.ts"; + +export type ReleaseFetch = ( + url: string, + init: RequestInit +) => Promise; +const OFFICIAL_HOSTS = new Set([ + "github.com", + "api.github.com", + "release-assets.githubusercontent.com", + "objects.githubusercontent.com", +]); +export function officialReleaseUrl(raw: string): string { + const url = new URL(raw); + if ( + url.protocol !== "https:" || + !OFFICIAL_HOSTS.has(url.hostname) || + url.username || + url.password || + url.hash || + (url.port && url.port !== "443") + ) { + throw new Error( + "Refusing nonofficial or non-HTTPS download; installation unchanged." + ); + } + return url.href; +} +const SHA256 = /^[a-f0-9]{64}$/; +const LENGTH = /^[0-9]{1,12}$/; +class TransferFailure extends Error { + readonly retryable: boolean; + constructor(message: string, retryable: boolean) { + super(message); + this.retryable = retryable; + } +} +/** Fresh retries share a deadline. No unverified partial is adopted; uncertain + * cancellation refuses retry. The request seam is never selected by environment. */ +export async function transferRelease(opts: { + readonly url: string; + readonly path: string; + readonly sha256?: string; + readonly request?: ReleaseFetch; + readonly maxBytes?: number; + readonly timeoutMs?: number; + readonly idleMs?: number; + readonly budget?: TransferBudget; +}) { + const budget = + opts.budget ?? + new TransferBudget({ + totalMs: opts.timeoutMs ?? 10 * 60_000, + inactivityMs: opts.idleMs, + }); + try { + if (opts.sha256 !== undefined && !SHA256.test(opts.sha256)) { + throw new Error("Invalid release SHA-256."); + } + for (let attempt = 0; ; attempt++) { + try { + await transferAttempt({ + ...opts, + request: opts.request ?? fetch, + budget, + limit: opts.maxBytes ?? 512 * 1024 * 1024, + }); + return; + } catch (error) { + if ( + !(error instanceof TransferFailure && error.retryable) || + attempt >= 2 || + budget.signal.aborted + ) { + throw error; + } + await budget.wait(() => + delay(50 * 2 ** attempt, undefined, { signal: budget.signal }) + ); + } + } + } finally { + if (!opts.budget) { + budget.close(); + } + } +} +async function transferAttempt(opts: { + readonly url: string; + readonly path: string; + readonly sha256?: string; + readonly request: ReleaseFetch; + readonly budget: TransferBudget; + readonly limit: number; +}) { + let url = officialReleaseUrl(opts.url); + const budget = opts.budget; + let response: Response | undefined; + let reader: ReadableStreamDefaultReader | undefined; + let file: Awaited> | undefined; + let complete = false; + let primaryFailure: unknown; + let terminalReadFailure = false; + let cleanupSettled = false; + try { + for (let redirects = 0; ; redirects++) { + try { + response = await budget.wait(() => + opts.request(url, { + redirect: "manual", + signal: budget.signal, + headers: { + "user-agent": "hack-cli", + "accept-encoding": "identity", + }, + }) + ); + } catch { + budget.signal.throwIfAborted(); + throw new TransferFailure( + "Network unavailable or interrupted; installation unchanged. Retry when connectivity returns.", + !budget.signal.aborted + ); + } + budget.progress(); + if (![301, 302, 303, 307, 308].includes(response.status)) { + break; + } + const location = response.headers.get("location"); + if (!location || redirects >= 5) { + throw new TransferFailure( + "Release redirect budget exceeded; installation unchanged.", + false + ); + } + url = officialReleaseUrl(new URL(location, url).href); + const body = response.body; + response = undefined; + if (body) { + await budget.settleCancellation(() => body.cancel()); + } + } + if (!(response.ok && response.body)) { + throw new TransferFailure( + `Release download failed (${response.status}); installation unchanged.`, + [502, 503, 504].includes(response.status) + ); + } + const header = response.headers.get("content-length"); + if (header !== null && !LENGTH.test(header)) { + throw new TransferFailure( + "Malformed release download length; installation unchanged.", + false + ); + } + const expected = header === null ? undefined : Number(header); + if (expected !== undefined && expected > opts.limit) { + throw new TransferFailure( + "Release download exceeds its size budget; installation unchanged.", + false + ); + } + file = await open( + opts.path, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW, + 0o600 + ); + const hash = createHash("sha256"); + reader = response.body.getReader(); + let size = 0; + for (;;) { + const activeReader = reader; + let chunk: Awaited>; + try { + chunk = await budget.wait(() => + activeReader.read().catch(async (error: unknown) => { + // A rejected read puts the WHATWG stream in its terminal errored + // state. cancel() would only reject with that same stored error. + let terminal = false; + void activeReader.closed.then( + () => { + terminal = true; + }, + () => { + terminal = true; + } + ); + await Promise.resolve(); + terminalReadFailure = terminal && !budget.signal.aborted; + throw error; + }) + ); + } catch { + budget.signal.throwIfAborted(); + throw new TransferFailure( + "Release download interrupted; installation unchanged. Retry the update.", + !budget.signal.aborted + ); + } + if (chunk.done) { + break; + } + if (chunk.value.length) { + budget.progress(); + } + size += chunk.value.length; + if (size > opts.limit || (expected !== undefined && size > expected)) { + throw new TransferFailure( + "Release download exceeds its size budget or declared length; installation unchanged.", + false + ); + } + hash.update(chunk.value); + await file.writeFile(chunk.value); + } + if (expected !== undefined && expected !== size) { + throw new TransferFailure( + "Release download is incomplete; installation unchanged.", + true + ); + } + if (opts.sha256 !== undefined && hash.digest("hex") !== opts.sha256) { + throw new TransferFailure( + "Release checksum mismatch; installation unchanged.", + false + ); + } + await file.sync(); + complete = true; + } catch (error) { + primaryFailure = error; + throw error; + } finally { + try { + if (reader) { + if (!terminalReadFailure) { + const active = reader; + await budget.settleCancellation( + () => active.cancel(), + primaryFailure + ); + } + } else if (response?.body) { + const body = response.body; + await budget.settleCancellation(() => body.cancel(), primaryFailure); + } + cleanupSettled = true; + } finally { + reader?.releaseLock(); + if (file) { + try { + if (!complete && cleanupSettled) { + await removeOwnedPartial(file, opts.path); + } + } finally { + await file.close(); + } + } + } + } +} + +async function removeOwnedPartial( + file: Awaited>, + path: string +) { + const owned = await file.stat(); + const current = await lstat(path); + if (current.dev !== owned.dev || current.ino !== owned.ino) { + throw new Error("Partial download identity changed; cleanup refused."); + } + await unlink(path); +} diff --git a/src/lib/release-version.ts b/src/lib/release-version.ts new file mode 100644 index 000000000..763265d4c --- /dev/null +++ b/src/lib/release-version.ts @@ -0,0 +1,17 @@ +import { execOrThrow } from "./shell.ts"; + +/** Execute only a checksum-verified staged CLI and require its documented output. */ +export async function verifyStagedReleaseVersion( + binary: string, + version: string +): Promise { + const result = await execOrThrow([binary, "--version"], { + stdin: "ignore", + timeoutMs: 10_000, + }); + if (result.stdout.trim() !== `hack v${version}`) { + throw new Error( + "Downloaded CLI version does not match the selected release; installation unchanged." + ); + } +} diff --git a/src/lib/self-update.ts b/src/lib/self-update.ts index ece391592..cddc0238d 100644 --- a/src/lib/self-update.ts +++ b/src/lib/self-update.ts @@ -1,12 +1,17 @@ -import { lstat, mkdir, readdir, readlink, rename, rm } from "node:fs/promises"; +import { lstat, mkdtemp, readlink, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; -import { ensureDir } from "./fs.ts"; import { getString, isRecord } from "./guards.ts"; +import { publishRelease } from "./release-install.ts"; +import { verifyReleaseDirectory } from "./release-integrity.ts"; +import { type ReleaseFetch, transferRelease } from "./release-transfer.ts"; import { compareSemver } from "./semver.ts"; import { execOrThrow } from "./shell.ts"; +const RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.+-]+)?$/; +const ARCHIVE_NAME = /^[A-Za-z0-9._/-]+$/; +const ASSET_DIGEST = /^sha256:[a-f0-9]{64}$/; const DEFAULT_REPO_OWNER = "hack-dance" as const; const DEFAULT_REPO_NAME = "hack" as const; const HOMEBREW_PREFIXES = ["/opt/homebrew", "/usr/local"] as const; @@ -30,6 +35,7 @@ export type UpdateTarget = { export type GithubReleaseAsset = { readonly name: string; readonly url: string; + readonly sha256?: string; }; export type GithubRelease = { @@ -271,10 +277,11 @@ export async function resolveGithubRelease(opts: { accept: "application/vnd.github+json", "user-agent": "hack-cli", }, + signal: AbortSignal.timeout(30_000), }); - } catch (error: unknown) { + } catch (_error: unknown) { const message = - error instanceof Error ? error.message : "Failed to reach GitHub"; + "Network unavailable; installation unchanged. Retry when connectivity returns."; return { ok: false, error: message }; } @@ -314,95 +321,94 @@ export type DownloadExtractResult = export async function downloadAndExtractRelease(opts: { readonly url: string; readonly version: string; + readonly sha256?: string; + readonly request?: ReleaseFetch; }): Promise { + if (!RELEASE_VERSION.test(opts.version)) { + return { + ok: false, + error: "Invalid release version; installation unchanged.", + }; + } const tmpRoot = await mkTmpDir({ prefix: "hack-update-" }); const tarballPath = resolve(tmpRoot, "release.tar.gz"); - - const downloadOk = await downloadToFile({ - url: opts.url, - outPath: tarballPath, - }); - if (!downloadOk.ok) { - await rm(tmpRoot, { recursive: true, force: true }); - return downloadOk; - } - - const tarExit = await extractTarGz({ tarballPath, outDir: tmpRoot }); - if (!tarExit.ok) { - await rm(tmpRoot, { recursive: true, force: true }); - return tarExit; - } - - const dirName = `hack-${opts.version}-release`; - const extractedReleaseDir = resolve(tmpRoot, dirName); - - const exists = await pathExists(extractedReleaseDir); - if (!exists) { + try { + await transferRelease({ + url: opts.url, + path: tarballPath, + sha256: opts.sha256, + request: opts.request, + }); + const rootName = `hack-${opts.version}-release`; + const { stdout: names } = await execOrThrow(["tar", "-tzf", tarballPath], { + stdin: "ignore", + timeoutMs: 30_000, + }); + const { stdout: details } = await execOrThrow( + ["tar", "-tvzf", tarballPath], + { stdin: "ignore", timeoutMs: 30_000 } + ); + if ( + !names.trim() || + names + .trim() + .split("\n") + .some( + (name) => + !( + ARCHIVE_NAME.test(name) && + (name === rootName || name.startsWith(`${rootName}/`)) + ) || name.split("/").some((part) => part === ".." || part === ".") + ) || + details + .trim() + .split("\n") + .some((line) => !["-", "d"].includes(line[0] ?? "")) + ) { + throw new Error("Unsafe release archive layout; installation unchanged."); + } + const tarExit = await extractTarGz({ tarballPath, outDir: tmpRoot }); + if (!tarExit.ok) { + throw new Error( + "Release archive extraction failed; installation unchanged." + ); + } + const extractedReleaseDir = resolve(tmpRoot, rootName); + await verifyReleaseDirectory(extractedReleaseDir); + return { ok: true, tmpDir: tmpRoot, extractedReleaseDir }; + } catch (error) { await rm(tmpRoot, { recursive: true, force: true }); return { ok: false, - error: `Missing extracted release dir: ${extractedReleaseDir}`, + error: + error instanceof Error + ? error.message + : "Release download failed; installation unchanged.", }; } - - return { ok: true, tmpDir: tmpRoot, extractedReleaseDir }; } export type InstallReleaseResult = - | { readonly ok: true } + | { readonly ok: true; readonly backupManifest: string } | { readonly ok: false; readonly error: string }; export async function installExtractedRelease(opts: { + readonly version: string; readonly extractedReleaseDir: string; readonly binaryPath: string; readonly assetsDir: string; }): Promise { - const srcHack = resolve(opts.extractedReleaseDir, "hack"); - const srcAssets = resolve(opts.extractedReleaseDir, "assets"); - const srcBinaries = resolve(opts.extractedReleaseDir, "binaries"); - - const hackExists = await pathExists(srcHack); - if (!hackExists) { - return { ok: false, error: `Missing hack binary in release: ${srcHack}` }; - } - - await ensureDir(dirname(opts.binaryPath)); - await ensureDir(opts.assetsDir); - - const replaceOk = await atomicReplaceFile({ - srcPath: srcHack, - destPath: opts.binaryPath, - chmodMode: 0o755, - }); - if (!replaceOk.ok) { - return replaceOk; - } - - const assetsExists = await pathExists(srcAssets); - if (assetsExists) { - const copyOk = await copyDirContents({ - srcDir: srcAssets, - destDir: opts.assetsDir, - }); - if (!copyOk.ok) { - return copyOk; - } - } - - const binariesExists = await pathExists(srcBinaries); - if (binariesExists) { - const destBinaries = resolve(opts.assetsDir, "binaries"); - await ensureDir(destBinaries); - const copyOk = await copyDirContents({ - srcDir: srcBinaries, - destDir: destBinaries, - }); - if (!copyOk.ok) { - return copyOk; - } + try { + return { ok: true, ...(await publishRelease(opts)) }; + } catch (error) { + return { + ok: false, + error: + error instanceof Error + ? error.message + : "Unable to publish release; previous installation retained.", + }; } - - return { ok: true }; } export function compareVersions(opts: { @@ -436,7 +442,15 @@ function parseGithubRelease(json: unknown): GithubRelease | null { const name = getString(entry, "name"); const url = getString(entry, "browser_download_url"); if (typeof name === "string" && typeof url === "string" && name && url) { - assets.push({ name, url }); + const digest = getString(entry, "digest"); + if (digest && !ASSET_DIGEST.test(digest)) { + return null; + } + assets.push({ + name, + url, + ...(digest ? { sha256: digest.slice(7) } : {}), + }); } } } @@ -455,55 +469,7 @@ async function safeReadText(res: Response): Promise { } async function mkTmpDir(opts: { readonly prefix: string }): Promise { - const dir = join( - tmpdir(), - `${opts.prefix}${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}` - ); - await mkdir(dir, { recursive: true }); - return dir; -} - -async function downloadToFile(opts: { - readonly url: string; - readonly outPath: string; -}): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } -> { - let res: Response; - try { - res = await fetch(opts.url, { - headers: { - accept: "application/octet-stream", - "user-agent": "hack-cli", - }, - }); - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to download release asset"; - return { ok: false, error: message }; - } - - if (!res.ok) { - const text = await safeReadText(res); - return { - ok: false, - error: `Download failed (${res.status}): ${text ?? res.statusText}`, - }; - } - - try { - const buf = await res.arrayBuffer(); - await Bun.write(opts.outPath, buf); - return { ok: true }; - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : "Failed to write downloaded file"; - return { ok: false, error: message }; - } + return await mkdtemp(join(tmpdir(), opts.prefix)); } async function extractTarGz(opts: { @@ -515,6 +481,7 @@ async function extractTarGz(opts: { try { await execOrThrow(["tar", "-xzf", opts.tarballPath, "-C", opts.outDir], { stdin: "ignore", + timeoutMs: 30_000, }); return { ok: true }; } catch (error: unknown) { @@ -523,92 +490,3 @@ async function extractTarGz(opts: { return { ok: false, error: message }; } } - -async function pathExists(path: string): Promise { - try { - await Bun.file(path).stat(); - return true; - } catch { - return false; - } -} - -async function atomicReplaceFile(opts: { - readonly srcPath: string; - readonly destPath: string; - readonly chmodMode?: number; -}): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } -> { - const destDir = dirname(opts.destPath); - const tmpName = `${basename(opts.destPath)}.tmp.${process.pid}.${Math.random().toString(16).slice(2)}`; - const tmpPath = resolve(destDir, tmpName); - - try { - await Bun.write(tmpPath, Bun.file(opts.srcPath)); - if (typeof opts.chmodMode === "number") { - await execOrThrow(["chmod", `${opts.chmodMode.toString(8)}`, tmpPath], { - stdin: "ignore", - }); - } - await rename(tmpPath, opts.destPath); - return { ok: true }; - } catch (error: unknown) { - await rm(tmpPath, { force: true }).catch(() => undefined); - const message = - error instanceof Error ? error.message : "Failed to install file"; - return { ok: false, error: message }; - } -} - -async function copyDirContents(opts: { - readonly srcDir: string; - readonly destDir: string; -}): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } -> { - let entries: string[]; - try { - entries = await readdir(opts.srcDir); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : `Failed to read ${opts.srcDir}`; - return { ok: false, error: message }; - } - - for (const entry of entries) { - if (entry.startsWith(".")) { - continue; - } - - const srcPath = resolve(opts.srcDir, entry); - const destPath = resolve(opts.destDir, entry); - - const stat = await Bun.file(srcPath) - .stat() - .catch(() => null); - if (!stat) { - continue; - } - - if (stat.isDirectory()) { - await ensureDir(destPath); - const nested = await copyDirContents({ - srcDir: srcPath, - destDir: destPath, - }); - if (!nested.ok) { - return nested; - } - continue; - } - - if (stat.isFile()) { - await ensureDir(dirname(destPath)); - await rm(destPath, { force: true }).catch(() => undefined); - await Bun.write(destPath, Bun.file(srcPath)); - } - } - - return { ok: true }; -} diff --git a/tests/fixtures/update-publication-child.ts b/tests/fixtures/update-publication-child.ts new file mode 100644 index 000000000..6ec7ba302 --- /dev/null +++ b/tests/fixtures/update-publication-child.ts @@ -0,0 +1,27 @@ +import { publishRelease } from "../../src/lib/release-install.ts"; + +const [release, binary, assets, phase] = Bun.argv.slice(2); +if ( + !( + release && + binary && + assets && + ["assets-published", "compiler-published"].includes(phase ?? "") + ) +) { + throw new Error("Missing publication fixture arguments."); +} +await publishRelease({ + version: "4.3.1", + extractedReleaseDir: release, + binaryPath: binary, + assetsDir: assets, + checkpoint: (point) => { + if (point === phase) { + process.kill(process.pid, "SIGKILL"); + } + }, +}); +throw new Error( + "Publication fixture failed to reach its hard-kill checkpoint." +); diff --git a/tests/fixtures/update-recovery-cli.ts b/tests/fixtures/update-recovery-cli.ts new file mode 100644 index 000000000..a72b04fa9 --- /dev/null +++ b/tests/fixtures/update-recovery-cli.ts @@ -0,0 +1,25 @@ +// Compile-time fixture entry. Production index.ts has no update endpoint override. +const endpoint = new URL( + process.env.HACK_TEST_UPDATE_FIXTURE_ENDPOINT ?? "http://127.0.0.1:1" +); +if ( + endpoint.protocol !== "http:" || + endpoint.hostname !== "127.0.0.1" || + !endpoint.port +) { + throw new Error("Fixture endpoint must be explicit loopback."); +} +const network = globalThis.fetch; +Object.defineProperty(globalThis, "fetch", { + value: (url: string | URL, init?: RequestInit) => { + const official = new URL(String(url)); + if ( + official.protocol !== "https:" || + !["api.github.com", "github.com"].includes(official.hostname) + ) { + throw new Error("Fixture refuses external network."); + } + return network(new URL(official.pathname, endpoint), init); + }, +}); +await import("../../index.ts"); diff --git a/tests/fixtures/v4-upgrade-cli.ts b/tests/fixtures/v4-upgrade-cli.ts new file mode 100644 index 000000000..804d70ce5 --- /dev/null +++ b/tests/fixtures/v4-upgrade-cli.ts @@ -0,0 +1,54 @@ +import dns from "node:dns/promises"; +import { syncBuiltinESMExports } from "node:module"; +import net from "node:net"; +import { dirname, resolve } from "node:path"; + +/** Compile-time fixture entry only. Production builds still use index.ts directly. */ +const blocked = () => { + throw new Error("Upgrade fixture refuses external network access."); +}; +Object.defineProperty(dns, "lookup", { value: async () => blocked() }); +syncBuiltinESMExports(); +Object.defineProperty(globalThis, "fetch", { value: async () => blocked() }); +const connect = net.Socket.prototype.connect; +Object.defineProperty(net.Socket.prototype, "connect", { + value(this: net.Socket, ...args: Parameters) { + const address: unknown = args[0]; + const path = + typeof address === "string" + ? address + : typeof address === "object" && address !== null && "path" in address + ? address.path + : null; + const temp = process.env.TMPDIR; + if ( + typeof path !== "string" || + !temp || + !resolve(path).startsWith(`${dirname(temp)}/`) + ) { + return blocked(); + } + return Reflect.apply(connect, this, args); + }, +}); + +if (Bun.argv[2] === "--upgrade-fixture-network-check") { + let refused = 0; + try { + await fetch("https://network-forbidden.invalid"); + } catch { + refused++; + } + try { + await dns.lookup("network-forbidden.invalid"); + } catch { + refused++; + } + try { + new net.Socket().connect(443, "network-forbidden.invalid"); + } catch { + refused++; + } + process.exit(refused === 3 ? 0 : 89); +} +await import("../../index.ts"); diff --git a/tests/helpers/v4-upgrade-fixture.ts b/tests/helpers/v4-upgrade-fixture.ts new file mode 100644 index 000000000..8533f7c06 --- /dev/null +++ b/tests/helpers/v4-upgrade-fixture.ts @@ -0,0 +1,414 @@ +import { createCipheriv, createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { runFixtureGit } from "./fixture-git.ts"; +import { privateCliEnv } from "./private-cli-env.ts"; + +const fixtureValue = (name: string) => + ["synthetic", "upgrade", name, "sentinel"].join("-"); +const projectKey = Buffer.alloc(32, 11).toString("base64"); +const globalKey = fixtureValue("global-key"); +const sha = (text: string | Buffer) => + createHash("sha256").update(text).digest("hex"); + +/** Encode historical v1 store bytes independently of today's store writer. Test keys only. */ +function legacyStore( + scope: string, + values: Readonly>, + key: string +) { + const cipher = createCipheriv( + "aes-256-gcm", + createHash("sha256").update(key).digest(), + Buffer.alloc(12, 7) + ); + const entries = Object.fromEntries( + Object.entries(values).map(([name, value]) => [ + `${encodeURIComponent(scope)}::${encodeURIComponent(name)}`, + value, + ]) + ); + const ciphertext = Buffer.concat([ + cipher.update(JSON.stringify({ version: 1, entries })), + cipher.final(), + ]); + return JSON.stringify({ + version: 1, + algorithm: "aes-256-gcm", + iv: Buffer.alloc(12, 7).toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ciphertext: ciphertext.toString("base64"), + }); +} + +export type UpgradeProject = { + readonly name: string; + readonly path: string; + readonly values: Readonly>; +}; + +/** + * Historical bytes, real Git tracking/worktrees, private HOME and no runtime services. + * The generic run/snapshot interface also supports future check/apply/finish/rollback argv. + * No current migration/secret-store writer is used to manufacture legacy inputs. + */ +export async function createV4UpgradeFixture(opts: { + readonly binary: string; + readonly globalKeyPresent: boolean; +}) { + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-v4-upgrade-")) + ); + try { + const home = join(root, "home"); + const hackHome = join(home, ".hack"); + const tools = join(root, "tools"); + const temp = join(root, "tmp"); + for (const path of [hackHome, tools, temp]) { + await mkdir(path, { recursive: true, mode: 0o700 }); + } + const globalConfig = join(hackHome, "hack.config.json"); + const globalStore = join(hackHome, "secrets.enc.json"); + const globalKeyPath = join(hackHome, "secrets-file.key"); + await writeFile( + globalConfig, + JSON.stringify({ + controlPlane: { + secrets: { + backend: "encrypted_file", + encryptedFile: { path: globalStore, keyPath: globalKeyPath }, + }, + }, + }), + { mode: 0o600 } + ); + const tracked: UpgradeProject = { + name: "legacy-tracked", + path: join(root, "tracked"), + values: { + TOKEN: fixtureValue("tracked-token"), + PLAIN: fixtureValue("tracked-plain"), + }, + }; + const untracked: UpgradeProject = { + name: "legacy-untracked", + path: join(root, "untracked"), + values: { + ...(opts.globalKeyPresent + ? { TOKEN: fixtureValue("global-token") } + : {}), + PLAIN: fixtureValue("untracked-plain"), + }, + }; + const keyPath = join(tracked.path, ".hack.secret.key"); + const storePath = join(tracked.path, "legacy-store.json"); + const git = async (cwd: string, args: readonly string[]) => { + const result = await runFixtureGit({ cwd, args }); + if (result.exitCode !== 0) { + throw new Error("Upgrade fixture Git setup failed (values omitted)."); + } + return result.stdout; + }; + for (const project of [tracked, untracked]) { + const config = { + name: project.name, + dev_host: `${project.name}.hack`, + ...(project === tracked + ? { + controlPlane: { + secrets: { + backend: "encrypted_file", + encryptedFile: { path: storePath, keyPath }, + }, + }, + } + : {}), + }; + await mkdir(join(project.path, ".hack"), { + recursive: true, + mode: 0o700, + }); + await writeFile( + join(project.path, ".hack/hack.config.json"), + JSON.stringify(config) + ); + await writeFile( + join(project.path, ".hack/docker-compose.yml"), + "services:\n app:\n image: alpine:3.22\n env_file: .env\n" + ); + await writeFile( + join(project.path, ".hack/hack.env.json"), + JSON.stringify({ + version: 1, + vars: [ + ...(project.values.TOKEN + ? [{ key: "TOKEN", source: "keychain", required: true }] + : []), + { key: "PLAIN", source: "plain_env", required: true }, + ], + }) + ); + await writeFile( + join(project.path, ".hack/.env"), + `PLAIN=${project.values.PLAIN}\n`, + { mode: 0o600 } + ); + await git(project.path, ["init", "--quiet"]); + await writeFile( + join(project.path, "fixture-anchor"), + "historical repository\n" + ); + await git(project.path, [ + "add", + "fixture-anchor", + ...(project === tracked + ? [ + ".hack/hack.config.json", + ".hack/docker-compose.yml", + ".hack/hack.env.json", + ".hack/.env", + ] + : []), + ]); + await git(project.path, [ + "commit", + "--quiet", + "-m", + "historical fixture bytes", + ]); + } + await writeFile(keyPath, `${projectKey}\n`, { mode: 0o600 }); + await writeFile( + storePath, + legacyStore( + tracked.name, + { TOKEN: tracked.values.TOKEN ?? "" }, + projectKey + ), + { mode: 0o600 } + ); + await writeFile( + globalStore, + legacyStore( + untracked.name, + { TOKEN: fixtureValue("global-token") }, + globalKey + ), + { mode: 0o600 } + ); + if (opts.globalKeyPresent) { + await writeFile(globalKeyPath, `${globalKey}\n`, { mode: 0o600 }); + } + const worktree: UpgradeProject = { + ...tracked, + path: join(root, "linked"), + values: { PLAIN: tracked.values.PLAIN ?? "" }, + }; + await git(tracked.path, [ + "worktree", + "add", + "--quiet", + "-b", + "upgrade-linked", + worktree.path, + ]); + // This historical branch declares only its tracked plaintext setting. A + // machine-local secret in the primary checkout is not a worktree input. + await writeFile( + join(worktree.path, ".hack/hack.env.json"), + JSON.stringify({ + version: 1, + vars: [{ key: "PLAIN", source: "plain_env", required: true }], + }) + ); + const now = new Date().toISOString(); + const registry = join(hackHome, "projects.json"); + const deadPath = join(root, "deleted-project"); + await writeFile( + registry, + JSON.stringify({ + version: 1, + projects: [ + ...[tracked, untracked].map((project, index) => ({ + id: `legacy-${index}`, + name: project.name, + repoRoot: project.path, + projectDirName: ".hack", + projectDir: join(project.path, ".hack"), + createdAt: now, + lastSeenAt: now, + ...(project === tracked + ? { + worktrees: [ + { + path: worktree.path, + branch: "upgrade-linked", + lastSeenAt: now, + }, + ], + } + : {}), + })), + { + id: "legacy-dead", + name: "deleted-project", + repoRoot: deadPath, + projectDirName: ".hack", + projectDir: join(deadPath, ".hack"), + createdAt: now, + }, + ], + }), + { mode: 0o600 } + ); + // Tool tripwires never contact Docker, the daemon, system services or the network. + for (const tool of ["docker", "launchctl", "systemctl", "security"]) { + await writeFile(join(tools, tool), "#!/bin/sh\nexit 97\n", { + mode: 0o700, + }); + } + await writeFile(join(tools, "mutagen"), "#!/bin/sh\nexit 0\n", { + mode: 0o700, + }); + const env = privateCliEnv({ + root, + home, + env: { + PATH: `${tools}:/usr/bin:/bin`, + TMPDIR: temp, + HACK_HOME: hackHome, + HACK_GLOBAL_CONFIG_PATH: globalConfig, + HACK_RUNTIME_BACKEND: "compose", + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + HACK_MUTAGEN_PATH: join(tools, "mutagen"), + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + NO_COLOR: "1", + }, + }); + const sentinels = [ + projectKey, + globalKey, + ...Object.values(tracked.values), + ...Object.values(untracked.values), + ]; + const active = new Set(); + const run = async (args: readonly string[], cwd = tracked.path) => { + const child = Bun.spawn([opts.binary, ...args], { + cwd, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + }); + active.add(child); + try { + const [stdout, stderr, code] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + const output = `${stdout}\n${stderr}`; + if (sentinels.some((value) => output.includes(value))) { + throw new Error( + "Upgrade command leaked a sentinel (values omitted)." + ); + } + return { code, output }; + } finally { + active.delete(child); + } + }; + const resolveValues = async (project: UpgradeProject) => { + const witness = join(root, `witness-${project.name}.ts`); + const expected = Object.entries(project.values).map(([name, value]) => [ + name, + sha(value), + ]); + await writeFile( + witness, + `import {createHash} from "node:crypto";const expected=${JSON.stringify(expected)};const mismatches=expected.filter(([key,hash])=>createHash("sha256").update(process.env[key]??"").digest("hex")!==hash).map(([key])=>key);if(mismatches.length)console.error("Unresolved fixture names: "+mismatches.join(","));process.exitCode=mismatches.length?41:0;` + ); + return await run( + [ + "env", + "exec", + "--path", + project.path, + "--target", + "host", + "--", + process.execPath, + witness, + ], + project.path + ); + }; + const snapshot = async () => { + const files: Record = {}; + const walk = async (path: string) => { + for (const entry of await readdir(path, { withFileTypes: true })) { + if (entry.name === ".git") { + continue; + } + const item = join(path, entry.name); + if (entry.isDirectory()) { + await walk(item); + } else if (entry.isFile()) { + files[relative(root, item)] = sha(await readFile(item)); + } + } + }; + for (const path of [ + tracked.path, + untracked.path, + worktree.path, + hackHome, + ]) { + await walk(path); + } + return Object.fromEntries( + Object.entries(files).sort(([a], [b]) => a.localeCompare(b)) + ); + }; + return { + root, + home, + hackHome, + globalConfig, + globalStore, + globalKeyPath, + keyPath, + storePath, + registry, + tracked, + untracked, + worktree, + sentinels, + run, + resolveValues, + snapshot, + git, + dispose: async () => { + for (const child of active) { + child.kill("SIGKILL"); + } + await Promise.all([...active].map((child) => child.exited)); + await rm(root, { recursive: true, force: true }); + }, + }; + } catch (error) { + await rm(root, { recursive: true, force: true }); + throw error; + } +} diff --git a/tests/python-free-download.test.ts b/tests/python-free-download.test.ts index 731b20d01..76348709d 100644 --- a/tests/python-free-download.test.ts +++ b/tests/python-free-download.test.ts @@ -14,7 +14,7 @@ import { hash, jsonBytes } from "../scripts/installer/integrity.ts"; import { TransferBudget, TransferCleanupUncertain, -} from "../scripts/installer/transfer-budget.ts"; +} from "../src/lib/release-transfer-budget.ts"; const roots: string[] = []; afterEach(() => { @@ -95,7 +95,7 @@ test("download checks redirects before request and refuses short, oversized, int ); await expect( download("https://github.com/file", join(root, "interrupted"), 10) - ).rejects.toThrow("interruption"); + ).rejects.toThrow("interrupted"); mock.mockImplementation( fetchMock((_url, options) => { expect(options?.redirect).toBe("manual"); @@ -584,8 +584,17 @@ for (const scenario of ["success", "total", "stall", "cleanup"] as const) { (value) => ({ value, error: undefined as unknown }), (error) => ({ value: undefined, error }) ); - await drain(); if (scenario === "stall" || scenario === "cleanup") { + // Advance the fake clock once asynchronous disk writes/close reach the + // request whose timeout and cancellation this case models. + for ( + let spin = 0; + spin < 100 && calls < (scenario === "stall" ? 2 : 5); + spin++ + ) { + await drain(); + } + expect(calls).toBe(scenario === "stall" ? 2 : 5); clock.advance(31); await drain(); if (scenario === "cleanup") { diff --git a/tests/python-free-installer.test.ts b/tests/python-free-installer.test.ts index 64e47e4da..650a12c6a 100644 --- a/tests/python-free-installer.test.ts +++ b/tests/python-free-installer.test.ts @@ -19,7 +19,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { gunzipSync, gzipSync } from "node:zlib"; import { extractArchive } from "../scripts/installer/bundle.ts"; -import { Channel, launcherBytes } from "../scripts/installer/channel.ts"; +import { + Channel, + launcherBytes, + rollbackUninstall, +} from "../scripts/installer/channel.ts"; import { download, secureUrl } from "../scripts/installer/download.ts"; import { archiveEntries, @@ -1053,3 +1057,117 @@ fixtureTest( ).toBe(false); } ); + +fixtureTest( + "uninstall previews read-only, retains complete homes on apply and rolls back without replacing an occupied root", + async () => { + const f = await installed(); + const home = join(f.channel.root, "versions/5.0.0-next.1/native-home"); + const sentinel = "synthetic-retained-home-value"; + writeFileSync(join(home, "user-state"), sentinel); + const before = readdirSync(f.root).sort(); + const preview = f.channel.uninstall(); + expect(preview.applied).toBe(false); + expect(readdirSync(f.root).sort()).toEqual(before); + expect(existsSync(preview.manifest)).toBe(false); + const result = f.channel.uninstall(true); + expect(result.applied).toBe(true); + expect(existsSync(f.channel.root)).toBe(false); + expect( + readFileSync( + join(result.to, "versions/5.0.0-next.1/native-home/user-state"), + "utf8" + ) + ).toBe(sentinel); + expect(readFileSync(result.manifest, "utf8").includes(sentinel)).toBe( + false + ); + mkdirSync(f.channel.root, { mode: 0o700 }); + expect(() => + rollbackUninstall(f.channel.root, result.manifest, f.source) + ).toThrow("occupied"); + rmSync(f.channel.root, { recursive: true }); + rollbackUninstall(f.channel.root, result.manifest, f.source); + f.channel.validate(); + expect(readFileSync(join(home, "user-state"), "utf8")).toBe(sentinel); + } +); +fixtureTest( + "uninstall refuses a busy candidate without moving it or writing a manifest", + async () => { + let busy = false; + const f = fixture({ + quiescence: () => { + if (busy) { + throw new Error("fixture active candidate"); + } + }, + }); + const input = fixtureArchive(f.root); + await f.channel.install("5.0.0-next.1", input.archive, input.checksum); + busy = true; + const before = readdirSync(f.root).sort(); + expect(() => f.channel.uninstall(true)).toThrow("active candidate"); + expect(readdirSync(f.root).sort()).toEqual(before); + f.channel.validate(); + } +); +fixtureTest( + "fresh install reclaims only recognized private abandoned stages", + async () => { + const f = fixture(); + const stage = join(f.channel.root, `.stage-${"a".repeat(32)}`); + mkdirSync(stage, { mode: 0o700 }); + writeFileSync(join(stage, "partial-download"), "partial bytes"); + const input = fixtureArchive(f.root); + await f.channel.install("5.0.0-next.1", input.archive, input.checksum); + expect(existsSync(stage)).toBe(false); + f.channel.validate(); + } +); +fixtureTest( + "unsafe abandoned stage refuses without deleting its contents", + async () => { + const f = fixture(); + const stage = join(f.channel.root, `.stage-${"b".repeat(32)}`); + mkdirSync(stage, { mode: 0o700 }); + chmodSync(stage, 0o755); + writeFileSync(join(stage, "keep"), "unrecognized ownership boundary"); + const input = fixtureArchive(f.root); + await expect( + f.channel.install("5.0.0-next.1", input.archive, input.checksum) + ).rejects.toThrow("mode"); + expect(readFileSync(join(stage, "keep"), "utf8")).toBe( + "unrecognized ownership boundary" + ); + } +); + +fixtureTest( + "failed uninstall publication removes only its orphan manifest and preserves the channel", + async () => { + let outer = ""; + const f = fixture({ + checkpoint: (phase) => { + if (phase !== "uninstall-manifest") { + return; + } + const manifest = readdirSync(outer).find( + (name) => name.endsWith(".json") && name.includes(".uninstalled-") + ); + if (!manifest) { + throw new Error("Missing fixture uninstall manifest"); + } + mkdirSync(join(outer, manifest.slice(0, -5)), { mode: 0o700 }); + }, + }); + outer = f.root; + expect(() => f.channel.uninstall(true)).toThrow("destination exists"); + expect( + readdirSync(f.root).some( + (name) => name.endsWith(".json") && name.includes(".uninstalled-") + ) + ).toBe(false); + f.channel.validate(); + } +); diff --git a/tests/upgrade-installer.test.ts b/tests/upgrade-installer.test.ts new file mode 100644 index 000000000..264437793 --- /dev/null +++ b/tests/upgrade-installer.test.ts @@ -0,0 +1,589 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { writeReleaseArchive } from "../scripts/build-release.ts"; +import { download } from "../scripts/installer/download.ts"; +import { publishRelease } from "../src/lib/release-install.ts"; +import { verifyReleaseDirectory } from "../src/lib/release-integrity.ts"; +import { + type ReleaseFetch, + transferRelease, +} from "../src/lib/release-transfer.ts"; +import { + TransferBudget, + TransferCleanupUncertain, +} from "../src/lib/release-transfer-budget.ts"; +import { + downloadAndExtractRelease, + installExtractedRelease, +} from "../src/lib/self-update.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const roots: string[] = []; +const servers: ReturnType[] = []; +afterEach(async () => { + for (const server of servers.splice(0)) { + await server.stop(true); + } + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); +async function scratch() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-upgrade-installer-")) + ); + roots.push(root); + return root; +} +const official = + "https://github.com/hack-dance/hack/releases/download/v4.3.1/hack-4.3.1-linux-x86_64.tar.gz"; +const sha = (bytes: string | Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); +function serverFixture(handler: (count: number) => Response) { + let count = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => handler(++count), + }); + servers.push(server); + // Production still validates every official URL and redirect. Only this + // explicit function maps that URL to an ephemeral loopback server. + const request: ReleaseFetch = (_url, init) => + fetch(`http://127.0.0.1:${server.port}/asset`, init); + return { request, calls: () => count }; +} +async function releaseFixture( + root: string, + version = "4.3.1", + fault?: "checksum" | "version" +) { + const release = join(root, `hack-${version}-release`); + await mkdir(join(release, "assets"), { recursive: true }); + await mkdir(join(release, "binaries")); + const files: Record = { + hack: `#!/bin/sh\ncase "$0" in */.hack-update-stage-*/hack) ;; *) exit 93 ;; esac\nprintf '%s\\n' 'hack v${fault === "version" ? "4.3.0" : version}'\n`, + "hack-config-compiler": "#!/bin/sh\nexit 0\n", + "assets/schema.json": "new schema\n", + "binaries/tool": "bundled executable\n", + }; + for (const [name, bytes] of Object.entries(files)) { + await writeFile(join(release, name), bytes, { + mode: name.startsWith("hack") ? 0o755 : 0o600, + }); + } + const archive = await writeReleaseArchive({ + releaseRoot: root, + version, + target: { platform: "linux", arch: "x86_64" }, + }); + if (fault === "checksum") { + await writeFile(join(release, "assets/schema.json"), "corrupted payload\n"); + // Corrupt payload without changing the producer's checksum inventory. + const child = Bun.spawn( + ["tar", "-czf", archive, "-C", root, `hack-${version}-release`], + { + env: { ...process.env, COPYFILE_DISABLE: "1" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + } + ); + const [code] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(code).toBe(0); + } + return { release, archive, bytes: new Uint8Array(await readFile(archive)) }; +} + +for (const fault of ["checksum", "version"] as const) { + test(`stable update refuses a release with wrong ${fault} before publication`, async () => { + const root = await scratch(); + const f = await releaseFixture(root, "4.3.1", fault); + const server = serverFixture(() => new Response(f.bytes)); + const before = (await readdir(tmpdir())) + .filter((name) => name.startsWith("hack-update-")) + .sort(); + const result = await downloadAndExtractRelease({ + url: official, + version: "4.3.1", + request: server.request, + }); + expect(result.ok).toBe(fault === "version"); + if (result.ok) { + // Version execution belongs to the executable stage beside bin/hack, + // never os.tmpdir(), which may be mounted noexec. + try { + const install = await installExtractedRelease({ + version: "4.3.1", + extractedReleaseDir: result.extractedReleaseDir, + binaryPath: join(root, "bin/hack"), + assetsDir: join(root, "installed-assets"), + }); + expect(install.ok).toBe(false); + if (!install.ok) { + expect(install.error).toContain("version does not match"); + } + expect(await Bun.file(join(root, "bin/hack")).exists()).toBe(false); + } finally { + await rm(result.tmpDir, { recursive: true, force: true }); + } + } else { + expect(result.error).toContain("checksum mismatch"); + } + expect( + (await readdir(tmpdir())) + .filter((name) => name.startsWith("hack-update-")) + .sort() + ).toEqual(before); + }); +} +for (const native of [false, true]) { + test(`${native ? "prerelease" : "stable"} download restarts a truncated transfer and retries 503 within one budget`, async () => { + const root = await scratch(); + const bytes = "complete verified download"; + const f = serverFixture((count) => + count === 1 + ? new Response("retry", { status: 503 }) + : new Response( + new ReadableStream({ + async start(controller) { + controller.enqueue( + new TextEncoder().encode( + count === 2 ? bytes.slice(0, 5) : bytes + ) + ); + await new Promise((resolve) => setTimeout(resolve, 10)); + if (count === 2) { + controller.error(new Error("fixture connection interrupted")); + } else { + controller.close(); + } + }, + }), + { headers: { "content-length": String(bytes.length) } } + ) + ); + const path = join(root, "download"); + const budget = new TransferBudget({ totalMs: 5000, inactivityMs: 1000 }); + try { + if (native) { + await download(official, path, 1024, budget, f.request); + } else { + await transferRelease({ + url: official, + path, + sha256: sha(bytes), + request: f.request, + budget, + }); + } + expect(await readFile(path, "utf8")).toBe(bytes); + expect(f.calls()).toBe(3); + expect(await readdir(root)).toEqual(["download"]); + } finally { + budget.close(); + } + }, 10_000); +} +test("bad checksum, 404, size overflow, stalled body and offline leave no partial install", async () => { + const root = await scratch(); + for (const kind of ["checksum", "404", "size", "offline", "stall"]) { + let calls = 0; + const path = join(root, kind); + const request: ReleaseFetch = async () => { + calls++; + if (kind === "offline") { + throw new TypeError("fixture offline"); + } + if (kind === "stall") { + return new Response( + new ReadableStream({ + pull: () => new Promise(() => {}), + cancel: () => {}, + }) + ); + } + return new Response("payload", { status: kind === "404" ? 404 : 200 }); + }; + await expect( + transferRelease({ + url: official, + path, + request, + sha256: "0".repeat(64), + maxBytes: kind === "size" ? 2 : 1024, + idleMs: kind === "offline" ? 500 : 100, + timeoutMs: 1000, + }) + ).rejects.toThrow(kind === "offline" ? "Network unavailable" : undefined); + expect(await Bun.file(path).exists()).toBe(false); + expect(calls).toBe(kind === "offline" ? 3 : 1); + } +}); +test("uncertain cancellation never retries or masquerades as a completed transfer", async () => { + const root = await scratch(); + let calls = 0; + const budget = new TransferBudget({ + totalMs: 1000, + inactivityMs: 30, + cleanupMs: 30, + }); + try { + await expect( + transferRelease({ + url: official, + path: join(root, "partial"), + budget, + request: async () => { + calls++; + return new Response( + new ReadableStream({ + pull: () => new Promise(() => {}), + cancel: () => new Promise(() => {}), + }) + ); + }, + }) + ).rejects.toBeInstanceOf(TransferCleanupUncertain); + expect(calls).toBe(1); + } finally { + budget.close(); + } +}); +test("existing download and unofficial redirect are never overwritten or followed", async () => { + const root = await scratch(); + const path = join(root, "keep"); + await writeFile(path, "existing bytes"); + const f = serverFixture(() => new Response("other bytes")); + await expect( + transferRelease({ url: official, path, request: f.request }) + ).rejects.toThrow(); + expect(await readFile(path, "utf8")).toBe("existing bytes"); + let calls = 0; + await expect( + transferRelease({ + url: official, + path: join(root, "foreign"), + request: async () => { + calls++; + return Response.redirect("https://foreign.invalid/asset", 302); + }, + }) + ).rejects.toThrow("nonofficial"); + expect(calls).toBe(1); +}); +test("v4 release layout downloads, verifies every payload and executes the selected version", async () => { + const root = await scratch(); + const f = await releaseFixture(root); + const server = serverFixture(() => new Response(f.bytes)); + const result = await downloadAndExtractRelease({ + url: official, + version: "4.3.1", + sha256: sha(f.bytes), + request: server.request, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error(result.error); + } + roots.push(result.tmpDir); + expect( + await Bun.file(join(result.extractedReleaseDir, "hack")).exists() + ).toBe(true); + expect( + await Bun.file( + join(result.extractedReleaseDir, "assets/schema.json") + ).exists() + ).toBe(true); + expect( + await Bun.file(join(result.extractedReleaseDir, "binaries/tool")).exists() + ).toBe(true); + await writeFile(join(f.release, "assets/schema.json"), "tampered"); + await expect(verifyReleaseDirectory(f.release)).rejects.toThrow( + "checksum mismatch" + ); + await writeFile( + join(f.release, "SHA256SUMS"), + `${"0".repeat(64)} ../escape\n` + ); + await expect(verifyReleaseDirectory(f.release)).rejects.toThrow("inventory"); +}); +for (const phase of [ + "staged", + "assets-published", + "compiler-published", + "binary-published", +]) { + test(`stable publication rolls back at ${phase}; a rerun installs matching binary/compiler/assets`, async () => { + const root = await scratch(); + const f = await releaseFixture(root); + const bin = join(root, "bin"); + const assets = join(root, "installed-assets"); + await mkdir(bin); + await mkdir(assets); + const binary = join(bin, "hack"); + const compiler = join(bin, "hack-config-compiler"); + await writeFile(binary, "old CLI"); + await writeFile(compiler, "old compiler"); + await writeFile(join(assets, "schema.json"), "old schema"); + await writeFile(join(assets, "mutagen"), "user managed extension"); + const opts = { + version: "4.3.1", + extractedReleaseDir: f.release, + binaryPath: binary, + assetsDir: assets, + }; + await expect( + publishRelease({ + ...opts, + checkpoint: (point) => { + if (point === phase) { + throw new Error("fixture publication fault"); + } + }, + }) + ).rejects.toThrow("fixture publication fault"); + expect(await readFile(binary, "utf8")).toBe("old CLI"); + expect(await readFile(compiler, "utf8")).toBe("old compiler"); + expect(await readFile(join(assets, "schema.json"), "utf8")).toBe( + "old schema" + ); + expect((await readdir(assets)).sort()).toEqual(["mutagen", "schema.json"]); + const result = await publishRelease(opts); + expect(await readFile(binary, "utf8")).toBe( + await readFile(join(f.release, "hack"), "utf8") + ); + expect(await readFile(compiler, "utf8")).toBe( + await readFile(join(f.release, "hack-config-compiler"), "utf8") + ); + expect(await readFile(join(assets, "schema.json"), "utf8")).toBe( + "new schema\n" + ); + expect(await readFile(join(assets, "mutagen"), "utf8")).toBe( + "user managed extension" + ); + expect(await readFile(join(result.backupManifest, "../hack"), "utf8")).toBe( + "old CLI" + ); + const manifest = await readFile(result.backupManifest, "utf8"); + for (const value of ["old CLI", "old schema", "user managed extension"]) { + expect(manifest.includes(value)).toBe(false); + } + await publishRelease(opts); + expect(await readFile(join(assets, "mutagen"), "utf8")).toBe( + "user managed extension" + ); + expect( + (await readdir(bin)).some((name) => + name.startsWith(".hack-update-stage-") + ) + ).toBe(false); + }); +} +test("stable uninstall can be repeated and preserves all user state", async () => { + const root = await scratch(); + const home = join(root, "home"); + await mkdir(join(home, ".hack/bin"), { recursive: true }); + await writeFile(join(home, ".hack/bin/hack"), "fixture CLI"); + await writeFile(join(home, ".hack/keep"), "synthetic user data"); + for (let attempt = 0; attempt < 2; attempt++) { + const child = Bun.spawn( + [process.execPath, "--no-env-file", "scripts/uninstall-cli.ts"], + { + env: privateCliEnv({ root, home }), + stdout: "pipe", + stderr: "pipe", + timeout: 5000, + } + ); + const [code] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(code).toBe(0); + } + expect(await Bun.file(join(home, ".hack/bin/hack")).exists()).toBe(false); + expect(await readFile(join(home, ".hack/keep"), "utf8")).toBe( + "synthetic user data" + ); +}); + +test("an offline loopback release server fails clearly without a leftover download", async () => { + const root = await scratch(); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response("unused"), + }); + const port = server.port; + await server.stop(true); + const target = join(root, "release"); + await expect( + transferRelease({ + url: official, + path: target, + request: (_url, init) => fetch(`http://127.0.0.1:${port}/offline`, init), + timeoutMs: 3000, + }) + ).rejects.toThrow("Network unavailable"); + expect(await readdir(root)).toEqual([]); +}); + +for (const phase of ["assets-published", "compiler-published"]) { + test(`a real hard kill at ${phase} leaves the previous built CLI able to rerun update`, async () => { + const root = await scratch(); + const bin = join(root, "bin"); + const assets = join(root, "installed-assets"); + await mkdir(bin); + await mkdir(assets); + const binary = join(bin, "hack"); + const compiler = join(bin, "hack-config-compiler"); + // The compiler is a tripwire: update must not need either version of it. + await writeFile(compiler, "#!/bin/sh\nexit 91\n", { mode: 0o755 }); + await writeFile(join(assets, "schema.json"), "old schema"); + const env = privateCliEnv({ + root, + home: join(root, "home"), + env: { + HACK_HOME: join(root, "home/.hack"), + HACK_ASSETS_DIR: assets, + HACK_RUNTIME_BACKEND: "compose", + HACK_NO_INTERACTIVE: "1", + }, + }); + const build = Bun.spawn( + [ + process.execPath, + "--no-env-file", + "build", + "tests/fixtures/update-recovery-cli.ts", + "--compile", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + "--no-compile-autoload-tsconfig", + "--no-compile-autoload-package-json", + "--outfile", + binary, + ], + { env, stdout: "pipe", stderr: "pipe", timeout: 120_000 } + ); + const [buildCode] = await Promise.all([ + build.exited, + new Response(build.stdout).text(), + new Response(build.stderr).text(), + ]); + expect(buildCode).toBe(0); + const before = sha(await readFile(binary)); + const release = await releaseFixture(root); + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + "tests/fixtures/update-publication-child.ts", + release.release, + binary, + assets, + phase, + ], + { env, stdout: "pipe", stderr: "pipe", timeout: 10_000 } + ); + await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(child.signalCode).toBe("SIGKILL"); + expect(sha(await readFile(binary))).toBe(before); + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + if (new URL(request.url).pathname.includes("/releases/latest")) { + return Response.json({ + tag_name: "v4.3.1", + assets: [ + { + name: `hack-4.3.1-${process.platform}-${process.arch === "x64" ? "x86_64" : "arm64"}.tar.gz`, + browser_download_url: official, + digest: `sha256:${sha(release.bytes)}`, + }, + ], + }); + } + return new Response(release.bytes); + }, + }); + servers.push(server); + const mutagen = join(root, "mutagen"); + await writeFile(mutagen, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + const retry = Bun.spawn([binary, "update", "--yes", "--json"], { + env: { + ...env, + HACK_TEST_UPDATE_FIXTURE_ENDPOINT: `http://127.0.0.1:${server.port}`, + HACK_MUTAGEN_PATH: mutagen, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + }); + const [code, stdout, stderr] = await Promise.all([ + retry.exited, + new Response(retry.stdout).text(), + new Response(retry.stderr).text(), + ]); + expect(code, stderr + stdout).toBe(0); + expect(await readFile(binary, "utf8")).toBe( + await readFile(join(release.release, "hack"), "utf8") + ); + expect(await readFile(compiler, "utf8")).toBe( + await readFile(join(release.release, "hack-config-compiler"), "utf8") + ); + expect(await readFile(join(assets, "schema.json"), "utf8")).toBe( + "new schema\n" + ); + }, 150_000); +} + +test("stage reclamation needs a private matching inode receipt and never removes unrecognized files", async () => { + const { markReleaseStage, reclaimReleaseStages } = await import( + "../src/lib/release-staging.ts" + ); + const root = await scratch(); + const binary = join(root, "hack"); + const recognized = join(root, ".hack-update-stage-ABC123"); + const unknown = join(root, ".hack-update-stage-DEF456"); + const malformed = join(root, ".hack-update-stage-GHI789"); + for (const path of [recognized, unknown, malformed]) { + await mkdir(path, { mode: 0o700 }); + } + await markReleaseStage(recognized, binary); + await writeFile(join(recognized, "hack"), "partial executable"); + await writeFile(join(unknown, "keep"), "unrecognized content"); + await writeFile(join(malformed, "stage.json"), "{", { mode: 0o600 }); + await reclaimReleaseStages(binary); + expect((await readdir(root)).sort()).toEqual([ + ".hack-update-stage-DEF456", + ".hack-update-stage-GHI789", + ]); + expect(await readFile(join(unknown, "keep"), "utf8")).toBe( + "unrecognized content" + ); +}); diff --git a/tests/v4-upgrade.test.ts b/tests/v4-upgrade.test.ts new file mode 100644 index 000000000..55c752206 --- /dev/null +++ b/tests/v4-upgrade.test.ts @@ -0,0 +1,281 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { mkdtemp, readdir, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import packageJson from "../package.json"; +import { verifyStagedReleaseVersion } from "../src/lib/release-version.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; +import { createV4UpgradeFixture } from "./helpers/v4-upgrade-fixture.ts"; + +const repository = resolve(import.meta.dir, ".."); +let buildRoot = ""; +let binary = ""; +beforeAll(async () => { + buildRoot = await realpath( + await mkdtemp(join(tmpdir(), "hack-upgrade-cli-")) + ); + binary = join(buildRoot, "hack"); + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + "build", + "tests/fixtures/v4-upgrade-cli.ts", + "--compile", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + "--no-compile-autoload-tsconfig", + "--no-compile-autoload-package-json", + "--outfile", + binary, + ], + { + cwd: repository, + env: privateCliEnv({ root: buildRoot }), + stdout: "pipe", + stderr: "pipe", + timeout: 120_000, + } + ); + const [code] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + if (code !== 0) { + throw new Error("Current-source upgrade CLI build failed."); + } + const fence = Bun.spawn([binary, "--upgrade-fixture-network-check"], { + env: privateCliEnv({ root: buildRoot }), + stdout: "pipe", + stderr: "pipe", + timeout: 5000, + }); + const [fenceCode] = await Promise.all([ + fence.exited, + new Response(fence.stdout).text(), + new Response(fence.stderr).text(), + ]); + if (fenceCode !== 0) { + throw new Error("Compiled upgrade CLI network fence was not effective."); + } + // Exercise the updater's exact parser with real compiled CLI output. + await verifyStagedReleaseVersion(binary, packageJson.version); +}, 125_000); +afterAll(async () => { + if (buildRoot) { + await rm(buildRoot, { recursive: true, force: true }); + } +}); + +for (const globalKeyPresent of [true, false]) { + test(`built CLI upgrades historical v4 state with global key ${globalKeyPresent ? "present" : "lost"}, preserving every live key and value`, async () => { + const f = await createV4UpgradeFixture({ binary, globalKeyPresent }); + try { + const keyBefore = await readFile(f.keyPath); + const storeBefore = await readFile(f.globalStore); + const configBefore = await readFile(f.globalConfig); + const legacyBefore = await f.snapshot(); + const doctor = await f.run(["doctor", "--path", f.tracked.path]); + expect(doctor.output).toContain("migrate-env-config"); + expect(await f.snapshot()).toEqual(legacyBefore); + if (!globalKeyPresent) { + // The documented missing-key recovery retires only an unused store; + // a live decryptable store must never be retired as cleanup. + expect( + (await f.run(["secrets", "retire-store", "--path", f.untracked.path])) + .code + ).toBe(1); + expect((await readFile(f.globalStore)).equals(storeBefore)).toBe(true); + const retire = await f.run([ + "secrets", + "retire-store", + "--path", + f.untracked.path, + "--yes", + "--allow-unchecked", + ]); + expect(retire.code, retire.output).toBe(0); + } + // Env execution can implicitly migrate. Qualify pre-upgrade resolution on + // an independent byte-equivalent fixture, leaving explicit consent input intact. + const baseline = await createV4UpgradeFixture({ + binary, + globalKeyPresent, + }); + try { + for (const project of [ + baseline.tracked, + baseline.untracked, + baseline.worktree, + ]) { + const resolution = await baseline.resolveValues(project); + expect(resolution.code, `${project.path}: ${resolution.output}`).toBe( + 0 + ); + } + } finally { + await baseline.dispose(); + } + const beforeRefusal = await f.snapshot(); + const refused = await f.run([ + "doctor", + "--path", + f.tracked.path, + "--migrate-env-config", + ]); + expect(refused.code).toBe(1); + expect(refused.output).toContain("--accept-tracked-env-changes"); + expect(await f.snapshot()).toEqual(beforeRefusal); + const apply = await f.run([ + "doctor", + "--path", + f.tracked.path, + "--migrate-env-config", + "--accept-tracked-env-changes", + ]); + expect(apply.output).not.toContain("E_INTERACTIVE_REQUIRED"); + // Assertions carry booleans/digests only, including their failure diagnostics. + expect((await readFile(f.keyPath)).equals(keyBefore)).toBe(true); + expect(await Bun.file(f.storePath).exists()).toBe(false); + expect( + await Bun.file(join(f.tracked.path, ".hack/hack.env.json")).exists(), + apply.output + ).toBe(false); + expect(await Bun.file(join(f.tracked.path, ".hack/.env")).exists()).toBe( + false + ); + expect( + await Bun.file( + join(f.tracked.path, ".hack/hack.env.default.yaml") + ).exists() + ).toBe(true); + expect( + await Bun.file( + join(f.tracked.path, ".hack/hack.env.local.yaml") + ).exists() + ).toBe(true); + expect((await f.resolveValues(f.tracked)).code).toBe(0); + // The linked checkout still resolves its independently declared plaintext value. + expect((await f.resolveValues(f.worktree)).code).toBe(0); + for (const project of [f.worktree, f.untracked]) { + const flags = + project === f.worktree ? ["--accept-tracked-env-changes"] : []; + const result = await f.run([ + "doctor", + "--path", + project.path, + "--migrate-env-config", + ...flags, + ]); + expect(result.output).not.toContain("E_INTERACTIVE_REQUIRED"); + expect((await f.resolveValues(project)).code).toBe(0); + } + expect((await readFile(f.keyPath)).equals(keyBefore)).toBe(true); + expect((await readFile(f.globalConfig)).equals(configBefore)).toBe(true); + if (globalKeyPresent) { + expect((await readFile(f.globalStore)).equals(storeBefore)).toBe(true); + } + const stable = await f.snapshot(); + const changed = Object.keys({ ...legacyBefore, ...stable }).filter( + (name) => legacyBefore[name] !== stable[name] + ); + const commands = changed.filter((name) => + name.startsWith("home/.hack/host-commands/") + ); + expect(commands.length).toBe(4); + for (const name of commands) { + const journal = await readFile(join(f.root, name), "utf8"); + expect(f.sentinels.some((value) => journal.includes(value))).toBe( + false + ); + } + const expected = [ + "linked/.hack/docker-compose.yml", + "linked/.hack/.gitignore", + "tracked/.hack/.env", + "tracked/.hack/docker-compose.yml", + "tracked/.hack/hack.config.json", + "tracked/.hack/hack.env.json", + "tracked/legacy-store.json", + "tracked/.gitignore", + "tracked/.hack/.gitignore", + "tracked/.hack/hack.env.default.yaml", + "tracked/.hack/hack.env.local.yaml", + "untracked/.hack/.env", + "untracked/.hack/docker-compose.yml", + "untracked/.hack/hack.env.json", + "untracked/.hack/.gitignore", + "untracked/.hack/hack.env.default.yaml", + "untracked/.hack/hack.env.local.yaml", + ...(globalKeyPresent + ? ["untracked/.gitignore", "untracked/.hack.secret.key"] + : [ + "home/.hack/secrets.enc.json", + "home/.hack/secrets-file.key", + "home/.hack//secrets.enc.json", + ]), + ]; + expect( + changed + .filter((name) => !commands.includes(name)) + .map((name) => + name.startsWith("home/.hack/secrets.enc.json.retired-") + ? "home/.hack//secrets.enc.json" + : name + ) + .sort() + ).toEqual(expected.sort()); + await f.run([ + "doctor", + "--path", + f.tracked.path, + "--migrate-env-config", + "--accept-tracked-env-changes", + ]); + expect(await f.snapshot()).toEqual(stable); + if (globalKeyPresent) { + const retire = await f.run([ + "secrets", + "retire-store", + "--path", + f.untracked.path, + "--yes", + "--allow-unchecked", + ]); + expect(retire.code).toBe(1); + expect(retire.output).toContain("nothing to retire"); + expect((await readFile(f.globalStore)).equals(storeBefore)).toBe(true); + } else { + expect(await Bun.file(f.globalStore).exists()).toBe(false); + const retired = (await readdir(f.hackHome)).filter((name) => + name.includes("retired") + ); + expect(retired.length).toBe(1); + expect( + ( + await readFile( + join(f.hackHome, retired[0] ?? "missing", "secrets.enc.json") + ) + ).equals(storeBefore) + ).toBe(true); + } + expect((await readFile(f.keyPath)).equals(keyBefore)).toBe(true); + if (globalKeyPresent) { + expect(await Bun.file(f.globalKeyPath).exists()).toBe(true); + } + // Migration may initialize a fresh global backend after the unused lost-key + // store is retired. The retired ciphertext must remain byte-identical. + else if (await Bun.file(f.globalKeyPath).exists()) { + expect( + (await readFile(f.globalKeyPath, "utf8")).trim().length > 0 + ).toBe(true); + } + for (const project of [f.tracked, f.untracked, f.worktree]) { + expect((await f.resolveValues(project)).code).toBe(0); + } + } finally { + await f.dispose(); + } + }, 120_000); +}