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
107 changes: 51 additions & 56 deletions src/asar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BasicFilesArray,
BasicStreamArray,
InputMetadata,
extractFileWithFd,
readArchiveHeaderSync,
readFilesystemSync,
readFileSync,
Expand Down Expand Up @@ -369,68 +370,62 @@ export function extractAll(archivePath: string, dest: string) {
// create destination directory
fs.mkdirpSync(dest);

// Read the entire data section at once — one syscall instead of one per file.
const headerSize = filesystem.getHeaderSize();
const archiveSize = fs.statSync(archivePath).size;
const dataStart = 8 + headerSize;
const dataSize = archiveSize - dataStart;
let dataBuf: Buffer | null = null;
if (dataSize > 0) {
dataBuf = Buffer.alloc(dataSize);
const fd = fs.openSync(archivePath, 'r');
try {
fs.readSync(fd, dataBuf, 0, dataSize, dataStart);
} finally {
fs.closeSync(fd);
}
}
// Open the archive once and stream each entry out of it, instead of re-opening per file.
// Reading the whole data section into a single buffer is not an option: `fs.readSync()`
// truncates its `length` to a signed 32-bit int, so archives with 2 GiB or more of data
// fail with `ERR_OUT_OF_RANGE`, and smaller ones still cost their full size in memory.
const dataStart = 8 + filesystem.getHeaderSize();
const fd = fs.openSync(archivePath, 'r');

const extractionErrors: Error[] = [];
for (const fullPath of filenames) {
// Remove leading slash
const filename = fullPath.substr(1);
const destFilename = ensureWithin(dest, filename);
const file = filesystem.getFile(filename, followLinks);
if ('files' in file) {
// it's a directory, create it and continue with the next entry
fs.mkdirpSync(destFilename);
} else if ('link' in file) {
// it's a symlink, create a symlink
const linkSrcPath = path.dirname(path.join(dest, file.link));
const linkDestPath = path.dirname(destFilename);
const relativePath = path.relative(linkDestPath, linkSrcPath);
// try to delete output file, because we can't overwrite a link
try {
fs.unlinkSync(destFilename);
} catch {}
const linkTo = path.join(relativePath, path.basename(file.link));
if (path.relative(dest, linkSrcPath).startsWith('..')) {
throw new Error(
`${fullPath}: file "${file.link}" links out of the package to "${linkSrcPath}"`,
);
}
fs.symlinkSync(linkTo, destFilename);
} else {
// it's a file, try to extract it
try {
let content: Buffer;
if (file.unpacked) {
content = fs.readFileSync(path.join(`${filesystem.getRootPath()}.unpacked`, filename));
} else if (file.size <= 0) {
content = Buffer.alloc(0);
} else {
// Slice from the pre-read data buffer — zero-copy view
const offset = parseInt(file.offset);
content = dataBuf!.subarray(offset, offset + file.size);
try {
for (const fullPath of filenames) {
// Remove leading slash
const filename = fullPath.substr(1);
const destFilename = ensureWithin(dest, filename);
const file = filesystem.getFile(filename, followLinks);
if ('files' in file) {
// it's a directory, create it and continue with the next entry
fs.mkdirpSync(destFilename);
} else if ('link' in file) {
// it's a symlink, create a symlink
const linkSrcPath = path.dirname(path.join(dest, file.link));
const linkDestPath = path.dirname(destFilename);
const relativePath = path.relative(linkDestPath, linkSrcPath);
// try to delete output file, because we can't overwrite a link
try {
fs.unlinkSync(destFilename);
} catch {}
const linkTo = path.join(relativePath, path.basename(file.link));
if (path.relative(dest, linkSrcPath).startsWith('..')) {
throw new Error(
`${fullPath}: file "${file.link}" links out of the package to "${linkSrcPath}"`,
);
}
fs.writeFileSync(destFilename, content);
if (file.executable) {
fs.chmodSync(destFilename, '755');
fs.symlinkSync(linkTo, destFilename);
} else {
// it's a file, try to extract it
try {
if (file.unpacked) {
const unpackedDir = `${filesystem.getRootPath()}.unpacked`;
fs.writeFileSync(destFilename, fs.readFileSync(ensureWithin(unpackedDir, filename)));
} else {
const offset = parseInt(file.offset);
if (Number.isNaN(offset) || offset < 0 || !Number.isSafeInteger(offset)) {
throw new Error(`Invalid file offset in archive header: ${file.offset}`);
}
extractFileWithFd(fd, destFilename, dataStart + offset, file.size);
}
if (file.executable) {
fs.chmodSync(destFilename, '755');
}
} catch (e) {
extractionErrors.push(e as Error);
}
} catch (e) {
extractionErrors.push(e as Error);
}
}
} finally {
fs.closeSync(fd);
}
if (extractionErrors.length) {
throw new Error(
Expand Down
44 changes: 44 additions & 0 deletions src/disk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,50 @@ export function readFileSync(filesystem: Filesystem, filename: string, info: Fil
}
}

/**
* Upper bound for a single `fs.readSync()` call when copying file contents out of an archive.
*
* `fs.readSync()` truncates its `length` argument to a signed 32-bit integer, so a single
* read of 2 GiB or more wraps to a negative value and fails with `ERR_OUT_OF_RANGE`.
* Chunking also keeps peak memory flat instead of scaling with the size of the archive.
*/
const EXTRACT_CHUNK_SIZE = 64 * 1024 * 1024;

/**
* Copy `size` bytes starting at `position` from an already-open archive descriptor straight
* to `destPath`, without buffering the whole entry in memory.
*/
export function extractFileWithFd(
fd: number,
destPath: string,
position: number,
size: number,
chunkSize: number = EXTRACT_CHUNK_SIZE,
) {
const out = fs.openSync(destPath, 'w');
try {
if (size <= 0) {
return;
}
const buffer = Buffer.alloc(Math.min(size, chunkSize));
let copied = 0;
while (copied < size) {
const wanted = Math.min(buffer.length, size - copied);
// `readSync` is allowed to return fewer bytes than requested, so always loop on the result.
const read = fs.readSync(fd, buffer, 0, wanted, position + copied);
if (read <= 0) {
throw new Error(
`Unexpected end of archive while extracting "${destPath}" (read ${copied} of ${size} bytes)`,
);
}
fs.writeSync(out, buffer, 0, read);
copied += read;
}
} finally {
fs.closeSync(out);
}
}

export function readFileWithFd(
fd: number,
filesystem: Filesystem,
Expand Down
77 changes: 77 additions & 0 deletions test/disk-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';

import { createPackage, getRawHeader, uncacheAll } from '../src/asar.js';
import {
extractFileWithFd,
readArchiveHeaderSync,
readFilesystemSync,
readFileSync,
Expand Down Expand Up @@ -319,4 +320,80 @@ describe('disk', () => {
expect(() => readFileWithFd(-1, filesystem, '../outside.txt', info)).toThrow('outside');
});
});

describe('extractFileWithFd', () => {
/**
* `fs.readSync()` truncates its `length` argument to a signed 32-bit integer, so entries
* of 2 GiB or more cannot be copied with a single read. Materialising such an archive in
* a test is impractical, so the chunk size is lowered instead to exercise the same loop.
*/
const writeSource = (dir: string, name: string, contents: Buffer) => {
const srcPath = path.join(dir, name);
fs.writeFileSync(srcPath, contents);
return srcPath;
};

it('copies contents that span multiple chunks', () => {
const dir = tmpDir('extract-multi-chunk');
const payload = Buffer.from('the quick brown fox jumps over the lazy dog');
const prefix = Buffer.from('HEADER');
const srcPath = writeSource(dir, 'multi-chunk.bin', Buffer.concat([prefix, payload]));
const destPath = path.join(dir, 'multi-chunk.out');

const fd = fs.openSync(srcPath, 'r');
try {
extractFileWithFd(fd, destPath, prefix.length, payload.length, 7);
} finally {
fs.closeSync(fd);
}

expect(fs.readFileSync(destPath).equals(payload)).toBe(true);
});

it('copies contents smaller than a single chunk', () => {
const dir = tmpDir('extract-small');
const payload = Buffer.from('short');
const srcPath = writeSource(dir, 'small.bin', payload);
const destPath = path.join(dir, 'small.out');

const fd = fs.openSync(srcPath, 'r');
try {
extractFileWithFd(fd, destPath, 0, payload.length, 1024);
} finally {
fs.closeSync(fd);
}

expect(fs.readFileSync(destPath).equals(payload)).toBe(true);
});

it('creates an empty file for zero-length entries', () => {
const dir = tmpDir('extract-empty');
const srcPath = writeSource(dir, 'empty-src.bin', Buffer.from('ignored'));
const destPath = path.join(dir, 'empty.out');

const fd = fs.openSync(srcPath, 'r');
try {
extractFileWithFd(fd, destPath, 0, 0);
} finally {
fs.closeSync(fd);
}

expect(fs.readFileSync(destPath).length).toBe(0);
});

it('throws when the archive ends before the entry does', () => {
const dir = tmpDir('extract-truncated');
const srcPath = writeSource(dir, 'truncated.bin', Buffer.from('only ten b'));
const destPath = path.join(dir, 'truncated.out');

const fd = fs.openSync(srcPath, 'r');
try {
expect(() => extractFileWithFd(fd, destPath, 0, 1000, 4)).toThrow(
/Unexpected end of archive/,
);
} finally {
fs.closeSync(fd);
}
});
});
});