Skip to content

Commit 797865c

Browse files
committed
fix(cli): cancel camera URL error bodies
1 parent 7fcbfa6 commit 797865c

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

src/cli/nodes-camera.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ async function expectPathMissing(targetPath: string): Promise<void> {
4949
throw new Error(`expected missing path: ${targetPath}`);
5050
}
5151

52+
function cancelTrackedResponse(init?: ResponseInit): {
53+
response: Response;
54+
wasCanceled: () => boolean;
55+
} {
56+
let canceled = false;
57+
const stream = new ReadableStream<Uint8Array>({
58+
start(controller) {
59+
controller.enqueue(new TextEncoder().encode("ignored"));
60+
},
61+
cancel() {
62+
canceled = true;
63+
},
64+
});
65+
return {
66+
response: new Response(stream, init),
67+
wasCanceled: () => canceled,
68+
};
69+
}
70+
5271
describe("nodes camera helpers", () => {
5372
beforeAll(async () => {
5473
({
@@ -289,6 +308,52 @@ describe("nodes camera helpers", () => {
289308
},
290309
);
291310

311+
it.each([
312+
{
313+
name: "non-ok status",
314+
response: () => cancelTrackedResponse({ status: 503, statusText: "Service Unavailable" }),
315+
expectedMessage: /503/i,
316+
},
317+
{
318+
name: "oversized content-length",
319+
response: () =>
320+
cancelTrackedResponse({
321+
status: 200,
322+
headers: { "content-length": String(999_999_999) },
323+
}),
324+
expectedMessage: /exceeds max/i,
325+
},
326+
] as const)(
327+
"cancels rejected url response bodies: $name",
328+
async ({ response, expectedMessage }) => {
329+
const tracked = response();
330+
stubFetchResponse(tracked.response);
331+
332+
await expect(
333+
writeUrlToFile("/tmp/ignored", "https://198.51.100.42/down.bin", {
334+
expectedHost: "198.51.100.42",
335+
}),
336+
).rejects.toThrow(expectedMessage);
337+
expect(tracked.wasCanceled()).toBe(true);
338+
},
339+
);
340+
341+
it("cancels response bodies when a redirect changes host", async () => {
342+
const tracked = cancelTrackedResponse({ status: 200 });
343+
fetchGuardMocks.fetchWithSsrFGuard.mockResolvedValueOnce({
344+
response: tracked.response,
345+
finalUrl: "https://198.51.100.43/clip.mp4",
346+
release: async () => {},
347+
});
348+
349+
await expect(
350+
writeUrlToFile("/tmp/ignored", "https://198.51.100.42/clip.mp4", {
351+
expectedHost: "198.51.100.42",
352+
}),
353+
).rejects.toThrow(/redirect host/i);
354+
expect(tracked.wasCanceled()).toBe(true);
355+
});
356+
292357
it("removes partially written file when url stream fails", async () => {
293358
const stream = new ReadableStream<Uint8Array>({
294359
start(controller) {

src/cli/nodes-camera.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ export type CameraClipPayload = {
3737
hasAudio: boolean;
3838
};
3939

40+
async function cancelIgnoredResponseBody(response: Response | undefined): Promise<void> {
41+
if (response?.bodyUsed !== true) {
42+
await response?.body?.cancel().catch(() => undefined);
43+
}
44+
}
45+
4046
/** Validate and normalize an unknown camera still-image payload. */
4147
export function parseCameraSnapPayload(value: unknown): CameraSnapPayload {
4248
const obj = asRecord(value);
@@ -119,17 +125,20 @@ export async function writeUrlToFile(
119125
policy,
120126
});
121127
release = guarded.release;
128+
const res = guarded.response;
122129
const finalUrl = new URL(guarded.finalUrl);
123130
if (finalUrl.protocol !== "https:") {
131+
await cancelIgnoredResponseBody(res);
124132
throw new Error(`writeUrlToFile: redirect resolved to non-https URL ${guarded.finalUrl}`);
125133
}
126134
if (normalizeHostname(finalUrl.hostname) !== expectedHost) {
135+
await cancelIgnoredResponseBody(res);
127136
throw new Error(
128137
`writeUrlToFile: redirect host ${finalUrl.hostname} must match node host ${opts.expectedHost}`,
129138
);
130139
}
131-
const res = guarded.response;
132140
if (!res.ok) {
141+
await cancelIgnoredResponseBody(res);
133142
throw new Error(`failed to download ${url}: ${res.status} ${res.statusText}`);
134143
}
135144

@@ -140,20 +149,22 @@ export async function writeUrlToFile(
140149
Number.isFinite(contentLength) &&
141150
contentLength > MAX_CAMERA_URL_DOWNLOAD_BYTES
142151
) {
152+
await cancelIgnoredResponseBody(res);
143153
throw new Error(
144154
`writeUrlToFile: content-length ${contentLength} exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
145155
);
146156
}
147157

148158
const body = res.body;
149159
if (!body) {
160+
await cancelIgnoredResponseBody(res);
150161
throw new Error(`failed to download ${url}: empty response body`);
151162
}
152163

153164
const fileHandle = await fs.open(filePath, "w");
154165
let thrown: unknown;
166+
const reader = body.getReader();
155167
try {
156-
const reader = body.getReader();
157168
while (true) {
158169
const { done, value } = await reader.read();
159170
if (done) {
@@ -164,6 +175,7 @@ export async function writeUrlToFile(
164175
}
165176
bytes += value.byteLength;
166177
if (bytes > MAX_CAMERA_URL_DOWNLOAD_BYTES) {
178+
await reader.cancel().catch(() => undefined);
167179
throw new Error(
168180
`writeUrlToFile: downloaded ${bytes} bytes, exceeds max ${MAX_CAMERA_URL_DOWNLOAD_BYTES}`,
169181
);
@@ -172,7 +184,9 @@ export async function writeUrlToFile(
172184
}
173185
} catch (err) {
174186
thrown = err;
187+
await reader.cancel().catch(() => undefined);
175188
} finally {
189+
reader.releaseLock();
176190
await fileHandle.close();
177191
}
178192

0 commit comments

Comments
 (0)