Skip to content

Commit 0f04cee

Browse files
committed
fix(miner): bound self-review manifest reads
1 parent 8cb5e74 commit 0f04cee

2 files changed

Lines changed: 110 additions & 4 deletions

File tree

packages/gittensory-miner/lib/self-review-context.js

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { buildCollisionReport, parseFocusManifestContent } from "@jsonbored/gittensory-engine";
1+
import { buildCollisionReport, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "@jsonbored/gittensory-engine";
22

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

213-
// Mirrors src/signals/focus-manifest-loader.ts's fetchRepoFocusManifestFile exactly: unauthenticated GET
214-
// against raw.githubusercontent.com, first candidate path that resolves wins.
213+
// Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read:
214+
// first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory.
215+
async function readBoundedManifestResponseText(response) {
216+
const contentLength = response.headers?.get?.("content-length") ?? null;
217+
if (contentLength !== null) {
218+
const parsedLength = Number.parseInt(contentLength, 10);
219+
if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null;
220+
}
221+
if (!response.body?.getReader) {
222+
const text = await response.text();
223+
if (typeof text !== "string") return null;
224+
return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text;
225+
}
226+
227+
const reader = response.body.getReader();
228+
const decoder = new TextDecoder();
229+
let totalBytes = 0;
230+
let text = "";
231+
try {
232+
while (true) {
233+
const { done, value } = await reader.read();
234+
if (done) break;
235+
totalBytes += value.byteLength;
236+
if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) {
237+
await reader.cancel();
238+
return null;
239+
}
240+
text += decoder.decode(value, { stream: true });
241+
}
242+
text += decoder.decode();
243+
return text;
244+
} finally {
245+
reader.releaseLock();
246+
}
247+
}
248+
215249
async function fetchManifestContent(target, resolved) {
216250
for (const path of MANIFEST_FILE_CANDIDATES) {
217251
const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`;
218252
try {
219253
const response = await resolved.fetchImpl(url, { method: "GET", headers: { accept: "application/json", "user-agent": "gittensory-miner" } });
220254
if (response.ok) {
221-
const text = await response.text();
255+
const text = await readBoundedManifestResponseText(response);
222256
if (typeof text === "string") return text;
223257
}
224258
} catch {

test/unit/miner-self-review-context.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ vi.mock("@jsonbored/gittensory-engine", async () => {
44
return import("../../packages/gittensory-engine/src/index");
55
});
66

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

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

28+
function oversizedContentLengthResponse() {
29+
return {
30+
ok: true,
31+
status: 200,
32+
headers: new Headers({ "content-length": String(MAX_FOCUS_MANIFEST_BYTES + 1) }),
33+
json: async () => null,
34+
text: async () => {
35+
throw new Error("oversized manifest should not be materialized");
36+
},
37+
};
38+
}
39+
40+
function chunkedManifestResponse(chunks: Uint8Array[], onCancel: () => void) {
41+
let index = 0;
42+
return {
43+
ok: true,
44+
status: 200,
45+
headers: new Headers(),
46+
body: {
47+
getReader: () => ({
48+
read: async () => (index < chunks.length ? { done: false, value: chunks[index++] } : { done: true, value: undefined }),
49+
cancel: async () => {
50+
onCancel();
51+
},
52+
releaseLock: () => {},
53+
}),
54+
},
55+
json: async () => null,
56+
text: async () => {
57+
throw new Error("streaming manifest should not use response.text()");
58+
},
59+
};
60+
}
61+
2762
const REPO_PAYLOAD = {
2863
name: "widgets",
2964
full_name: "acme/widgets",
@@ -283,6 +318,43 @@ describe("fetchSelfReviewContext (#5145)", () => {
283318
expect(result.manifest.gate).toBeDefined();
284319
});
285320

321+
it("rejects oversized manifest responses from content-length before reading the body", async () => {
322+
let manifestRequests = 0;
323+
const fetchImpl = async (url: string) => {
324+
if (url.includes("raw.githubusercontent.com")) {
325+
manifestRequests += 1;
326+
return oversizedContentLengthResponse();
327+
}
328+
if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]);
329+
if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]);
330+
if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD);
331+
if (url.includes("api.gittensor.io/miners")) return jsonResponse([]);
332+
return jsonResponse(null, 404);
333+
};
334+
335+
const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never });
336+
expect(manifestRequests).toBe(4);
337+
expect(result.manifest.present).toBe(false);
338+
expect(result.manifest.warnings).toEqual([]);
339+
});
340+
341+
it("cancels chunked manifest responses once the byte cap is exceeded", async () => {
342+
const chunks = [new Uint8Array(MAX_FOCUS_MANIFEST_BYTES), new Uint8Array([123])];
343+
let cancelCount = 0;
344+
const fetchImpl = async (url: string) => {
345+
if (url.includes("raw.githubusercontent.com")) return chunkedManifestResponse(chunks, () => { cancelCount += 1; });
346+
if (url.includes("/repos/acme/widgets/issues")) return jsonResponse([]);
347+
if (url.includes("/repos/acme/widgets/pulls")) return jsonResponse([]);
348+
if (url.includes("/repos/acme/widgets")) return jsonResponse(REPO_PAYLOAD);
349+
if (url.includes("api.gittensor.io/miners")) return jsonResponse([]);
350+
return jsonResponse(null, 404);
351+
};
352+
353+
const result = await fetchSelfReviewContext("acme/widgets", { fetchImpl: fetchImpl as never });
354+
expect(cancelCount).toBe(4);
355+
expect(result.manifest.present).toBe(false);
356+
});
357+
286358
it("stops at the first manifest candidate that resolves, skipping the rest", async () => {
287359
let requestedPaths: string[] = [];
288360
const fetchImpl = async (url: string) => {

0 commit comments

Comments
 (0)