fix: restore public file streaming and contain ZIP failures

This commit is contained in:
Thomas Durieux committed 2026-09-14 15:06:29 +00:00
1 parent ecba15ce2e
commit c9c729c2c5
7 files changed
+226 -22

No files matched your search

+3 -2
View File
@@ -18,6 +18,7 @@ import FileModel from "./model/files/files.model";
import { IFile } from "./model/files/files.types";
import { FilterQuery } from "mongoose";
import { createLogger, serializeError } from "./logger";
import { githubTokenForStreamer } from "./github-token-context";
const logger = createLogger("anonymized-file");
@@ -356,7 +357,7 @@ export default class AnonymizedFile {
return got.stream(join(config.STREAMER_ENTRYPOINT, "api"), {
method: "POST",
json: {
token: await this.repository.getToken(),
token: await githubTokenForStreamer(await this.repository.getToken(), this.repository.model.source.repositoryName),
repoFullName: this.repository.model.source.repositoryName,
commit: this.repository.model.source.commit,
branch: this.repository.model.source.branch,
@@ -403,7 +404,7 @@ export default class AnonymizedFile {
json: {
sha,
size,
token,
token: await githubTokenForStreamer(token, this.repository.model.source.repositoryName),
repoFullName: this.repository.model.source.repositoryName,
commit: this.repository.model.source.commit,
branch: this.repository.model.source.branch,
+11
View File
@@ -11,6 +11,17 @@ export function githubTokenContext(token: string) {
if (!context && token.startsWith("public-read:")) throw new Error("Public repository access context expired");
return context;
}
// Public handles belong to this process. Revalidate here, then let the
// streamer fetch public bytes anonymously without forwarding the owner's token.
export async function githubTokenForStreamer(token: string, repository: string | undefined): Promise<string> {
const context = githubTokenContext(token);
if (!context?.publicRepository) return token;
if (!repository || context.publicRepository.toLowerCase() !== repository.toLowerCase()) {
throw new Error("Public repository access context mismatch");
}
await context.renew();
return "";
}
export function githubQuotaKey(token: string) {
return contexts.get(token)?.quotaKey || createHash("sha256").update(token).digest("hex").slice(0, 24);
}
+2 -2
View File
@@ -111,7 +111,7 @@ export default class GitHubStream extends GitHubBase {
logger.debug("downloading via raw URL (LFS)", { url });
return got.stream(url, {
hooks: { beforeRequest: [async () => { await githubTokenContext(token)?.renew(); }] },
headers: githubTokenContext(token)?.publicRepository ? {} : { authorization: `token ${token}` },
headers: !token || githubTokenContext(token)?.publicRepository ? {} : { authorization: `token ${token}` },
followRedirect: true,
});
}
@@ -127,7 +127,7 @@ export default class GitHubStream extends GitHubBase {
): Promise<stream.Readable> {
// Public raw downloads need no bearer token and do not consume the
// unauthenticated REST API quota. GitHub also resolves LFS pointers here.
if (githubTokenContext(token)?.publicRepository) {
if (!token || githubTokenContext(token)?.publicRepository) {
return Promise.resolve(this.downloadFileViaRaw(token, filePath));
}
return new Promise<stream.Readable>((resolve) => {
+18 -17
View File
@@ -124,9 +124,14 @@ export async function streamAnonymizedZip(
// opens). Destroy the response instead so the client sees a connection
// drop and knows the download failed. Same class of silent-truncation
// bug as #694.
let upstreamSucceeded = false;
let failed = false;
const parser = Parse();
const fail = (error: Error) => {
if (failed) return;
failed = true;
logger.error("upstream zipball failed", serializeError(error));
downloadStream.destroy();
downloadStream.unpipe(parser);
archive.abort();
const destroyable = res as unknown as {
destroy?: (err?: Error) => void;
@@ -138,10 +143,13 @@ export async function streamAnonymizedZip(
destroyable.end();
}
};
// pipe() returns the destination. Listen on the archive itself as well,
// including errors emitted after the upstream ZIP has finished downloading.
archive.on("error", fail);
downloadStream
.on("error", fail)
.pipe(Parse())
.pipe(parser)
.on("entry", (entry: NodeJS.ReadableStream & { type: string; path: string; autodrain: () => void }) => {
if (entry.type === "File") {
try {
@@ -161,11 +169,13 @@ export async function streamAnonymizedZip(
...opt.anonymizerOptions,
filePath: entry.path,
});
entry.on("error", fail);
anonymizer.on("error", fail);
const st = entry.pipe(anonymizer);
archive.append(st, { name: fileName });
} catch (error) {
entry.autodrain();
logger.error("entry transform failed", serializeError(error));
fail(error as Error);
}
} else {
entry.autodrain();
@@ -173,22 +183,13 @@ export async function streamAnonymizedZip(
})
.on("error", fail)
.on("finish", () => {
upstreamSucceeded = true;
if (failed) return;
try {
archive.finalize();
} catch {
/* ignored */
archive.finalize().catch(fail);
} catch (error) {
fail(error as Error);
}
});
archive.pipe(res).on("error", (error) => {
logger.error("archive pipe error", serializeError(error));
if (!upstreamSucceeded) {
// archive errored while we were still depending on upstream bytes:
// treat as failure rather than truncating.
fail(error);
return;
}
(res as { end?: () => void }).end?.();
});
archive.pipe(res).on("error", fail);
}