Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions cspell.config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ import:
version: '0.2'
words:
- rcompare
- unstub
- xsea
89 changes: 86 additions & 3 deletions packages/sea-builder/src/utils/downloadArchive.mts
Original file line number Diff line number Diff line change
@@ -1,17 +1,100 @@
import { createHash } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { rename, rm } from 'node:fs/promises';
import { basename } from 'node:path/posix';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream as NodeWebReadableStream } from 'node:stream/web';
import type { DownloadFunction } from './types.mjs';

/**
* Download Node.js archive.
* Convert a Fetch API {@link Response.body} into a Node.js
* {@link Readable}.
*
* `fetch`'s `Response.body` is typed against the DOM `lib.dom.d.ts`
* `ReadableStream`, while {@link Readable.fromWeb} expects
* `node:stream/web`'s `ReadableStream`. The two describe the same
* runtime object but are not mutually assignable under TypeScript, so
* this is an explicit, narrowly-scoped cast rather than a bare
* `@ts-expect-error` on the call site.
* @param body A Fetch API response body stream.
* @returns The same stream, as a Node.js {@link Readable}.
*/
const toNodeReadable = (body: ReadableStream<Uint8Array>): Readable =>
Readable.fromWeb(body as unknown as NodeWebReadableStream<Uint8Array>);

/**
* Find the published SHA-256 checksum for a file in a `SHASUMS256.txt`
* listing.
* @param shasums Contents of the `SHASUMS256.txt` file.
* @param filename Archive file name to look up.
* @returns The expected lowercase hex SHA-256 digest.
* @throws {Error} If no parseable checksum line names `filename`.
*/
const findExpectedChecksum = (shasums: string, filename: string): string => {
const line = shasums
.split('\n')
.find((l) => l.trim().split(/\s+/).at(-1) === filename);
const checksum = line?.trim().split(/\s+/).at(0);
if (!checksum) {
throw new Error(`No checksum entry found for ${filename}`);
}
return checksum;
Comment thread
kurone-kito marked this conversation as resolved.
Outdated
};

/**
* Download a Node.js archive to a temporary path, verify it against the
* published `SHASUMS256.txt` checksum, then atomically move it onto
* {@link dest}. A non-2xx response, a missing or unparseable checksum
* entry, or a checksum mismatch all throw and leave no file behind at
* {@link dest}; a failed or interrupted attempt removes its own
* temporary file so the next run does not treat it as a cache hit.
Comment thread
kurone-kito marked this conversation as resolved.
* @param url Source URL.
* @param dest Destination file path.
*/
export const downloadArchive: DownloadFunction = async (url, dest) => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url}: HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is empty');
}
// @ts-expect-error
await pipeline(response.body, createWriteStream(dest));
const filename = basename(new URL(url).pathname);
const shasumsUrl = new URL('SHASUMS256.txt', url);
const shasumsResponse = await fetch(shasumsUrl);
if (!shasumsResponse.ok) {
throw new Error(
`Failed to download ${shasumsUrl.toString()}: HTTP ${shasumsResponse.status}`,
);
}
const expectedChecksum = findExpectedChecksum(
await shasumsResponse.text(),
filename,
);

const tempPath = `${dest}.download-${process.pid}`;
try {
const hash = createHash('sha256');
await pipeline(
toNodeReadable(response.body),
async function* (source) {
for await (const chunk of source) {
hash.update(chunk as Uint8Array);
yield chunk;
}
},
createWriteStream(tempPath),
);
const actualChecksum = hash.digest('hex');
if (actualChecksum !== expectedChecksum) {
throw new Error(
`Checksum mismatch for ${filename}: expected ${expectedChecksum}, got ${actualChecksum}`,
);
}
await rename(tempPath, dest);
} catch (error) {
Comment thread
kurone-kito marked this conversation as resolved.
Outdated
await rm(tempPath, { force: true });
throw error;
}
};
174 changes: 174 additions & 0 deletions packages/sea-builder/src/utils/downloadArchive.spec.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { createHash } from 'node:crypto';
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { downloadArchive } from './downloadArchive.mjs';

