mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
feat: include public GitHub submodules in shared repositories (#914)
* feat: include public GitHub submodules in shared repositories * fix: preserve submodule access across token and document contexts
This commit is contained in:
1 parent
6099356e0e
commit
d0b217a448
17 files changed
+617
-87
No files matched your search
@@ -153,8 +153,18 @@ export default class AnonymizedFile {
|
||||
return this._file.size;
|
||||
}
|
||||
|
||||
private visibleFile(file: IFile): IFile {
|
||||
if (this.repository.model?.source?.type !== "Zip" && file.name === ".gitmodules") {
|
||||
throw new AnonymousError("file_not_found", { httpStatus: 404 });
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
async getFileInfo(): Promise<IFile> {
|
||||
if (this._file) return this._file;
|
||||
if (this.repository.model?.source?.type !== "Zip" && basename(this.anonymizedPath) === ".gitmodules") {
|
||||
throw new AnonymousError("file_not_found", { httpStatus: 404 });
|
||||
}
|
||||
if (this._file) return this.visibleFile(this._file);
|
||||
let fileDir = dirname(this.anonymizedPath);
|
||||
if (fileDir == ".") fileDir = "";
|
||||
if (fileDir.endsWith("/")) fileDir = fileDir.slice(0, -1);
|
||||
@@ -180,7 +190,7 @@ export default class AnonymizedFile {
|
||||
if (exact) {
|
||||
if (exact.metadataPending) await this.repository.completeRecoveredFileMetadata(exact);
|
||||
this._file = exact;
|
||||
return exact;
|
||||
return this.visibleFile(exact);
|
||||
}
|
||||
|
||||
const terms = this.repository.options.terms || [];
|
||||
@@ -213,7 +223,7 @@ export default class AnonymizedFile {
|
||||
const compiled = compileTerms(terms);
|
||||
if (typeof this.repository.findAnonymizedPath === "function") {
|
||||
const indexed = await this.repository.findAnonymizedPath(this.anonymizedPath);
|
||||
if (indexed) { this._file = indexed; return indexed; }
|
||||
if (indexed) { this._file = indexed; return this.visibleFile(indexed); }
|
||||
if (indexed === null) {
|
||||
if (anonymizePathCompiled(this.anonymizedPath, compiled) === this.anonymizedPath) {
|
||||
const recovered = await this.recoverTruncatedFile(fileDir);
|
||||
@@ -237,7 +247,7 @@ export default class AnonymizedFile {
|
||||
) {
|
||||
if (candidate.metadataPending) await this.repository.completeRecoveredFileMetadata(candidate);
|
||||
this._file = candidate;
|
||||
return candidate;
|
||||
return this.visibleFile(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +322,7 @@ export default class AnonymizedFile {
|
||||
if (!this._file) {
|
||||
this._file = await this.getFileInfo();
|
||||
}
|
||||
this.visibleFile(this._file);
|
||||
return join(this._file.path, this._file.name);
|
||||
}
|
||||
extension() {
|
||||
@@ -398,6 +409,7 @@ export default class AnonymizedFile {
|
||||
branch: this.repository.model.source.branch,
|
||||
repoId: this.repository.repoId,
|
||||
filePath: this.filePath,
|
||||
upstream: (await this.getFileInfo()).upstream,
|
||||
sha: await this.sha(),
|
||||
size: await this.size(),
|
||||
anonymizerOptions: anonymizer.opt,
|
||||
@@ -446,6 +458,7 @@ export default class AnonymizedFile {
|
||||
json: {
|
||||
sha,
|
||||
size,
|
||||
upstream: (await this.getFileInfo()).upstream,
|
||||
token: await githubTokenForStreamer(token, this.repository.model.source.repositoryName),
|
||||
repoFullName: this.repository.model.source.repositoryName,
|
||||
commit: this.repository.model.source.commit,
|
||||
|
||||
@@ -244,7 +244,7 @@ export function octokit(token: string) {
|
||||
const prefix = `/repos/${context.publicRepository.split("/").map(encodeURIComponent).join("/")}`.toLowerCase();
|
||||
const path = url.pathname.toLowerCase();
|
||||
const suffix = path.slice(prefix.length);
|
||||
const readable = /^(?:\/?|\/branches(?:\/[^/]+)?|\/commits(?:\/[^/]+)?|\/readme|\/pages|\/zipball\/[^/]+|\/git\/(?:trees|blobs)\/[^/]+|\/pulls\/\d+|\/issues\/\d+\/comments)$/.test(suffix);
|
||||
const readable = /^(?:\/?|\/branches(?:\/[^/]+)?|\/commits(?:\/[^/]+)?|\/contents(?:\/[^/]+)*|\/readme|\/pages|\/zipball\/[^/]+|\/git\/(?:trees|blobs)\/[^/]+|\/pulls\/\d+|\/issues\/\d+\/comments)$/.test(suffix);
|
||||
if (!readable || (options.method !== "GET" && !(options.method === "HEAD" && suffix.startsWith("/zipball/"))) || url.origin !== "https://api.github.com" ||
|
||||
(path !== prefix && !path.startsWith(prefix + "/"))) {
|
||||
throw new AnonymousError("github_app_access_required", { httpStatus: 403 });
|
||||
|
||||
+17
-2
@@ -8,6 +8,7 @@ import { RepositoryStatus } from "./types";
|
||||
import { Readable } from "stream";
|
||||
import * as sha1 from "crypto-js/sha1";
|
||||
import User from "./User";
|
||||
import { SubmoduleMount } from "./source/github-submodules";
|
||||
import GitHubStream from "./source/GitHubStream";
|
||||
import Zip from "./source/Zip";
|
||||
import {
|
||||
@@ -43,14 +44,14 @@ async function buildPathIndex(repoId: string, key: string, terms: string[], gene
|
||||
const task = (async () => {
|
||||
const compiled = compileTerms(terms);
|
||||
const cursor = FileModel.find({ repoId, treeGeneration: generation || { $exists: false } })
|
||||
.select("name path sha size").sort({ path: 1, name: 1 }).lean().cursor();
|
||||
.select("name path sha size upstream").sort({ path: 1, name: 1 }).lean().cursor();
|
||||
let batch: Parameters<typeof AnonymizedPathModel.bulkWrite>[0] = [];
|
||||
try {
|
||||
for await (const file of cursor) {
|
||||
const name = anonymizePathCompiled(file.name, compiled);
|
||||
const directory = anonymizePathCompiled(file.path, compiled);
|
||||
batch.push({ updateOne: { filter: { repoId, key, originalId: file._id }, update: { $set: {
|
||||
repoId, key, originalId: file._id, name: file.name, path: file.path, sha: file.sha, size: file.size,
|
||||
repoId, key, originalId: file._id, name: file.name, path: file.path, sha: file.sha, size: file.size, upstream: file.upstream,
|
||||
anonymousName: name, anonymousDirectory: directory, createdAt: new Date(),
|
||||
searchName: name.toLowerCase(), searchDirectory: directory.toLowerCase(),
|
||||
anonymousPath: anonymizePathCompiled(file.path ? `${file.path}/${file.name}` : file.name, compiled),
|
||||
@@ -202,6 +203,17 @@ export default class Repository {
|
||||
return await FileModel.find(query).exec();
|
||||
}
|
||||
|
||||
async submoduleMounts(): Promise<SubmoduleMount[]> {
|
||||
if (this.model.source.type === "Zip") return [];
|
||||
await this.ensureFileTree();
|
||||
const mounts = await FileModel.find({ repoId: this.repoId,
|
||||
treeGeneration: this.model.treeGeneration || { $exists: false }, submodule: "included",
|
||||
}).select("name path upstream").lean().exec();
|
||||
return mounts.filter(file => file.upstream).map(file => ({
|
||||
...file.upstream!, path: [file.path, file.name].filter(Boolean).join("/"),
|
||||
}));
|
||||
}
|
||||
|
||||
private async ensureFileTree(opt: { force?: boolean; progress?: (status: string) => void } = {}) {
|
||||
this.assertNotArchived();
|
||||
let hasFile = this.hasEmptyTree() || await FileModel.exists({ repoId: this.repoId, treeGeneration: this.model.treeGeneration || { $exists: false } }).exec();
|
||||
@@ -219,6 +231,9 @@ export default class Repository {
|
||||
hasFile = null;
|
||||
}
|
||||
}
|
||||
if (hasFile && this.source instanceof GitHubStream && await FileModel.exists({
|
||||
repoId: this.repoId, treeGeneration: this.model.treeGeneration || { $exists: false }, name: ".gitmodules",
|
||||
}).exec()) hasFile = null;
|
||||
if (!hasFile || opt.force) {
|
||||
const previousGeneration = this.model.treeGeneration;
|
||||
const previousPrefix = contentGenerationPrefix(`${previousGeneration || "legacy"}:${this.model.anonymizeDate?.toISOString() || ""}`);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { UpstreamFileSchema } from "./files/files.schema";
|
||||
import { IFile } from "./files/files.types";
|
||||
import { model, Schema } from "mongoose";
|
||||
const schema = new Schema({ repoId: { type: String, required: true }, key: { type: String, required: true }, originalId: Schema.Types.ObjectId,
|
||||
name: { type: String, required: true }, path: { type: String, default: "", required: true }, sha: String, size: Number,
|
||||
upstream: { type: UpstreamFileSchema, default: undefined },
|
||||
anonymousName: String, anonymousDirectory: String, anonymousPath: String,
|
||||
searchName: String, searchDirectory: String,
|
||||
createdAt: { type: Date, default: Date.now } });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Schema } from "mongoose";
|
||||
|
||||
export const UpstreamFileSchema = new Schema({ organization: String, repoName: String, commit: String, path: String }, { _id: false });
|
||||
|
||||
const FileSchema = new Schema({
|
||||
name: { type: String, index: true },
|
||||
path: { type: String, index: true },
|
||||
@@ -7,6 +9,8 @@ const FileSchema = new Schema({
|
||||
treeGeneration: String,
|
||||
treeStaged: Boolean,
|
||||
metadataPending: Boolean,
|
||||
upstream: { type: UpstreamFileSchema, default: undefined },
|
||||
submodule: { type: String, enum: ["included", "unsupported", "unavailable", "limit", "nested"] },
|
||||
sha: {
|
||||
type: String,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Document, Model } from "mongoose";
|
||||
|
||||
export interface UpstreamFile {
|
||||
organization: string;
|
||||
repoName: string;
|
||||
commit: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface IFile {
|
||||
/** Internal source metadata, never returned by the public tree API. */
|
||||
upstream?: UpstreamFile;
|
||||
submodule?: "included" | "unsupported" | "unavailable" | "limit" | "nested";
|
||||
name: string;
|
||||
path: string;
|
||||
repoId: string;
|
||||
|
||||
@@ -18,7 +18,8 @@ import * as stream from "stream";
|
||||
import AnonymousError from "../AnonymousError";
|
||||
import { octokit, waitForTokenGate, RateLimitDelayError } from "../GitHubUtils";
|
||||
import FileModel from "../model/files/files.model";
|
||||
import { IFile } from "../model/files/files.types";
|
||||
import { expandSubmodules, validUpstream } from "./github-submodules";
|
||||
import { IFile, UpstreamFile } from "../model/files/files.types";
|
||||
import { createLogger, serializeError } from "../logger";
|
||||
import { measureStage, recordCacheHit } from "../request-monitoring";
|
||||
import config from "../../config";
|
||||
@@ -322,16 +323,27 @@ export default class GitHubStream extends GitHubBase {
|
||||
filePath: string,
|
||||
repoId: string,
|
||||
fileMeta: () =>
|
||||
| Promise<{ sha: string; size?: number }>
|
||||
| { sha: string; size?: number }
|
||||
| Promise<{ sha: string; size?: number; upstream?: UpstreamFile }>
|
||||
| { sha: string; size?: number; upstream?: UpstreamFile }
|
||||
| Promise<string>
|
||||
| string
|
||||
) {
|
||||
const meta = await fileMeta();
|
||||
const expected: { sha: string; size?: number } =
|
||||
const expected: { sha: string; size?: number; upstream?: UpstreamFile } =
|
||||
typeof meta === "string" ? { sha: meta } : meta;
|
||||
if (expected.upstream && !validUpstream(expected.upstream)) throw new AnonymousError("invalid_request", { httpStatus: 400 });
|
||||
// Hydrated Mongoose subdocuments expose source fields through getters,
|
||||
// so spreading them would retain the parent's repository coordinates.
|
||||
const origin = expected.upstream ? new GitHubStream({
|
||||
...this.data,
|
||||
organization: expected.upstream.organization,
|
||||
repoName: expected.upstream.repoName,
|
||||
commit: expected.upstream.commit,
|
||||
getToken: () => "",
|
||||
}) : this;
|
||||
const sourcePath = expected.upstream?.path || filePath;
|
||||
const generation = createHash("sha256").update(JSON.stringify([
|
||||
this.data.commit, expected.sha, this.data.cacheGeneration, this.data.cacheRevision,
|
||||
this.data.commit, expected.sha, this.data.cacheGeneration, this.data.cacheRevision, ...(expected.upstream ? [expected.upstream] : []),
|
||||
])).digest("hex");
|
||||
const cachePath = this.data.cacheGeneration ? `${contentGenerationPrefix(this.data.cacheGeneration)}/${generation}/${filePath}` : filePath;
|
||||
const assertActive = async () => {
|
||||
@@ -383,8 +395,9 @@ export default class GitHubStream extends GitHubBase {
|
||||
if (cacheFills.size >= 64) throw new AnonymousError("cache_busy", { httpStatus: 503 });
|
||||
filling = coordinatedFill(key, cached, async () => {
|
||||
await assertActive();
|
||||
const token = await this.data.getToken();
|
||||
const sourceMiss = cachedContentMiss(this.data, token);
|
||||
const parentToken = await this.data.getToken();
|
||||
const token = expected.upstream ? "" : parentToken;
|
||||
const sourceMiss = cachedContentMiss(this.data, parentToken);
|
||||
if (sourceMiss && sourceMiss !== "file_not_found") throw new AnonymousError(sourceMiss, { httpStatus: 404 });
|
||||
const missKey = createHash("sha256").update(JSON.stringify([
|
||||
key, this.data.organization, this.data.repoName, token,
|
||||
@@ -393,7 +406,7 @@ export default class GitHubStream extends GitHubBase {
|
||||
if (missed) throw new AnonymousError(missed, { httpStatus: 404 });
|
||||
try {
|
||||
const fetchContent = () => measureStage("upstream", async () => {
|
||||
const content = await this.downloadWithFallback(token, expected.sha, filePath)
|
||||
const content = await origin.downloadWithFallback(token, expected.sha, sourcePath)
|
||||
.catch(error => { throw downloadError(error, filePath); });
|
||||
let count = 0;
|
||||
const limited = new stream.Transform({ transform(chunk, encoding, callback) {
|
||||
@@ -413,9 +426,14 @@ export default class GitHubStream extends GitHubBase {
|
||||
finally { clearTimeout(deadline); limited.destroy(); content.destroy(); }
|
||||
return true;
|
||||
});
|
||||
return await this.confirmOwnerAccess(repoId, fetchContent);
|
||||
return expected.upstream ? await fetchContent() : await this.confirmOwnerAccess(repoId, fetchContent);
|
||||
} catch (error) {
|
||||
if (error instanceof AnonymousError && error.message === "file_not_found" && error.httpStatus === 404) {
|
||||
if (expected.upstream && error instanceof AnonymousError &&
|
||||
["file_not_found", "file_not_accessible"].includes(error.message)) {
|
||||
// A removed public dependency is not a lost parent credential.
|
||||
throw new AnonymousError("submodule_not_available", { httpStatus: 404, cause: error });
|
||||
}
|
||||
if (!expected.upstream && error instanceof AnonymousError && error.message === "file_not_found" && error.httpStatus === 404) {
|
||||
const code = await classifyContentMiss(this.data, token);
|
||||
missingContent.set(missKey, code, 30_000);
|
||||
if (code !== "file_not_found") throw new AnonymousError(code, { httpStatus: 404, cause: error });
|
||||
@@ -448,14 +466,14 @@ export default class GitHubStream extends GitHubBase {
|
||||
object: file,
|
||||
});
|
||||
}
|
||||
return { sha: fileSha, size: await file.size() };
|
||||
return { sha: fileSha, size: await file.size(), upstream: (await file.getFileInfo()).upstream };
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getFiles(progress?: (status: string) => void) {
|
||||
this._truncatedFolders = [];
|
||||
return this.getTruncatedTree(this.data.commit, progress);
|
||||
return expandSubmodules(await this.getTruncatedTree(this.data.commit, progress), this.data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -485,6 +503,7 @@ export default class GitHubStream extends GitHubBase {
|
||||
}
|
||||
|
||||
private async fetchFileMetadata(filePath: string): Promise<IFile | null> {
|
||||
if (basename(filePath) === ".gitmodules") return null;
|
||||
const token = await this.data.getToken();
|
||||
const oct = octokit(token);
|
||||
try {
|
||||
@@ -665,6 +684,7 @@ export default class GitHubStream extends GitHubBase {
|
||||
path: pathFile,
|
||||
repoId: this.data.repoId,
|
||||
size: elem.size,
|
||||
...(elem.type === "commit" ? { submodule: "unsupported" } : {}),
|
||||
sha: elem.sha,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { octokit, waitForTokenGate } from "../GitHubUtils";
|
||||
import AnonymousError from "../AnonymousError";
|
||||
import { IFile, UpstreamFile } from "../model/files/files.types";
|
||||
import { GitHubBaseData } from "./GitHubBase";
|
||||
|
||||
export const MAX_SUBMODULES = 20;
|
||||
export const MAX_SUBMODULE_ENTRIES = 10000;
|
||||
export interface SubmoduleMount extends Omit<UpstreamFile, "path"> { path: string; }
|
||||
|
||||
export function safeRepositoryPath(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 &&
|
||||
!value.includes("\\") && !value.includes("\0") &&
|
||||
value.split("/").every(part => part !== "" && part !== "." && part !== "..");
|
||||
}
|
||||
|
||||
export function validUpstream(value: unknown): value is UpstreamFile {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const v = value as UpstreamFile;
|
||||
return typeof v.organization === "string" && /^[a-zA-Z0-9-]+$/.test(v.organization) &&
|
||||
typeof v.repoName === "string" && /^[a-zA-Z0-9_.-]+$/.test(v.repoName) && ![".", ".."].includes(v.repoName) &&
|
||||
typeof v.commit === "string" && /^[a-f0-9]{40}$/.test(v.commit) && safeRepositoryPath(v.path);
|
||||
}
|
||||
|
||||
export function submoduleRepository(url: unknown, parent: GitHubBaseData): { organization: string; repoName: string } | null {
|
||||
if (typeof url !== "string") return null;
|
||||
const normalized = url.replace(/^git@github\.com:/, "https://github.com/").replace(/^ssh:\/\/git@github\.com\//, "https://github.com/").replace(/^git:\/\/github\.com\//, "https://github.com/");
|
||||
try {
|
||||
const parsed = new URL(normalized, url.startsWith("./") || url.startsWith("../")
|
||||
? `https://github.com/${parent.organization}/${parent.repoName}/` : undefined);
|
||||
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) return null;
|
||||
const parts = parsed.pathname.replace(/\.git\/?$/, "").replace(/\/$/, "").split("/");
|
||||
if (parts.length !== 3) return null;
|
||||
const candidate = { organization: parts[1], repoName: parts[2], commit: "a".repeat(40), path: "file" };
|
||||
return validUpstream(candidate) ? { organization: candidate.organization, repoName: candidate.repoName } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Resolve only direct public GitHub dependencies. Never send the parent token to a child. */
|
||||
export async function expandSubmodules(files: IFile[], data: GitHubBaseData): Promise<IFile[]> {
|
||||
const output = files.filter(file => file.name !== ".gitmodules");
|
||||
const modules = output.filter(file => file.submodule);
|
||||
let remaining = MAX_SUBMODULE_ENTRIES;
|
||||
for (const [index, mount] of modules.entries()) {
|
||||
if (index >= MAX_SUBMODULES) { mount.submodule = "limit"; continue; }
|
||||
const mountPath = [mount.path, mount.name].filter(Boolean).join("/");
|
||||
if (!safeRepositoryPath(mountPath) || !mount.sha || !/^[a-f0-9]{40}$/.test(mount.sha)) continue;
|
||||
const token = await data.getToken();
|
||||
await waitForTokenGate(token);
|
||||
try {
|
||||
const response = await octokit(token).repos.getContent({
|
||||
owner: data.organization, repo: data.repoName, path: mountPath, ref: data.commit,
|
||||
});
|
||||
const metadata = response.data as { submodule_git_url?: string; sha?: string };
|
||||
const repository = submoduleRepository(metadata.submodule_git_url, data);
|
||||
if (!repository || metadata.sha !== mount.sha) continue;
|
||||
// An unauthenticated request proves public access, even for an owner whose
|
||||
// parent token can also read private dependencies. No credentials cross repos.
|
||||
await waitForTokenGate("");
|
||||
const child = await octokit("").git.getTree({
|
||||
owner: repository.organization, repo: repository.repoName, tree_sha: mount.sha, recursive: "1",
|
||||
});
|
||||
if (child.data.truncated || child.data.tree.length > remaining) { mount.submodule = "limit"; continue; }
|
||||
const entries = child.data.tree;
|
||||
if (entries.some(entry => !safeRepositoryPath(entry.path))) { mount.submodule = "unsupported"; continue; }
|
||||
remaining -= entries.length;
|
||||
mount.submodule = "included";
|
||||
mount.upstream = { ...repository, commit: mount.sha, path: "" };
|
||||
for (const entry of entries) {
|
||||
const relative = entry.path!;
|
||||
if (relative.split("/").pop() === ".gitmodules") continue;
|
||||
const fullPath = `${mountPath}/${relative}`;
|
||||
const slash = fullPath.lastIndexOf("/");
|
||||
output.push({
|
||||
repoId: data.repoId, name: fullPath.slice(slash + 1), path: fullPath.slice(0, slash),
|
||||
sha: entry.sha, size: entry.size,
|
||||
...(entry.type === "commit" ? { submodule: "nested" as const } : {}),
|
||||
...(entry.type === "blob" ? { upstream: { ...repository, commit: mount.sha, path: relative } } : {}),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as { status?: number }).status === 404) { mount.submodule = "unavailable"; continue; }
|
||||
// Do not persist a partial tree on rate limits, outages or credential errors.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function validateMounts(value: unknown): SubmoduleMount[] {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > MAX_SUBMODULES || value.some(mount => !validUpstream(mount))) {
|
||||
throw new AnonymousError("invalid_request", { httpStatus: 400 });
|
||||
}
|
||||
const paths = value.map(mount => mount.path as string);
|
||||
if (paths.some((path, i) => paths.some((other, j) => i !== j && (path === other || path.startsWith(other + "/"))))) {
|
||||
throw new AnonymousError("invalid_request", { httpStatus: 400 });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+86
-57
@@ -1,4 +1,6 @@
|
||||
import got from "got";
|
||||
import config from "../config";
|
||||
import { MAX_SUBMODULE_ENTRIES, SubmoduleMount, safeRepositoryPath, validateMounts } from "./source/github-submodules";
|
||||
import { Readable, Transform, Writable } from "stream";
|
||||
import { Parse } from "unzip-stream";
|
||||
import archiver = require("archiver");
|
||||
@@ -19,6 +21,7 @@ const logger = createLogger("zip-stream");
|
||||
|
||||
export interface StreamAnonymizedZipOptions {
|
||||
repoId: string;
|
||||
submodules?: SubmoduleMount[];
|
||||
organization: string;
|
||||
repoName: string;
|
||||
commit: string;
|
||||
@@ -78,6 +81,7 @@ export async function streamAnonymizedZip(
|
||||
on(event: string, listener: (...args: unknown[]) => void): unknown;
|
||||
}
|
||||
): Promise<void> {
|
||||
const submodules = validateMounts(opt.submodules);
|
||||
let response;
|
||||
try {
|
||||
const token = await opt.getToken();
|
||||
@@ -113,14 +117,10 @@ export async function streamAnonymizedZip(
|
||||
},
|
||||
});
|
||||
}
|
||||
const upstreamDone = startStage("upstream");
|
||||
const downloadStream = got.stream(response.url);
|
||||
downloadStream.once("end", () => upstreamDone());
|
||||
downloadStream.once("error", () => upstreamDone(true));
|
||||
downloadStream.once("close", () => upstreamDone(!downloadStream.readableEnded));
|
||||
|
||||
let downloadStream: Readable;
|
||||
const archive = archiver("zip", {});
|
||||
const parser = Parse() as Transform;
|
||||
let parser: Transform;
|
||||
let childEntries = 0;
|
||||
const activeStreams = new Set<Readable>();
|
||||
const compiledTerms = compileTerms(opt.anonymizerOptions.terms || []);
|
||||
let stopped = false;
|
||||
@@ -160,8 +160,8 @@ export async function streamAnonymizedZip(
|
||||
stopped = true;
|
||||
cleanupDrain?.();
|
||||
cleanupDrain = undefined;
|
||||
downloadStream.destroy();
|
||||
parser.destroy();
|
||||
downloadStream?.destroy();
|
||||
parser?.destroy();
|
||||
for (const stream of activeStreams) stream.destroy();
|
||||
activeStreams.clear();
|
||||
output.destroy();
|
||||
@@ -187,53 +187,82 @@ export async function streamAnonymizedZip(
|
||||
archive.on("error", fail);
|
||||
archive.pipe(output);
|
||||
|
||||
downloadStream
|
||||
.on("error", fail)
|
||||
.pipe(parser)
|
||||
.on("entry", (entry: Readable & { type: string; path: string; autodrain: () => void }) => {
|
||||
if (stopped) {
|
||||
entry.destroy();
|
||||
return;
|
||||
}
|
||||
entry.on("error", fail);
|
||||
activeStreams.add(entry);
|
||||
entry.once("close", () => activeStreams.delete(entry));
|
||||
if (entry.type === "File") {
|
||||
try {
|
||||
const fileName = anonymizePathCompiled(
|
||||
entry.path.substring(entry.path.indexOf("/") + 1),
|
||||
compiledTerms
|
||||
);
|
||||
if (!isEntryAllowed(entry.path, opt.contentOptions)) {
|
||||
entry.autodrain();
|
||||
return;
|
||||
}
|
||||
// Pass filePath via the constructor — AnonymizeTransformer reads it
|
||||
// there to decide whether the entry is text (and therefore should be
|
||||
// anonymized) vs binary (passthrough). Assigning afterwards leaves
|
||||
// isText=false for every file, so the zip ships unanonymized.
|
||||
parser.pause();
|
||||
const onArchived = () => { entryWaiting = true; setImmediate(resumeParser); };
|
||||
archive.once("entry", onArchived);
|
||||
const anonymizer = new AnonymizeTransformer({
|
||||
...opt.anonymizerOptions,
|
||||
filePath: entry.path,
|
||||
});
|
||||
anonymizer.on("error", fail);
|
||||
activeStreams.add(anonymizer);
|
||||
anonymizer.once("close", () => activeStreams.delete(anonymizer));
|
||||
const st = entry.pipe(anonymizer);
|
||||
archive.append(st, zipEntryOptions(fileName, entry.path));
|
||||
} catch (error) {
|
||||
fail(error as Error);
|
||||
const startArchive = (url: string, prefix: string, next: number) => {
|
||||
if (stopped) return;
|
||||
const upstreamDone = startStage("upstream");
|
||||
const input = got.stream(url);
|
||||
downloadStream = input;
|
||||
input.once("end", () => upstreamDone());
|
||||
input.once("error", () => upstreamDone(true));
|
||||
input.once("close", () => upstreamDone(!input.readableEnded));
|
||||
parser = Parse() as Transform;
|
||||
downloadStream
|
||||
.on("error", fail)
|
||||
.pipe(parser)
|
||||
.on("entry", (entry: Readable & { type: string; path: string; autodrain: () => void }) => {
|
||||
if (stopped) {
|
||||
entry.destroy();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
})
|
||||
.on("error", fail)
|
||||
.on("end", () => {
|
||||
if (stopped) return;
|
||||
void archive.finalize().catch(fail);
|
||||
});
|
||||
entry.on("error", fail);
|
||||
activeStreams.add(entry);
|
||||
entry.once("close", () => activeStreams.delete(entry));
|
||||
if (entry.type === "File") {
|
||||
try {
|
||||
const relativePath = entry.path.substring(entry.path.indexOf("/") + 1);
|
||||
if (!safeRepositoryPath(relativePath)) throw new AnonymousError("invalid_file_path", { httpStatus: 502 });
|
||||
if (relativePath.split("/").pop() === ".gitmodules") { entry.autodrain(); return; }
|
||||
if (prefix && ++childEntries > MAX_SUBMODULE_ENTRIES) throw new AnonymousError("file_too_big", { httpStatus: 413 });
|
||||
const mountedPath = prefix ? `${prefix}/${relativePath}` : relativePath;
|
||||
const fileName = anonymizePathCompiled(mountedPath, compiledTerms);
|
||||
if (!isEntryAllowed(entry.path, opt.contentOptions)) {
|
||||
entry.autodrain();
|
||||
return;
|
||||
}
|
||||
// Pass filePath via the constructor — AnonymizeTransformer reads it
|
||||
// there to decide whether the entry is text (and therefore should be
|
||||
// anonymized) vs binary (passthrough). Assigning afterwards leaves
|
||||
// isText=false for every file, so the zip ships unanonymized.
|
||||
parser.pause();
|
||||
const onArchived = () => { entryWaiting = true; setImmediate(resumeParser); };
|
||||
archive.once("entry", onArchived);
|
||||
const anonymizer = new AnonymizeTransformer({
|
||||
...opt.anonymizerOptions,
|
||||
filePath: mountedPath,
|
||||
});
|
||||
anonymizer.on("error", fail);
|
||||
activeStreams.add(anonymizer);
|
||||
anonymizer.once("close", () => activeStreams.delete(anonymizer));
|
||||
let source: Readable = entry;
|
||||
if (prefix) {
|
||||
let bytes = 0;
|
||||
const limited = new Transform({ transform(chunk, encoding, callback) {
|
||||
bytes += chunk.length;
|
||||
callback(bytes > config.MAX_FILE_SIZE ? new AnonymousError("file_too_big", { httpStatus: 413 }) : null, chunk);
|
||||
} });
|
||||
limited.on("error", fail);
|
||||
activeStreams.add(limited);
|
||||
limited.once("close", () => activeStreams.delete(limited));
|
||||
source = entry.pipe(limited);
|
||||
}
|
||||
const st = source.pipe(anonymizer);
|
||||
archive.append(st, zipEntryOptions(fileName, mountedPath));
|
||||
} catch (error) {
|
||||
fail(error as Error);
|
||||
}
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
})
|
||||
.on("error", fail)
|
||||
.on("end", () => {
|
||||
if (stopped) return;
|
||||
const mount = submodules[next];
|
||||
if (mount) {
|
||||
// Public child archives never receive parent credentials.
|
||||
startArchive(`https://codeload.github.com/${encodeURIComponent(mount.organization)}/${encodeURIComponent(mount.repoName)}/zip/${mount.commit}`, mount.path, next + 1);
|
||||
} else void archive.finalize().catch(fail);
|
||||
});
|
||||
};
|
||||
startArchive(response.url, "", 0);
|
||||
}
|
||||
Reference in new issue
Block a user