Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
42 changes: 38 additions & 4 deletions packages/gittensory-miner/lib/self-review-context.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildCollisionReport, parseFocusManifestContent } from "@jsonbored/gittensory-engine";
import { buildCollisionReport, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "@jsonbored/gittensory-engine";

// Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass
// (packages/gittensory-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's
Expand Down Expand Up @@ -210,15 +210,49 @@ async function fetchOpenPullRequestRecords(target, resolved) {
return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr));
}

// Mirrors src/signals/focus-manifest-loader.ts's fetchRepoFocusManifestFile exactly: unauthenticated GET
// against raw.githubusercontent.com, first candidate path that resolves wins.
// Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read:
// first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory.
async function readBoundedManifestResponseText(response) {
const contentLength = response.headers?.get?.("content-length") ?? null;
if (contentLength !== null) {
const parsedLength = Number.parseInt(contentLength, 10);
if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null;
}
if (!response.body?.getReader) {
const text = await response.text();
if (typeof text !== "string") return null;
return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text;
}

const reader = response.body.getReader();
const decoder = new TextDecoder();
let totalBytes = 0;
let text = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) {
await reader.cancel();
return null;
}
text += decoder.decode(value, { stream: true });
}
text += decoder.decode();
return text;
} finally {
reader.releaseLock();
}
}

async function fetchManifestContent(target, resolved) {
for (const path of MANIFEST_FILE_CANDIDATES) {
const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`;
try {
const response = await resolved.fetchImpl(url, { method: "GET", headers: { accept: "application/json", "user-agent": "gittensory-miner" } });
if (response.ok) {
const text = await response.text();
const text = await readBoundedManifestResponseText(response);
if (typeof text === "string") return text;
}
} catch {
Expand Down
72 changes: 72 additions & 0 deletions test/unit/miner-self-review-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ vi.mock("@jsonbored/gittensory-engine", async () => {
return import("../../packages/gittensory-engine/src/index");
});

import { MAX_FOCUS_MANIFEST_BYTES } from "../../packages/gittensory-engine/src/index";
import { fetchSelfReviewContext } from "../../packages/gittensory-miner/lib/self-review-context.js";

function jsonResponse(body: unknown, status = 200) {
Expand All @@ -24,6 +25,40 @@ function textResponse(text: string, status = 200) {
};
}

function oversizedContentLengthResponse() {
return {
ok: true,
status: 200,
headers: new Headers({ "content-length": String(MAX_FOCUS_MANIFEST_BYTES + 1) }),
json: async () => null,
text: async () => {
throw new Error("oversized manifest should not be materialized");
},
};
}

function chunkedManifestResponse(chunks: Uint8Array[], onCancel: () => void) {
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader: () => ({
read: async () => (index < chunks.length ? { done: false, value: chunks[index++] } : { done: true, value: undefined }),
cancel: async () => {
onCancel();
},
releaseLock: () => {},
}),
},
json: async () => null,
text: async () => {
throw new Error("streaming manifest should not use response.text()");
},
};
}

const REPO_PAYLOAD = {
name: "widgets",
full_name: "acme/widgets",
Expand Down Expand Up @@ -283,6 +318,43 @@ describe("fetchSelfReviewContext (#5145)", () => {
expect(result.manifest.gate).toBeDefined();
});

it("rejects oversized manifest responses from content-length before reading the body", async () => {
let manifestRequests = 0;
const fetchImpl = async (url: string) => {
if (url.includes("raw.githubusercontent.com")) {
manifestRequests += 1;
return oversizedContentLengthResponse();
}
if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]);
if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]);
if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD);
if (url.includes("api.gittensor.io/miners")) return jsonResponse([]);
return jsonResponse(null, 404);
};

const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never });
expect(manifestRequests).toBe(4);
expect(result.manifest.present).toBe(false);
expect(result.manifest.warnings).toEqual([]);
});

it("cancels chunked manifest responses once the byte cap is exceeded", async () => {
const chunks = [new Uint8Array(MAX_FOCUS_MANIFEST_BYTES), new Uint8Array([123])];
let cancelCount = 0;
const fetchImpl = async (url: string) => {
if (url.includes("raw.githubusercontent.com")) return chunkedManifestResponse(chunks, () => { cancelCount += 1; });
if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]);
if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]);
if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD);
if (url.includes("api.gittensor.io/miners")) return jsonResponse([]);
return jsonResponse(null, 404);
};

const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never });
expect(cancelCount).toBe(4);
expect(result.manifest.present).toBe(false);
});

it("stops at the first manifest candidate that resolves, skipping the rest", async () => {
let requestedPaths: string[] = [];
const fetchImpl = async (url: string) => {
Expand Down