const ARCHIVE_CONTENT = 'fake-archive-bytes';
const ARCHIVE_NAME = 'node-v22.23.1-linux-x64.tar.gz';
const ARCHIVE_HASH = createHash('sha256').update(ARCHIVE_CONTENT).digest('hex');
const ARCHIVE_URL = `https://nodejs.org/dist/v22.23.1/${ARCHIVE_NAME}`;

const bodyStream = (content: string): ReadableStream<Uint8Array> =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(content));
controller.close();
},
});

const erroringBodyStream = (
partial: string,
error: Error,
): ReadableStream<Uint8Array> =>
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(partial));
controller.error(error);
},
});

const shasumsResponse = (body: string, ok = true, status = 200) => ({
ok,
status,
text: async () => body,
});

const archiveResponse = (
body: ReadableStream<Uint8Array> | null,
ok = true,
status = 200,
) => ({ body, ok, status });

let dir: string;
let dest: string;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'download-archive-'));
dest = join(dir, ARCHIVE_NAME);
});

afterEach(() => {
rmSync(dir, { force: true, recursive: true });
vi.unstubAllGlobals();
});

describe('downloadArchive', () => {
it('downloads and verifies a valid archive', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL) =>
input.toString().endsWith('SHASUMS256.txt')
? shasumsResponse(`${ARCHIVE_HASH} ${ARCHIVE_NAME}\n`)
: archiveResponse(bodyStream(ARCHIVE_CONTENT)),
),
);

await downloadArchive(ARCHIVE_URL, dest);

expect(existsSync(dest)).toBe(true);
expect(readFileSync(dest, 'utf8')).toBe(ARCHIVE_CONTENT);
expect(readdirSync(dir)).toEqual([ARCHIVE_NAME]);
});

it('throws and leaves no file for a non-2xx archive response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => archiveResponse(null, false, 404)),
);

await expect(downloadArchive(ARCHIVE_URL, dest)).rejects.toThrow(
/HTTP 404/,
);
expect(existsSync(dest)).toBe(false);
});

it('throws and leaves no file for a non-2xx SHASUMS256.txt response', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL) =>
input.toString().endsWith('SHASUMS256.txt')
? shasumsResponse('', false, 404)
: archiveResponse(bodyStream(ARCHIVE_CONTENT)),
),
);

await expect(downloadArchive(ARCHIVE_URL, dest)).rejects.toThrow(
/HTTP 404/,
);
expect(existsSync(dest)).toBe(false);
});

it('throws when no checksum entry names the archive', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL) =>
input.toString().endsWith('SHASUMS256.txt')
? shasumsResponse('deadbeef some-other-file.tar.gz\n')
: archiveResponse(bodyStream(ARCHIVE_CONTENT)),
),
);

await expect(downloadArchive(ARCHIVE_URL, dest)).rejects.toThrow(
/No checksum entry/,
);
expect(existsSync(dest)).toBe(false);
});

it('throws and leaves no file when the checksum does not match', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL) =>
input.toString().endsWith('SHASUMS256.txt')
? shasumsResponse(`deadbeef ${ARCHIVE_NAME}\n`)
: archiveResponse(bodyStream(ARCHIVE_CONTENT)),
),
);

await expect(downloadArchive(ARCHIVE_URL, dest)).rejects.toThrow(
/Checksum mismatch/,
);
expect(existsSync(dest)).toBe(false);
});

it('leaves no temporary file when the download stream errors mid-transfer', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL) =>
input.toString().endsWith('SHASUMS256.txt')
? shasumsResponse(`${ARCHIVE_HASH} ${ARCHIVE_NAME}\n`)
: archiveResponse(
erroringBodyStream(
'partial-bytes',
new Error('network interrupted'),
),
),
),
);

await expect(downloadArchive(ARCHIVE_URL, dest)).rejects.toThrow(
/network interrupted/,
);
expect(existsSync(dest)).toBe(false);
expect(readdirSync(dir)).toEqual([]);
});

it('throws when the archive response body is empty', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => archiveResponse(null)),
);

await expect(downloadArchive(ARCHIVE_URL, dest)).rejects.toThrow(
/Response body is empty/,
);
expect(existsSync(dest)).toBe(false);
});
});
16 changes: 0 additions & 16 deletions packages/sea-builder/src/utils/toPipelineSource.mts

This file was deleted.

18 changes: 0 additions & 18 deletions packages/sea-builder/src/utils/toPipelineSource.spec.mts

This file was deleted.

Loading