Files
electron/lib/node/asar-fs-wrapper.ts
T
Samuel AttardandClaude 8a6429e414 build: upgrade to TypeScript 7 (#54172)
TypeScript 7.0 is the native compiler and ships no JavaScript API. The
earlier changes in this series already moved everything that transpiled
on the fly (script/ and spec/) onto Node's own type stripping, so what
is left here is the compiler swap itself:

* typescript ^5.8.3 -> ^7.0.2.
* build/bundle/typecheck.mjs spawns tsc instead of driving the compiler
  API (the rootDir/private-field diagnostics it used to filter out are
  handled by tsconfig.electron.json setting rootDir to the parent
  checkout instead).
* tsconfig.json drops the removed baseUrl option and anchors the paths
  mapping explicitly; tsconfig.default_app.json moves off the removed
  "node" module resolution; `declare module NodeJS` becomes a namespace.
* A few lib/, spec and docs snippets wrap Buffers for the stricter
  Buffer/ArrayBuffer typings that Blob now expects.

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-21 15:21:32 -07:00

2494 lines
91 KiB
TypeScript

import { Buffer } from 'buffer';
import { Dirent, constants } from 'fs';
import * as path from 'path';
import * as util from 'util';
import type * as Crypto from 'crypto';
import type * as os from 'os';
const asar = process._linkedBinding('electron_common_asar');
const v8Util = process._linkedBinding('electron_common_v8_util');
const Module = require('module') as NodeJS.ModuleInternal;
const Promise: PromiseConstructor = global.Promise;
const envNoAsar = process.env.ELECTRON_NO_ASAR && process.type !== 'browser' && process.type !== 'renderer';
const isAsarDisabled = () => process.noAsar || envNoAsar;
// realm.js is a script, not a module, so its loader exports have no type.
const { internalBinding } = require('internal/bootstrap/realm') as {
internalBinding: (name: string) => any;
};
const nextTick = (functionToCall: Function, args: any[] = []) => {
process.nextTick(() => functionToCall(...args));
};
const binding = internalBinding('fs');
const dirBinding = internalBinding('fs_dir');
// libuv's error numbers differ from POSIX errno on Windows (UV_EACCES is
// -4092 there, not -13); Node decodes `errno` through libuv's table, so the
// errors created here must carry libuv's values.
const uvBinding = internalBinding('uv');
const { kUsePromises } = binding;
// Cache asar archive objects.
const cachedArchives = new Map<string, NodeJS.AsarArchive>();
const getOrCreateArchive = (archivePath: string) => {
const cached = cachedArchives.get(archivePath);
if (cached !== undefined) return cached;
try {
const newArchive = new asar.Archive(archivePath);
cachedArchives.set(archivePath, newArchive);
return newArchive;
} catch {
return null;
}
};
const { getValidatedPath, getOptions, getDirent, getStatsFromBinding } =
require('internal/fs/utils') as typeof import('@node/lib/internal/fs/utils');
const { assignFunctionName } = require('internal/util') as typeof import('@node/lib/internal/util');
const { validateBoolean, validateFunction } =
require('internal/validators') as typeof import('@node/lib/internal/validators');
const { codes: errorCodes } = require('internal/errors') as typeof import('@node/lib/internal/errors');
// In the renderer node internals use the node global URL but we do not set that to be
// the global URL instance. We need to do instanceof checks against the internal URL impl
const { URL: NodeURL } = require('internal/url') as typeof import('node:url');
type SplitPathResult = { isAsar: false } | { isAsar: true; asarPath: string; filePath: string };
const NOT_ASAR: SplitPathResult = Object.freeze({ isAsar: <const>false });
const kNeedsNormalization = -2;
const isWindows = process.platform === 'win32';
const isSeparatorCode = isWindows ? (c: number) => c === 47 || c === 92 : (c: number) => c === 47;
// Cheap pre-check so the many paths that cannot involve an archive never
// leave JS.
const asarRe = /\.asar/i;
const splitStringPath = (archivePath: string): SplitPathResult => {
if (!asarRe.test(archivePath)) return NOT_ASAR;
// Windows paths are always run through path.normalize() first (drive
// letters, mixed separators); POSIX paths only when the native side reports
// ".", ".." or empty components, which user code rarely passes and the
// module loader never does.
if (isWindows) archivePath = path.normalize(archivePath);
let prefixLength = asar.splitPath(archivePath, !isWindows);
if (prefixLength === kNeedsNormalization) {
archivePath = path.normalize(archivePath);
prefixLength = asar.splitPath(archivePath, false);
}
if (prefixLength < 0) return NOT_ASAR;
// The entry path is whatever follows the archive, without the separators
// around it.
let start = prefixLength;
let end = archivePath.length;
while (start < end && isSeparatorCode(archivePath.charCodeAt(start))) start++;
while (end > start && isSeparatorCode(archivePath.charCodeAt(end - 1))) end--;
return {
isAsar: true,
asarPath: archivePath.slice(0, prefixLength),
filePath: start < end ? archivePath.slice(start, end) : ''
};
};
// Separate asar package's path from full path.
const splitPath = (archivePathOrBuffer: string | Buffer | URL): SplitPathResult => {
// Shortcut for disabled asar.
if (isAsarDisabled()) return NOT_ASAR;
let archivePath = archivePathOrBuffer;
if (typeof archivePath !== 'string') {
// Check for a bad argument type.
if (Buffer.isBuffer(archivePathOrBuffer)) {
archivePath = archivePathOrBuffer.toString();
} else if (archivePath instanceof NodeURL) {
archivePath = getValidatedPath(archivePath);
}
if (typeof archivePath !== 'string') return NOT_ASAR;
}
return splitStringPath(archivePath);
};
// readdir(withFileTypes) for a directory inside an archive: one native call
// that returns every child's name and type, turned into node Dirents whose
// parentPath is the path the caller passed.
const readAsarDirents = (archive: NodeJS.AsarArchive, filePath: string, parentPath: string): Dirent[] | false => {
const listing = archive.readdirWithTypes(filePath);
if (!listing) return false;
const { 0: names, 1: types } = listing;
const dirents: Dirent[] = new Array(names.length);
for (let i = 0; i < names.length; i++) dirents[i] = getDirent(parentPath, names[i], types[i])!;
return dirents;
};
// The on-disk location of an entry that was left outside of the archive
// (`asar.unpacked`) by the packager. Mirrors what the native
// Archive::CopyFileOut() computes for unpacked entries, without taking the
// archive's external-files lock.
const getUnpackedPath = (asarPath: string, filePath: string) => path.join(`${asarPath}.unpacked`, filePath);
// Symbolic links inside an archive are stored with their target relative to
// the archive root. Follows a chain of them (bounded, like the kernel's
// ELOOP limit) and returns the final entry and its stats, or null if any hop
// is missing or the chain is too long.
const kMaxSymlinkHops = 40;
function resolveAsarLinks(archive: NodeJS.AsarArchive, filePath: string) {
let current = filePath;
for (let hop = 0; hop < kMaxSymlinkHops; hop++) {
const stats = archive.stat(current);
if (!stats) return null;
if (stats.type !== AsarFileType.kLink) return { filePath: current, stats };
const target = archive.realpath(current);
if (target === false) return null;
current = target;
}
return null;
}
// Convert asar archive's Stats object to fs's Stats object.
let nextInode = 0;
const uid = process.getuid?.() ?? 0;
const gid = process.getgid?.() ?? 0;
const fakeTime = new Date();
const fakeTimeSec = Math.floor(fakeTime.getTime() / 1000);
const fakeTimeNsec = (fakeTime.getTime() % 1000) * 1e6;
enum AsarFileType {
kFile = (constants as any).UV_DIRENT_FILE,
kDirectory = (constants as any).UV_DIRENT_DIR,
kLink = (constants as any).UV_DIRENT_LINK
}
const fileTypeToMode = new Map<AsarFileType, number>([
[AsarFileType.kFile, constants.S_IFREG],
[AsarFileType.kDirectory, constants.S_IFDIR],
[AsarFileType.kLink, constants.S_IFLNK]
]);
// Literal permission bits rather than fs.constants.S_I*: the group/other and
// execute constants are not defined on Windows, and these fake modes should
// look the same on every platform.
const kReadOnlyMode = 0o644;
const kExecuteMode = 0o111;
// Builds the raw stat array that node's fs binding would have produced, so
// that node's own getStatsFromBinding() can turn it into a (BigInt)Stats.
// Permissions follow the usual defaults (0644 files, 0755 directories and
// executables, 0777 symlinks) so that copies of these entries made with the
// reported mode remain usable.
//
// The constant fields are written once into module-level scratch arrays (the
// same trick node's own binding uses with `statValues`); a call only stores
// the four that vary. `fillStatArray` hands back the shared scratch array, so
// its result must be consumed synchronously (getStatsFromBinding copies the
// values out); `makeStatArray` returns a private copy for results that are
// delivered later through a request object.
const kStatFields = 18;
const statScratch = new Float64Array(kStatFields);
const bigintStatScratch = new BigInt64Array(kStatFields);
statScratch[0] = 1; // dev
statScratch[2] = 1; // nlink
statScratch[3] = uid;
statScratch[4] = gid;
statScratch[6] = 4096; // blksize
for (let i = 10; i < kStatFields; i += 2) {
statScratch[i] = fakeTimeSec; // a/m/c/birthtime
statScratch[i + 1] = fakeTimeNsec;
}
for (let i = 0; i < kStatFields; i++) bigintStatScratch[i] = BigInt(statScratch[i]);
function statMode(type: AsarFileType, executable: boolean) {
let mode = kReadOnlyMode | fileTypeToMode.get(type)!;
if (executable || type !== AsarFileType.kFile) mode |= kExecuteMode;
if (type === AsarFileType.kLink) mode |= 0o022;
return mode;
}
function fillStatArray(type: AsarFileType, size: number, ino: number, useBigint: boolean, executable = false) {
const mode = statMode(type, executable);
const blocks = Math.ceil(size / 512); // 512-byte units
if (useBigint) {
bigintStatScratch[1] = BigInt(mode);
bigintStatScratch[7] = BigInt(ino);
bigintStatScratch[8] = BigInt(size);
bigintStatScratch[9] = BigInt(blocks);
return bigintStatScratch;
}
statScratch[1] = mode;
statScratch[7] = ino;
statScratch[8] = size;
statScratch[9] = blocks;
return statScratch;
}
function makeStatArray(type: AsarFileType, size: number, ino: number, useBigint: boolean, executable = false) {
return fillStatArray(type, size, ino, useBigint, executable).slice();
}
const asarStatsToFsStats = function (stats: NodeJS.AsarFileStat, options?: any) {
const useBigint = Boolean(options && typeof options === 'object' && options.bigint);
return getStatsFromBinding(fillStatArray(stats.type, stats.size, ++nextInode, useBigint, stats.executable));
};
const enum AsarError {
NOT_FOUND = 'NOT_FOUND',
NOT_DIR = 'NOT_DIR',
IS_DIR = 'IS_DIR',
NO_ACCESS = 'NO_ACCESS',
INVALID_ARCHIVE = 'INVALID_ARCHIVE',
NOT_LINK = 'NOT_LINK',
EXISTS = 'EXISTS',
NOT_SUPPORTED = 'NOT_SUPPORTED',
TOO_MANY_OPEN = 'TOO_MANY_OPEN'
}
type AsarErrorObject = Error & { code?: string; errno?: number; syscall?: string; path?: string };
const createError = (
errorType: AsarError,
{ asarPath, filePath, syscall }: { asarPath?: string; filePath?: string; syscall?: string } = {}
) => {
let error: AsarErrorObject;
switch (errorType) {
case AsarError.NOT_FOUND:
error = new Error(`ENOENT, ${filePath} not found in ${asarPath}`);
error.code = 'ENOENT';
error.errno = uvBinding.UV_ENOENT;
break;
case AsarError.NOT_DIR:
error = new Error('ENOTDIR, not a directory');
error.code = 'ENOTDIR';
error.errno = uvBinding.UV_ENOTDIR;
break;
case AsarError.IS_DIR:
error = new Error(`EISDIR: illegal operation on a directory, ${filePath} in ${asarPath}`);
error.code = 'EISDIR';
error.errno = uvBinding.UV_EISDIR;
break;
case AsarError.NO_ACCESS:
error = new Error(`EACCES: permission denied, access '${filePath}'`);
error.code = 'EACCES';
error.errno = uvBinding.UV_EACCES;
break;
case AsarError.INVALID_ARCHIVE:
error = new Error(`Invalid package ${asarPath}`);
break;
case AsarError.NOT_LINK:
error = new Error(`EINVAL: invalid argument, ${filePath} in ${asarPath} is not a symbolic link`);
error.code = 'EINVAL';
error.errno = uvBinding.UV_EINVAL;
break;
case AsarError.EXISTS:
error = new Error(`EEXIST: file already exists, ${filePath}`);
error.code = 'EEXIST';
error.errno = uvBinding.UV_EEXIST;
break;
case AsarError.NOT_SUPPORTED:
error = new Error(`ENOTSUP: operation not supported on asar archive entry ${filePath}`);
error.code = 'ENOTSUP';
error.errno = uvBinding.UV_ENOTSUP;
break;
case AsarError.TOO_MANY_OPEN:
error = new Error(`EMFILE: too many open files, could not open ${filePath} in ${asarPath}`);
error.code = 'EMFILE';
error.errno = uvBinding.UV_EMFILE;
break;
default:
throw new Error(`Invalid error type "${errorType}" passed to createError.`);
}
if (syscall) error.syscall = syscall;
if (asarPath !== undefined && filePath !== undefined && errorType !== AsarError.INVALID_ARCHIVE) {
error.path = path.join(asarPath, filePath);
}
return error;
};
// Delivers a result the way a native fs binding call would have: thrown /
// returned synchronously when there is no request object, as a promise when
// node passed `kUsePromises`, and via `req.oncomplete` for an FSReqCallback.
// `oncomplete` must be invoked as a method so that handlers which read
// `this.context` (e.g. readFile's internals) keep working.
function completeRequest<T>(req: any, error: Error | null, value?: T): any {
if (req === undefined) {
if (error) throw error;
return value;
}
if (req === kUsePromises) {
return error ? Promise.reject(error) : Promise.resolve(value);
}
process.nextTick(() => {
if (error) req.oncomplete(error);
else req.oncomplete(null, value);
});
}
// Like completeRequest, but for a value that is produced asynchronously.
function completeRequestWithPromise<T>(req: any, promise: globalThis.Promise<T>): any {
if (req === kUsePromises) return promise;
promise.then(
(value) => req.oncomplete(null, value),
(error) => req.oncomplete(error)
);
}
// A faithful copy of a native binding object (own properties, including
// symbols and non-enumerables, with their descriptors).
function cloneBinding<T extends object>(source: T): T {
const clone = Object.create(Object.getPrototypeOf(source));
for (const key of Reflect.ownKeys(source)) {
Object.defineProperty(clone, key, Object.getOwnPropertyDescriptor(source, key)!);
}
return clone;
}
// `original-fs` is generated from the same sources as `fs` and would
// otherwise share these binding objects (and therefore the archive-aware
// overrides installed below). Stash pristine copies for it to use instead;
// script/node/generate_original_fs.py points the generated modules here.
if (!Object.prototype.hasOwnProperty.call(binding, '_electronOriginalBindings')) {
Object.defineProperty(binding, '_electronOriginalBindings', {
value: Object.freeze({ fs: cloneBinding(binding), fs_dir: cloneBinding(dirBinding) }),
enumerable: false,
configurable: false,
writable: false
});
}
// The subset of the original fs binding we call into. Captured before any of
// them are overridden below.
const originalBinding = {
open: binding.open,
openFileHandle: binding.openFileHandle,
read: binding.read,
readBuffers: binding.readBuffers,
fstat: binding.fstat,
close: binding.close,
readFileUtf8: binding.readFileUtf8,
writeFileUtf8: binding.writeFileUtf8,
fchmod: binding.fchmod,
fchown: binding.fchown,
futimes: binding.futimes,
copyFile: binding.copyFile,
readlink: binding.readlink,
writeBuffer: binding.writeBuffer,
writeBuffers: binding.writeBuffers,
writeString: binding.writeString,
ftruncate: binding.ftruncate,
cpSyncCheckPaths: binding.cpSyncCheckPaths,
cpSyncOverrideFile: binding.cpSyncOverrideFile,
cpSyncCopyDir: binding.cpSyncCopyDir
};
const originalDirBinding = {
opendir: dirBinding.opendir,
opendirSync: dirBinding.opendirSync
};
const overrideAPISync = function (
module: Record<string, any>,
name: string,
pathArgumentIndex?: number | null,
fromAsync: boolean = false
) {
if (pathArgumentIndex == null) pathArgumentIndex = 0;
const old = module[name];
const func = function (this: any, ...args: any[]) {
const pathArgument = args[pathArgumentIndex!];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return old.apply(this, args);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
const newPath = archive.copyFileOut(filePath);
if (!newPath) throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
args[pathArgumentIndex!] = newPath;
return old.apply(this, args);
};
if (fromAsync) {
return func;
}
module[name] = func;
};
const overrideAPI = function (module: Record<string, any>, name: string, pathArgumentIndex?: number | null) {
if (pathArgumentIndex == null) pathArgumentIndex = 0;
const old = module[name];
module[name] = function (this: any, ...args: any[]) {
const pathArgument = args[pathArgumentIndex!];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return old.apply(this, args);
const { asarPath, filePath } = pathInfo;
const callback = args[args.length - 1];
if (typeof callback !== 'function') {
return overrideAPISync(module, name, pathArgumentIndex!, true)!.apply(this, args);
}
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
const newPath = archive.copyFileOut(filePath);
if (!newPath) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
args[pathArgumentIndex!] = newPath;
return old.apply(this, args);
};
if (old[util.promisify.custom]) {
module[name][util.promisify.custom] = assignFunctionName(
name,
makePromiseFunction(old[util.promisify.custom], pathArgumentIndex)
);
}
if (module.promises && module.promises[name]) {
module.promises[name] = makePromiseFunction(module.promises[name], pathArgumentIndex);
}
};
let crypto: typeof Crypto;
// Delay load crypto to improve app boot performance
// when integrity protection is not enabled
const getCrypto = () => {
crypto = crypto || require('crypto');
return crypto;
};
// Terminates the process on an integrity failure. This must be synchronous
// and must never return to the caller: in the main process `process.exit()`
// is mapped to the graceful, asynchronous `app.exit()`, which would let JS
// keep running (and consume the tampered bytes) until the quit lands. The
// message is written with a blocking write so it is not lost on the way out.
const integrityViolation = (actual: string, expected: string): never => {
const message = `ASAR Integrity Violation: got a hash mismatch (${actual} vs ${expected})\n`;
try {
(require('fs') as typeof import('fs')).writeSync(2, message);
} catch {
console.error(message);
}
// Bypass libc exit(): it runs static destructors under still-live Chromium
// threads, which on Windows intermittently faults with 0xC0000005.
return v8Util.exitImmediately(1);
};
const sha256HexSync = (buffer: Uint8Array) => getCrypto().createHash('sha256').update(buffer).digest('hex');
// Hashes off the JS thread; used by the asynchronous read paths so that
// validating a block never stalls the event loop.
const sha256HexAsync = async (buffer: Uint8Array) => {
const digest = await getCrypto().webcrypto.subtle.digest('SHA-256', buffer);
return Buffer.from(digest).toString('hex');
};
function validateBufferIntegrity(buffer: Buffer, integrity: NodeJS.AsarFileInfo['integrity']) {
if (!integrity) return;
const actual = sha256HexSync(buffer);
if (actual !== integrity.hash.toLowerCase()) integrityViolation(actual, integrity.hash);
}
async function validateBufferIntegrityAsync(buffer: Buffer, integrity: NodeJS.AsarFileInfo['integrity']) {
if (!integrity) return;
const actual = await sha256HexAsync(buffer);
if (actual !== integrity.hash.toLowerCase()) integrityViolation(actual, integrity.hash);
}
const makePromiseFunction = function (orig: Function, pathArgumentIndex: number) {
return function (this: any, ...args: any[]) {
const pathArgument = args[pathArgumentIndex];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return orig.apply(this, args);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
return Promise.reject(createError(AsarError.INVALID_ARCHIVE, { asarPath }));
}
const newPath = archive.copyFileOut(filePath);
if (!newPath) {
return Promise.reject(createError(AsarError.NOT_FOUND, { asarPath, filePath }));
}
args[pathArgumentIndex] = newPath;
return orig.apply(this, args);
};
};
//
// Direct, integrity-validated reads of packed entries.
//
// An AsarEntryReader serves reads of a single packed entry straight from the
// archive's retained file handle: positions are translated to archive offsets
// and clamped to the entry. The descriptor number the caller gets back from
// open() is a separate sentinel (a write-only handle on the null device) that
// only identifies the reader within this module: reading it directly fails
// with EBADF, so code that bypasses fs fails loudly rather than being handed
// archive bytes, and no handle to the archive itself is ever exposed.
// When the archive carries integrity information, bytes are only ever handed
// out from a buffer whose block hash has just been verified - the reader
// keeps the most recently validated block so that sequential consumers such
// as streams pay for one read and one hash per block, and never re-reads a
// block from disk without re-validating it.
//
interface ValidatedBlock {
index: number;
buffer: Buffer;
}
// Upper bound on the number of validated blocks retained across all live
// readers, so that many concurrently open handles cannot pin unbounded
// memory. Past this, readers still validate every block they serve, they
// just do not keep it around for the next read.
const kMaxRetainedBlocks = 32;
let retainedBlockCount = 0;
// Reads exactly `length` bytes at `position` from `fd`, looping over short
// reads. Returns the number of bytes actually read (less than `length` only
// at end of file).
function readFullySync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number) {
let total = 0;
while (total < length) {
const n = originalBinding.read(fd, buffer, offset + total, length - total, position + total);
if (n === 0) break;
total += n;
}
return total;
}
async function readFully(fd: number, buffer: Uint8Array, offset: number, length: number, position: number) {
let total = 0;
while (total < length) {
const n =
(await originalBinding.read(fd, buffer, offset + total, length - total, position + total, kUsePromises)) || 0;
if (n === 0) break;
total += n;
}
return total;
}
class AsarEntryReader {
// The sentinel descriptor handed to the caller; owned by whoever owns the
// open (fs.close / FileHandle), never read or written by this module.
readonly fd: number;
// The archive's retained handle; all reads go through it, positionally.
readonly archiveFd: number;
readonly size: number;
readonly offset: number;
readonly executable: boolean;
readonly ino: number;
// Position used by reads that do not specify one (like a kernel file
// position). Advanced when such a read is issued.
position = 0;
readonly integrity: NonNullable<NodeJS.AsarFileInfo['integrity']> | null;
private validatedBlock: ValidatedBlock | null = null;
private closed = false;
constructor(fd: number, archiveFd: number, info: NodeJS.AsarFileInfo) {
this.fd = fd;
this.archiveFd = archiveFd;
this.size = info.size;
this.offset = info.offset;
this.executable = info.executable;
this.ino = ++nextInode;
this.integrity = info.integrity && info.integrity.blockSize > 0 ? info.integrity : null;
}
statArray(useBigint: boolean) {
return makeStatArray(AsarFileType.kFile, this.size, this.ino, useBigint, this.executable);
}
// Resolves the (position, length) of a read: `position < 0` means "use and
// advance the reader's own position".
resolveRange(length: number, position: number) {
const start = position < 0 ? this.position : position;
const available = Math.max(0, this.size - start);
const count = Math.min(length, available);
if (position < 0) this.position = start + count;
return { start, count };
}
private retainBlock(block: ValidatedBlock) {
if (this.validatedBlock !== null) {
this.validatedBlock = null;
retainedBlockCount--;
}
// A validation that completes after the reader was closed must not pin
// anything.
if (!this.closed && retainedBlockCount < kMaxRetainedBlocks) {
this.validatedBlock = block;
retainedBlockCount++;
}
}
private blockRange(index: number) {
const start = index * this.integrity!.blockSize;
return { start, length: Math.min(this.integrity!.blockSize, this.size - start) };
}
private checkBlock(index: number, buffer: Buffer, actual: string) {
const expected = this.integrity!.blocks[index];
if (typeof expected !== 'string' || actual !== expected.toLowerCase()) {
integrityViolation(actual, String(expected));
}
const block = { index, buffer };
this.retainBlock(block);
return block;
}
private validateBlockSync(index: number): ValidatedBlock {
const { start, length } = this.blockRange(index);
const buffer = Buffer.allocUnsafeSlow(length);
readFullySync(this.archiveFd, buffer, 0, length, this.offset + start);
return this.checkBlock(index, buffer, sha256HexSync(buffer));
}
private async validateBlock(index: number): Promise<ValidatedBlock> {
const { start, length } = this.blockRange(index);
const buffer = Buffer.allocUnsafeSlow(length);
await readFully(this.archiveFd, buffer, 0, length, this.offset + start);
return this.checkBlock(index, buffer, await sha256HexAsync(buffer));
}
// Copies `count` bytes starting at entry offset `start` out of validated
// blocks into `buffer`.
private copyFromBlocks(
buffer: Uint8Array,
bufferOffset: number,
start: number,
count: number,
getBlock: (i: number) => ValidatedBlock
) {
const { blockSize } = this.integrity!;
let done = 0;
while (done < count) {
const current = start + done;
const index = Math.floor(current / blockSize);
const block = this.validatedBlock?.index === index ? this.validatedBlock : getBlock(index);
const inBlock = current - index * blockSize;
const take = Math.min(count - done, block.buffer.length - inBlock);
block.buffer.copy(buffer, bufferOffset + done, inBlock, inBlock + take);
done += take;
}
return count;
}
readSync(buffer: Uint8Array, bufferOffset: number, length: number, position: number): number {
const { start, count } = this.resolveRange(length, position);
if (count === 0) return 0;
if (!this.integrity) return readFullySync(this.archiveFd, buffer, bufferOffset, count, this.offset + start);
return this.copyFromBlocks(buffer, bufferOffset, start, count, (i) => this.validateBlockSync(i));
}
async read(buffer: Uint8Array, bufferOffset: number, length: number, position: number): Promise<number> {
const { start, count } = this.resolveRange(length, position);
if (count === 0) return 0;
if (!this.integrity) return readFully(this.archiveFd, buffer, bufferOffset, count, this.offset + start);
// Validate every block the read touches up front (asynchronously), then
// copy out synchronously.
const { blockSize } = this.integrity;
const first = Math.floor(start / blockSize);
const last = Math.floor((start + count - 1) / blockSize);
const blocks = new Map<number, ValidatedBlock>();
for (let index = first; index <= last; index++) {
blocks.set(index, this.validatedBlock?.index === index ? this.validatedBlock : await this.validateBlock(index));
}
return this.copyFromBlocks(buffer, bufferOffset, start, count, (i) => blocks.get(i)!);
}
// Reads from the current position to the end of the entry.
readToEndSync() {
const buffer = Buffer.allocUnsafeSlow(Math.max(0, this.size - this.position));
const n = this.readSync(buffer, 0, buffer.length, -1);
return n === buffer.length ? buffer : buffer.subarray(0, n);
}
// Releases retained validation state; does not close the fd.
dispose() {
if (this.closed) return;
this.closed = true;
if (this.validatedBlock !== null) {
this.validatedBlock = null;
retainedBlockCount--;
}
}
}
// Every fd handed out by open()/openSync()/promises.open() for a packed
// entry, keyed by fd number. For FileHandles the fd is owned by node's C++
// FileHandle (which also closes it on GC), so the entry additionally holds a
// weak reference to that handle and is only considered live while the handle
// is alive and still owns that fd number - a stale entry can therefore never
// capture reads of an unrelated file that later reuses the number.
interface OpenAsarFile {
reader: AsarEntryReader;
handle?: WeakRef<{ fd: number }>;
}
const openAsarFiles = new Map<number, OpenAsarFile>();
function lookupAsarFd(fd: number): AsarEntryReader | undefined {
if (openAsarFiles.size === 0 || typeof fd !== 'number') return undefined;
const entry = openAsarFiles.get(fd);
if (entry === undefined) return undefined;
if (entry.handle !== undefined) {
const handle = entry.handle.deref();
if (handle === undefined || handle.fd !== fd) {
openAsarFiles.delete(fd);
entry.reader.dispose();
return undefined;
}
}
return entry.reader;
}
function forgetAsarFd(fd: number) {
const entry = openAsarFiles.get(fd);
if (entry === undefined) return;
openAsarFiles.delete(fd);
entry.reader.dispose();
}
// A descriptor number handed out (or handed back) by native fs may still have
// an entry here if the previous owner of that number was closed behind our
// back (a native FileHandle built on it, e.g. by http2's respondWithFile, a
// FileHandle transferred to a worker, or one dropped and closed on GC). Such
// an entry must never capture the new owner's reads.
function forgetStaleAsarFd(fd: unknown) {
if (openAsarFiles.size !== 0 && typeof fd === 'number') forgetAsarFd(fd);
}
// Calls a native open-like binding (args[3] being its request) and drops any
// stale entry for the descriptor it produces. The check is only wired up
// while entries exist, so the common case stays a plain pass-through.
function passThroughOpen(fn: Function, args: any[], getFd: (value: any) => unknown) {
if (openAsarFiles.size === 0) return fn.apply(undefined, args);
const req = args[3];
if (req === undefined) {
const fd = fn.apply(undefined, args);
forgetStaleAsarFd(getFd(fd));
return fd;
}
if (req === kUsePromises) {
return fn.apply(undefined, args).then((value: any) => {
forgetStaleAsarFd(getFd(value));
return value;
});
}
const oncomplete = req.oncomplete;
req.oncomplete = function (this: any, error: any, value: any) {
if (!error) forgetStaleAsarFd(getFd(value));
return oncomplete.call(this, error, value);
};
return fn.apply(undefined, args);
}
function sweepStaleAsarFds() {
for (const [fd, entry] of openAsarFiles) {
if (entry.handle === undefined) continue;
const handle = entry.handle.deref();
if (handle === undefined || handle.fd !== fd) forgetAsarFd(fd);
}
}
// Applies an fs `encoding` option to a name the way the native bindings do:
// 'buffer' yields a Buffer, other encodings re-encode the UTF-8 name.
const encodeName = (name: string, encoding: any) => {
if (encoding === 'buffer') return Buffer.from(name);
if (encoding && encoding !== 'utf8' && encoding !== 'utf-8') return Buffer.from(name).toString(encoding);
return name;
};
// The fs binding accepts a position as a number, a bigint, or null /
// undefined / -1 for "the current file position".
const normalizePosition = (position: number | bigint | null | undefined) => {
if (position == null) return -1;
if (typeof position === 'bigint') return Number(position);
return position;
};
// Node's fs.constants values for the "opens for writing" bits. Any of these
// on a packed entry is refused: archives are read-only, and silently
// redirecting the write elsewhere would be worse than failing.
const kWriteFlags = constants.O_WRONLY | constants.O_RDWR | constants.O_APPEND | constants.O_TRUNC;
// Resolves an asar path for open()-like calls. Returns either a reader for
// a packed entry, the real path of an unpacked entry, or throws.
// `withSentinel: false` is for internal consumers (copyFile) that read the
// entry themselves and never hand a descriptor number to anyone; the reader's
// `fd` is -1 then.
function openAsarEntry(
asarPath: string,
filePath: string,
flags: number,
syscall: string,
withSentinel = true
): { unpackedPath: string } | { reader: AsarEntryReader } {
const archive = getOrCreateArchive(asarPath);
if (!archive) throw createError(AsarError.INVALID_ARCHIVE, { asarPath, syscall });
const info = archive.getFileInfo(filePath);
if (!info) {
// getFileInfo follows links itself, so a link to a directory ends up here
// as well; resolve links to tell EISDIR from ENOENT.
const resolved = resolveAsarLinks(archive, filePath);
if (resolved && resolved.stats.type === AsarFileType.kDirectory) {
throw createError(AsarError.IS_DIR, { asarPath, filePath, syscall });
}
throw createError(AsarError.NOT_FOUND, { asarPath, filePath, syscall });
}
if (info.unpacked) return { unpackedPath: getUnpackedPath(asarPath, filePath) };
if (flags & kWriteFlags) throw createError(AsarError.NO_ACCESS, { asarPath, filePath, syscall });
if ((flags & constants.O_CREAT) !== 0 && (flags & constants.O_EXCL) !== 0) {
throw createError(AsarError.EXISTS, { asarPath, filePath, syscall });
}
const archiveFd = archive.getFdAndValidateIntegrityLater();
if (!(archiveFd >= 0)) throw createError(AsarError.INVALID_ARCHIVE, { asarPath, syscall });
if (!withSentinel) return { reader: new AsarEntryReader(-1, archiveFd, info) };
const fd = asar.createSentinelFd();
if (!(fd >= 0)) throw createError(AsarError.TOO_MANY_OPEN, { asarPath, filePath, syscall });
return { reader: new AsarEntryReader(fd, archiveFd, info) };
}
// Override fs APIs.
export const wrapFsWithAsar = (fs: Record<string, any>) => {
const logFDs = new Map<string, number>();
// Read once at startup (set it in the environment that launches the app):
// process.env goes through node's env interceptor and this is consulted on
// every read of a packed file.
const logAsarReads = Boolean(process.env.ELECTRON_LOG_ASAR_READS);
const logASARAccess = (asarPath: string, filePath: string, offset: number) => {
if (!logAsarReads) return;
if (!logFDs.has(asarPath)) {
const logFilename = `${path.basename(asarPath, '.asar')}-access-log.txt`;
const logPath = path.join((require('os') as typeof os).tmpdir(), logFilename);
logFDs.set(asarPath, fs.openSync(logPath, 'a'));
}
fs.writeSync(logFDs.get(asarPath), `${offset}: ${filePath}\n`);
};
const shouldThrowStatError = (options: any) => {
if (options && typeof options === 'object' && options.throwIfNoEntry === false) {
return false;
}
return true;
};
// internalModuleStat-shaped check (1 = directory, 0 = file, negative =
// missing) used by the recursive readdir implementations below. Unlike
// internalModuleStat itself, symbolic links inside archives are not
// followed: recursive readdir descends into whatever this reports as a
// directory, and archives may legitimately contain link cycles (a real
// filesystem walk would only be stopped by ENAMETOOLONG).
const statTypeForReaddir = (pathArgument: string): number => {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return internalModuleStat(pathArgument);
const archive = getOrCreateArchive(pathInfo.asarPath);
if (!archive) return -34;
const stats = archive.stat(pathInfo.filePath);
if (!stats) return -34;
return stats.type === AsarFileType.kDirectory ? 1 : 0;
};
const { lstatSync } = fs;
fs.lstatSync = (pathArgument: string, options: any) => {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return lstatSync(pathArgument, options);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
if (shouldThrowStatError(options)) {
throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
}
return null;
}
const stats = archive.stat(filePath);
if (!stats) {
if (shouldThrowStatError(options)) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
return null;
}
return asarStatsToFsStats(stats, options);
};
const { lstat } = fs;
fs.lstat = (pathArgument: string, options: any, callback: any) => {
const pathInfo = splitPath(pathArgument);
if (typeof options === 'function') {
callback = options;
options = {};
}
if (!pathInfo.isAsar) return lstat(pathArgument, options, callback);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
const stats = archive.stat(filePath);
if (!stats) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
const fsStats = asarStatsToFsStats(stats, options);
nextTick(callback, [null, fsStats]);
};
fs.promises.lstat = util.promisify(fs.lstat);
const { statSync } = fs;
fs.statSync = (pathArgument: string, options: any) => {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return statSync(pathArgument, options);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
if (shouldThrowStatError(options)) {
throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
}
return null;
}
const resolved = resolveAsarLinks(archive, filePath);
if (!resolved) {
if (shouldThrowStatError(options)) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
return null;
}
return asarStatsToFsStats(resolved.stats, options);
};
const { stat } = fs;
fs.stat = (pathArgument: string, options: any, callback: any) => {
const pathInfo = splitPath(pathArgument);
if (typeof options === 'function') {
callback = options;
options = {};
}
if (!pathInfo.isAsar) return stat(pathArgument, options, callback);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
nextTick(callback, [createError(AsarError.INVALID_ARCHIVE, { asarPath })]);
return;
}
const resolved = resolveAsarLinks(archive, filePath);
if (!resolved) {
nextTick(callback, [createError(AsarError.NOT_FOUND, { asarPath, filePath })]);
return;
}
nextTick(callback, [null, asarStatsToFsStats(resolved.stats, options)]);
};
fs.promises.stat = util.promisify(fs.stat);
// The on-disk real path of an archive file, per archive path. An archive is
// held open (and its parsed header reused) for the life of the process once
// it is in `cachedArchives`, so where its own path resolves to is treated as
// equally fixed instead of being re-resolved through the real filesystem on
// every realpath() of an entry inside it -- the module loader does one per
// file it loads. Only successful lookups of archives that opened are kept.
// realpath() and realpath.native() can disagree (case-insensitive volumes,
// substituted drives), so each variant keeps its own answers.
const archiveRealPaths = { js: new Map<string, string>(), native: new Map<string, string>() };
const encodeRealpathResult = (result: string, options: any) => {
const encoding = !options ? null : typeof options === 'string' ? options : options.encoding;
if (!encoding || encoding === 'utf8' || encoding === 'utf-8') return result;
const buffer = Buffer.from(result);
return encoding === 'buffer' ? buffer : buffer.toString(encoding);
};
const wrapRealpathSync = function (realpathSync: Function, resolvedArchives: Map<string, string>) {
return function (this: any, pathArgument: string, options: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return realpathSync.apply(this, arguments);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
}
const fileRealPath = archive.realpath(filePath);
if (fileRealPath === false) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
let archiveRealPath = resolvedArchives.get(asarPath);
if (archiveRealPath === undefined) {
archiveRealPath = String(realpathSync(asarPath));
resolvedArchives.set(asarPath, archiveRealPath);
}
return encodeRealpathResult(path.join(archiveRealPath, fileRealPath), options);
};
};
const { realpathSync } = fs;
fs.realpathSync = wrapRealpathSync(realpathSync, archiveRealPaths.js);
fs.realpathSync.native = wrapRealpathSync(realpathSync.native, archiveRealPaths.native);
const wrapRealpath = function (realpath: Function, resolvedArchives: Map<string, string>) {
return function (this: any, pathArgument: string, options: any, callback: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return realpath.apply(this, arguments);
const { asarPath, filePath } = pathInfo;
if (arguments.length < 3) {
callback = options;
options = {};
}
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
const fileRealPath = archive.realpath(filePath);
if (fileRealPath === false) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
const cachedRealPath = resolvedArchives.get(asarPath);
if (cachedRealPath !== undefined) {
nextTick(callback, [null, encodeRealpathResult(path.join(cachedRealPath, fileRealPath), options)]);
return;
}
realpath(asarPath, (error: Error | null, archiveRealPath: string) => {
if (error === null) {
resolvedArchives.set(asarPath, archiveRealPath);
callback(null, encodeRealpathResult(path.join(archiveRealPath, fileRealPath), options));
} else {
callback(error);
}
});
};
};
const { realpath } = fs;
fs.realpath = wrapRealpath(realpath, archiveRealPaths.js);
fs.realpath.native = wrapRealpath(realpath.native, archiveRealPaths.native);
fs.promises.realpath = util.promisify(fs.realpath.native);
const { exists: nativeExists } = fs;
fs.exists = function exists(pathArgument: string, callback: any) {
let pathInfo: ReturnType<typeof splitPath>;
try {
pathInfo = splitPath(pathArgument);
} catch {
nextTick(callback, [false]);
return;
}
if (!pathInfo.isAsar) return nativeExists(pathArgument, callback);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
nextTick(callback, [false]);
return;
}
const pathExists = archive.stat(filePath) !== false;
nextTick(callback, [pathExists]);
};
fs.exists[util.promisify.custom] = function exists(pathArgument: string) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return nativeExists[util.promisify.custom](pathArgument);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
return Promise.resolve(false);
}
return Promise.resolve(archive.stat(filePath) !== false);
};
const { existsSync } = fs;
fs.existsSync = (pathArgument: string) => {
let pathInfo: ReturnType<typeof splitPath>;
try {
pathInfo = splitPath(pathArgument);
} catch {
return false;
}
if (!pathInfo.isAsar) return existsSync(pathArgument);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) return false;
return archive.stat(filePath) !== false;
};
const { access } = fs;
fs.access = function (pathArgument: string, mode: number, callback: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return access.apply(this, arguments);
const { asarPath, filePath } = pathInfo;
if (typeof mode === 'function') {
callback = mode;
mode = fs.constants.F_OK;
}
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
const info = archive.getFileInfo(filePath);
if (!info) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
if (info.unpacked) {
return fs.access(getUnpackedPath(asarPath, filePath), mode, callback);
}
const stats = archive.stat(filePath);
if (!stats) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
if (mode & fs.constants.W_OK) {
const error = createError(AsarError.NO_ACCESS, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
nextTick(callback);
};
const { access: accessPromise } = fs.promises;
const promisifiedAccess = util.promisify(fs.access);
fs.promises.access = function (pathArgument: string, mode: number) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) {
return accessPromise.apply(this, arguments);
}
return promisifiedAccess(pathArgument, mode);
};
const { accessSync } = fs;
fs.accessSync = function (pathArgument: string, mode: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return accessSync.apply(this, arguments);
const { asarPath, filePath } = pathInfo;
if (mode == null) mode = fs.constants.F_OK;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
}
const info = archive.getFileInfo(filePath);
if (!info) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
if (info.unpacked) {
return fs.accessSync(getUnpackedPath(asarPath, filePath), mode);
}
const stats = archive.stat(filePath);
if (!stats) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
if (mode & fs.constants.W_OK) {
throw createError(AsarError.NO_ACCESS, { asarPath, filePath });
}
};
function normalizeReadFileOptions(options: any) {
if (typeof options === 'string') {
options = { encoding: options };
} else if (options === null || options === undefined) {
options = { encoding: null };
} else if (typeof options !== 'object') {
throw new TypeError('Bad arguments');
}
return options;
}
function fsReadFileAsar(pathArgument: string, options: any, callback: any) {
const pathInfo = splitPath(pathArgument);
if (pathInfo.isAsar) {
const { asarPath, filePath } = pathInfo;
if (typeof options === 'function') {
callback = options;
options = { encoding: null };
} else {
options = normalizeReadFileOptions(options);
}
const { encoding } = options;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
const info = archive.getFileInfo(filePath);
if (!info) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
if (info.size === 0) {
nextTick(callback, [null, encoding ? '' : Buffer.alloc(0)]);
return;
}
if (info.unpacked) {
return fs.readFile(getUnpackedPath(asarPath, filePath), options, callback);
}
const fd = archive.getFdAndValidateIntegrityLater();
if (!(fd >= 0)) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
logASARAccess(asarPath, filePath, info.offset);
const buffer = Buffer.allocUnsafeSlow(info.size);
readFully(fd, buffer, 0, info.size, info.offset)
.then(async (bytesRead) => {
// Only what was actually read gets validated and returned; a
// truncated archive must not surface as a spurious hash mismatch.
const data = bytesRead === info.size ? buffer : buffer.subarray(0, bytesRead);
await validateBufferIntegrityAsync(data, info.integrity);
return data;
})
.then(
(data) => callback(null, encoding ? data.toString(encoding) : data),
(error) => callback(error)
);
}
}
const { readFile } = fs;
fs.readFile = function (pathArgument: string, options: any, callback: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) {
return readFile.apply(this, arguments);
}
return fsReadFileAsar(pathArgument, options, callback);
};
const { readFile: readFilePromise } = fs.promises;
const promisifiedReadFileAsar = util.promisify(fsReadFileAsar);
fs.promises.readFile = function (pathArgument: string, options: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) {
return readFilePromise.apply(this, arguments);
}
return promisifiedReadFileAsar(pathArgument, options);
};
function readFileFromArchiveSync(
pathInfo: { asarPath: string; filePath: string },
options: any
): ReturnType<typeof readFileSync> {
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
const info = archive.getFileInfo(filePath);
if (!info) throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
options = normalizeReadFileOptions(options);
const { encoding } = options;
if (info.size === 0) return encoding ? '' : Buffer.alloc(0);
if (info.unpacked) {
return fs.readFileSync(getUnpackedPath(asarPath, filePath), options);
}
const fd = archive.getFdAndValidateIntegrityLater();
if (!(fd >= 0)) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
logASARAccess(asarPath, filePath, info.offset);
let buffer = Buffer.allocUnsafeSlow(info.size);
const bytesRead = readFullySync(fd, buffer, 0, info.size, info.offset);
if (bytesRead !== info.size) buffer = buffer.subarray(0, bytesRead);
validateBufferIntegrity(buffer, info.integrity);
return encoding ? buffer.toString(encoding) : buffer;
}
const { readFileSync } = fs;
fs.readFileSync = function (pathArgument: string, options: any) {
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return readFileSync.apply(this, arguments);
return readFileFromArchiveSync(pathInfo, options);
};
type ReaddirOptions =
| { encoding: BufferEncoding | null; withFileTypes?: false; recursive?: false }
| undefined
| null;
type ReaddirCallback = (err: NodeJS.ErrnoException | null, files?: string[]) => void;
const processReaddirResult = (args: any) =>
args.context.withFileTypes ? handleDirents(args) : handleFilePaths(args);
function handleDirents({ result, currentPath, context }: { result: any[]; currentPath: string; context: any }) {
const length = result[0].length;
for (let i = 0; i < length; i++) {
const resultPath = path.join(currentPath, result[0][i]);
const info = splitPath(resultPath);
let type = result[1][i];
if (info.isAsar) {
const archive = getOrCreateArchive(info.asarPath);
if (!archive) continue;
const stats = archive.stat(info.filePath);
if (!stats) continue;
type = stats.type;
}
const dirent = getDirent(currentPath, result[0][i], type);
const stat = statTypeForReaddir(resultPath);
context.readdirResults.push(dirent);
if (dirent!.isDirectory() || stat === 1) {
context.pathsQueue.push(path.join(dirent!.parentPath, dirent!.name));
}
}
}
function handleFilePaths({ result, currentPath, context }: { result: string[]; currentPath: string; context: any }) {
for (let i = 0; i < result.length; i++) {
const resultPath = path.join(currentPath, result[i]);
const relativeResultPath = path.relative(context.basePath, resultPath);
const stat = statTypeForReaddir(resultPath);
context.readdirResults.push(relativeResultPath);
if (stat === 1) {
context.pathsQueue.push(resultPath);
}
}
}
function readdirRecursive(basePath: string, options: ReaddirOptions, callback: ReaddirCallback) {
const context = {
withFileTypes: Boolean(options!.withFileTypes),
encoding: options!.encoding,
basePath,
readdirResults: [],
pathsQueue: [basePath]
};
let i = 0;
function read(pathArg: string) {
const req = new binding.FSReqCallback();
req.oncomplete = (err: any, result: string) => {
if (err) {
callback(err);
return;
}
if (result === undefined) {
callback(null, context.readdirResults);
return;
}
processReaddirResult({
result,
currentPath: pathArg,
context
});
if (i < context.pathsQueue.length) {
read(context.pathsQueue[i++]);
} else {
callback(null, context.readdirResults);
}
};
const pathInfo = splitPath(pathArg);
if (pathInfo.isAsar) {
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback, [error]);
return;
}
// Same shape as the native binding: names, or [names, types].
const readdirResult = context.withFileTypes ? archive.readdirWithTypes(filePath) : archive.readdir(filePath);
if (!readdirResult) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback, [error]);
return;
}
processReaddirResult({
result: readdirResult,
currentPath: pathArg,
context
});
if (i < context.pathsQueue.length) {
read(context.pathsQueue[i++]);
} else {
callback(null, context.readdirResults);
}
} else {
binding.readdir(pathArg, context.encoding, context.withFileTypes, req);
}
}
read(context.pathsQueue[i++]);
}
const { readdir } = fs;
fs.readdir = function (pathArgument: string, options: ReaddirOptions, callback: ReaddirCallback) {
callback = typeof options === 'function' ? options : callback;
validateFunction(callback, 'callback')!;
options = getOptions(options);
pathArgument = getValidatedPath(pathArgument);
if (options?.recursive != null) {
validateBoolean(options?.recursive, 'options.recursive')!;
}
if (options?.recursive) {
readdirRecursive(pathArgument, options, callback);
return;
}
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return readdir.apply(this, arguments);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
nextTick(callback!, [error]);
return;
}
const files = options?.withFileTypes ? readAsarDirents(archive, filePath, pathArgument) : archive.readdir(filePath);
if (!files) {
const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
nextTick(callback!, [error]);
return;
}
nextTick(callback!, [null, files]);
};
const { readdir: readdirPromise } = fs.promises;
fs.promises.readdir = async function (pathArgument: string, options: ReaddirOptions) {
options = getOptions(options);
pathArgument = getValidatedPath(pathArgument);
if (options?.recursive != null) {
validateBoolean(options?.recursive, 'options.recursive')!;
}
if (options?.recursive) {
return readdirRecursivePromises(pathArgument, options);
}
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return readdirPromise(pathArgument, options);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
return Promise.reject(createError(AsarError.INVALID_ARCHIVE, { asarPath }));
}
const files = options?.withFileTypes ? readAsarDirents(archive, filePath, pathArgument) : archive.readdir(filePath);
if (!files) {
return Promise.reject(createError(AsarError.NOT_FOUND, { asarPath, filePath }));
}
return Promise.resolve(files);
};
const { readdirSync } = fs;
fs.readdirSync = function (pathArgument: string, options: ReaddirOptions) {
options = getOptions(options);
pathArgument = getValidatedPath(pathArgument);
if (options?.recursive != null) {
validateBoolean(options?.recursive, 'options.recursive')!;
}
if (options?.recursive) {
return readdirSyncRecursive(pathArgument, options);
}
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return readdirSync.apply(this, arguments);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
throw createError(AsarError.INVALID_ARCHIVE, { asarPath });
}
const files = options?.withFileTypes ? readAsarDirents(archive, filePath, pathArgument) : archive.readdir(filePath);
if (!files) {
throw createError(AsarError.NOT_FOUND, { asarPath, filePath });
}
return files;
};
const modBinding = internalBinding('modules');
modBinding.overrideReadFileSync((jsonPath: string): Buffer | false | undefined => {
const pathInfo = splitPath(jsonPath);
// Fallback to Node.js internal implementation
if (!pathInfo.isAsar) return undefined;
try {
return readFileFromArchiveSync(pathInfo, undefined);
} catch {
// Not found
return false;
}
});
// Module resolution repeats most archive probes and an open archive's header
// never changes, so answers derived from it are kept (bounded).
const moduleStatCache = new Map<string, number>();
const kModuleStatCacheLimit = 16 * 1024;
const { internalModuleStat } = binding;
internalBinding('fs').internalModuleStat = (pathArgument: string) => {
// Only archive paths are ever cached, so a hit needs no splitPath().
const cached = moduleStatCache.get(pathArgument);
if (cached !== undefined && !isAsarDisabled()) return cached;
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return internalModuleStat(pathArgument);
const { asarPath, filePath } = pathInfo;
// -ENOENT; not cached, the archive may appear later.
const archive = getOrCreateArchive(asarPath);
if (!archive) return -34;
// Like a real stat, follow symbolic links inside the archive.
const resolved = resolveAsarLinks(archive, filePath);
const result = !resolved ? -34 : resolved.stats.type === AsarFileType.kDirectory ? 1 : 0;
if (moduleStatCache.size >= kModuleStatCacheLimit) moduleStatCache.clear();
moduleStatCache.set(pathArgument, result);
return result;
};
async function readdirRecursivePromises(originalPath: string, options: ReaddirOptions) {
const result: any[] = [];
const pathInfo = splitPath(originalPath);
let queue: [string, string[]][] = [];
const withFileTypes = Boolean(options?.withFileTypes);
let initialItem = [];
if (pathInfo.isAsar) {
const archive = getOrCreateArchive(pathInfo.asarPath);
if (!archive) return result;
// Same shape as the native binding: names, or [names, types].
const files = withFileTypes ? archive.readdirWithTypes(pathInfo.filePath) : archive.readdir(pathInfo.filePath);
if (!files) return result;
initialItem = files;
} else {
initialItem = await binding.readdir(
path.toNamespacedPath(originalPath),
options!.encoding,
withFileTypes,
kUsePromises
);
}
queue = [[originalPath, initialItem]];
if (withFileTypes) {
while (queue.length > 0) {
// @ts-expect-error this is a valid array destructure assignment.
const { 0: pathArg, 1: readDir } = queue.pop();
for (const dirent of getDirents(pathArg, readDir)) {
result.push(dirent);
if (dirent.isDirectory()) {
const direntPath = path.join(pathArg, dirent.name);
const info = splitPath(direntPath);
let readdirResult;
if (info.isAsar) {
const archive = getOrCreateArchive(info.asarPath);
if (!archive) continue;
readdirResult = archive.readdirWithTypes(info.filePath);
if (!readdirResult) continue;
} else {
readdirResult = await binding.readdir(direntPath, options!.encoding, true, kUsePromises);
}
queue.push([direntPath, readdirResult]);
}
}
}
} else {
while (queue.length > 0) {
// @ts-expect-error this is a valid array destructure assignment.
const { 0: pathArg, 1: readDir } = queue.pop();
for (const ent of readDir) {
const direntPath = path.join(pathArg, ent);
const stat = statTypeForReaddir(direntPath);
result.push(path.relative(originalPath, direntPath));
if (stat === 1) {
const subPathInfo = splitPath(direntPath);
let item = [];
if (subPathInfo.isAsar) {
const archive = getOrCreateArchive(subPathInfo.asarPath);
if (!archive) return result;
const files = archive.readdir(subPathInfo.filePath);
if (!files) return result;
item = files;
} else {
item = await binding.readdir(path.toNamespacedPath(direntPath), options!.encoding, false, kUsePromises);
}
queue.push([direntPath, item]);
}
}
}
}
return result;
}
function readdirSyncRecursive(basePath: string, options: ReaddirOptions) {
const context = {
withFileTypes: Boolean(options!.withFileTypes),
encoding: options!.encoding,
basePath,
readdirResults: [] as any,
pathsQueue: [basePath]
};
function read(pathArg: string) {
let readdirResult;
const pathInfo = splitPath(pathArg);
if (pathInfo.isAsar) {
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) return;
// Same shape as the native binding: names, or [names, types].
readdirResult = context.withFileTypes ? archive.readdirWithTypes(filePath) : archive.readdir(filePath);
if (!readdirResult) return;
} else {
readdirResult = binding.readdir(path.toNamespacedPath(pathArg), context.encoding, context.withFileTypes);
}
if (readdirResult === undefined) {
return;
}
processReaddirResult({
result: readdirResult,
currentPath: pathArg,
context
});
}
for (let i = 0; i < context.pathsQueue.length; i++) {
read(context.pathsQueue[i]);
}
return context.readdirResults;
}
// Calling mkdir for directory inside asar archive should throw ENOTDIR
// error, but on Windows it throws ENOENT.
if (process.platform === 'win32') {
const { mkdir } = fs;
fs.mkdir = (pathArgument: string, options: any, callback: any) => {
if (typeof options === 'function') {
callback = options;
options = {};
}
const pathInfo = splitPath(pathArgument);
if (pathInfo.isAsar && pathInfo.filePath.length > 0) {
const error = createError(AsarError.NOT_DIR);
nextTick(callback, [error]);
return;
}
mkdir(pathArgument, options, callback);
};
fs.promises.mkdir = util.promisify(fs.mkdir);
const { mkdirSync } = fs;
fs.mkdirSync = function (pathArgument: string, options: any) {
const pathInfo = splitPath(pathArgument);
if (pathInfo.isAsar && pathInfo.filePath.length) throw createError(AsarError.NOT_DIR);
return mkdirSync(pathArgument, options);
};
}
function invokeWithNoAsar(func: Function) {
return function (this: any) {
const processNoAsarOriginalValue = process.noAsar;
process.noAsar = true;
try {
return func.apply(this, arguments);
} finally {
process.noAsar = processNoAsarOriginalValue;
}
};
}
//
// fd-based access to packed entries: fs.open / fs.openSync /
// fs.promises.open and everything built on them (fs.createReadStream,
// fs.readFile(fd), FileHandle#read/readFile/stat/createReadStream, ...).
//
// These are hooked at the fs binding layer, below node's own argument
// parsing, so that every JS entry point in node that funnels down to
// binding.read / binding.fstat / binding.close for one of our fds is
// covered without re-implementing node's overload handling.
//
// Node's native fs functions CHECK() their exact argument count to decide
// between sync and async forms, so pass-through calls must forward the
// original argument list untouched (in particular, never append a trailing
// `undefined`). Forwarding uses Function#apply and index access rather
// than spread / array destructuring: those depend on Array.prototype's
// iterator, which user code is allowed to delete (Node's own internals use
// primordials for the same reason) and fs must keep working when it does.
binding.open = function (...args: any[]) {
const pathArgument = args[0];
const flags = args[1];
const req = args[3];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return passThroughOpen(originalBinding.open, args, (fd) => fd);
const { asarPath, filePath } = pathInfo;
let opened;
try {
opened = openAsarEntry(asarPath, filePath, flags, 'open');
} catch (error) {
return completeRequest(req, error as Error);
}
if ('unpackedPath' in opened) {
args[0] = opened.unpackedPath;
return passThroughOpen(originalBinding.open, args, (fd) => fd);
}
const { reader } = opened;
if (openAsarFiles.size > 256) sweepStaleAsarFds();
// The number may have belonged to a handle that was closed behind our
// back; make sure its entry (and retained block) is released first.
forgetAsarFd(reader.fd);
openAsarFiles.set(reader.fd, { reader });
return completeRequest(req, null, reader.fd);
};
binding.openFileHandle = function (...args: any[]) {
const pathArgument = args[0];
const flags = args[1];
const req = args[3];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return passThroughOpen(originalBinding.openFileHandle, args, (handle) => handle?.fd);
const { asarPath, filePath } = pathInfo;
let opened;
try {
opened = openAsarEntry(asarPath, filePath, flags, 'open');
} catch (error) {
return completeRequest(req, error as Error);
}
if ('unpackedPath' in opened) {
args[0] = opened.unpackedPath;
return passThroughOpen(originalBinding.openFileHandle, args, (handle) => handle?.fd);
}
const { reader } = opened;
// Node's C++ FileHandle takes ownership of the fd (and closes it on
// close() or garbage collection); the JS FileHandle wraps this object.
const handle = new binding.FileHandle(reader.fd);
// close() runs the actual close(2) on the threadpool and only resets the
// handle's fd to -1 afterwards, on the loop thread; forget the entry as
// soon as close is requested so that a real file opened in between and
// handed the same descriptor number can never be routed through this
// reader.
const { close } = handle;
handle.close = function (this: any, ...closeArgs: any[]) {
forgetAsarFd(reader.fd);
return close.apply(this, closeArgs);
};
if (openAsarFiles.size > 256) sweepStaleAsarFds();
forgetAsarFd(reader.fd);
openAsarFiles.set(reader.fd, { reader, handle: new WeakRef(handle) });
return completeRequest(req, null, handle);
};
binding.read = function (...args: any[]) {
const fd = args[0];
const buffer = args[1];
const offset = args[2];
const length = args[3];
const position = args[4];
const req = args[5];
const reader = lookupAsarFd(fd);
if (reader === undefined) return originalBinding.read.apply(undefined, args);
const pos = normalizePosition(position);
if (reader.integrity === null) {
// Fast path: without integrity information the read only needs its
// position translated and clamped to the entry, so hand it straight to
// the native binding with the caller's own request object.
const { start, count } = reader.resolveRange(length, pos);
if (count === 0) return completeRequest(req, null, 0);
args[0] = reader.archiveFd;
args[3] = count;
args[4] = reader.offset + start;
return originalBinding.read.apply(undefined, args);
}
if (req === undefined) return reader.readSync(buffer, offset, length, pos);
return completeRequestWithPromise(req, reader.read(buffer, offset, length, pos));
};
binding.readBuffers = function (...args: any[]) {
const fd: number = args[0];
const buffers: Uint8Array[] = args[1];
const position: number | bigint | null = args[2];
const req = args[3];
const reader = lookupAsarFd(fd);
if (reader === undefined) return originalBinding.readBuffers.apply(undefined, args);
const pos = normalizePosition(position);
if (reader.integrity === null) {
// Fast path (see binding.read): when every buffer fits inside the
// entry the native readv can be used as-is with a translated position.
let wanted = 0;
for (let i = 0; i < buffers.length; i++) wanted += buffers[i].byteLength;
const { start, count } = reader.resolveRange(wanted, pos);
if (count === 0) return completeRequest(req, null, 0);
if (count === wanted) {
args[0] = reader.archiveFd;
args[2] = reader.offset + start;
return originalBinding.readBuffers.apply(undefined, args);
}
// Otherwise the tail must be clamped; the read position was already
// advanced by resolveRange, so serve the buffers positionally from
// `start`.
const filled: number[] = [];
let remaining = count;
for (let i = 0; i < buffers.length; i++) {
const take = Math.min(remaining, buffers[i].byteLength);
filled.push(take);
remaining -= take;
}
// (Zero-length buffers must not end the loop: like preadv, they are
// skipped and later buffers are still filled.)
const readAllClamped = async () => {
let total = 0;
for (let i = 0; i < buffers.length; i++) {
total += await readFully(reader.archiveFd, buffers[i], 0, filled[i], reader.offset + start + total);
}
return total;
};
if (req === undefined) {
let total = 0;
for (let i = 0; i < buffers.length; i++) {
total += readFullySync(reader.archiveFd, buffers[i], 0, filled[i], reader.offset + start + total);
}
return total;
}
return completeRequestWithPromise(req, readAllClamped());
}
if (req === undefined) {
let total = 0;
for (let i = 0; i < buffers.length; i++) {
const buffer = buffers[i];
const n = reader.readSync(buffer, 0, buffer.byteLength, pos < 0 ? -1 : pos + total);
total += n;
if (n < buffer.byteLength) break;
}
return total;
}
const readAll = async () => {
let total = 0;
for (let i = 0; i < buffers.length; i++) {
const buffer = buffers[i];
const n = await reader.read(buffer, 0, buffer.byteLength, pos < 0 ? -1 : pos + total);
total += n;
if (n < buffer.byteLength) break;
}
return total;
};
return completeRequestWithPromise(req, readAll());
};
binding.fstat = function (...args: any[]) {
const fd = args[0];
const useBigint = args[1];
const req = args[2];
const reader = lookupAsarFd(fd);
if (reader === undefined) return originalBinding.fstat.apply(undefined, args);
return completeRequest(req, null, reader.statArray(Boolean(useBigint)));
};
binding.close = function (...args: any[]) {
const fd = args[0];
if (lookupAsarFd(fd) !== undefined) forgetAsarFd(fd);
return originalBinding.close.apply(undefined, args);
};
binding.readFileUtf8 = function (...args: any[]) {
const reader = lookupAsarFd(args[0]);
if (reader === undefined) return originalBinding.readFileUtf8.apply(undefined, args);
return reader.readToEndSync().toString('utf8');
};
// fs.writeFileSync's utf8 fast path opens the file natively by path; route
// it through the same checks as open() so writes into archives fail with
// EACCES (or land in the real file for unpacked entries) instead of
// whatever the OS says about a path that goes through a file.
binding.writeFileUtf8 = function (...args: any[]) {
const pathOrFd = args[0];
const flags = args[2];
// The fast path also accepts a descriptor.
if (lookupAsarFd(pathOrFd) !== undefined) {
throw createError(AsarError.NO_ACCESS, { filePath: `fd ${pathOrFd}`, syscall: 'write' });
}
const pathInfo = splitPath(pathOrFd);
if (!pathInfo.isAsar) return originalBinding.writeFileUtf8.apply(undefined, args);
const { asarPath, filePath } = pathInfo;
const opened = openAsarEntry(asarPath, filePath, flags, 'open', false);
if ('unpackedPath' in opened) {
args[0] = opened.unpackedPath;
return originalBinding.writeFileUtf8.apply(undefined, args);
}
// Only reachable with read-only flags, which writeFile never uses.
opened.reader.dispose();
throw createError(AsarError.NO_ACCESS, { asarPath, filePath, syscall: 'open' });
};
// The sentinel descriptor is a write-only null device: reads through
// anything but fs fail (EBADF), but writes, truncation and metadata changes
// would quietly succeed against the null device. Archives are read-only,
// so refuse all of those explicitly with EACCES. The sync write bindings
// still use the legacy (..., undefined, ctx) convention where errors are
// reported through `ctx` instead of being thrown.
const refuseMutation = (name: keyof typeof originalBinding) => {
const original = originalBinding[name] as Function;
binding[name] = function (...args: any[]) {
const fd = args[0];
if (lookupAsarFd(fd) === undefined) return original.apply(undefined, args);
const error = createError(AsarError.NO_ACCESS, { filePath: `fd ${fd}`, syscall: name });
const last = args[args.length - 1];
const isObject = last !== null && typeof last === 'object';
if (last === kUsePromises || (isObject && 'oncomplete' in last)) {
return completeRequest(last, error);
}
if (args.length >= 2 && args[args.length - 2] === undefined && isObject) {
// (…, undefined, ctx): report through ctx like the native sync path.
last.errno = error.errno;
last.code = error.code;
last.syscall = name;
return;
}
throw error;
};
};
for (const name of [
'fchmod',
'fchown',
'futimes',
'ftruncate',
'writeBuffer',
'writeBuffers',
'writeString'
] as const) {
refuseMutation(name);
}
//
// fs.copyFile / fs.copyFileSync / fs.promises.copyFile from a packed entry:
// stream the (validated) bytes straight into the destination instead of
// materialising a temporary copy first.
//
const kCopyChunkSize = 1024 * 1024;
const kMaxCopyFileMode = constants.COPYFILE_EXCL | constants.COPYFILE_FICLONE | constants.COPYFILE_FICLONE_FORCE;
function copyFileFlagsToOpenFlags(mode: number) {
return (
constants.O_WRONLY |
constants.O_CREAT |
((mode & constants.COPYFILE_EXCL) !== 0 ? constants.O_EXCL : constants.O_TRUNC)
);
}
function copyPackedFileSync(reader: AsarEntryReader, dest: string, mode: number) {
const destMode = reader.executable ? 0o755 : 0o644;
const destFd = originalBinding.open(dest, copyFileFlagsToOpenFlags(mode), destMode);
try {
const chunk = Buffer.allocUnsafeSlow(Math.min(kCopyChunkSize, Math.max(reader.size, 1)));
let position = 0;
while (position < reader.size) {
const n = reader.readSync(chunk, 0, Math.min(chunk.length, reader.size - position), position);
if (n === 0) break;
let written = 0;
while (written < n) {
// The synchronous writeBuffer binding still uses the legacy ctx
// calling convention, so go through fs.writeSync (not overridden here).
written += fs.writeSync(destFd, chunk, written, n - written);
}
position += n;
}
} finally {
originalBinding.close(destFd);
reader.dispose();
}
}
async function copyPackedFile(reader: AsarEntryReader, dest: string, mode: number) {
const destMode = reader.executable ? 0o755 : 0o644;
const destFd = await originalBinding.open(dest, copyFileFlagsToOpenFlags(mode), destMode, kUsePromises);
try {
const chunk = Buffer.allocUnsafeSlow(Math.min(kCopyChunkSize, Math.max(reader.size, 1)));
let position = 0;
while (position < reader.size) {
const n = await reader.read(chunk, 0, Math.min(chunk.length, reader.size - position), position);
if (n === 0) break;
let written = 0;
while (written < n) {
written += (await originalBinding.writeBuffer(destFd, chunk, written, n - written, null, kUsePromises)) || 0;
}
position += n;
}
} finally {
await originalBinding.close(destFd, kUsePromises);
reader.dispose();
}
}
binding.copyFile = function (...args: any[]) {
const src = args[0];
const dest = args[1];
const mode = args[2];
const req = args[3];
const pathInfo = splitPath(src);
if (!pathInfo.isAsar) return originalBinding.copyFile.apply(undefined, args);
const { asarPath, filePath } = pathInfo;
let opened;
const modeFlags = mode ?? 0;
try {
// Same validation as node's native CopyFile (GetValidFileMode).
if (mode !== null && mode !== undefined && (typeof mode !== 'number' || (mode | 0) !== mode)) {
throw new (errorCodes as any).ERR_INVALID_ARG_TYPE('mode', 'int32', mode);
}
if (modeFlags < 0 || modeFlags > kMaxCopyFileMode) {
throw new (errorCodes as any).ERR_OUT_OF_RANGE('mode', `>= 0 && <= ${kMaxCopyFileMode}`, mode);
}
opened = openAsarEntry(asarPath, filePath, constants.O_RDONLY, 'copyfile', false);
} catch (error) {
return completeRequest(req, error as Error);
}
if ('unpackedPath' in opened) {
// Unpacked entries are real files: let native fs handle them, including
// copy-on-write clones.
args[0] = opened.unpackedPath;
return originalBinding.copyFile.apply(undefined, args);
}
const { reader } = opened;
if ((modeFlags & constants.COPYFILE_FICLONE_FORCE) !== 0) {
// A packed entry can never be reflinked.
reader.dispose();
return completeRequest(req, createError(AsarError.NOT_SUPPORTED, { asarPath, filePath, syscall: 'copyfile' }));
}
if (req === undefined) {
copyPackedFileSync(reader, dest, mode ?? 0);
return;
}
return completeRequestWithPromise(req, copyPackedFile(reader, dest, mode ?? 0));
};
//
// fs.readlink / fs.readlinkSync / fs.promises.readlink for symbolic links
// stored in an archive. Archives store link targets relative to the
// archive root; what a real filesystem would report is the target relative
// to the link's own directory, so that is what gets returned (which also
// makes trees copied out with fs.cp({ verbatimSymlinks: true }) work).
//
binding.readlink = function (...args: any[]) {
const pathArgument = args[0];
const encoding = args[1];
const req = args[2];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return originalBinding.readlink.apply(undefined, args);
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) {
return completeRequest(req, createError(AsarError.INVALID_ARCHIVE, { asarPath, syscall: 'readlink' }));
}
const stats = archive.stat(filePath);
if (!stats) {
return completeRequest(req, createError(AsarError.NOT_FOUND, { asarPath, filePath, syscall: 'readlink' }));
}
if (stats.type !== AsarFileType.kLink) {
return completeRequest(req, createError(AsarError.NOT_LINK, { asarPath, filePath, syscall: 'readlink' }));
}
const linkTarget = archive.realpath(filePath);
if (linkTarget === false) {
return completeRequest(req, createError(AsarError.NOT_FOUND, { asarPath, filePath, syscall: 'readlink' }));
}
// Both sides are archive-relative; anchor them at a root so that the
// computation does not depend on the process's working directory.
const target = path.relative(path.join(path.sep, path.dirname(filePath)), path.join(path.sep, linkTarget)) || '.';
return completeRequest(req, null, encodeName(target, encoding));
};
//
// fs.opendir / fs.opendirSync / fs.promises.opendir on archive directories.
// Node's Dir class drives a native DirHandle through a small protocol
// (read(encoding, bufferSize[, req]) -> [name, type, name, type, ...] |
// null; close([req])); provide the same over the archive header. Because
// Dir also opens sub-directories through this binding when `recursive` is
// set, recursive iteration works too.
//
class AsarDirHandle {
private entries: [string, number][] | null;
constructor(entries: [string, number][]) {
this.entries = entries;
}
read(encoding: any, bufferSize: number, req?: any) {
let result: any[] | null = null;
if (this.entries !== null && this.entries.length > 0) {
const count = Math.max(1, bufferSize | 0);
const taken = this.entries.splice(0, count);
result = [];
for (const [name, type] of taken) {
result.push(encodeName(name, encoding), type);
}
}
return completeRequest(req, null, result);
}
close(req?: any) {
this.entries = null;
return completeRequest(req, null, undefined);
}
}
function openAsarDir(pathInfo: { asarPath: string; filePath: string }, req: any) {
const { asarPath, filePath } = pathInfo;
const archive = getOrCreateArchive(asarPath);
if (!archive) return completeRequest(req, createError(AsarError.INVALID_ARCHIVE, { asarPath, syscall: 'opendir' }));
// opendir follows symbolic links.
const resolved = resolveAsarLinks(archive, filePath);
if (!resolved) {
return completeRequest(req, createError(AsarError.NOT_FOUND, { asarPath, filePath, syscall: 'opendir' }));
}
if (resolved.stats.type !== AsarFileType.kDirectory) {
return completeRequest(req, createError(AsarError.NOT_DIR, { asarPath, filePath, syscall: 'opendir' }));
}
const listing = archive.readdirWithTypes(filePath);
if (!listing) {
return completeRequest(req, createError(AsarError.NOT_FOUND, { asarPath, filePath, syscall: 'opendir' }));
}
const { 0: names, 1: types } = listing;
const entries: [string, number][] = new Array(names.length);
for (let i = 0; i < names.length; i++) entries[i] = [names[i], types[i]];
return completeRequest(req, null, new AsarDirHandle(entries));
}
dirBinding.opendir = function (...args: any[]) {
const pathArgument = args[0];
const req = args[2];
const pathInfo = splitPath(pathArgument);
if (!pathInfo.isAsar) return originalDirBinding.opendir.apply(undefined, args);
return openAsarDir(pathInfo, req);
};
dirBinding.opendirSync = function (...args: any[]) {
const pathInfo = splitPath(args[0]);
if (!pathInfo.isAsar) return originalDirBinding.opendirSync.apply(undefined, args);
return openAsarDir(pathInfo, undefined);
};
//
// fs.cpSync from an archive. Node's cp() (async) is written on top of
// fs.promises and works against the overrides above; cpSync() calls into
// native helpers that resolve paths themselves, so those are given
// archive-aware equivalents.
//
const { ERR_FS_CP_EINVAL, ERR_FS_CP_DIR_TO_NON_DIR, ERR_FS_CP_NON_DIR_TO_DIR, ERR_FS_EISDIR, ERR_FS_CP_EEXIST } =
errorCodes as any;
const isSubdirectory = (parent: string, child: string) => {
const relative = path.relative(parent, child);
return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
};
binding.cpSyncCheckPaths = function (...args: any[]) {
const src = args[0];
const dest = args[1];
const dereference = args[2];
const recursive = args[3];
if (!splitPath(src).isAsar) return originalBinding.cpSyncCheckPaths.apply(undefined, args);
const statFn = dereference ? fs.statSync : fs.lstatSync;
const srcStat = statFn(src);
const destStat = statFn(dest, { throwIfNoEntry: false });
const srcIsDir = srcStat.isDirectory();
if (destStat) {
const destIsDir = destStat.isDirectory();
if (srcIsDir && !destIsDir) {
throw new ERR_FS_CP_DIR_TO_NON_DIR({
message: `Cannot overwrite non-directory ${dest} with directory ${src}`,
path: dest,
syscall: 'cp',
errno: -22,
code: 'EINVAL'
});
}
if (!srcIsDir && destIsDir) {
throw new ERR_FS_CP_NON_DIR_TO_DIR({
message: `Cannot overwrite directory ${dest} with non-directory ${src}`,
path: dest,
syscall: 'cp',
errno: -22,
code: 'EINVAL'
});
}
}
if (srcIsDir && isSubdirectory(path.resolve(src), path.resolve(dest))) {
throw new ERR_FS_CP_EINVAL({
message: `Cannot copy ${src} to a subdirectory of self ${dest}`,
path: dest,
syscall: 'cp',
errno: -22,
code: 'EINVAL'
});
}
if (srcIsDir && !recursive) {
throw new ERR_FS_EISDIR({
message: `Recursive option not enabled, cannot copy a directory: ${src}`,
path: src,
syscall: 'cp',
errno: -21,
code: 'EISDIR'
});
}
};
binding.cpSyncOverrideFile = function (...args: any[]) {
const src = args[0];
const dest = args[1];
const mode = args[2];
const preserveTimestamps = args[3];
if (!splitPath(src).isAsar) return originalBinding.cpSyncOverrideFile.apply(undefined, args);
fs.unlinkSync(dest);
fs.copyFileSync(src, dest, mode);
if (preserveTimestamps) {
const srcStat = fs.statSync(src);
fs.utimesSync(dest, srcStat.atime, srcStat.mtime);
}
};
binding.cpSyncCopyDir = function (...args: any[]) {
const src = args[0];
const dest = args[1];
const force = args[2];
const dereference = args[3];
const errorOnExist = args[4];
const verbatimSymlinks = args[5];
const preserveTimestamps = args[6];
if (!splitPath(src).isAsar) return originalBinding.cpSyncCopyDir.apply(undefined, args);
const copyDir = (srcDir: string, destDir: string) => {
fs.mkdirSync(destDir, { recursive: true });
for (const dirent of fs.readdirSync(srcDir, { withFileTypes: true }) as Dirent[]) {
const srcItem = path.join(srcDir, dirent.name);
const destItem = path.join(destDir, dirent.name);
let isDirectory = dirent.isDirectory();
if (dirent.isSymbolicLink()) {
if (!dereference) {
let target = fs.readlinkSync(srcItem);
if (!verbatimSymlinks && !path.isAbsolute(target)) target = path.resolve(srcDir, target);
// Like node's native implementation: an existing symlink at the
// destination is replaced, anything else is an error.
const existing = fs.lstatSync(destItem, { throwIfNoEntry: false });
if (existing) {
if (!existing.isSymbolicLink()) {
throw createError(AsarError.EXISTS, { asarPath: dest, filePath: dirent.name, syscall: 'cp' });
}
fs.unlinkSync(destItem);
}
fs.symlinkSync(target, destItem);
continue;
}
isDirectory = fs.statSync(srcItem).isDirectory();
}
if (isDirectory) {
copyDir(srcItem, destItem);
continue;
}
const destStat = fs.lstatSync(destItem, { throwIfNoEntry: false });
if (destStat) {
if (!force) {
if (errorOnExist) {
throw new ERR_FS_CP_EEXIST({
message: `${destItem} already exists`,
path: destItem,
syscall: 'cp',
errno: -17,
code: 'EEXIST'
});
}
continue;
}
fs.unlinkSync(destItem);
}
fs.copyFileSync(srcItem, destItem);
if (preserveTimestamps) {
const srcStat = fs.statSync(srcItem);
fs.utimesSync(destItem, srcStat.atime, srcStat.mtime);
}
}
};
copyDir(src, dest);
};
// Native modules and child processes need a real file on disk, so these
// are the only remaining consumers of Archive::CopyFileOut.
overrideAPISync(process, 'dlopen', 1);
overrideAPISync(Module._extensions, '.node', 1);
const overrideChildProcess = (childProcess: Record<string, any>) => {
// Executing a command string containing a path to an asar archive
// confuses `childProcess.execFile`, which is internally called by
// `childProcess.{exec,execSync}`, causing Electron to consider the full
// command as a single path to an archive.
const { exec, execSync } = childProcess;
childProcess.exec = invokeWithNoAsar(exec);
childProcess.exec[util.promisify.custom] = assignFunctionName(
'exec',
invokeWithNoAsar(exec[util.promisify.custom])
);
childProcess.execSync = invokeWithNoAsar(execSync);
overrideAPI(childProcess, 'execFile');
overrideAPISync(childProcess, 'execFileSync');
};
// `lib/node/init.ts` requires child_process straight after this runs (to
// hook `fork`), so there is nothing to gain from deferring this behind a
// Module._load hook -- and such a hook taxes every require() and is never
// reached by `import ... from 'node:child_process'`.
overrideChildProcess(require('child_process'));
};
function getDirents(p: string, { 0: names, 1: types }: any[][]): Dirent[] {
for (let i = 0; i < names.length; i++) {
let type = types[i];
const info = splitPath(path.join(p, names[i]));
if (info.isAsar) {
const archive = getOrCreateArchive(info.asarPath);
if (!archive) continue;
const stats = archive.stat(info.filePath);
if (!stats) continue;
type = stats.type;
}
names[i] = getDirent(p, names[i], type);
}
return names;
}