Merge PR #6557: fix(update): verify GitHub release asset and digest (@rvagg)

This commit is contained in:
can1357
2026-07-26 15:45:31 +02:00
5 changed files with 409 additions and 28 deletions
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, spyOn, vi } from "bun:test";
import { createHash } from "node:crypto";
import * as nodeFs from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
@@ -11,12 +12,15 @@ import {
buildMiseForceInstallArgs,
buildMiseUpgradeArgs,
buildNpmInstallArgs,
downloadVerifiedBinary,
parseUpdateArgs,
pruneBunInstallCache,
replaceBinaryForUpdate,
resolveBunGlobalNodeModulesDirFromLocations,
resolveReleaseBinaryAsset,
resolveUpdateMethodForTest,
sweepStaleBackups,
updateViaBinaryAt,
} from "@oh-my-pi/pi-coding-agent/cli/update-cli";
import Update from "@oh-my-pi/pi-coding-agent/commands/update";
import { removeWithRetries } from "@oh-my-pi/pi-utils";
@@ -32,6 +36,7 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map(dir => removeWithRetries(dir)));
});
const TEST_CONFIG: CliConfig = {
@@ -353,6 +358,205 @@ describe("update-cli bun cache pruning", () => {
});
});
describe("update-cli release binary integrity", () => {
const tag = "v17.1.2";
const binaryName = "omp-linux-x64";
const url = `https://github.com/can1357/oh-my-pi/releases/download/${tag}/${binaryName}`;
const content = "verified binary";
const digest = `sha256:${createHash("sha256").update(content).digest("hex")}`;
function releaseAsset(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
tag_name: tag,
draft: false,
prerelease: false,
assets: [
{
name: binaryName,
state: "uploaded",
size: Buffer.byteLength(content),
digest,
browser_download_url: url,
...overrides,
},
],
};
}
it("selects an uploaded asset with a valid SHA-256 digest", () => {
expect(resolveReleaseBinaryAsset(releaseAsset(), tag, binaryName)).toEqual({
url,
size: Buffer.byteLength(content),
digest,
});
});
it("rejects missing and unsupported release asset digests", () => {
expect(() => resolveReleaseBinaryAsset(releaseAsset({ digest: null }), tag, binaryName)).toThrow("has no digest");
expect(() => resolveReleaseBinaryAsset(releaseAsset({ digest: "sha512:abc" }), tag, binaryName)).toThrow(
"has an unsupported digest",
);
});
it("rejects release metadata that does not identify one exact stable asset", () => {
expect(() => resolveReleaseBinaryAsset({ ...releaseAsset(), prerelease: true }, tag, binaryName)).toThrow(
"is not a published stable release",
);
expect(() => resolveReleaseBinaryAsset({ ...releaseAsset(), assets: [] }, tag, binaryName)).toThrow(
`has 0 assets named ${binaryName}`,
);
expect(() =>
resolveReleaseBinaryAsset(
{ ...releaseAsset(), assets: [releaseAsset().assets, releaseAsset().assets].flat() },
tag,
binaryName,
),
).toThrow(`has 2 assets named ${binaryName}`);
expect(() =>
resolveReleaseBinaryAsset(
releaseAsset({ browser_download_url: "https://example.com/omp-linux-x64" }),
tag,
binaryName,
),
).toThrow("has an unexpected download URL");
});
it("writes a download only after its size and digest match", async () => {
const dir = await makeTempDir();
const targetPath = path.join(dir, binaryName);
await downloadVerifiedBinary({
url,
targetPath,
expectedSize: Buffer.byteLength(content),
expectedDigest: digest,
fetchImpl: async () => new Response(content),
});
expect(await Bun.file(targetPath).text()).toBe(content);
expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755);
});
it("aborts the response stream as soon as it exceeds the expected size", async () => {
const dir = await makeTempDir();
const targetPath = path.join(dir, binaryName);
let pulls = 0;
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
pulls++;
controller.enqueue(new Uint8Array(pulls === 1 ? 2 : 1));
if (pulls === 2) controller.close();
},
},
{ highWaterMark: 0 },
);
await expect(
downloadVerifiedBinary({
url,
targetPath,
expectedSize: 1,
expectedDigest: digest,
fetchImpl: async () => new Response(body),
}),
).rejects.toThrow("received at least 2");
expect(pulls).toBe(1);
expect(await Bun.file(targetPath).exists()).toBe(false);
});
it("removes downloads whose size or digest does not match", async () => {
const dir = await makeTempDir();
const targetPath = path.join(dir, binaryName);
const fetchImpl = async () => new Response(content);
await expect(
downloadVerifiedBinary({
url,
targetPath,
expectedSize: Buffer.byteLength(content) + 1,
expectedDigest: digest,
fetchImpl,
}),
).rejects.toThrow("size mismatch");
expect(await Bun.file(targetPath).exists()).toBe(false);
await expect(
downloadVerifiedBinary({
url,
targetPath,
expectedSize: Buffer.byteLength(content),
expectedDigest: `sha256:${createHash("sha256").update("different binary").digest("hex")}`,
fetchImpl,
}),
).rejects.toThrow("digest mismatch");
expect(await Bun.file(targetPath).exists()).toBe(false);
});
it("rejects an altered version-reporting executable before replacing the installed binary", async () => {
const dir = await makeTempDir();
const targetPath = path.join(dir, binaryName);
const installed = "#!/bin/sh\necho omp/17.0.8\n";
const altered = "#!/bin/sh\necho omp/17.1.2\n";
const expectedDigest = `sha256:${createHash("sha256")
.update("x".repeat(Buffer.byteLength(altered)))
.digest("hex")}`;
await Bun.write(targetPath, installed);
await fs.chmod(targetPath, 0o755);
const metadataAuthorizations: Array<string | null> = [];
const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
const requestUrl = String(input);
if (requestUrl.startsWith("https://api.github.com/")) {
metadataAuthorizations.push(new Headers(init?.headers).get("Authorization"));
return new Response(
JSON.stringify(
releaseAsset({
size: Buffer.byteLength(altered),
digest: expectedDigest,
}),
),
);
}
if (requestUrl === url) return new Response(altered);
throw new Error(`Unexpected request: ${requestUrl}`);
};
const previousGitHubToken = Bun.env.GITHUB_TOKEN;
Bun.env.GITHUB_TOKEN = "test-token";
try {
await expect(
updateViaBinaryAt(targetPath, "17.1.2", {
binaryName,
fetchImpl,
}),
).rejects.toThrow("digest mismatch");
expect(metadataAuthorizations).toEqual(["Bearer test-token"]);
expect(await Bun.file(targetPath).text()).toBe(installed);
expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755);
expect(await Bun.file(`${targetPath}.new`).exists()).toBe(false);
} finally {
if (previousGitHubToken === undefined) delete Bun.env.GITHUB_TOKEN;
else Bun.env.GITHUB_TOKEN = previousGitHubToken;
}
});
it("explains how to authenticate after an anonymous GitHub API rate limit", async () => {
const dir = await makeTempDir();
const targetPath = path.join(dir, binaryName);
const fetchImpl = async () => new Response(null, { status: 403, statusText: "rate limit exceeded" });
await expect(
updateViaBinaryAt(targetPath, "17.1.2", {
binaryName,
fetchImpl,
githubToken: "",
}),
).rejects.toThrow("retry later or set GITHUB_TOKEN or GH_TOKEN");
expect(await Bun.file(targetPath).exists()).toBe(false);
});
});
describe("update-cli binary replacement", () => {
it("restores the previous binary when the replacement fails verification", async () => {
const dir = await makeTempDir();