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
+192 -25
View File
@@ -4,11 +4,13 @@
* Handles `omp update` to check for and install updates.
* Uses the installer that owns the active omp executable when it can be detected.
*/
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
import { $env, $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils";
import { $ } from "bun";
import chalk from "chalk";
import { theme } from "../modes/theme/theme";
@@ -30,6 +32,7 @@ const MISE_TOOL = "github:can1357/oh-my-pi";
* See #1686.
*/
const NPM_REGISTRY = "https://registry.npmjs.org/";
const GITHUB_API = "https://api.github.com";
const RELEASE_METADATA_TIMEOUT_MS = 30_000;
const BINARY_DOWNLOAD_TIMEOUT_MS = 15 * 60_000;
@@ -65,6 +68,173 @@ interface ReleaseInfo {
version: string;
}
export interface ReleaseBinaryAsset {
url: string;
size: number;
digest: string;
}
type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
/**
* Select and validate the binary asset from GitHub release metadata.
*/
export function resolveReleaseBinaryAsset(
release: unknown,
expectedTag: string,
binaryName: string,
): ReleaseBinaryAsset {
if (!isRecord(release)) {
throw new Error("Invalid GitHub release metadata");
}
if (release.tag_name !== expectedTag) {
throw new Error(`GitHub release tag mismatch: expected ${expectedTag}`);
}
if (release.draft !== false || release.prerelease !== false) {
throw new Error(`GitHub release ${expectedTag} is not a published stable release`);
}
if (!Array.isArray(release.assets)) {
throw new Error(`GitHub release ${expectedTag} has no asset list`);
}
const matches = release.assets.filter(asset => isRecord(asset) && asset.name === binaryName);
if (matches.length !== 1) {
throw new Error(`GitHub release ${expectedTag} has ${matches.length} assets named ${binaryName}`);
}
const asset = matches[0];
if (!isRecord(asset) || asset.state !== "uploaded") {
throw new Error(`GitHub release asset ${binaryName} is not fully uploaded`);
}
if (typeof asset.size !== "number" || !Number.isSafeInteger(asset.size) || asset.size <= 0) {
throw new Error(`GitHub release asset ${binaryName} has an invalid size`);
}
if (typeof asset.digest !== "string") {
throw new Error(`GitHub release asset ${binaryName} has no digest`);
}
const digest = /^sha256:([0-9a-f]{64})$/i.exec(asset.digest)?.[1];
if (!digest) {
throw new Error(`GitHub release asset ${binaryName} has an unsupported digest`);
}
const expectedUrl = `https://github.com/${REPO}/releases/download/${expectedTag}/${binaryName}`;
if (asset.browser_download_url !== expectedUrl) {
throw new Error(`GitHub release asset ${binaryName} has an unexpected download URL`);
}
return {
url: expectedUrl,
size: asset.size,
digest: `sha256:${digest.toLowerCase()}`,
};
}
async function getReleaseBinaryAsset(
expectedVersion: string,
binaryName: string,
fetchImpl: Fetch = fetch,
githubToken: string | undefined = $env.GITHUB_TOKEN || $env.GH_TOKEN,
): Promise<ReleaseBinaryAsset> {
const tag = `v${expectedVersion}`;
const headers: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
};
if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
let response: Response;
try {
response = await fetchImpl(`${GITHUB_API}/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`, {
headers,
signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new Error("Timed out fetching GitHub release metadata after 30s", { cause: err });
}
throw err;
}
if ((response.status === 403 && !githubToken) || response.status === 429) {
throw new Error(
"GitHub API rate limit exceeded while fetching release metadata; retry later or set GITHUB_TOKEN or GH_TOKEN",
);
}
if (!response.ok) {
throw new Error(`Failed to fetch GitHub release metadata: ${response.statusText}`);
}
return resolveReleaseBinaryAsset(await response.json(), tag, binaryName);
}
export interface VerifiedBinaryDownloadOptions {
url: string;
targetPath: string;
expectedSize: number;
expectedDigest: string;
fetchImpl?: Fetch;
}
/**
* Download a binary and verify its GitHub-reported size and SHA-256 digest.
*/
export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOptions): Promise<void> {
const fetchImpl = options.fetchImpl ?? fetch;
await unlinkIfExists(options.targetPath);
let response: Response;
try {
response = await fetchImpl(options.url, {
redirect: "follow",
signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
}
throw err;
}
if (!response.ok || !response.body) {
throw new Error(`Download failed: ${response.statusText}`);
}
const hash = createHash("sha256");
let size = 0;
const verifier = new Transform({
transform(chunk, _encoding, callback) {
size += chunk.byteLength;
if (size > options.expectedSize) {
callback(
new Error(
`Downloaded binary size mismatch: expected ${options.expectedSize} bytes, received at least ${size}`,
),
);
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
try {
await pipeline(response.body, verifier, fs.createWriteStream(options.targetPath, { mode: 0o600 }));
const digest = `sha256:${hash.digest("hex")}`;
if (size !== options.expectedSize) {
throw new Error(`Downloaded binary size mismatch: expected ${options.expectedSize} bytes, received ${size}`);
}
if (digest !== options.expectedDigest) {
throw new Error(`Downloaded binary digest mismatch: expected ${options.expectedDigest}, received ${digest}`);
}
await fs.promises.chmod(options.targetPath, 0o755);
} catch (err) {
await unlinkIfExists(options.targetPath);
throw err;
}
}
/** Result from running the installed binary and parsing its reported version. */
export interface InstalledVersionVerification {
ok: boolean;
@@ -928,36 +1098,33 @@ async function updateViaMise(expectedVersion: string, force: boolean): Promise<v
/**
* Download a release binary to a target path, replacing an existing file.
*/
async function updateViaBinaryAt(targetPath: string, expectedVersion: string): Promise<void> {
const binaryName = getBinaryName();
const tag = `v${expectedVersion}`;
const url = `https://github.com/${REPO}/releases/download/${tag}/${binaryName}`;
export async function updateViaBinaryAt(
targetPath: string,
expectedVersion: string,
options: {
binaryName?: string;
fetchImpl?: Fetch;
githubToken?: string;
verifyInstalledVersion?: typeof verifyInstalledVersion;
} = {},
): Promise<void> {
const binaryName = options.binaryName ?? getBinaryName();
const tempPath = `${targetPath}.new`;
// Unique per attempt: a stale backup from an earlier update may still be
// locked (it is the previous process image on Windows), and a fixed name
// would force the move-aside rename to overwrite it. pid + timestamp keeps
// two forced updates in the same millisecond from colliding.
const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`;
const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken);
console.log(chalk.dim(`Downloading ${binaryName}…`));
let response: Response;
try {
response = await fetch(url, {
redirect: "follow",
signal: withTimeoutSignal(BINARY_DOWNLOAD_TIMEOUT_MS),
});
} catch (err) {
if (isTimeoutError(err)) {
throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
}
throw err;
}
if (!response.ok || !response.body) {
throw new Error(`Download failed: ${response.statusText}`);
}
const fileStream = fs.createWriteStream(tempPath, { mode: 0o755 });
await pipeline(response.body, fileStream);
await downloadVerifiedBinary({
url: asset.url,
targetPath: tempPath,
expectedSize: asset.size,
expectedDigest: asset.digest,
fetchImpl: options.fetchImpl,
});
console.log(chalk.dim(`Verified ${asset.digest}`));
console.log(chalk.dim("Installing update..."));
await replaceBinaryForUpdate({
@@ -965,7 +1132,7 @@ async function updateViaBinaryAt(targetPath: string, expectedVersion: string): P
tempPath,
backupPath,
expectedVersion,
verifyInstalledVersion,
verifyInstalledVersion: options.verifyInstalledVersion ?? verifyInstalledVersion,
});
// Reclaim backups from earlier updates whose owning process has since exited.
await sweepStaleBackups(targetPath);