diff --git a/.agents/cloud-setup.sh b/.agents/cloud-setup.sh index 7f589553..c913cade 100755 --- a/.agents/cloud-setup.sh +++ b/.agents/cloud-setup.sh @@ -56,8 +56,8 @@ BUN_CACHE="/var/cache/stll-cloud/bun/$OUTPUT_UID" [[ "$(awk 'NF { if (++count > 1 || NF != 1) exit 1; value=$1 } END { if (count != 1) exit 1; print value }' "$REPO_ROOT/.node-version")" == "$NODE_VERSION" ]] || fail 'Node selector changed; regenerate cloud-setup.sh' cd -- "$REPO_ROOT" case "$(uname -m)" in - x86_64) ARCH=amd64; NODE_ARCH=x64; BUN_ARCH=x64 ;; - aarch64|arm64) ARCH=arm64; NODE_ARCH=arm64; BUN_ARCH=aarch64 ;; + x86_64) ARCH=amd64; NODE_ARCH=x64; BUN_ARCH=x64; NODE_SHA256='ca70e9e349de048b9522abb3adc05b3bd6f43c5ffd3ec57916c7da292f59f022'; BUN_SHA256='bf8ccbfb2b0b80159e0f9a6d15c55317add8077534dbc6130a3edb52fccb395c' ;; + aarch64|arm64) ARCH=arm64; NODE_ARCH=arm64; BUN_ARCH=aarch64; NODE_SHA256='7a6353f63eb3d04765004b4adf172616243e4522434635cb1d26288658b04ab5'; BUN_SHA256='baa6bb36f51d6e58561e02bbea825038a9df454f673d6d776e8bbf86f8c7850e' ;; *) fail 'Only amd64 and arm64 are supported' ;; esac @@ -209,6 +209,7 @@ install_runtimes() ( if ! node_ready; then node_archive="node-v$NODE_VERSION-linux-$NODE_ARCH.tar.xz" download_file "https://nodejs.org/dist/v$NODE_VERSION/$node_archive" "$scratch/$node_archive" 120 + (cd "$scratch"; printf '%s %s\n' "$NODE_SHA256" "$node_archive" | sha256sum --strict -c -) download_file "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt" "$scratch/node-sums" 45 (cd "$scratch"; awk -v name="$node_archive" '$2 == name { print; count++ } END { if (count != 1) exit 1 }' node-sums | sha256sum --strict -c -) install -d -m 755 "$NODE_DIR" @@ -218,6 +219,7 @@ install_runtimes() ( bun_folder="bun-linux-$BUN_ARCH" bun_archive="$bun_folder.zip" download_file "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$bun_archive" "$scratch/$bun_archive" 120 + (cd "$scratch"; printf '%s %s\n' "$BUN_SHA256" "$bun_archive" | sha256sum --strict -c -) download_file "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/SHASUMS256.txt" "$scratch/bun-sums" 45 (cd "$scratch"; awk -v name="$bun_archive" '$2 == name || $2 == "*" name { print $1 " " name; count++ } END { if (count != 1) exit 1 }' bun-sums | sha256sum --strict -c -) unzip -q "$scratch/$bun_archive" -d "$scratch/bun" @@ -310,20 +312,20 @@ apt_install() ( policy_state=installed printf '%s\n' '#!/bin/sh' 'exit 101' > /usr/sbin/policy-rc.d chmod 755 /usr/sbin/policy-rc.d - local package + # The network deadline covers downloads only; unpacking and maintainer scripts + # (for example locale generation or cluster creation) run offline afterwards. + DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only "$@" + local package pending for package in "$@"; do if [[ "$package" == */* ]]; then - # apt cannot fetch local archives with --download-only, so their installs - # stay one call. Package triggers can be slow on cloud filesystems; the - # longer whole-call deadline covers them while Acquire timeouts still - # bound each network request. - APT_DEADLINE=600 DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends "$@" + # apt rejects --no-download for local archives, so prove instead that + # only local file: archives remain; --print-uris reads local state only. + pending="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '$1 !~ /^\047file:/')" + [[ -z "$pending" ]] || fail "Package downloads remain after the bounded download step: $pending" + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" return fi done - # The network deadline covers downloads only; unpacking and maintainer scripts - # (for example locale generation or cluster creation) run offline afterwards. - DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only "$@" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" ) diff --git a/.changeset/cloud-runtime-checksums.md b/.changeset/cloud-runtime-checksums.md new file mode 100644 index 00000000..149bcf11 --- /dev/null +++ b/.changeset/cloud-runtime-checksums.md @@ -0,0 +1,5 @@ +--- +"@stll/oxlint-config": minor +--- + +Cloud setup verifies Node and Bun archives against SHA256 values recorded at generation time. The shared policy records them for its own releases; a repository on another accepted Node patch or a newer Bun records that release's values in `cloud.runtimeArchiveSha256`. Local package archives now download under the network deadline, and their installation, package triggers and maintainer scripts run without one. diff --git a/packages/oxlint-config/README.md b/packages/oxlint-config/README.md index 4c205760..2baeeaaf 100644 --- a/packages/oxlint-config/README.md +++ b/packages/oxlint-config/README.md @@ -464,6 +464,13 @@ it gets no network allowance. Generation fails when the script is not in the root `package.json`. Generation uses the installed policy's Bun, Node, PostgreSQL, and Valkey pins. +Node and Bun archives are verified against SHA256 values recorded at generation +time, in addition to the upstream checksum manifests. The shared policy records +them for its own Node patch and Bun release. A repository that tracks another +accepted Node patch or a newer Bun records that one release's amd64 and arm64 +values from the official `SHASUMS256.txt` in `cloud.runtimeArchiveSha256`, for +example `{ "node": { "26.11.0": { "amd64": "…", "arm64": "…" } } }`; a release +the shared policy pins must not be repeated there. PostgreSQL uses the PGDG major-version packages; minor updates within the pinned major are accepted for the disposable database. Before adding PGDG, provisioning reuses an existing source only after verifying its signing-key fingerprint; diff --git a/packages/oxlint-config/src/archive-pins.ts b/packages/oxlint-config/src/archive-pins.ts new file mode 100644 index 00000000..bdf51ea4 --- /dev/null +++ b/packages/oxlint-config/src/archive-pins.ts @@ -0,0 +1,36 @@ +import { valid } from "semver"; + +export type ArchivePins = Readonly< + Record +>; + +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** Parse a map from exact releases to their amd64 and arm64 archive SHA256 values. */ +export const parseArchiveSha256 = ( + input: unknown, + label: string, +): ArchivePins => { + if (input === undefined) return {}; + if (!record(input)) throw new Error(`${label} must be a version map`); + const releases: Record = {}; + for (const [version, value] of Object.entries(input)) { + if (valid(version) !== version || !record(value)) + throw new Error(`${label} requires exact release keys`); + const keys = Object.keys(value); + if (keys.length !== 2 || !keys.includes("amd64") || !keys.includes("arm64")) + throw new Error(`${label} ${version} requires amd64 and arm64 pins`); + const amd64 = value["amd64"]; + const arm64 = value["arm64"]; + if ( + typeof amd64 !== "string" || + typeof arm64 !== "string" || + !/^[a-f0-9]{64}$/.test(amd64) || + !/^[a-f0-9]{64}$/.test(arm64) + ) + throw new Error(`${label} ${version} requires lowercase SHA256 pins`); + releases[version] = { amd64, arm64 }; + } + return releases; +}; diff --git a/packages/oxlint-config/src/cloud-setup-install.test.ts b/packages/oxlint-config/src/cloud-setup-install.test.ts index 2dd7dd4c..814b2280 100644 --- a/packages/oxlint-config/src/cloud-setup-install.test.ts +++ b/packages/oxlint-config/src/cloud-setup-install.test.ts @@ -32,6 +32,7 @@ const runInstall = ( exitCode: number, offlineExitCode = 0, packages: string[] = ["fixture-package"], + uris = "", ) => { expect(fragment).not.toContain("/var/lock/"); expect(fragment).not.toContain("/usr/sbin/"); @@ -53,6 +54,11 @@ apt_network() { apt-get() { [[ "$DEBIAN_FRONTEND" == noninteractive ]] [[ "$(cat "$FIXTURE_POLICY")" == $'#!/bin/sh\nexit 101' ]] + if [[ "$1" == --print-uris ]]; then + printf 'resolve:%s\n' "$*" >> "$FIXTURE_CALLS" + printf '%s' "$FIXTURE_URIS" + return 0 + fi printf 'offline:%s\n' "$*" >> "$FIXTURE_CALLS" return "$FIXTURE_OFFLINE_EXIT" } @@ -71,6 +77,7 @@ apt_install "$@" FIXTURE_CALLS: join(directory, "apt.calls"), FIXTURE_EXIT: String(exitCode), FIXTURE_OFFLINE_EXIT: String(offlineExitCode), + FIXTURE_URIS: uris, }, }, ); @@ -142,15 +149,23 @@ test("service installation uses shared bounded network helpers for every downloa ...parseToolchainPolicy(policy), services, }); - // The only raw apt-get is the offline install, which cannot download. + // Every download runs under the network deadline first. The raw apt-get + // calls afterwards either cannot download (--no-download), only read + // local state (--print-uris), or install local archives once that read + // proved nothing remote remains. const offline = ' DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@"\n'; + const pending = + ` pending="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '$1 !~ /^\\047file:/')"\n` + + ' [[ -z "$pending" ]] || fail "Package downloads remain after the bounded download step: $pending"\n' + + ' DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"\n'; expect(rendered.split(offline)).toHaveLength(2); - expect(rendered.replace(offline, "")).not.toMatch( + expect(rendered.split(pending)).toHaveLength(2); + expect(rendered.replace(offline, "").replace(pending, "")).not.toMatch( /\b(?:curl|wget|apt-get)\b/, ); expect(rendered).toContain( - 'DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only "$@"\n DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@"\n)', + ' DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only "$@"\n local package pending\n', ); expect(rendered).not.toContain("apt_network update"); const refreshes = [ @@ -549,14 +564,45 @@ test("offline package configuration failures propagate and still restore the pol } }, 15_000); -test("local package archives install in one bounded step because apt cannot pre-download them", () => { +test("local package archives download under the deadline, then install without one once only local archives remain", () => { const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); try { - const result = runInstall(directory, 0, 0, ["/tmp/a.deb", "/tmp/b.deb"]); + // apt still lists local archives as file: URIs after every download. + const uris = "'file:/tmp/a.deb' a.deb 1 \n'file:/tmp/b.deb' b.deb 1 \n"; + const result = runInstall( + directory, + 0, + 0, + ["/tmp/a.deb", "/tmp/b.deb"], + uris, + ); expect(result.stderr.toString()).toBe(""); expect(result.exitCode).toBe(0); expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( - "network@600:install -y --no-install-recommends /tmp/a.deb /tmp/b.deb\n", + "network:install -y --no-install-recommends --download-only /tmp/a.deb /tmp/b.deb\n" + + "resolve:--print-uris -qq -y --no-install-recommends install /tmp/a.deb /tmp/b.deb\n" + + "offline:install -y --no-install-recommends /tmp/a.deb /tmp/b.deb\n", + ); + expect(existsSync(join(directory, "policy-rc.d"))).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}, 15_000); + +test("local package archives refuse the unbounded install while remote downloads remain", () => { + const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); + try { + const uris = + "'file:/tmp/a.deb' a.deb 1 \n" + + "'http://archive.test/pool/libl/liblzf1_3.6-4_amd64.deb' liblzf1_3.6-4_amd64.deb 1 SHA256:00\n"; + const result = runInstall(directory, 0, 0, ["/tmp/a.deb"], uris); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toBe( + "Package downloads remain after the bounded download step: 'http://archive.test/pool/libl/liblzf1_3.6-4_amd64.deb' liblzf1_3.6-4_amd64.deb 1 SHA256:00\n", + ); + expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( + "network:install -y --no-install-recommends --download-only /tmp/a.deb\n" + + "resolve:--print-uris -qq -y --no-install-recommends install /tmp/a.deb\n", ); expect(existsSync(join(directory, "policy-rc.d"))).toBe(false); } finally { diff --git a/packages/oxlint-config/src/cloud-setup-install.ts b/packages/oxlint-config/src/cloud-setup-install.ts index 2351d209..681c0a97 100644 --- a/packages/oxlint-config/src/cloud-setup-install.ts +++ b/packages/oxlint-config/src/cloud-setup-install.ts @@ -41,20 +41,20 @@ apt_install() ( policy_state=installed printf '%s\n' '#!/bin/sh' 'exit 101' > /usr/sbin/policy-rc.d chmod 755 /usr/sbin/policy-rc.d - local package + # The network deadline covers downloads only; unpacking and maintainer scripts + # (for example locale generation or cluster creation) run offline afterwards. + DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only "$@" + local package pending for package in "$@"; do if [[ "$package" == */* ]]; then - # apt cannot fetch local archives with --download-only, so their installs - # stay one call. Package triggers can be slow on cloud filesystems; the - # longer whole-call deadline covers them while Acquire timeouts still - # bound each network request. - APT_DEADLINE=600 DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends "$@" + # apt rejects --no-download for local archives, so prove instead that + # only local file: archives remain; --print-uris reads local state only. + pending="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '$1 !~ /^\047file:/')" + [[ -z "$pending" ]] || fail "Package downloads remain after the bounded download step: $pending" + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@" return fi done - # The network deadline covers downloads only; unpacking and maintainer scripts - # (for example locale generation or cluster creation) run offline afterwards. - DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only "$@" DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" ) diff --git a/packages/oxlint-config/src/cloud-setup-regressions.test.ts b/packages/oxlint-config/src/cloud-setup-regressions.test.ts index de80ac4f..96da9885 100644 --- a/packages/oxlint-config/src/cloud-setup-regressions.test.ts +++ b/packages/oxlint-config/src/cloud-setup-regressions.test.ts @@ -18,6 +18,15 @@ import { cloudInstallCommand, parseCloudSetup } from "./cloud-setup-schema"; import { parseToolchainPolicy } from "./toolchain-schema"; const policy = parseToolchainPolicy(policyInput); +const archivePins = ( + releases: Readonly>, + version: string, +) => { + const pins = releases[version]; + if (pins === undefined) + throw new Error(`Missing fixture pins for ${version}`); + return pins; +}; const generate = (rust: boolean) => { const cloud = parseCloudSetup({ install: cloudInstallCommand }); if (cloud === undefined) throw new Error("Cloud fixture missing"); @@ -114,6 +123,58 @@ test("cached Node requires working matching npm and npx launchers", () => { } }, 15_000); +test("Bun archive rejects a matching downloaded SHASUMS entry when the policy pin differs", () => { + withFixture((root) => { + binary(join(root, "node/bin/node"), "v26.10.0"); + binary(join(root, "node/bin/npm"), "10.1.0"); + binary(join(root, "node/bin/npx"), "10.1.0"); + const bundle = join(root, "ca-certificates.crt"); + writeFileSync(bundle, "fixture certificate bundle"); + const functions = runtimeFunctions().replaceAll( + "/etc/ssl/certs/ca-certificates.crt", + bundle, + ); + const result = Bun.spawnSync( + [ + "bash", + "-euo", + "pipefail", + "-c", + ` +fail() { exit 98; } +apt_refresh_for() { :; } +apt_install() { :; } +ldconfig() { printf '%s\\n' libatomic.so.1; } +download_file() { + if [[ "$1" == */SHASUMS256.txt ]]; then + printf '%s %s\\n' "$(printf malicious | sha256sum | cut -d ' ' -f1)" bun-linux-x64.zip > "$2" + else + printf malicious > "$2" + fi +} +${functions} +install_runtimes +`, + ], + { + env: { + ...process.env, + NODE_DIR: join(root, "node"), + NODE_VERSION: "26.10.0", + NODE_ARCH: "x64", + NODE_SHA256: archivePins(policy.nodeArchiveSha256, "26.10.0").amd64, + BUN_DIR: join(root, "bun"), + BUN_VERSION: policy.bun, + BUN_ARCH: "x64", + BUN_SHA256: archivePins(policy.bunArchiveSha256, policy.bun).amd64, + }, + }, + ); + expect(result.exitCode).not.toBe(0); + expect(result.stderr.toString()).toContain("checksum did NOT match"); + }); +}); + test("runtime scratch is removed when download, verification or extraction exits under errexit", () => { for (const phase of ["download", "checksum", "extraction"] as const) { withFixture((root) => { @@ -162,9 +223,11 @@ printf '%s\\n' unreachable NODE_DIR: join(root, "node"), NODE_VERSION: "26.10.0", NODE_ARCH: "x64", + NODE_SHA256: archivePins(policy.nodeArchiveSha256, "26.10.0").amd64, BUN_DIR: join(root, "bun"), BUN_VERSION: policy.bun, BUN_ARCH: "x64", + BUN_SHA256: archivePins(policy.bunArchiveSha256, policy.bun).amd64, }, }, ); diff --git a/packages/oxlint-config/src/cloud-setup-schema.ts b/packages/oxlint-config/src/cloud-setup-schema.ts index 6ee4f7e0..464d7f8d 100644 --- a/packages/oxlint-config/src/cloud-setup-schema.ts +++ b/packages/oxlint-config/src/cloud-setup-schema.ts @@ -1,3 +1,4 @@ +import { type ArchivePins, parseArchiveSha256 } from "./archive-pins"; import { cloudSetupArtifacts, type CloudSetupArtifactTool, @@ -130,6 +131,32 @@ const parseTools = (input: unknown) => { return result; }; +// A repository that selects a Node patch or Bun release the shared policy +// does not pin records the official archive SHA256 values itself, bound to +// that exact release. +const cloudRuntimes = ["node", "bun"] as const; +const parseRuntimeArchives = (input: unknown) => { + if (input === undefined) return undefined; + if ( + !object(input) || + Object.keys(input).some( + (key) => !cloudRuntimes.some((runtime) => runtime === key), + ) + ) + throw new Error("cloud.runtimeArchiveSha256 accepts only node and bun"); + const result: Partial> = + {}; + for (const runtime of cloudRuntimes) { + if (!Object.hasOwn(input, runtime)) continue; + const label = `cloud.runtimeArchiveSha256.${runtime}`; + const pins = parseArchiveSha256(input[runtime], label); + if (Object.keys(pins).length !== 1) + throw new Error(`${label} pins exactly one release`); + result[runtime] = pins; + } + return result; +}; + export const cloudInstallCommand = "bun install --frozen-lockfile"; // Lifecycle scripts (postinstall code generation) can outlast a cloud setup // budget; a repository opts out and lets tasks generate what they need. @@ -209,12 +236,16 @@ export const parseCloudSetup = (input: unknown) => { key !== "rust" && key !== "tools" && key !== "prepare" && - key !== "submodules" + key !== "submodules" && + key !== "runtimeArchiveSha256" ) throw new Error(`unsupported cloud key: ${key}`); const rust = parseRust("rust" in input ? input.rust : undefined); const tools = parseTools("tools" in input ? input.tools : undefined); const prepare = parsePrepare("prepare" in input ? input.prepare : undefined); + const runtimeArchiveSha256 = parseRuntimeArchives( + "runtimeArchiveSha256" in input ? input.runtimeArchiveSha256 : undefined, + ); const submodules = "submodules" in input ? input.submodules : false; if (typeof submodules !== "boolean") throw new Error("cloud.submodules must be a boolean when provided"); @@ -222,6 +253,7 @@ export const parseCloudSetup = (input: unknown) => { ...(rust === undefined ? {} : { rust }), ...(tools === undefined ? {} : { tools }), ...(prepare === undefined ? {} : { prepare }), + ...(runtimeArchiveSha256 === undefined ? {} : { runtimeArchiveSha256 }), }; const selected = "services" in input ? input.services : []; if (!Array.isArray(selected)) diff --git a/packages/oxlint-config/src/cloud-setup.test.ts b/packages/oxlint-config/src/cloud-setup.test.ts index 92ebc66c..1605fbbb 100644 --- a/packages/oxlint-config/src/cloud-setup.test.ts +++ b/packages/oxlint-config/src/cloud-setup.test.ts @@ -314,6 +314,106 @@ test("cloud generation rejects every nonexact or out-of-series Node selector", ( ); }, 15_000); +test("cloud generation embeds the shared archive pins for the policy Node and Bun releases", () => { + const script = generate(declaration); + const node = policy.nodeArchiveSha256["26.10.0"]; + const bun = policy.bunArchiveSha256[policy.bun]; + if (node === undefined || bun === undefined) + throw new Error("Shared policy lacks pins for its own releases"); + expect(script).toContain( + `NODE_SHA256='${node.amd64}'; BUN_SHA256='${bun.amd64}'`, + ); + expect(script).toContain( + `NODE_SHA256='${node.arm64}'; BUN_SHA256='${bun.arm64}'`, + ); +}); + +test("an accepted Node patch or newer Bun without shared pins needs repository pins for that exact release", () => { + const pins = (seed: string) => ({ + amd64: seed.repeat(64), + arm64: seed.repeat(32) + "0".repeat(32), + }); + const run = ( + runtimeArchiveSha256: unknown, + nodeVersion = "26.11.0", + bun = "1.4.5", + ) => { + const cloud = parseCloudSetup({ + ...declaration, + ...(runtimeArchiveSha256 === undefined ? {} : { runtimeArchiveSha256 }), + }); + if (cloud === undefined) throw new Error("Missing cloud declaration"); + return generateCloudSetup({ + policy: { ...policy, bun }, + cloud, + nodeVersion, + }); + }; + expect(() => run(undefined)).toThrow( + "the shared policy has no node 26.11.0 archive SHA256 pins; declare cloud.runtimeArchiveSha256.node for 26.11.0 with the amd64 and arm64 values from https://nodejs.org/dist/v26.11.0/SHASUMS256.txt", + ); + expect(() => run({ node: { "26.11.0": pins("a") } })).toThrow( + "the shared policy has no bun 1.4.5 archive SHA256 pins; declare cloud.runtimeArchiveSha256.bun for 1.4.5 with the amd64 and arm64 values from https://github.com/oven-sh/bun/releases/download/bun-v1.4.5/SHASUMS256.txt", + ); + const script = run({ + node: { "26.11.0": pins("a") }, + bun: { "1.4.5": pins("b") }, + }); + expect(script).toContain( + `NODE_SHA256='${pins("a").amd64}'; BUN_SHA256='${pins("b").amd64}'`, + ); + expect(script).toContain( + `NODE_SHA256='${pins("a").arm64}'; BUN_SHA256='${pins("b").arm64}'`, + ); + // Pins stay bound to the release they were recorded for. + expect(() => + run( + { node: { "26.11.0": pins("a") }, bun: { "1.4.5": pins("b") } }, + "26.12.0", + ), + ).toThrow( + "cloud.runtimeArchiveSha256.node pins 26.11.0, but the repository selects node 26.12.0", + ); + // The shared policy stays the only source for releases it pins. + expect(() => + run({ node: { "26.10.0": pins("a") } }, "26.10.0", policy.bun), + ).toThrow( + "the shared policy pins node 26.10.0 archives; remove cloud.runtimeArchiveSha256.node", + ); +}); + +test("repository runtime archive pins accept exactly one lowercase amd64 and arm64 pair per runtime", () => { + const pin = "c".repeat(64); + for (const [runtimeArchiveSha256, message] of [ + [{ deno: {} }, "cloud.runtimeArchiveSha256 accepts only node and bun"], + [{ node: {} }, "cloud.runtimeArchiveSha256.node pins exactly one release"], + [ + { + node: { + "26.11.0": { amd64: pin, arm64: pin }, + "26.12.0": { amd64: pin, arm64: pin }, + }, + }, + "cloud.runtimeArchiveSha256.node pins exactly one release", + ], + [ + { bun: { "1.4": { amd64: pin, arm64: pin } } }, + "cloud.runtimeArchiveSha256.bun requires exact release keys", + ], + [ + { bun: { "1.4.5": { amd64: pin } } }, + "cloud.runtimeArchiveSha256.bun 1.4.5 requires amd64 and arm64 pins", + ], + [ + { bun: { "1.4.5": { amd64: pin.toUpperCase(), arm64: pin } } }, + "cloud.runtimeArchiveSha256.bun 1.4.5 requires lowercase SHA256 pins", + ], + ] as const) + expect(() => + parseCloudSetup({ ...declaration, runtimeArchiveSha256 }), + ).toThrow(message); +}); + test("shell quoting preserves canonical literal metacharacters through single-pass replacement", () => { withRepository((root) => { const envFile = ".env'\"$`@SERVICE_INSTALL@"; diff --git a/packages/oxlint-config/src/cloud-setup.ts b/packages/oxlint-config/src/cloud-setup.ts index f982cf1b..cd84483b 100644 --- a/packages/oxlint-config/src/cloud-setup.ts +++ b/packages/oxlint-config/src/cloud-setup.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { valid } from "semver"; +import type { ArchivePins } from "./archive-pins"; import { renderCloudServiceInstall } from "./cloud-setup-install"; import { renderCloudNetwork } from "./cloud-setup-network"; import { cloudServices, parseCloudSetup } from "./cloud-setup-schema"; @@ -82,8 +83,8 @@ BUN_CACHE="/var/cache/stll-cloud/bun/$OUTPUT_UID" [[ "$(awk 'NF { if (++count > 1 || NF != 1) exit 1; value=$1 } END { if (count != 1) exit 1; print value }' "$REPO_ROOT/.node-version")" == "$NODE_VERSION" ]] || fail 'Node selector changed; regenerate cloud-setup.sh' cd -- "$REPO_ROOT" case "$(uname -m)" in - x86_64) ARCH=amd64; NODE_ARCH=x64; BUN_ARCH=x64 ;; - aarch64|arm64) ARCH=arm64; NODE_ARCH=arm64; BUN_ARCH=aarch64 ;; + x86_64) ARCH=amd64; NODE_ARCH=x64; BUN_ARCH=x64; NODE_SHA256='@NODE_AMD64_SHA@'; BUN_SHA256='@BUN_AMD64_SHA@' ;; + aarch64|arm64) ARCH=arm64; NODE_ARCH=arm64; BUN_ARCH=aarch64; NODE_SHA256='@NODE_ARM64_SHA@'; BUN_SHA256='@BUN_ARM64_SHA@' ;; *) fail 'Only amd64 and arm64 are supported' ;; esac @@ -177,6 +178,7 @@ install_runtimes() ( if ! node_ready; then node_archive="node-v$NODE_VERSION-linux-$NODE_ARCH.tar.xz" download_file "https://nodejs.org/dist/v$NODE_VERSION/$node_archive" "$scratch/$node_archive" 120 + (cd "$scratch"; printf '%s %s\n' "$NODE_SHA256" "$node_archive" | sha256sum --strict -c -) download_file "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt" "$scratch/node-sums" 45 (cd "$scratch"; awk -v name="$node_archive" '$2 == name { print; count++ } END { if (count != 1) exit 1 }' node-sums | sha256sum --strict -c -) install -d -m 755 "$NODE_DIR" @@ -186,6 +188,7 @@ install_runtimes() ( bun_folder="bun-linux-$BUN_ARCH" bun_archive="$bun_folder.zip" download_file "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/$bun_archive" "$scratch/$bun_archive" 120 + (cd "$scratch"; printf '%s %s\n' "$BUN_SHA256" "$bun_archive" | sha256sum --strict -c -) download_file "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/SHASUMS256.txt" "$scratch/bun-sums" 45 (cd "$scratch"; awk -v name="$bun_archive" '$2 == name || $2 == "*" name { print $1 " " name; count++ } END { if (count != 1) exit 1 }' bun-sums | sha256sum --strict -c -) unzip -q "$scratch/$bun_archive" -d "$scratch/bun" @@ -416,6 +419,47 @@ const renderPrepare = ( ].join("\n"); }; +const runtimeArchiveManifests = { + node: (version: string) => + `https://nodejs.org/dist/v${version}/SHASUMS256.txt`, + bun: (version: string) => + `https://github.com/oven-sh/bun/releases/download/bun-v${version}/SHASUMS256.txt`, +} as const; + +// Shared pins cover the policy releases; a repository on another accepted +// release declares that release's pins itself. One source per release. +const runtimeArchivePins = ({ + runtime, + version, + shared, + declared, +}: { + runtime: keyof typeof runtimeArchiveManifests; + version: string; + shared: ArchivePins; + declared: ArchivePins | undefined; +}) => { + const key = `cloud.runtimeArchiveSha256.${runtime}`; + const sharedPins = shared[version]; + if (declared !== undefined) { + if (sharedPins !== undefined) + throw new Error( + `the shared policy pins ${runtime} ${version} archives; remove ${key}`, + ); + const pins = declared[version]; + if (pins === undefined) + throw new Error( + `${key} pins ${Object.keys(declared).join(", ")}, but the repository selects ${runtime} ${version}; update it with the amd64 and arm64 values from ${runtimeArchiveManifests[runtime](version)}`, + ); + return pins; + } + if (sharedPins === undefined) + throw new Error( + `the shared policy has no ${runtime} ${version} archive SHA256 pins; declare ${key} for ${version} with the amd64 and arm64 values from ${runtimeArchiveManifests[runtime](version)}`, + ); + return sharedPins; +}; + export const generateCloudSetup = ({ policy, cloud, @@ -433,6 +477,21 @@ export const generateCloudSetup = ({ throw new Error( "cloud requires a tracked exact .node-version within the shared Node series", ); + // Callers resolve the repository's Bun (at or above the shared minimum) + // into policy.bun before generation. + const bunVersion = policy.bun; + const nodeArchiveSha256 = runtimeArchivePins({ + runtime: "node", + version: exactNode, + shared: policy.nodeArchiveSha256, + declared: selected.runtimeArchiveSha256?.node, + }); + const bunArchiveSha256 = runtimeArchivePins({ + runtime: "bun", + version: bunVersion, + shared: policy.bunArchiveSha256, + declared: selected.runtimeArchiveSha256?.bun, + }); const toolchains = resolveCloudToolchains({ cloud: selected, files, @@ -450,7 +509,7 @@ export const generateCloudSetup = ({ renderCloudToolInstall(toolchains) + renderCloudShellInstall({ nodeVersion: exactNode, - bunVersion: policy.bun, + bunVersion, rust: toolchains.rust, }), ], @@ -479,8 +538,12 @@ export const generateCloudSetup = ({ ) .join("\n"), ], - ["@BUN@", policy.bun], + ["@BUN@", bunVersion], ["@NODE@", exactNode], + ["@NODE_AMD64_SHA@", nodeArchiveSha256.amd64], + ["@NODE_ARM64_SHA@", nodeArchiveSha256.arm64], + ["@BUN_AMD64_SHA@", bunArchiveSha256.amd64], + ["@BUN_ARM64_SHA@", bunArchiveSha256.arm64], [ "@ENV_ASSIGNMENTS@", selected.envFile === undefined diff --git a/packages/oxlint-config/src/toolchain-schema.ts b/packages/oxlint-config/src/toolchain-schema.ts index 9e103854..65c78a47 100644 --- a/packages/oxlint-config/src/toolchain-schema.ts +++ b/packages/oxlint-config/src/toolchain-schema.ts @@ -1,5 +1,6 @@ import { valid } from "semver"; +import { parseArchiveSha256 } from "./archive-pins"; import { parseFormatterPolicy } from "./toolchain-formatter"; import { nodePolicyValid } from "./toolchain-node"; @@ -85,8 +86,16 @@ export const parseToolchainPolicy = (input: unknown) => { const packages: Record = {}; for (const key of packagePinKeys) packages[key] = string(input, key); const bun = string(input, "bun"); + const bunArchiveSha256 = parseArchiveSha256( + input["bunArchiveSha256"], + "bunArchiveSha256", + ); const typescript = string(input, "typescript"); const node = string(input, "node"); + const nodeArchiveSha256 = parseArchiveSha256( + input["nodeArchiveSha256"], + "nodeArchiveSha256", + ); const consumerNode = string(input, "consumerNode"); const consumerNpm = string(input, "consumerNpm"); const consumerPnpm = string(input, "consumerPnpm"); @@ -219,9 +228,11 @@ export const parseToolchainPolicy = (input: unknown) => { } return { bun, + bunArchiveSha256, formatter: parseFormatterPolicy(input["formatter"]), typescript, node, + nodeArchiveSha256, postgres, valkey, valkeyArtifacts, diff --git a/packages/oxlint-config/toolchain.json b/packages/oxlint-config/toolchain.json index 684a9d6f..3fcdbb78 100644 --- a/packages/oxlint-config/toolchain.json +++ b/packages/oxlint-config/toolchain.json @@ -1,6 +1,12 @@ { "schemaVersion": 1, "bun": "1.4.4", + "bunArchiveSha256": { + "1.4.4": { + "amd64": "bf8ccbfb2b0b80159e0f9a6d15c55317add8077534dbc6130a3edb52fccb395c", + "arm64": "baa6bb36f51d6e58561e02bbea825038a9df454f673d6d776e8bbf86f8c7850e" + } + }, "typescript": "7.0.2", "oxlint": "1.87.0", "@oxlint/plugins": "1.87.0", @@ -68,6 +74,12 @@ "@stll/oxlint-plugin": "0.6.2", "lefthook": "2.1.16", "node": "26.x", + "nodeArchiveSha256": { + "26.10.0": { + "amd64": "ca70e9e349de048b9522abb3adc05b3bd6f43c5ffd3ec57916c7da292f59f022", + "arm64": "7a6353f63eb3d04765004b4adf172616243e4522434635cb1d26288658b04ab5" + } + }, "postgres": "18", "valkey": "9.1.1", "valkeyArtifacts": {