Skip to content
Open
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
56 changes: 46 additions & 10 deletions src/lib/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,17 +636,24 @@ describe('streamUrlToFile retry', () => {
calls++;
throw new Error('ENETUNREACH dns lookup failed');
};
await expect(
streamUrlToFile(
'https://example.com/x',
let caught: unknown;

try {
await streamUrlToFile(
'https://example.com/x?X-Amz-Signature=secret-token',
'/tmp/will-not-be-written',
fetchImpl as typeof globalThis.fetch,
{ sleep: noSleep },
),
).rejects.toMatchObject({
);
} catch (err) {
caught = err;
}

expect(caught).toMatchObject({
name: 'TransportError',
message: expect.stringContaining('ENETUNREACH'),
});
expect(caught).not.toMatchObject({ message: expect.stringContaining('secret-token') });
expect(calls).toBe(STREAM_URL_MAX_RETRIES);
});

Expand All @@ -656,17 +663,46 @@ describe('streamUrlToFile retry', () => {
calls++;
return new Response('Forbidden', { status: 403 });
};
await expect(
streamUrlToFile(
'https://example.com/x',
let caught: unknown;
const presignedUrl = 'https://example.com/x?X-Amz-Signature=secret-token#download';

try {
await streamUrlToFile(
presignedUrl,
'/tmp/will-not-be-written',
fetchImpl as typeof globalThis.fetch,
{ sleep: noSleep },
),
).rejects.toMatchObject({ code: 'UNAVAILABLE' });
);
} catch (err) {
caught = err;
}

expect(caught).toMatchObject({
code: 'UNAVAILABLE',
details: { status: 403, artifactUrl: 'https://example.com/x' },
});
const details = (caught as { details?: Record<string, unknown> }).details;
expect(details).not.toHaveProperty('url');
expect(JSON.stringify(details)).not.toContain('secret-token');
expect(calls).toBe(1);
});

it('disables automatic redirects so unsafe redirect targets cannot bypass URL validation', async () => {
const dir = mkdtempSync(join(tmpdir(), 'stream-test-'));
const dest = join(dir, 'out.bin');
const redirects: Array<RequestInit['redirect']> = [];
const fetchImpl = async (_url: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
redirects.push(init?.redirect);
return new Response('hello', { status: 200 });
};

await streamUrlToFile('https://example.com/x', dest, fetchImpl as typeof globalThis.fetch, {
sleep: noSleep,
});

expect(redirects).toEqual(['error']);
});

it('sleeps between retries', async () => {
const sleepDelays: number[] = [];
const fetchImpl = async () => {
Expand Down
20 changes: 15 additions & 5 deletions src/lib/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,17 +712,18 @@ export async function streamUrlToFile(
deps?: { sleep?: (ms: number) => Promise<void> },
): Promise<void> {
const sleepFn = deps?.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
const artifactUrl = redactArtifactUrlForDetails(url);
for (let attempt = 1; attempt <= STREAM_URL_MAX_RETRIES; attempt++) {
let response: Response;
try {
response = await fetchImpl(url);
response = await fetchImpl(url, { redirect: 'error' });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (attempt < STREAM_URL_MAX_RETRIES) {
await sleepFn(STREAM_URL_RETRY_DELAY_MS);
continue;
}
throw new TransportError(`Failed to download presigned URL ${url}: ${message}`);
throw new TransportError(`Failed to download presigned URL ${artifactUrl}: ${message}`);
}
if (!response.ok) {
// Non-2xx: the URL itself is bad (expired, unauthorized, not found).
Expand All @@ -734,7 +735,7 @@ export async function streamUrlToFile(
nextAction:
'Re-run `testsprite test failure get`. Presigned URLs in the bundle expire after 15 minutes.',
requestId: 'local',
details: { status: response.status, url },
details: { status: response.status, artifactUrl },
},
});
}
Expand All @@ -754,7 +755,7 @@ export async function streamUrlToFile(
await sleepFn(STREAM_URL_RETRY_DELAY_MS);
continue;
}
throw new TransportError(`Failed to download presigned URL ${url}: ${message}`);
throw new TransportError(`Failed to download presigned URL ${artifactUrl}: ${message}`);
}
}
await mkdir(dirname(filePath), { recursive: true });
Expand All @@ -776,11 +777,20 @@ export async function streamUrlToFile(
await sleepFn(STREAM_URL_RETRY_DELAY_MS);
continue;
}
throw new TransportError(`Failed mid-download of ${url}: ${message}`);
throw new TransportError(`Failed mid-download of ${artifactUrl}: ${message}`);
}
}
}

function redactArtifactUrlForDetails(url: string): string {
try {
const parsed = new URL(url);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return '<invalid-url>';
}
}

function isPresignedUrl(value: string): boolean {
return value.startsWith('https://');
}
Expand Down
Loading