Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
100 changes: 100 additions & 0 deletions scripts/github-mirror.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,106 @@ test("GitHub release mirror caches empty assets as a short cache miss", () => {
`);
});

test("GitHub release mirror follows repository redirects and rewrites asset URLs", () => {
runMirrorTest(`
import assert from "node:assert/strict";
import {
getCachedGitHubRelease,
} from "./web/src/lib/github/mirror.ts";

const seen = [];
const writes = [];
globalThis.fetch = async (url) => {
seen.push(String(url));
if (String(url) === "https://api.github.com/repos/jdx/rtx/releases/latest") {
return new Response("Moved Permanently", {
status: 301,
headers: {
Location: "https://api.github.com/repositories/586920414/releases/latest",
},
});
}
if (String(url) === "https://api.github.com/repositories/586920414/releases/latest") {
return new Response(JSON.stringify({
tag_name: "v2026.6.0",
draft: false,
prerelease: false,
created_at: "2026-06-03T18:02:06Z",
published_at: "2026-06-03T18:02:06Z",
assets: [{
name: "install.sh",
browser_download_url: "https://github.com/jdx/mise/releases/download/v2026.6.0/install.sh",
url: "https://api.github.com/repos/jdx/mise/releases/assets/437498665",
digest: "sha256:abc123",
}],
}), { status: 200 });
}
return new Response("unexpected URL", { status: 500 });
};

const env = {
DB: {},
GITHUB_CACHE: {
get: async () => null,
put: async (key, value) => writes.push({ key, value }),
},
};

const release = await getCachedGitHubRelease(env, "jdx", "rtx", "latest");

assert.deepEqual(seen, [
"https://api.github.com/repos/jdx/rtx/releases/latest",
"https://api.github.com/repositories/586920414/releases/latest",
]);
assert.equal(release.assets[0].browser_download_url, "https://github.com/jdx/rtx/releases/download/v2026.6.0/install.sh");
assert.equal(release.assets[0].url, "https://api.github.com/repos/jdx/rtx/releases/assets/437498665");
assert.equal(writes[0].key, "github:release:jdx/rtx:latest");
`);
Comment on lines +109 to +110
});

test("GitHub release mirror rewrites canonical asset casing to the requested repo", () => {
runMirrorTest(`
import assert from "node:assert/strict";
import {
getCachedGitHubRelease,
} from "./web/src/lib/github/mirror.ts";

globalThis.fetch = async (url) => {
assert.equal(String(url), "https://api.github.com/repos/Dicklesworthstone/Destructive_command_guard/releases/tags/v0.5.6");
return new Response(JSON.stringify({
tag_name: "v0.5.6",
draft: false,
prerelease: false,
created_at: "2026-05-27T00:04:32Z",
published_at: "2026-05-27T00:30:31Z",
assets: [{
name: "dcg-aarch64-apple-darwin.tar.xz",
browser_download_url: "https://github.com/Dicklesworthstone/destructive_command_guard/releases/download/v0.5.6/dcg-aarch64-apple-darwin.tar.xz",
url: "https://api.github.com/repos/Dicklesworthstone/destructive_command_guard/releases/assets/430632958",
}],
}), { status: 200 });
};

const env = {
DB: {},
GITHUB_CACHE: {
get: async () => null,
put: async () => {},
},
};

const release = await getCachedGitHubRelease(
env,
"Dicklesworthstone",
"Destructive_command_guard",
"v0.5.6",
);

assert.equal(release.assets[0].browser_download_url, "https://github.com/Dicklesworthstone/Destructive_command_guard/releases/download/v0.5.6/dcg-aarch64-apple-darwin.tar.xz");
assert.equal(release.assets[0].url, "https://api.github.com/repos/Dicklesworthstone/Destructive_command_guard/releases/assets/430632958");
`);
});

test("GitHub attestations hydrate signed blob bundle URLs without GitHub tokens", () => {
runMirrorTest(`
import assert from "node:assert/strict";
Expand Down
124 changes: 105 additions & 19 deletions web/src/lib/github/mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const NEGATIVE_ATTESTATION_FRESH_MS = 30 * 60 * 1000;
const EDGE_SHORT_TTL_SECONDS = 10 * 60;
const EDGE_NEGATIVE_ATTESTATION_TTL_SECONDS = 30 * 60;
const EDGE_IMMUTABLE_TTL_SECONDS = 365 * 24 * 60 * 60;
const MAX_GITHUB_REDIRECTS = 3;

interface CacheEntry<T> {
cached_at: number;
Expand Down Expand Up @@ -269,12 +270,24 @@ async function fetchGitHubRelease(
prerelease: data.prerelease,
created_at: data.created_at,
immutable,
assets: assets.map((asset) => ({
name: asset.name,
browser_download_url: asset.browser_download_url,
url: asset.url,
digest: asset.digest ?? null,
})),
assets: assets.map((asset) => normalizeGitHubAsset(asset, owner, repo)),
};
}

function normalizeGitHubAsset(
asset: GitHubAsset,
owner: string,
repo: string,
): GitHubAsset {
return {
name: asset.name,
browser_download_url: withRequestedGitHubRepo(
asset.browser_download_url,
owner,
repo,
),
url: withRequestedGitHubRepo(asset.url, owner, repo),
digest: asset.digest ?? null,
};
}

Expand Down Expand Up @@ -340,20 +353,31 @@ async function githubJson<T>(
url: string,
token: TokenRecord | null,
): Promise<T> {
const headers = githubJsonHeaders(url, token);
const response = await fetch(url, { headers, redirect: "manual" });
if (response.status === 404) {
throw new GitHubError(404, "Not found", response.headers, url);
}
if (!response.ok) {
throw new GitHubError(
response.status,
await response.text(),
response.headers,
url,
);
let currentUrl = url;
for (let redirects = 0; redirects <= MAX_GITHUB_REDIRECTS; redirects++) {
const headers = githubJsonHeaders(currentUrl, token);
const response = await fetch(currentUrl, { headers, redirect: "manual" });
if (isRedirect(response.status)) {
const nextUrl = githubRedirectUrl(currentUrl, response);
if (nextUrl && redirects < MAX_GITHUB_REDIRECTS) {
currentUrl = nextUrl;
continue;
}
Comment on lines +356 to +370
Comment on lines +362 to +370
}
if (response.status === 404) {
throw new GitHubError(404, "Not found", response.headers, currentUrl);
}
if (!response.ok) {
throw new GitHubError(
response.status,
await response.text(),
response.headers,
currentUrl,
);
}
return readJsonResponse(response);
}
return readJsonResponse(response);
throw new GitHubError(508, "Too many GitHub redirects", new Headers(), url);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
}

async function readJsonResponse<T>(response: Response): Promise<T> {
Expand Down Expand Up @@ -400,6 +424,66 @@ function isGitHubApiUrl(value: string | undefined): boolean {
}
}

function isRedirect(status: number): boolean {
return [301, 302, 303, 307, 308].includes(status);
}
Comment on lines +442 to +444

function githubRedirectUrl(
currentUrl: string,
response: Response,
): string | null {
const location = response.headers.get("location");
if (!location || !isGitHubApiUrl(currentUrl)) {
return null;
}
try {
const nextUrl = new URL(location, currentUrl).toString();
return isGitHubApiUrl(nextUrl) ? nextUrl : null;
} catch {
return null;
}
}

function withRequestedGitHubRepo(
value: string,
owner: string,
repo: string,
): string {
try {
const url = new URL(value);
if (url.protocol !== "https:") {
return value;
}
const segments = url.pathname.split("/");
if (
url.hostname === "github.com" &&
segments.length >= 6 &&
segments[3] === "releases" &&
segments[4] === "download"
) {
segments[1] = owner;
segments[2] = repo;
url.pathname = segments.join("/");
return url.toString();
}
if (
url.hostname === "api.github.com" &&
segments.length === 7 &&
segments[1] === "repos" &&
segments[4] === "releases" &&
segments[5] === "assets"
) {
segments[2] = owner;
segments[3] = repo;
url.pathname = segments.join("/");
return url.toString();
}
return value;
} catch {
return value;
}
}

function githubJsonHeaders(
url: string,
token: TokenRecord | null,
Expand Down Expand Up @@ -454,7 +538,9 @@ class GitHubError extends Error {
export const __testing = {
GitHubError,
githubJsonHeaders,
githubRedirectUrl,
isGitHubApiUrl,
isRateLimited,
validGitHubAttestationBundleUrl,
withRequestedGitHubRepo,
};