fix(cli): trust GitHub release asset redirects (#54)
Co-authored-by: alvinfei <alvinfei@tencent.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
10 files changed
+137
-36
No files matched your search
@@ -12,7 +12,6 @@
|
||||
* It is generated rather than hand-written so the two cannot drift: this reads
|
||||
* the real package.json and only strips what is provably not needed at runtime.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, resolve } from "node:path";
|
||||
@@ -20,12 +19,14 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, "..");
|
||||
const repositoryRoot = resolve(root, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveBuildVersion } = require("../../../scripts/release-version.cjs");
|
||||
const releaseVersion = resolveBuildVersion(process.env, repositoryRoot);
|
||||
const { assertSemver } = require("../../../scripts/release-version.cjs");
|
||||
const pkgPath = resolve(root, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
||||
const publishVersion = assertSemver(
|
||||
process.env.MEMHUB_NPM_VERSION?.trim() || pkg.version,
|
||||
"MEMHUB_NPM_VERSION",
|
||||
);
|
||||
const publishName = process.env.MEMHUB_NPM_PACKAGE?.trim();
|
||||
if (!publishName || !/^(?:@[a-z0-9._-]+\/)?[a-z0-9._-]+$/.test(publishName)) {
|
||||
throw new Error(
|
||||
@@ -53,7 +54,7 @@ mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const published = { ...pkg };
|
||||
published.name = publishName;
|
||||
published.version = releaseVersion;
|
||||
published.version = publishVersion;
|
||||
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
||||
if (!published[section]) continue;
|
||||
published[section] = Object.fromEntries(
|
||||
|
||||
@@ -331,7 +331,7 @@ import {
|
||||
renderVersion,
|
||||
renderVersionDocument,
|
||||
} from "./usage.js";
|
||||
import { CLI_VERSION, PRODUCT } from "./version.js";
|
||||
import { CLI_VERSION, PRODUCT, RUNTIME_RELEASE_VERSION } from "./version.js";
|
||||
import { runWatchLoop, type WatchEvent, type WatchRead } from "./watch-loop.js";
|
||||
|
||||
export interface RunControls {
|
||||
@@ -417,7 +417,8 @@ async function selectInstallSource(
|
||||
throw new Error("release_manifest_url_unavailable");
|
||||
}
|
||||
const manifestUrl =
|
||||
explicitManifestUrl ?? template.replaceAll("{version}", CLI_VERSION);
|
||||
explicitManifestUrl ??
|
||||
template.replaceAll("{version}", RUNTIME_RELEASE_VERSION);
|
||||
const allowedHosts = parseAllowedReleaseHosts(
|
||||
environment.MEMHUB_RELEASE_ALLOWED_HOSTS,
|
||||
);
|
||||
@@ -462,7 +463,7 @@ async function resolveRemoteInstallPreview(
|
||||
): Promise<ResolvedRuntimeRelease> {
|
||||
return (controls.resolveRuntimeRelease ?? resolveRuntimeRelease)({
|
||||
manifestUrl: selection.manifestUrl,
|
||||
expectedVersion: CLI_VERSION,
|
||||
expectedVersion: RUNTIME_RELEASE_VERSION,
|
||||
channel: selection.channel,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
@@ -476,7 +477,7 @@ async function acquireRemoteInstallSource(
|
||||
): Promise<AcquiredRuntimeDistribution> {
|
||||
return (controls.acquireRuntimeDistribution ?? acquireRuntimeDistribution)({
|
||||
manifestUrl: selection.manifestUrl,
|
||||
expectedVersion: CLI_VERSION,
|
||||
expectedVersion: RUNTIME_RELEASE_VERSION,
|
||||
channel: selection.channel,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
@@ -6520,7 +6521,7 @@ function parseInstallOptions(
|
||||
...(releaseManifestUrl ? { releaseManifestUrl } : {}),
|
||||
releaseChannel:
|
||||
releaseChannel ??
|
||||
(CLI_VERSION.includes("-") ? "prerelease" : "stable"),
|
||||
(RUNTIME_RELEASE_VERSION.includes("-") ? "prerelease" : "stable"),
|
||||
installRoot: resolvedInstallRoot,
|
||||
home: resolvedHome,
|
||||
uid: uid ?? process.getuid?.(),
|
||||
|
||||
@@ -48,7 +48,11 @@ describe("publish manifest is production-safe", () => {
|
||||
execFileSync("node", ["scripts/prepare-publish-manifest.mjs"], {
|
||||
cwd: root,
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, MEMHUB_NPM_PACKAGE: "@example/memhub-cli" },
|
||||
env: {
|
||||
...process.env,
|
||||
MEMHUB_NPM_PACKAGE: "@example/memhub-cli",
|
||||
MEMHUB_NPM_VERSION: "1.0.5",
|
||||
},
|
||||
});
|
||||
const published = JSON.parse(
|
||||
readFileSync(path.join(root, "dist-publish", "package.json"), "utf8"),
|
||||
@@ -56,6 +60,7 @@ describe("publish manifest is production-safe", () => {
|
||||
expect(published.dependencies).toBeUndefined();
|
||||
expect(published.private).toBe(false);
|
||||
expect(published.name).toBe("@example/memhub-cli");
|
||||
expect(published.version).toBe("1.0.5");
|
||||
expect(published.bin).toMatchObject({
|
||||
memhub: "dist/one-memory.cjs",
|
||||
"memhub-cli": "dist/one-memory.cjs",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
declare const __ONE_MEMORY_VERSION__: string;
|
||||
declare const __ONE_MEMORY_BUILD_NUMBER__: string;
|
||||
declare const __MEMHUB_DEFAULT_RUNTIME_VERSION__: string;
|
||||
|
||||
const SEMVER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
|
||||
const embeddedVersion =
|
||||
typeof __ONE_MEMORY_VERSION__ === "string"
|
||||
@@ -9,13 +13,18 @@ const embeddedBuildNumber =
|
||||
typeof __ONE_MEMORY_BUILD_NUMBER__ === "string"
|
||||
? __ONE_MEMORY_BUILD_NUMBER__.trim()
|
||||
: "";
|
||||
const embeddedRuntimeVersion =
|
||||
typeof __MEMHUB_DEFAULT_RUNTIME_VERSION__ === "string"
|
||||
? __MEMHUB_DEFAULT_RUNTIME_VERSION__.trim()
|
||||
: "";
|
||||
|
||||
export const CLI_VERSION =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
|
||||
embeddedVersion,
|
||||
)
|
||||
? embeddedVersion
|
||||
: "development";
|
||||
export const CLI_VERSION = SEMVER.test(embeddedVersion)
|
||||
? embeddedVersion
|
||||
: "development";
|
||||
|
||||
export const RUNTIME_RELEASE_VERSION = SEMVER.test(embeddedRuntimeVersion)
|
||||
? embeddedRuntimeVersion
|
||||
: CLI_VERSION;
|
||||
|
||||
export const CLI_BUILD_NUMBER = /^\d+$/.test(embeddedBuildNumber)
|
||||
? Number(embeddedBuildNumber)
|
||||
|
||||
@@ -2,11 +2,22 @@ import { createRequire } from "node:module";
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { resolveBuildVersion } =
|
||||
const { assertSemver, resolveBuildVersion } =
|
||||
require("../../scripts/release-version.cjs") as {
|
||||
assertSemver(value: string, label?: string): string;
|
||||
resolveBuildVersion(environment?: NodeJS.ProcessEnv, root?: string): string;
|
||||
};
|
||||
const releaseVersion = resolveBuildVersion(process.env);
|
||||
const packageVersion = (require("./package.json") as { version: string })
|
||||
.version;
|
||||
const cliVersion = assertSemver(
|
||||
process.env.MEMHUB_NPM_VERSION?.trim() || packageVersion,
|
||||
"MEMHUB_NPM_VERSION",
|
||||
);
|
||||
const runtimeReleaseVersion = assertSemver(
|
||||
process.env.MEMHUB_DEFAULT_RUNTIME_VERSION?.trim() || releaseVersion,
|
||||
"MEMHUB_DEFAULT_RUNTIME_VERSION",
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
entry: { "one-memory": "src/entry.ts" },
|
||||
@@ -15,7 +26,7 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
define: {
|
||||
__ONE_MEMORY_VERSION__: JSON.stringify(releaseVersion),
|
||||
__ONE_MEMORY_VERSION__: JSON.stringify(cliVersion),
|
||||
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify(
|
||||
process.env.ONE_MEMORY_BUILD_NUMBER ?? "unknown",
|
||||
),
|
||||
@@ -25,6 +36,7 @@ export default defineConfig({
|
||||
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(
|
||||
process.env.MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE ?? "",
|
||||
),
|
||||
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(runtimeReleaseVersion),
|
||||
},
|
||||
// The shared operations package ships TypeScript sources, so it must be
|
||||
// inlined into the CJS bundle the same way runtime-control is; otherwise the
|
||||
|
||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
||||
__ONE_MEMORY_BUILD_NUMBER__: JSON.stringify("unknown"),
|
||||
__ONE_MEMORY_COMMIT__: JSON.stringify("unknown"),
|
||||
__MEMHUB_DEFAULT_RELEASE_MANIFEST_URL_TEMPLATE__: JSON.stringify(""),
|
||||
__MEMHUB_DEFAULT_RUNTIME_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
|
||||
@@ -312,6 +312,48 @@ describe("remote Runtime acquisition", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows the exact GitHub Release asset redirect hosts by default", async () => {
|
||||
const value = await fixture();
|
||||
const manifestUrl =
|
||||
"https://github.com/example/project/releases/download/v1.2.3/release-manifest.json";
|
||||
const artifactUrl =
|
||||
"https://github.com/example/project/releases/download/v1.2.3/runtime.tar.gz";
|
||||
const manifestAssetUrl =
|
||||
"https://release-assets.githubusercontent.com/manifest-download";
|
||||
const runtimeAssetUrl =
|
||||
"https://release-assets.githubusercontent.com/runtime-download";
|
||||
const release = {
|
||||
...value.release,
|
||||
artifacts: [{ ...value.artifact, url: artifactUrl }],
|
||||
};
|
||||
const temporaryParent = await mkdtemp(
|
||||
path.join(os.tmpdir(), "memhub-github-release-parent-"),
|
||||
);
|
||||
roots.push(temporaryParent);
|
||||
|
||||
const acquired = await acquireRuntimeDistribution({
|
||||
manifestUrl,
|
||||
expectedVersion: "1.2.3",
|
||||
channel: "stable",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
temporaryParent,
|
||||
httpGet: httpFixture({
|
||||
[manifestUrl]: response(new Uint8Array(), 302, {
|
||||
location: manifestAssetUrl,
|
||||
}),
|
||||
[manifestAssetUrl]: response(Buffer.from(JSON.stringify(release))),
|
||||
[artifactUrl]: response(new Uint8Array(), 302, {
|
||||
location: runtimeAssetUrl,
|
||||
}),
|
||||
[runtimeAssetUrl]: response(value.archiveBytes),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(acquired.release.version).toBe("1.2.3");
|
||||
await acquired.cleanup();
|
||||
});
|
||||
|
||||
it("rejects an archive whose internal commit drifts from the Release", async () => {
|
||||
const value = await fixture();
|
||||
const release = { ...value.release, git_commit: "b".repeat(40) };
|
||||
@@ -368,7 +410,6 @@ describe("remote Runtime acquisition", () => {
|
||||
);
|
||||
|
||||
it("rejects a redirect to an untrusted host", async () => {
|
||||
const value = await fixture();
|
||||
await expect(
|
||||
acquireRuntimeDistribution({
|
||||
manifestUrl: "https://downloads.example.test/release-manifest.json",
|
||||
|
||||
@@ -38,6 +38,10 @@ const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const COMMIT = /^[0-9a-f]{40}$/;
|
||||
const SAFE_FILE_NAME = /^[0-9A-Za-z][0-9A-Za-z._-]{0,255}$/;
|
||||
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
|
||||
const GITHUB_RELEASE_REDIRECT_HOSTS = [
|
||||
"release-assets.githubusercontent.com",
|
||||
"objects.githubusercontent.com",
|
||||
] as const;
|
||||
|
||||
export type ReleaseChannel = "stable" | "prerelease";
|
||||
export type RuntimeReleasePlatform = "darwin" | "linux";
|
||||
@@ -330,8 +334,12 @@ export async function resolveRuntimeRelease(options: {
|
||||
maxRedirects?: number;
|
||||
}): Promise<ResolvedRuntimeRelease> {
|
||||
const manifestUrl = trustedUrl(options.manifestUrl);
|
||||
const defaultHosts =
|
||||
normalizedHost(manifestUrl.hostname) === "github.com"
|
||||
? [manifestUrl.hostname, ...GITHUB_RELEASE_REDIRECT_HOSTS]
|
||||
: [manifestUrl.hostname];
|
||||
const allowedHosts = new Set(
|
||||
(options.allowedHosts ?? [manifestUrl.hostname]).map(normalizedHost),
|
||||
(options.allowedHosts ?? defaultHosts).map(normalizedHost),
|
||||
);
|
||||
if (!allowedHosts.has(normalizedHost(manifestUrl.hostname))) {
|
||||
throw new Error("release_url_untrusted");
|
||||
|
||||
@@ -23,7 +23,8 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
const statImpl = dependencies.statImpl ?? stat;
|
||||
const buildEnvironment = {
|
||||
...environment,
|
||||
ONE_MEMORY_VERSION: options.version,
|
||||
MEMHUB_NPM_VERSION: options.version,
|
||||
MEMHUB_DEFAULT_RUNTIME_VERSION: options.runtimeVersion,
|
||||
ONE_MEMORY_BUILD_NUMBER: String(options.buildNumber),
|
||||
ONE_MEMORY_COMMIT: options.gitCommit,
|
||||
};
|
||||
@@ -31,7 +32,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
const versionReceipt = await verifyVersion(
|
||||
[
|
||||
"--version",
|
||||
options.version,
|
||||
options.runtimeVersion,
|
||||
"--channel",
|
||||
options.channel,
|
||||
"--tag",
|
||||
@@ -43,7 +44,7 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
);
|
||||
const releaseReceipt = await verifyRelease({
|
||||
manifestUrl: options.manifestUrl,
|
||||
version: options.version,
|
||||
version: options.runtimeVersion,
|
||||
gitCommit: options.gitCommit,
|
||||
channel: options.channel,
|
||||
platform: options.platform,
|
||||
@@ -106,7 +107,8 @@ export async function publishCliAfterRelease(options, dependencies = {}) {
|
||||
return {
|
||||
schema_version: 1,
|
||||
product: "memhub-cli-publication",
|
||||
version: versionReceipt.version,
|
||||
version: options.version,
|
||||
runtime_version: releaseReceipt.version,
|
||||
build_number: versionReceipt.build_number,
|
||||
tag: versionReceipt.tag,
|
||||
release: releaseReceipt,
|
||||
@@ -196,6 +198,11 @@ function parseArgs(arguments_) {
|
||||
options.manifestUrl = value;
|
||||
else if (flag === "--version" && options.version === undefined)
|
||||
options.version = value;
|
||||
else if (
|
||||
flag === "--runtime-version" &&
|
||||
options.runtimeVersion === undefined
|
||||
)
|
||||
options.runtimeVersion = value;
|
||||
else if (flag === "--git-commit" && options.gitCommit === undefined)
|
||||
options.gitCommit = value;
|
||||
else if (flag === "--channel" && options.channel === undefined)
|
||||
@@ -219,6 +226,7 @@ function parseArgs(arguments_) {
|
||||
if (
|
||||
!options.manifestUrl ||
|
||||
!options.version ||
|
||||
!options.runtimeVersion ||
|
||||
!options.gitCommit ||
|
||||
!options.channel ||
|
||||
!options.tag ||
|
||||
@@ -230,7 +238,7 @@ function parseArgs(arguments_) {
|
||||
!options.npmTag
|
||||
) {
|
||||
throw new Error(
|
||||
"usage: publish-cli-after-release.mjs --manifest-url <https-url> --version <SemVer> --git-commit <sha> --channel <stable|prerelease> --tag <vSemVer> --build-number <integer> --platform <darwin|linux> --arch <x64|arm64> --registry <https-url> --npm-tag <tag> [--access <public|restricted>] [--provenance] [--allowed-host <hostname>] [--dry-run]",
|
||||
"usage: publish-cli-after-release.mjs --manifest-url <https-url> --version <CLI SemVer> --runtime-version <Runtime SemVer> --git-commit <sha> --channel <stable|prerelease> --tag <vSemVer> --build-number <integer> --platform <darwin|linux> --arch <x64|arm64> --registry <https-url> --npm-tag <tag> [--access <public|restricted>] [--provenance] [--allowed-host <hostname>] [--dry-run]",
|
||||
);
|
||||
}
|
||||
return options;
|
||||
|
||||
@@ -6,6 +6,9 @@ import test from "node:test";
|
||||
|
||||
import { publishCliAfterRelease } from "./publish-cli-after-release.mjs";
|
||||
|
||||
const CLI_VERSION = "0.1.1";
|
||||
const RUNTIME_VERSION = "0.1.0-internal.1";
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "memhub-publish-gate-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
@@ -15,10 +18,10 @@ async function fixture(t) {
|
||||
await mkdir(output, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(publish, "package.json"),
|
||||
`${JSON.stringify({ name: "@example/memhub-cli", version: "0.1.0-internal.1", private: false })}\n`,
|
||||
`${JSON.stringify({ name: "@example/memhub-cli", version: CLI_VERSION, private: false })}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(output, "example-memhub-cli-0.1.0-internal.1.tgz"),
|
||||
path.join(output, `example-memhub-cli-${CLI_VERSION}.tgz`),
|
||||
"tarball",
|
||||
);
|
||||
return root;
|
||||
@@ -26,10 +29,11 @@ async function fixture(t) {
|
||||
|
||||
const options = {
|
||||
manifestUrl: "https://releases.example.test/release-manifest.json",
|
||||
version: "0.1.0-internal.1",
|
||||
version: CLI_VERSION,
|
||||
runtimeVersion: RUNTIME_VERSION,
|
||||
gitCommit: "a".repeat(40),
|
||||
channel: "prerelease",
|
||||
tag: "v0.1.0-internal.1",
|
||||
tag: `v${RUNTIME_VERSION}`,
|
||||
buildNumber: 1,
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
@@ -49,15 +53,24 @@ test("verifies the Runtime before building and publishing the CLI", async (t) =>
|
||||
{
|
||||
verifyVersion: async () => {
|
||||
events.push("version");
|
||||
return { version: options.version, build_number: 1, tag: options.tag };
|
||||
return {
|
||||
version: options.runtimeVersion,
|
||||
build_number: 1,
|
||||
tag: options.tag,
|
||||
};
|
||||
},
|
||||
verifyRelease: async (verifyOptions) => {
|
||||
events.push("release");
|
||||
assert.equal(verifyOptions.gitCommit, options.gitCommit);
|
||||
return { version: options.version, sha256: "a".repeat(64) };
|
||||
assert.equal(verifyOptions.version, options.runtimeVersion);
|
||||
return { version: options.runtimeVersion, sha256: "a".repeat(64) };
|
||||
},
|
||||
runCommand: (command, arguments_, commandOptions) => {
|
||||
assert.equal(commandOptions.env.ONE_MEMORY_VERSION, options.version);
|
||||
assert.equal(commandOptions.env.MEMHUB_NPM_VERSION, options.version);
|
||||
assert.equal(
|
||||
commandOptions.env.MEMHUB_DEFAULT_RUNTIME_VERSION,
|
||||
options.runtimeVersion,
|
||||
);
|
||||
assert.equal(
|
||||
commandOptions.env.ONE_MEMORY_BUILD_NUMBER,
|
||||
String(options.buildNumber),
|
||||
@@ -74,6 +87,8 @@ test("verifies the Runtime before building and publishing the CLI", async (t) =>
|
||||
assert.match(events[3], /--access public/);
|
||||
assert.match(events[3], /--provenance/);
|
||||
assert.equal(receipt.npm_package, "@example/memhub-cli");
|
||||
assert.equal(receipt.version, CLI_VERSION);
|
||||
assert.equal(receipt.runtime_version, RUNTIME_VERSION);
|
||||
assert.equal(receipt.dry_run, true);
|
||||
assert.equal(receipt.npm_access, "public");
|
||||
assert.equal(receipt.provenance, true);
|
||||
@@ -87,7 +102,7 @@ test("never builds or invokes npm when the Runtime release is unavailable", asyn
|
||||
{ ...options, root },
|
||||
{
|
||||
verifyVersion: async () => ({
|
||||
version: options.version,
|
||||
version: options.runtimeVersion,
|
||||
build_number: 1,
|
||||
tag: options.tag,
|
||||
}),
|
||||
@@ -115,11 +130,11 @@ test("rejects a package identity that differs from the configured publish name",
|
||||
},
|
||||
{
|
||||
verifyVersion: async () => ({
|
||||
version: options.version,
|
||||
version: options.runtimeVersion,
|
||||
build_number: 1,
|
||||
tag: options.tag,
|
||||
}),
|
||||
verifyRelease: async () => ({ version: options.version }),
|
||||
verifyRelease: async () => ({ version: options.runtimeVersion }),
|
||||
runCommand: () => undefined,
|
||||
},
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user