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
4 changes: 3 additions & 1 deletion npm/code-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ npm test
# Dry-run `npm pack` and inspect the published file list
npm run pack:dry

# Pre-publish guard: version check + tests
# Pre-publish guard: version check, pin release checksums, tests.
# Publish only after the GitHub release for v<version> exists: the pin step downloads
# each release binary and writes checksums.json into the package.
npm run prepublishOnly

# Full CI/presubmit checks
Expand Down
22 changes: 18 additions & 4 deletions npm/code-cli/lib/download.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadPinnedChecksums, pinnedSha256 } from "./pinned-checksums.mjs";
import { detectTarget, UnsupportedPlatformError } from "./platform.mjs";

export const RELEASE_BASE = "https://github.com/frontal-labs/code/releases/download";
Expand Down Expand Up @@ -51,7 +52,7 @@ export async function fetchSha256(version, assetName) {
}
}

async function downloadTo({ version, destDir, log = () => undefined }) {
async function downloadTo({ version, destDir, log = () => undefined, pinned }) {
const { target, assetName, binName } = detectTarget();
const url = releaseAssetUrl(version, assetName);
log(`Downloading ${assetName} (${target}) from ${url}`);
Expand All @@ -72,7 +73,15 @@ async function downloadTo({ version, destDir, log = () => undefined }) {
if (!verifySha256(buf, expected)) {
throw new Error(`SHA-256 mismatch for ${assetName}`);
}
log(`Verified SHA-256 for ${assetName}`);
// The sidecar comes from the same release as the binary, so it cannot catch a release
// that was replaced wholesale. The checksum pinned at publish time can.
if (!verifySha256(buf, pinnedSha256(pinned, version, assetName))) {
throw new Error(
`SHA-256 for ${assetName} does not match the checksum pinned in this npm package. ` +
`The release may have been altered after publish; refusing to install it.`,
);
}
log(`Verified SHA-256 for ${assetName} against the pinned checksum`);

const binDir = join(destDir, "vendor", target, "bin");
mkdirSync(binDir, { recursive: true });
Expand All @@ -82,15 +91,20 @@ async function downloadTo({ version, destDir, log = () => undefined }) {
}

// Public entry point. Returns the resolved binary path under vendor/.
export async function downloadRelease({ version, destDir, log = () => undefined }) {
export async function downloadRelease({
version,
destDir,
log = () => undefined,
pinned = loadPinnedChecksums(),
}) {
if (!version || /dev$/.test(version) || version === "0.0.0") {
throw new Error(
`Refusing to download for dev version "${version}". Use a tagged release version.`,
);
}
let binPath;
try {
binPath = await downloadTo({ version, destDir, log });
binPath = await downloadTo({ version, destDir, log, pinned });
} catch (e) {
if (e instanceof UnsupportedPlatformError) throw e;
throw e;
Expand Down
43 changes: 43 additions & 0 deletions npm/code-cli/lib/pinned-checksums.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Checksums pinned into the npm package at publish time.
//
// `checksums.json` is written by `scripts/pin-checksums.mjs` from the release binaries
// during `prepublishOnly`, so it ships inside the npm tarball. The launcher checks a
// downloaded binary against these hashes as well as the release's `.sha256` sidecar.
// A tampered GitHub release that replaces a binary and its sidecar still fails here,
// because the npm package was published from the genuine release.

import { readFileSync } from "node:fs";

export const CHECKSUMS_URL = new URL("../checksums.json", import.meta.url);

// Load the pinned file. Returns null when it is absent (for example, a source checkout
// that has not been through the publish step).
export function loadPinnedChecksums(url = CHECKSUMS_URL) {
try {
return JSON.parse(readFileSync(url, "utf8"));
} catch (e) {
if (e.code === "ENOENT") return null;
throw e;
}
}

// Return the pinned SHA-256 for one asset of one version. Throws when the pinned file is
// for another version or has no entry for the asset.
export function pinnedSha256(pinned, version, assetName) {
if (!pinned) {
throw new Error(
`No pinned checksums are bundled with this package, so ${assetName} cannot be verified. ` +
`Reinstall a published release, or build from source with \`cargo build --release -p cli\`.`,
);
}
if (pinned.version !== version) {
throw new Error(
`Bundled checksums are for v${pinned.version}, not v${version}. Reinstall the package for this version.`,
);
}
const hex = pinned.assets?.[assetName];
if (!hex) {
throw new Error(`No pinned SHA-256 for ${assetName} in v${version}.`);
}
return hex;
}
6 changes: 6 additions & 0 deletions npm/code-cli/lib/platform.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ const TARGETS = {
"win32/x64": { target: "x86_64-pc-windows-msvc", ext: ".exe" },
};

// Every release asset name the launcher can download. The checksum pin script uses this
// list so the packaged checksums cover every platform, not just the one that publishes.
export function allAssetNames() {
return Object.values(TARGETS).map((entry) => `code-cli-${entry.target}${entry.ext}`);
}

// Pure helper: resolve a descriptor from explicit platform/arch.
export function targetFor(platform, arch) {
const entry = TARGETS[`${platform}/${arch}`];
Expand Down
1 change: 1 addition & 0 deletions npm/code-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"files": [
"bin/index.js",
"lib/**/*.mjs",
"checksums.json",
"scripts/postinstall.sh",
"README.md"
],
Expand Down
48 changes: 48 additions & 0 deletions npm/code-cli/scripts/pin-checksums.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env node
// Writes checksums.json for one release version. Run from prepublishOnly, after the
// GitHub release for `v<version>` exists.
//
// It downloads every platform binary and hashes the bytes. It also checks each hash
// against the release's `.sha256` sidecar, so a mismatch fails the publish instead of
// pinning a bad value.

import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { fetchSha256, fetchUrl, releaseAssetUrl } from "../lib/download.mjs";
import { allAssetNames } from "../lib/platform.mjs";

export async function pinChecksums(
version,
{ out, fetchBinary = fetchUrl, fetchSidecar = fetchSha256 } = {},
) {
const assets = {};
for (const assetName of allAssetNames()) {
const bytes = await fetchBinary(releaseAssetUrl(version, assetName));
const actual = createHash("sha256").update(bytes).digest("hex");
const sidecar = await fetchSidecar(version, assetName);
if (!sidecar) {
throw new Error(`No .sha256 sidecar published for ${assetName} in v${version}.`);
}
if (sidecar.toLowerCase() !== actual) {
throw new Error(
`Sidecar for ${assetName} does not match the binary (sidecar ${sidecar}, binary ${actual}).`,
);
}
assets[assetName] = actual;
}
const pinned = { version, assets };
if (out) writeFileSync(out, `${JSON.stringify(pinned, null, 2)}\n`);
return pinned;
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
const version = process.argv[2];
if (!version || /dev$/.test(version) || version === "0.0.0") {
console.error("usage: pin-checksums.mjs <release-version> (refuses dev versions)");
process.exit(1);
}
const out = fileURLToPath(new URL("../checksums.json", import.meta.url));
await pinChecksums(version, { out });
console.log(`Pinned ${allAssetNames().length} checksums for v${version} -> checksums.json`);
}
4 changes: 4 additions & 0 deletions npm/code-cli/scripts/prepublish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ for f in bin/index.js lib/platform.mjs lib/resolve-binary.mjs lib/download.mjs s
fi
done

# Pin the release binaries' checksums into the package. Requires the GitHub release
# for v$VERSION to exist, so publish after the release workflow has finished.
node ./scripts/pin-checksums.mjs "$VERSION"

echo "==> Pre-publish checks passed (v$VERSION). Running tests."
exec ./scripts/test.sh
100 changes: 100 additions & 0 deletions npm/code-cli/tests/pinned-checksums.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { downloadRelease } from "../lib/download.mjs";
import { pinnedSha256 } from "../lib/pinned-checksums.mjs";
import { allAssetNames, detectTarget } from "../lib/platform.mjs";
import { pinChecksums } from "../scripts/pin-checksums.mjs";

const sha = (buf) => createHash("sha256").update(buf).digest("hex");
const VERSION = "9.9.9";

test("pinnedSha256 refuses when no checksums are bundled", () => {
assert.throws(() => pinnedSha256(null, VERSION, "code-cli-x"), /No pinned checksums/);
});

test("pinnedSha256 refuses checksums for a different version", () => {
const pinned = { version: "1.0.0", assets: { "code-cli-x": "aa" } };
assert.throws(() => pinnedSha256(pinned, VERSION, "code-cli-x"), /are for v1\.0\.0/);
});

test("pinnedSha256 refuses an asset with no entry and returns a matching one", () => {
const pinned = { version: VERSION, assets: { "code-cli-x": "abc" } };
assert.equal(pinnedSha256(pinned, VERSION, "code-cli-x"), "abc");
assert.throws(() => pinnedSha256(pinned, VERSION, "code-cli-y"), /No pinned SHA-256/);
});

test("allAssetNames lists every platform asset once", () => {
const names = allAssetNames();
assert.equal(new Set(names).size, names.length);
assert.equal(names.length, 5);
assert.ok(names.includes(detectTarget().assetName));
});

test("downloadRelease refuses a binary that matches its sidecar but not the pinned checksum", async () => {
// A release replaced wholesale: the binary and its sidecar agree with each other, but
// not with the checksum pinned when the npm package was published.
const { assetName } = detectTarget();
const tampered = Buffer.from("tampered binary");
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) =>
String(url).endsWith(".sha256")
? new Response(`${sha(tampered)} ${assetName}`, { status: 200 })
: new Response(tampered, { status: 200 });
try {
const pinned = { version: VERSION, assets: { [assetName]: sha(Buffer.from("genuine")) } };
await assert.rejects(
downloadRelease({
version: VERSION,
destDir: mkdtempSync(join(tmpdir(), "fc-pin-")),
pinned,
}),
/does not match the checksum pinned/,
);
} finally {
globalThis.fetch = realFetch;
}
});

test("downloadRelease installs a binary that matches both the sidecar and the pinned checksum", async () => {
const { assetName } = detectTarget();
const genuine = Buffer.from("genuine binary");
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) =>
String(url).endsWith(".sha256")
? new Response(`${sha(genuine)} ${assetName}`, { status: 200 })
: new Response(genuine, { status: 200 });
try {
const pinned = { version: VERSION, assets: { [assetName]: sha(genuine) } };
const binPath = await downloadRelease({
version: VERSION,
destDir: mkdtempSync(join(tmpdir(), "fc-pin-")),
pinned,
});
assert.ok(binPath.endsWith(detectTarget().binName));
} finally {
globalThis.fetch = realFetch;
}
});

test("pinChecksums hashes the binaries and rejects a sidecar that disagrees", async () => {
const bytes = Buffer.from("release bytes");
const ok = await pinChecksums(VERSION, {
fetchBinary: async () => bytes,
fetchSidecar: async () => sha(bytes),
});
assert.equal(ok.version, VERSION);
assert.equal(Object.keys(ok.assets).length, allAssetNames().length);
for (const hex of Object.values(ok.assets)) assert.equal(hex, sha(bytes));

await assert.rejects(
pinChecksums(VERSION, {
fetchBinary: async () => bytes,
fetchSidecar: async () => sha(Buffer.from("other")),
}),
/does not match the binary/,
);
});
Loading