From 8bd014af9b1f5dd7644b76d8df291d040e30a91d Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Sun, 18 Oct 2026 09:05:56 +0200 Subject: [PATCH 1/3] fix(oxlint-config): pin cloud Node and Bun archive checksums and never time out dpkg --- .agents/cloud-setup.sh | 14 ++--- .changeset/cloud-runtime-checksums.md | 5 ++ .../src/cloud-setup-install.test.ts | 27 +++++++-- .../oxlint-config/src/cloud-setup-install.ts | 8 +-- .../src/cloud-setup-regressions.test.ts | 60 +++++++++++++++++++ .../oxlint-config/src/cloud-setup.test.ts | 41 +++++++++++-- packages/oxlint-config/src/cloud-setup.ts | 36 +++++++++-- .../oxlint-config/src/toolchain-schema.ts | 34 +++++++++++ packages/oxlint-config/toolchain.json | 12 ++++ 9 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 .changeset/cloud-runtime-checksums.md diff --git a/.agents/cloud-setup.sh b/.agents/cloud-setup.sh index e89f4e3f..ef945979 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='3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18' ;; + aarch64|arm64) ARCH=arm64; NODE_ARCH=arm64; BUN_ARCH=aarch64; NODE_SHA256='7a6353f63eb3d04765004b4adf172616243e4522434635cb1d26288658b04ab5'; BUN_SHA256='efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21' ;; *) fail 'Only amd64 and arm64 are supported' ;; esac @@ -152,6 +152,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" @@ -161,6 +162,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" @@ -246,11 +248,9 @@ apt_install() ( local package 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 "$@" + # Local archives are already downloaded and verified; never put dpkg, + # package triggers, or maintainer scripts under a network deadline. + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" return fi done diff --git a/.changeset/cloud-runtime-checksums.md b/.changeset/cloud-runtime-checksums.md new file mode 100644 index 00000000..3ef5e348 --- /dev/null +++ b/.changeset/cloud-runtime-checksums.md @@ -0,0 +1,5 @@ +--- +"@stll/oxlint-config": patch +--- + +Pin Node and Bun cloud runtime archives to policy SHA-256 values, and keep dpkg installation outside network timeouts. diff --git a/packages/oxlint-config/src/cloud-setup-install.test.ts b/packages/oxlint-config/src/cloud-setup-install.test.ts index 8d0e5de4..57154616 100644 --- a/packages/oxlint-config/src/cloud-setup-install.test.ts +++ b/packages/oxlint-config/src/cloud-setup-install.test.ts @@ -128,6 +128,19 @@ test("package installation refuses symlink policies before invoking apt", () => } }); +test("local package installation runs only the unbounded offline apt step", () => { + const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); + try { + const result = runInstall(directory, 0, 0, ["/tmp/fixture.deb"]); + expect(result.exitCode).toBe(0); + expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( + "offline:install -y --no-install-recommends --no-download /tmp/fixture.deb\n", + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + const pgdgFragment = renderCloudServiceInstall({ ...parseToolchainPolicy(policy), services: ["postgres"], @@ -142,18 +155,20 @@ 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. + // Both package paths converge on the raw offline install; neither can download. const offline = ' DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@"\n'; - expect(rendered.split(offline)).toHaveLength(2); - expect(rendered.replace(offline, "")).not.toMatch( + expect(rendered.split(offline)).toHaveLength(3); + expect(rendered.replaceAll(offline, "")).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)', ); expect(rendered).not.toContain("apt_network update"); - const refreshes = [...rendered.matchAll(/^ *apt_refresh_for (.+)\n *apt_install (.+)$/gm)]; + const refreshes = [ + ...rendered.matchAll(/^ *apt_refresh_for (.+)\n *apt_install (.+)$/gm), + ]; expect(refreshes.length).toBe(services.length); for (const [, refreshed, installed] of refreshes) expect(refreshed).toBe(installed); @@ -547,14 +562,14 @@ test("offline package configuration failures propagate and still restore the pol } }); -test("local package archives install in one bounded step because apt cannot pre-download them", () => { +test("local package archives install without a deadline because they are already downloaded", () => { const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); try { const result = runInstall(directory, 0, 0, ["/tmp/a.deb", "/tmp/b.deb"]); 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", + "offline:install -y --no-install-recommends --no-download /tmp/a.deb /tmp/b.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..1ee7f642 100644 --- a/packages/oxlint-config/src/cloud-setup-install.ts +++ b/packages/oxlint-config/src/cloud-setup-install.ts @@ -44,11 +44,9 @@ apt_install() ( local package 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 "$@" + # Local archives are already downloaded and verified; never put dpkg, + # package triggers, or maintainer scripts under a network deadline. + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" return fi done diff --git a/packages/oxlint-config/src/cloud-setup-regressions.test.ts b/packages/oxlint-config/src/cloud-setup-regressions.test.ts index d1b08e9d..1169e751 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,55 @@ test("cached Node requires working matching npm and npx launchers", () => { } }); +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; } +download_file() { + if [[ "$1" == */SHASUMS256.txt ]]; then + printf '%s %s\n' "$(printf malicious | /usr/bin/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 +220,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.test.ts b/packages/oxlint-config/src/cloud-setup.test.ts index be9c18fd..c8146cc3 100644 --- a/packages/oxlint-config/src/cloud-setup.test.ts +++ b/packages/oxlint-config/src/cloud-setup.test.ts @@ -189,6 +189,30 @@ test("cloud generation rejects every nonexact or out-of-series Node selector", ( ); }); +test("cloud generation requires exact Node and packageManager Bun archive pins", () => { + const cloud = parseCloudSetup(declaration); + if (cloud === undefined) throw new Error("Missing cloud declaration"); + expect(() => + generateCloudSetup({ + policy: { ...policy, nodeArchiveSha256: {} }, + cloud, + nodeVersion: "26.10.0", + }), + ).toThrow("cloud Node 26.10.0 has no archive SHA256 pins"); + expect(() => + generateCloudSetup({ + policy, + cloud, + nodeVersion: "26.10.0", + files: { + "package.json": JSON.stringify({ packageManager: "bun@1.4.2" }), + }, + }), + ).toThrow( + "cloud packageManager Bun 1.4.2 has no archive SHA256 pins; policy Bun is 1.4.3", + ); +}); + test("shell quoting preserves canonical literal metacharacters through single-pass replacement", () => { withRepository((root) => { const envFile = ".env'\"$`@SERVICE_INSTALL@"; @@ -534,7 +558,9 @@ test("the declared install command is rendered verbatim into the pinned install" test("every lifecycle phase runs through the timed step logger", () => { const generated = generate(declaration); - const body = generated.slice(generated.indexOf('if [[ "$MODE" == install ]]')); + const body = generated.slice( + generated.indexOf('if [[ "$MODE" == install ]]'), + ); const start = generated.indexOf("start_environment() {"); const end = generated.indexOf( '\n}\nif [[ "$MODE" == session ]]; then', @@ -563,9 +589,7 @@ test("every lifecycle phase runs through the timed step logger", () => { "prepare_state", "start_services", ]) - expect(startFunction).toMatch( - new RegExp(`^ *step [a-z-]+ ${phase}$`, "m"), - ); + expect(startFunction).toMatch(new RegExp(`^ *step [a-z-]+ ${phase}$`, "m")); expect(generated).toContain( 'if [[ "$MODE" == session ]]; then\n start_environment >&2\nelse\n start_environment\nfi', ); @@ -682,7 +706,9 @@ test("a prepare script must be a root package script", () => { ]) { expect(() => generatePrepared({ "package.json": JSON.stringify({ scripts }) }), - ).toThrow("cloud.prepare.script generate:cloud is not a root package script"); + ).toThrow( + "cloud.prepare.script generate:cloud is not a root package script", + ); } }); @@ -747,7 +773,10 @@ test.skipIf(Bun.which("timeout") === null)( // Reaping can lag the kill by a moment; poll briefly. let alive = true; for (let attempt = 0; attempt < 50 && alive; attempt += 1) { - alive = Bun.spawnSync(["kill", "-0", String(pid)]).exitCode === 0; + const process = Bun.spawnSync(["ps", "-o", "stat=", "-p", String(pid)]); + alive = + process.exitCode === 0 && + !process.stdout.toString().trimStart().startsWith("Z"); if (alive) Bun.sleepSync(100); } expect(alive).toBe(false); diff --git a/packages/oxlint-config/src/cloud-setup.ts b/packages/oxlint-config/src/cloud-setup.ts index 8d889bdb..46ebd548 100644 --- a/packages/oxlint-config/src/cloud-setup.ts +++ b/packages/oxlint-config/src/cloud-setup.ts @@ -82,8 +82,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 @@ -120,6 +120,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" @@ -129,6 +130,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" @@ -328,6 +330,28 @@ export const generateCloudSetup = ({ throw new Error( "cloud requires a tracked exact .node-version within the shared Node series", ); + let bunVersion = policy.bun; + const manifest = files["package.json"]; + if (manifest !== undefined) { + const parsed: unknown = JSON.parse(manifest); + if ( + typeof parsed === "object" && + parsed !== null && + "packageManager" in parsed + ) { + const manager = parsed.packageManager; + if (typeof manager === "string" && manager.startsWith("bun@")) + bunVersion = manager.slice(4); + } + } + const nodeArchiveSha256 = policy.nodeArchiveSha256[exactNode]; + if (nodeArchiveSha256 === undefined) + throw new Error(`cloud Node ${exactNode} has no archive SHA256 pins`); + const bunArchiveSha256 = policy.bunArchiveSha256[bunVersion]; + if (bunArchiveSha256 === undefined) + throw new Error( + `cloud packageManager Bun ${bunVersion} has no archive SHA256 pins; policy Bun is ${policy.bun}`, + ); const toolchains = resolveCloudToolchains({ cloud: selected, files, @@ -345,7 +369,7 @@ export const generateCloudSetup = ({ renderCloudToolInstall(toolchains) + renderCloudShellInstall({ nodeVersion: exactNode, - bunVersion: policy.bun, + bunVersion, rust: toolchains.rust, }), ], @@ -371,8 +395,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 aa84f4d2..79e31f07 100644 --- a/packages/oxlint-config/src/toolchain-schema.ts +++ b/packages/oxlint-config/src/toolchain-schema.ts @@ -20,6 +20,30 @@ export const valkeyArtifactPackages = { const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const parseArchiveSha256 = (input: unknown, label: string) => { + 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; +}; + const artifactRecord = (value: unknown, keys: readonly string[]) => { if ( !record(value) || @@ -85,8 +109,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"); @@ -240,8 +272,10 @@ export const parseToolchainPolicy = (input: unknown) => { } return { bun, + bunArchiveSha256, typescript, node, + nodeArchiveSha256, postgres, valkey, valkeyArtifacts, diff --git a/packages/oxlint-config/toolchain.json b/packages/oxlint-config/toolchain.json index bc870067..53c35b68 100644 --- a/packages/oxlint-config/toolchain.json +++ b/packages/oxlint-config/toolchain.json @@ -1,6 +1,12 @@ { "schemaVersion": 1, "bun": "1.4.3", + "bunArchiveSha256": { + "1.4.3": { + "amd64": "3e730528d89775f03f87f05c06787c4df4099d0a37f3f252e7b829cb23816e18", + "arm64": "efa9813da5ed72423bf847f916e8d2c47c0d776add972354026a75e10da9aa21" + } + }, "typescript": "7.0.2", "oxlint": "1.87.0", "@oxlint/plugins": "1.87.0", @@ -35,6 +41,12 @@ "@stll/oxlint-plugin": "0.2.2", "lefthook": "2.1.16", "node": "26.x", + "nodeArchiveSha256": { + "26.10.0": { + "amd64": "ca70e9e349de048b9522abb3adc05b3bd6f43c5ffd3ec57916c7da292f59f022", + "arm64": "7a6353f63eb3d04765004b4adf172616243e4522434635cb1d26288658b04ab5" + } + }, "postgres": "18", "valkey": "9.1.1", "valkeyArtifacts": { From 49163bc70804c011e7b4f97647408a225c5e865a Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Fri, 23 Oct 2026 12:19:54 +0200 Subject: [PATCH 2/3] fix(oxlint-config): fetch local archives' missing dependencies before the offline install A local package archive is installed offline so dpkg and maintainer scripts never run under a network deadline, but its dependencies may not be in the archive cache, and the offline install then stops with "Unable to fetch some archives". The install now asks apt which downloads are missing (--print-uris contacts no server), fetches only those under the network deadline, and then installs offline. --- .../src/cloud-setup-install.test.ts | 45 ++++++++++++++++--- .../oxlint-config/src/cloud-setup-install.ts | 13 +++++- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/oxlint-config/src/cloud-setup-install.test.ts b/packages/oxlint-config/src/cloud-setup-install.test.ts index 57154616..d334337a 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, }, }, ); @@ -128,13 +135,14 @@ test("package installation refuses symlink policies before invoking apt", () => } }); -test("local package installation runs only the unbounded offline apt step", () => { +test("local package installation resolves offline, then runs only the unbounded offline apt step", () => { const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); try { const result = runInstall(directory, 0, 0, ["/tmp/fixture.deb"]); expect(result.exitCode).toBe(0); expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( - "offline:install -y --no-install-recommends --no-download /tmp/fixture.deb\n", + "resolve:--print-uris -qq -y --no-install-recommends install /tmp/fixture.deb\n" + + "offline:install -y --no-install-recommends --no-download /tmp/fixture.deb\n", ); } finally { rmSync(directory, { recursive: true, force: true }); @@ -158,10 +166,14 @@ test("service installation uses shared bounded network helpers for every downloa // Both package paths converge on the raw offline install; neither can download. const offline = ' DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@"\n'; + // --print-uris only reads the local indexes; it never downloads. + const resolve = + ` missing="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '{ name = $2; sub(/_.*/, "", name); print name }')"\n`; expect(rendered.split(offline)).toHaveLength(3); - expect(rendered.replaceAll(offline, "")).not.toMatch( - /\b(?:curl|wget|apt-get)\b/, - ); + expect(rendered.split(resolve)).toHaveLength(2); + expect( + rendered.replaceAll(offline, "").replaceAll(resolve, ""), + ).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)', ); @@ -569,7 +581,28 @@ test("local package archives install without a deadline because they are already expect(result.stderr.toString()).toBe(""); expect(result.exitCode).toBe(0); expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( - "offline:install -y --no-install-recommends --no-download /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 --no-download /tmp/a.deb /tmp/b.deb\n", + ); + expect(existsSync(join(directory, "policy-rc.d"))).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("local package archives fetch their missing dependencies under the network deadline first", () => { + const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); + try { + const uris = + "'http://archive.test/pool/libj/libjemalloc2_5.3.0-2_amd64.deb' libjemalloc2_5.3.0-2_amd64.deb 1 SHA256:00\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.stderr.toString()).toBe(""); + expect(result.exitCode).toBe(0); + expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( + "resolve:--print-uris -qq -y --no-install-recommends install /tmp/a.deb\n" + + "network:install -y --no-install-recommends --download-only libjemalloc2 liblzf1\n" + + "offline:install -y --no-install-recommends --no-download /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 1ee7f642..0c806131 100644 --- a/packages/oxlint-config/src/cloud-setup-install.ts +++ b/packages/oxlint-config/src/cloud-setup-install.ts @@ -44,8 +44,17 @@ apt_install() ( local package for package in "$@"; do if [[ "$package" == */* ]]; then - # Local archives are already downloaded and verified; never put dpkg, - # package triggers, or maintainer scripts under a network deadline. + # Local archives are already downloaded and verified, but their + # dependencies may not be: --print-uris names what is missing without + # contacting any server, the network deadline covers only fetching it, + # and dpkg, package triggers and maintainer scripts run offline. + local missing + missing="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '{ name = $2; sub(/_.*/, "", name); print name }')" + if [[ -n "$missing" ]]; then + # Debian package names never contain whitespace: split one per word. + # shellcheck disable=SC2086 + DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only $missing + fi DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" return fi From f153bdf9fa0e9a417fdef6dcdfd72d236aa68409 Mon Sep 17 00:00:00 2001 From: jan-kubica Date: Mon, 26 Oct 2026 09:20:49 +0100 Subject: [PATCH 3/3] fix(oxlint-config): download local archives under the deadline and accept repository runtime pins apt fails internally when --no-download meets a local archive, so local archives now take the same bounded --download-only step as indexed packages. The install that follows runs without a deadline only after --print-uris shows that nothing but local file: archives remains to fetch. The shared policy pins archives only for its own Node patch and Bun release. A repository on another accepted Node patch or a newer Bun declares that one release's amd64 and arm64 values in cloud.runtimeArchiveSha256; a release the shared policy pins cannot be repeated there. Generation takes the repository Bun its callers already resolve instead of reading packageManager again. --- .agents/cloud-setup.sh | 25 ++-- .changeset/cloud-runtime-checksums.md | 4 +- packages/oxlint-config/README.md | 7 ++ packages/oxlint-config/src/archive-pins.ts | 36 ++++++ .../src/cloud-setup-install.test.ts | 72 +++++------ .../oxlint-config/src/cloud-setup-install.ts | 25 ++-- .../src/cloud-setup-regressions.test.ts | 5 +- .../oxlint-config/src/cloud-setup-schema.ts | 34 +++++- .../oxlint-config/src/cloud-setup.test.ts | 112 +++++++++++++++--- packages/oxlint-config/src/cloud-setup.ts | 79 ++++++++---- .../oxlint-config/src/toolchain-schema.ts | 25 +--- 11 files changed, 288 insertions(+), 136 deletions(-) create mode 100644 packages/oxlint-config/src/archive-pins.ts diff --git a/.agents/cloud-setup.sh b/.agents/cloud-setup.sh index 5ba206d8..c913cade 100755 --- a/.agents/cloud-setup.sh +++ b/.agents/cloud-setup.sh @@ -312,27 +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 - # Local archives are already downloaded and verified, but their - # dependencies may not be: --print-uris names what is missing without - # contacting any server, the network deadline covers only fetching it, - # and dpkg, package triggers and maintainer scripts run offline. - local missing - missing="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '{ name = $2; sub(/_.*/, "", name); print name }')" - if [[ -n "$missing" ]]; then - # Debian package names never contain whitespace: split one per word. - # shellcheck disable=SC2086 - DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only $missing - fi - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" + # 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 index 3ef5e348..149bcf11 100644 --- a/.changeset/cloud-runtime-checksums.md +++ b/.changeset/cloud-runtime-checksums.md @@ -1,5 +1,5 @@ --- -"@stll/oxlint-config": patch +"@stll/oxlint-config": minor --- -Pin Node and Bun cloud runtime archives to policy SHA-256 values, and keep dpkg installation outside network timeouts. +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 710de8a0..814b2280 100644 --- a/packages/oxlint-config/src/cloud-setup-install.test.ts +++ b/packages/oxlint-config/src/cloud-setup-install.test.ts @@ -135,20 +135,6 @@ test("package installation refuses symlink policies before invoking apt", () => } }, 15_000); -test("local package installation resolves offline, then runs only the unbounded offline apt step", () => { - const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); - try { - const result = runInstall(directory, 0, 0, ["/tmp/fixture.deb"]); - expect(result.exitCode).toBe(0); - expect(readFileSync(join(directory, "apt.calls"), "utf8")).toBe( - "resolve:--print-uris -qq -y --no-install-recommends install /tmp/fixture.deb\n" + - "offline:install -y --no-install-recommends --no-download /tmp/fixture.deb\n", - ); - } finally { - rmSync(directory, { recursive: true, force: true }); - } -}); - const pgdgFragment = renderCloudServiceInstall({ ...parseToolchainPolicy(policy), services: ["postgres"], @@ -163,19 +149,23 @@ test("service installation uses shared bounded network helpers for every downloa ...parseToolchainPolicy(policy), services, }); - // Both package paths converge on the raw offline install; neither can 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'; - // --print-uris only reads the local indexes; it never downloads. - const resolve = - ` missing="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '{ name = $2; sub(/_.*/, "", name); print name }')"\n`; - expect(rendered.split(offline)).toHaveLength(3); - expect(rendered.split(resolve)).toHaveLength(2); - expect( - rendered.replaceAll(offline, "").replaceAll(resolve, ""), - ).not.toMatch(/\b(?:curl|wget|apt-get)\b/); + 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.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 = [ @@ -574,35 +564,45 @@ test("offline package configuration failures propagate and still restore the pol } }, 15_000); -test("local package archives install without a deadline because they are already downloaded", () => { +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( - "resolve:--print-uris -qq -y --no-install-recommends install /tmp/a.deb /tmp/b.deb\n" + - "offline:install -y --no-install-recommends --no-download /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 fetch their missing dependencies under the network deadline first", () => { +test("local package archives refuse the unbounded install while remote downloads remain", () => { const directory = mkdtempSync(join(tmpdir(), "cloud-apt-test-")); try { const uris = - "'http://archive.test/pool/libj/libjemalloc2_5.3.0-2_amd64.deb' libjemalloc2_5.3.0-2_amd64.deb 1 SHA256:00\n" + + "'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.stderr.toString()).toBe(""); - expect(result.exitCode).toBe(0); + 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( - "resolve:--print-uris -qq -y --no-install-recommends install /tmp/a.deb\n" + - "network:install -y --no-install-recommends --download-only libjemalloc2 liblzf1\n" + - "offline:install -y --no-install-recommends --no-download /tmp/a.deb\n", + "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 0c806131..681c0a97 100644 --- a/packages/oxlint-config/src/cloud-setup-install.ts +++ b/packages/oxlint-config/src/cloud-setup-install.ts @@ -41,27 +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 - # Local archives are already downloaded and verified, but their - # dependencies may not be: --print-uris names what is missing without - # contacting any server, the network deadline covers only fetching it, - # and dpkg, package triggers and maintainer scripts run offline. - local missing - missing="$(set -o pipefail; DEBIAN_FRONTEND=noninteractive apt-get --print-uris -qq -y --no-install-recommends install "$@" | awk '{ name = $2; sub(/_.*/, "", name); print name }')" - if [[ -n "$missing" ]]; then - # Debian package names never contain whitespace: split one per word. - # shellcheck disable=SC2086 - DEBIAN_FRONTEND=noninteractive apt_network install -y --no-install-recommends --download-only $missing - fi - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends --no-download "$@" + # 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 2b267d50..96da9885 100644 --- a/packages/oxlint-config/src/cloud-setup-regressions.test.ts +++ b/packages/oxlint-config/src/cloud-setup-regressions.test.ts @@ -142,9 +142,12 @@ test("Bun archive rejects a matching downloaded SHASUMS entry when the policy pi "-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 | /usr/bin/sha256sum | cut -d ' ' -f1)" bun-linux-x64.zip > "$2" + printf '%s %s\\n' "$(printf malicious | sha256sum | cut -d ' ' -f1)" bun-linux-x64.zip > "$2" else printf malicious > "$2" fi 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 91edeac0..1605fbbb 100644 --- a/packages/oxlint-config/src/cloud-setup.test.ts +++ b/packages/oxlint-config/src/cloud-setup.test.ts @@ -314,30 +314,106 @@ test("cloud generation rejects every nonexact or out-of-series Node selector", ( ); }, 15_000); -test("cloud generation requires exact Node and packageManager Bun archive pins", () => { - const cloud = parseCloudSetup(declaration); - if (cloud === undefined) throw new Error("Missing cloud declaration"); - expect(() => - generateCloudSetup({ - policy: { ...policy, nodeArchiveSha256: {} }, +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: "26.10.0", - }), - ).toThrow("cloud Node 26.10.0 has no archive SHA256 pins"); + 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(() => - generateCloudSetup({ - policy, - cloud, - nodeVersion: "26.10.0", - files: { - "package.json": JSON.stringify({ packageManager: "bun@1.4.2" }), - }, - }), + 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( - `cloud packageManager Bun 1.4.2 has no archive SHA256 pins; policy Bun is ${policy.bun}`, + "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 95c9fe14..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"; @@ -418,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, @@ -435,28 +477,21 @@ export const generateCloudSetup = ({ throw new Error( "cloud requires a tracked exact .node-version within the shared Node series", ); - let bunVersion = policy.bun; - const manifest = files["package.json"]; - if (manifest !== undefined) { - const parsed: unknown = JSON.parse(manifest); - if ( - typeof parsed === "object" && - parsed !== null && - "packageManager" in parsed - ) { - const manager = parsed.packageManager; - if (typeof manager === "string" && manager.startsWith("bun@")) - bunVersion = manager.slice(4); - } - } - const nodeArchiveSha256 = policy.nodeArchiveSha256[exactNode]; - if (nodeArchiveSha256 === undefined) - throw new Error(`cloud Node ${exactNode} has no archive SHA256 pins`); - const bunArchiveSha256 = policy.bunArchiveSha256[bunVersion]; - if (bunArchiveSha256 === undefined) - throw new Error( - `cloud packageManager Bun ${bunVersion} has no archive SHA256 pins; policy Bun is ${policy.bun}`, - ); + // 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, diff --git a/packages/oxlint-config/src/toolchain-schema.ts b/packages/oxlint-config/src/toolchain-schema.ts index 62470d25..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"; @@ -20,30 +21,6 @@ export const valkeyArtifactPackages = { const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -const parseArchiveSha256 = (input: unknown, label: string) => { - 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; -}; - const artifactRecord = (value: unknown, keys: readonly string[]) => { if ( !record(value) ||