Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 13 additions & 11 deletions .agents/cloud-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 "$@"
)

Expand Down
5 changes: 5 additions & 0 deletions .changeset/cloud-runtime-checksums.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/oxlint-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
36 changes: 36 additions & 0 deletions packages/oxlint-config/src/archive-pins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { valid } from "semver";

export type ArchivePins = Readonly<
Record<string, { readonly amd64: string; readonly arm64: string }>
>;

const record = (value: unknown): value is Record<string, unknown> =>
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<string, { amd64: string; arm64: string }> = {};
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;
};
58 changes: 52 additions & 6 deletions packages/oxlint-config/src/cloud-setup-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/");
Expand All @@ -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"
}
Expand All @@ -71,6 +77,7 @@ apt_install "$@"
FIXTURE_CALLS: join(directory, "apt.calls"),
FIXTURE_EXIT: String(exitCode),
FIXTURE_OFFLINE_EXIT: String(offlineExitCode),
FIXTURE_URIS: uris,
},
},
);
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 {
Expand Down
18 changes: 9 additions & 9 deletions packages/oxlint-config/src/cloud-setup-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "$@"
)

Expand Down
63 changes: 63 additions & 0 deletions packages/oxlint-config/src/cloud-setup-regressions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ import { cloudInstallCommand, parseCloudSetup } from "./cloud-setup-schema";
import { parseToolchainPolicy } from "./toolchain-schema";

const policy = parseToolchainPolicy(policyInput);
const archivePins = (
releases: Readonly<Record<string, { amd64: string; arm64: string }>>,
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");
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
},
},
);
Expand Down
34 changes: 33 additions & 1 deletion packages/oxlint-config/src/cloud-setup-schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type ArchivePins, parseArchiveSha256 } from "./archive-pins";
import {
cloudSetupArtifacts,
type CloudSetupArtifactTool,
Expand Down Expand Up @@ -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<Record<(typeof cloudRuntimes)[number], ArchivePins>> =
{};
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.
Expand Down Expand Up @@ -209,19 +236,24 @@ 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");
const extensions = {
...(rust === undefined ? {} : { rust }),
...(tools === undefined ? {} : { tools }),
...(prepare === undefined ? {} : { prepare }),
...(runtimeArchiveSha256 === undefined ? {} : { runtimeArchiveSha256 }),
};
const selected = "services" in input ? input.services : [];
if (!Array.isArray(selected))
Expand Down
Loading
Loading