From d828cd025258a4705c1a12f1256c4d18a5f2e483 Mon Sep 17 00:00:00 2001 From: Nick DiZazzo Date: Wed, 16 Sep 2026 18:42:26 -0400 Subject: [PATCH] feat: validate the Linux glibc floor in runtime manifests Upstream 325e4bc added runtime.platform.min_glibc so a host can refuse a Linux runtime its glibc cannot load. Packaging copied the runtime manifest through unchanged and never looked at the field, so a malformed or wrongly-attached floor would ship. Reject a min_glibc that is not a major.minor version or that is attached to a non-Linux runtime, and record the declared floor in upstream-provenance.json as runtime_min_glibc. The field stays optional. Absence makes no claim, and host and runtime always travel together in one product bundle, so a runtime predating the field is always paired with a host predating the check that would skip it. Releases through v0.76.2 verify exactly as before. Packaging does not re-derive the floor from ELF headers. verify_linux_min_glibc_consistency upstream owns that, and the runtime manifest is already digest-bound to the verified producer, so recomputing it here would duplicate the producer rather than check it. --- TODO.md | 16 +++++++++ docs/native-packages.md | 10 ++++++ scripts/upstream-archive.ts | 38 ++++++++++++++++++--- tests/upstream-archive.test.ts | 61 +++++++++++++++++++++++++++++++--- 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 49f65e2..6b5d4e2 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,21 @@ # Production Readiness TODO +- [x] Validate the Linux glibc metadata upstream `325e4bc` added. + Final result: extraction rejects a `runtime.platform.min_glibc` that is not a + `major.minor` version or that is attached to a non-Linux runtime, and records + the declared floor in `upstream-provenance.json` as `runtime_min_glibc`. The + field stays optional: absence makes no claim, and host and runtime always ship + in the same product bundle, so a runtime predating the field is always paired + with a host predating the check that would skip it. Packaging does not + re-derive the floor from ELF headers; upstream's + `verify_linux_min_glibc_consistency` owns that, and the runtime manifest is + already digest-bound to the verified producer. + QA: archive fixtures accept a declared `2.35` floor and surface it in + provenance, reject `2.35.1`, `2`, `""`, `two.35`, a number, and a boolean, + reject a floor on a macOS runtime, and accept all three shapes a pre-field + release can take (no platform block, a platform block without the key, and an + explicit null). + - [ ] Keep no-driver package QA reliable under hosted-runner load while still enforcing bounded SIGINT shutdown. QA: the shared Linux smoke and Homebrew formula use the same 30-second bound, focused regression tests pass, the full diff --git a/docs/native-packages.md b/docs/native-packages.md index d2566a1..cd69b1e 100644 --- a/docs/native-packages.md +++ b/docs/native-packages.md @@ -15,6 +15,16 @@ plan downloads that schema at the immutable upstream source SHA and byte-checks it against this checkout before any archive, package, or image job starts. Contract changes update both copies in the same cross-repository change; a release must not proceed with unexplained schema drift. + +The per-runtime `manifest.json` stays outside that schema, but two of its claims +are checked at extraction. Every runtime file outside `lib/`, `tools/`, +`manifest.json`, and `README.md` must be declared in `runtime.files` with a +matching SHA-256. And `runtime.platform.min_glibc`, which upstream added so a +host can refuse a Linux runtime its glibc cannot load, must be a `major.minor` +version attached to a Linux runtime; provenance records it as +`runtime_min_glibc`. Both checks are conditional. A runtime that declares +neither makes no claim and verifies as before, which is what keeps releases +through v0.76.2 packageable. `packaging/native/build-package.sh` stages that verified bundle and produces exactly one package with version, distro, architecture, backend, and backend version in its filename. diff --git a/scripts/upstream-archive.ts b/scripts/upstream-archive.ts index 0066c8e..05d33e8 100755 --- a/scripts/upstream-archive.ts +++ b/scripts/upstream-archive.ts @@ -166,19 +166,47 @@ function filesBelow(root: string, current = root): string[] { }); } -function declaredRuntimeFileDigests(runtimeDir: string): Record { +type RuntimeManifest = { runtime?: { files?: unknown; platform?: { os?: unknown; min_glibc?: unknown } } }; + +function readRuntimeManifest(runtimeDir: string): RuntimeManifest { const raw = readFileSync(resolve(runtimeDir, "manifest.json"), "utf8"); - let parsed: unknown; try { - parsed = JSON.parse(raw); + return JSON.parse(raw) as RuntimeManifest; } catch { throw new Error("native runtime manifest is not valid JSON"); } - const files = (parsed as { runtime?: { files?: unknown } })?.runtime?.files; +} + +function declaredRuntimeFileDigests(runtimeDir: string): Record { + const files = readRuntimeManifest(runtimeDir)?.runtime?.files; if (!files || typeof files !== "object" || Array.isArray(files)) return {}; return files as Record; } +/** + * Upstream 325e4bc added `platform.min_glibc` so a host can refuse a Linux + * runtime its glibc cannot load. Packaging copies the runtime manifest + * unchanged, so the least it owes the field is refusing to ship a floor that is + * malformed or attached to the wrong OS. + * + * An absent field makes no claim and is accepted. Releases predating the field, + * v0.76.2 among them, pair that runtime with a host that predates the check, and + * host and runtime always travel together in one product bundle. + */ +export function validateRuntimeMinGlibc(runtimeDir: string): string | null { + const platform = readRuntimeManifest(runtimeDir)?.runtime?.platform; + if (!platform || typeof platform !== "object") return null; + const declared = platform.min_glibc; + if (declared === undefined || declared === null) return null; + if (typeof declared !== "string" || !/^\d+\.\d+$/.test(declared)) { + throw new Error(`native runtime platform min_glibc must be a major.minor version: ${JSON.stringify(declared)}`); + } + if (platform.os !== "linux") { + throw new Error(`native runtime platform min_glibc is only meaningful on linux: ${JSON.stringify(platform.os)}`); + } + return declared; +} + /** * Accepts only the extra runtime files the producer declared, and proves their * bytes. The archive listing already bounded these to `licenses/`; this @@ -269,12 +297,14 @@ export async function verifyAndExtract(input: Inputs) { const runtimeManifestSha256 = await sha256File(resolve(runtime, "manifest.json")); if (runtimeManifestSha256 !== productManifest.runtime.manifest_sha256) throw new Error("product runtime manifest digest does not match"); await verifyDeclaredRuntimeFiles(runtime); + const runtimeMinGlibc = validateRuntimeMinGlibc(runtime); const provenance = resolve(input.outputDir, "upstream-provenance.json"); writeFileSync(provenance, `${JSON.stringify({ archive: archiveName, flavor: input.flavor, host_sha256: productManifest.host?.sha256, runtime_id: productManifest.runtime?.id, + runtime_min_glibc: runtimeMinGlibc, runtime_sha256: productManifest.runtime?.sha256, sha256: actual, source_url: input.sourceUrl, diff --git a/tests/upstream-archive.test.ts b/tests/upstream-archive.test.ts index d7bbf15..5b703b1 100644 --- a/tests/upstream-archive.test.ts +++ b/tests/upstream-archive.test.ts @@ -14,6 +14,7 @@ import { validateArchiveEntries, validateArchiveEntryTypes, validateProductManifest, + validateRuntimeMinGlibc, verifyAndExtract, verifyDeclaredRuntimeFiles, } from "../scripts/upstream-archive.ts"; @@ -50,6 +51,10 @@ type FixtureOptions = { // runtime manifest claims about them. `declare: false` stages the bytes // without declaring them; `digest` overrides the declared digest. licenses?: { name: string; contents: string; declare?: boolean; digest?: string }[]; + // Written into the runtime manifest's platform block verbatim, so a test can + // stage a malformed or wrongly-attached floor. `undefined` writes no platform + // block at all, matching releases that predate the field. + platform?: Record; }; async function fixture(t: { after(callback: () => void): void }, options: FixtureOptions = {}) { @@ -64,12 +69,12 @@ async function fixture(t: { after(callback: () => void): void }, options: Fixtur mkdirSync(resolve(runtime, "lib"), { recursive: true }); writeFileSync(resolve(runtime, "README.md"), "runtime\n"); writeFileSync(resolve(runtime, "lib/libllama.so"), "runtime\n"); - if (options.licenses?.length) { - mkdirSync(resolve(runtime, "licenses"), { recursive: true }); + if (options.licenses?.length || options.platform) { const files: Record = { "lib/libllama.so": await sha256File(resolve(runtime, "lib/libllama.so")), }; - for (const license of options.licenses) { + if (options.licenses?.length) mkdirSync(resolve(runtime, "licenses"), { recursive: true }); + for (const license of options.licenses ?? []) { const relative = `licenses/${license.name}`; writeFileSync(resolve(runtime, relative), license.contents); if (license.declare === false) continue; @@ -77,7 +82,12 @@ async function fixture(t: { after(callback: () => void): void }, options: Fixtur } writeFileSync(resolve(runtime, "manifest.json"), `${JSON.stringify({ build: { primary_library: "lib/libllama.so" }, - runtime: { files, id: "linux-cpu", libraries: ["lib/libllama.so"] }, + runtime: { + files, + id: "linux-cpu", + libraries: ["lib/libllama.so"], + ...(options.platform ? { platform: options.platform } : {}), + }, }, null, 2)}\n`); } else { writeFileSync(resolve(runtime, "manifest.json"), "{}\n"); @@ -116,6 +126,7 @@ test("verifies checksum, layout, extraction, and provenance", async (t) => { flavor: "cpu", host_sha256: data.hostSha256, runtime_id: "linux-cpu", + runtime_min_glibc: null, runtime_sha256: data.runtimeSha256, sha256: result.sha256, source_url: "https://example.test/archive", @@ -194,6 +205,48 @@ test("rejects runtime license bytes that drifted from the declared digest", asyn ); }); +const extractTo = async (data: { directory: string; archive: string; checksum: string }, name: string) => { + const outputDir = resolve(data.directory, name); + await verifyAndExtract({ archive: data.archive, checksum: data.checksum, outputDir, sourceUrl: "https://example.test/archive", version: "0.73.1", flavor: "cpu" }); + return resolve(outputDir, "native-runtimes/linux-cpu"); +}; + +test("records a declared Linux glibc floor in provenance", async (t) => { + const data = await fixture(t, { platform: { arch: "x86_64", min_glibc: "2.35", os: "linux" } }); + const outputDir = resolve(data.directory, "output"); + const result = await verifyAndExtract({ archive: data.archive, checksum: data.checksum, outputDir, sourceUrl: "https://example.test/archive", version: "0.73.1", flavor: "cpu" }); + assert.equal(JSON.parse(readFileSync(result.provenance, "utf8")).runtime_min_glibc, "2.35"); + assert.equal(validateRuntimeMinGlibc(resolve(outputDir, "native-runtimes/linux-cpu")), "2.35"); +}); + +test("rejects a malformed glibc floor", async (t) => { + for (const min_glibc of ["2.35.1", "2", "", "two.35", 2.35, true]) { + const data = await fixture(t, { platform: { arch: "x86_64", min_glibc, os: "linux" } }); + await assert.rejects( + verifyAndExtract({ archive: data.archive, checksum: data.checksum, outputDir: resolve(data.directory, "output"), sourceUrl: "https://example.test/archive", version: "0.73.1", flavor: "cpu" }), + /min_glibc must be a major\.minor version/, + `accepted ${JSON.stringify(min_glibc)}`, + ); + } +}); + +test("rejects a glibc floor attached to a non-Linux runtime", async (t) => { + const data = await fixture(t, { platform: { arch: "aarch64", min_glibc: "2.35", os: "macos" } }); + await assert.rejects( + verifyAndExtract({ archive: data.archive, checksum: data.checksum, outputDir: resolve(data.directory, "output"), sourceUrl: "https://example.test/archive", version: "0.73.1", flavor: "cpu" }), + /min_glibc is only meaningful on linux/, + ); +}); + +test("accepts runtimes that predate the glibc floor", async (t) => { + // v0.76.2 and earlier ship no platform.min_glibc. Absence makes no claim, and + // the host in the same bundle predates the check that would reject it. + for (const platform of [undefined, { arch: "x86_64", os: "linux" }, { arch: "x86_64", min_glibc: null, os: "linux" }]) { + const data = await fixture(t, platform ? { platform } : {}); + assert.equal(validateRuntimeMinGlibc(await extractTo(data, "output")), null); + } +}); + test("makes no declaration demand of runtimes that ship no extra files", async (t) => { const data = await fixture(t); const outputDir = resolve(data.directory, "output");