// SPDX-License-Identifier: AGPL-3.0-or-later import {Buffer} from 'node:buffer'; import fs from 'node:fs'; import path from 'node:path'; import {Readable} from 'node:stream'; import {resolveAsarUnpackedPath} from '@electron/common/ModuleSystem'; import {isServedLocalAppExtension, localAppCacheControl, localAppContentType} from '@electron/main/LocalAppMime'; import {isLocalAppURL} from '@electron/main/LocalAppURL'; import {app} from 'electron'; const INDEX_HTML = 'index.html'; const RENDERER_DIRECTORY_SEGMENTS: ReadonlyArray = Object.freeze(['dist', 'renderer']); const NOT_FOUND_ERROR_CODE = 'ENOENT'; const LOCAL_APP_STREAMED_RESPONSES_IN_FLIGHT_MAX = 512; const LOCAL_APP_BUFFERED_RESPONSE_MAX_BYTES = 256 * 1024; const LOCAL_APP_BUFFERED_RESPONSE_BUDGET_BYTES = 64 * 1024 * 1024; export const LocalAppFileResolution = Object.freeze({ FILE: 'file', NOT_FOUND: 'not-found', BLOCKED: 'blocked', } as const); export type LocalAppFileResolution = (typeof LocalAppFileResolution)[keyof typeof LocalAppFileResolution]; export const LocalAppFileBlockReason = Object.freeze({ INVALID_ORIGIN: 'invalid-origin', INVALID_PATH: 'invalid-path', PATH_ESCAPE: 'path-escape', } as const); export type LocalAppFileBlockReason = (typeof LocalAppFileBlockReason)[keyof typeof LocalAppFileBlockReason]; export interface LocalAppFile { readonly type: typeof LocalAppFileResolution.FILE; readonly filePath: string; readonly size: number; readonly cacheControl: string; readonly contentType: string; } export type LocalAppResolvedFile = | LocalAppFile | {readonly type: typeof LocalAppFileResolution.NOT_FOUND} | {readonly type: typeof LocalAppFileResolution.BLOCKED; readonly reason: LocalAppFileBlockReason}; interface DesktopLocalAppFilesOptions { readonly rendererRoot?: string; } interface LocalAppModuleIndex { readonly root: string; readonly files: ReadonlyMap; } interface MissingLocalRendererIndexErrorContext { readonly root: string; } class MissingLocalRendererIndexError extends Error { constructor({root}: MissingLocalRendererIndexErrorContext) { super(`Local renderer root is missing ${INDEX_HTML}: ${root}`); this.name = 'MissingLocalRendererIndexError'; } } class LocalRendererFileChangedDuringOpenError extends Error { constructor() { super('Local renderer file changed while it was being opened'); this.name = 'LocalRendererFileChangedDuringOpenError'; } } interface LocalRendererFileTooLargeErrorContext { readonly filePath: string; readonly maxBytes: number; } class LocalRendererFileTooLargeError extends RangeError { constructor({filePath, maxBytes}: LocalRendererFileTooLargeErrorContext) { super(`Local renderer file exceeds ${maxBytes} bytes: ${filePath}`); this.name = 'LocalRendererFileTooLargeError'; } } class LocalRendererRewrittenFileTooLargeError extends RangeError { constructor(actualBytes: number, maxBytes: number) { super(`Rewritten local renderer file is ${actualBytes} bytes and exceeds the ${maxBytes} byte limit`); this.name = 'LocalRendererRewrittenFileTooLargeError'; } } class InvalidLocalRendererFileSizeError extends RangeError { constructor() { super('Local renderer file has an invalid size'); this.name = 'InvalidLocalRendererFileSizeError'; } } class InvalidLocalRendererReservationError extends RangeError { constructor(amount: number) { super(`Local renderer resource reservation is not a positive integer: ${amount}`); this.name = 'InvalidLocalRendererReservationError'; } } class LocalRendererFileResponseStateInvariantError extends Error { constructor() { super('Local renderer response ownership was transferred more than once'); this.name = 'LocalRendererFileResponseStateInvariantError'; } } class LocalAppResourcePermit { private releaseResource: (() => void) | null; constructor(releaseResource: () => void) { this.releaseResource = releaseResource; } release(): void { const releaseResource = this.releaseResource; if (releaseResource == null) { return; } this.releaseResource = null; releaseResource(); } } interface LocalAppResourceWaiter { readonly amount: number; readonly admit: (permit: LocalAppResourcePermit) => void; } export class LocalAppResourceBudget { private used = 0; private draining = false; private readonly limit: number; private readonly waiting: Array = []; constructor(limit: number) { this.limit = limit; } get inUse(): number { return this.used; } get waitingCount(): number { return this.waiting.length; } acquire(amount: number, signal: AbortSignal): Promise { if (!Number.isSafeInteger(amount) || amount <= 0) { return Promise.reject(new InvalidLocalRendererReservationError(amount)); } if (signal.aborted) { return Promise.reject(signal.reason); } const required = Math.min(amount, this.limit); if (this.waiting.length === 0 && required <= this.limit - this.used) { return Promise.resolve(this.grant(required)); } return new Promise((resolve, reject) => { let waiter: LocalAppResourceWaiter; const onAbort = (): void => { const index = this.waiting.indexOf(waiter); if (index === -1) { return; } this.waiting.splice(index, 1); reject(signal.reason); this.drain(); }; const admit = (permit: LocalAppResourcePermit): void => { signal.removeEventListener('abort', onAbort); if (signal.aborted) { permit.release(); reject(signal.reason); return; } resolve(permit); }; waiter = {amount: required, admit}; this.waiting.push(waiter); signal.addEventListener('abort', onAbort, {once: true}); }); } private grant(amount: number): LocalAppResourcePermit { this.used += amount; return new LocalAppResourcePermit(() => { this.used -= amount; this.drain(); }); } private drain(): void { if (this.draining) { return; } this.draining = true; try { while (this.waiting.length > 0) { const waiter = this.waiting[0]; if (waiter == null || waiter.amount > this.limit - this.used) { return; } this.waiting.shift(); waiter.admit(this.grant(waiter.amount)); } } finally { this.draining = false; } } } class LocalAppFileResponseReservation { private permits: ReadonlyArray | null; constructor(permits: ReadonlyArray) { this.permits = permits; } release(): void { const permits = this.permits; if (permits == null) { return; } this.permits = null; for (const permit of permits) { permit.release(); } } transfer(): LocalAppFileResponseReservation { if (this.permits == null) { throw new LocalRendererFileResponseStateInvariantError(); } const transferred = new LocalAppFileResponseReservation(this.permits); this.permits = null; return transferred; } } export interface LocalAppPreparedResponseBody { readonly body: ReadableStream | null; readonly byteLength: number; } export interface LocalAppBufferedResponseFile { readonly contents: Buffer; prepareResponseBody(body: string, includeBody: boolean): LocalAppPreparedResponseBody; release(): void; } class BufferedLocalAppResponseFile implements LocalAppBufferedResponseFile { readonly contents: Buffer; private readonly maxBytes: number; private readonly reservation: LocalAppFileResponseReservation; private readonly signal: AbortSignal; constructor(contents: Buffer, maxBytes: number, reservation: LocalAppFileResponseReservation, signal: AbortSignal) { this.contents = contents; this.maxBytes = maxBytes; this.reservation = reservation; this.signal = signal; } prepareResponseBody(body: string, includeBody: boolean): LocalAppPreparedResponseBody { const byteLength = Buffer.byteLength(body); if (byteLength > this.maxBytes) { throw new LocalRendererRewrittenFileTooLargeError(byteLength, this.maxBytes); } if (!includeBody) { return {body: null, byteLength}; } const bytes = Buffer.from(body); return { body: singleChunkResponseBody(bytes, this.reservation.transfer(), this.signal), byteLength, }; } release(): void { this.reservation.release(); } } export class DesktopLocalAppFiles { private readonly configuredRendererRoot: string | null; private readonly streamSlots = new LocalAppResourceBudget(LOCAL_APP_STREAMED_RESPONSES_IN_FLIGHT_MAX); private readonly bufferedBytes = new LocalAppResourceBudget(LOCAL_APP_BUFFERED_RESPONSE_BUDGET_BYTES); private rendererRootInitialization: Promise | null = null; private moduleIndex: LocalAppModuleIndex | null = null; constructor(options?: DesktopLocalAppFilesOptions) { this.configuredRendererRoot = options?.rendererRoot ?? null; } setModuleIndex(moduleIndex: LocalAppModuleIndex | null): void { this.moduleIndex = moduleIndex; } async resolve(requestURL: string): Promise { if (!isLocalAppURL(requestURL)) { return {type: LocalAppFileResolution.BLOCKED, reason: LocalAppFileBlockReason.INVALID_ORIGIN}; } const relativePath = getSafeRelativePath(new URL(requestURL).pathname); if (relativePath == null) { return {type: LocalAppFileResolution.BLOCKED, reason: LocalAppFileBlockReason.INVALID_PATH}; } const moduleFile = await this.resolveModuleFile(relativePath); if (moduleFile != null) { return moduleFile; } const root = await this.resolveRendererRoot(); if (root == null && (this.moduleIndex?.files.size ?? 0) === 0) { throw new MissingLocalRendererIndexError({root: this.rendererRootPath()}); } if (root != null) { const requestedPath = path.resolve(root, relativePath); if (!isPathInsideRoot(root, requestedPath)) { return {type: LocalAppFileResolution.BLOCKED, reason: LocalAppFileBlockReason.PATH_ESCAPE}; } const requestedFile = await statFile(requestedPath); if (requestedFile != null) { return describeFile(requestedPath, requestedFile.size); } } if (isServedLocalAppExtension(relativePath)) { return {type: LocalAppFileResolution.NOT_FOUND}; } const moduleIndexFile = await this.resolveModuleFile(INDEX_HTML); if (moduleIndexFile != null) { return moduleIndexFile; } if (root == null) { return {type: LocalAppFileResolution.NOT_FOUND}; } const indexPath = path.join(root, INDEX_HTML); const indexFile = await statFile(indexPath); if (indexFile == null) { throw new MissingLocalRendererIndexError({root}); } return describeFile(indexPath, indexFile.size); } async readForResponse( filePath: string, maxBytes: number, signal: AbortSignal, ): Promise { signal.throwIfAborted(); const contents = await readLocalRendererFile(filePath, maxBytes); return new BufferedLocalAppResponseFile(contents, maxBytes, new LocalAppFileResponseReservation([]), signal); } async openStream(filePath: string, expectedSize: number, signal: AbortSignal): Promise> { signal.throwIfAborted(); if (expectedSize <= LOCAL_APP_BUFFERED_RESPONSE_MAX_BYTES) { return await this.openBufferedBody(filePath, expectedSize, signal); } const responseSlot = await this.streamSlots.acquire(1, signal); let file: fs.promises.FileHandle | null = null; try { signal.throwIfAborted(); file = await fs.promises.open(filePath, readOnlyOpenFlags()); const stats = await file.stat(); if (!stats.isFile() || stats.size !== expectedSize) { throw new LocalRendererFileChangedDuringOpenError(); } const stream = Readable.toWeb(file.createReadStream({signal})) as unknown as ReadableStream; file = null; return holdReservationForStream(stream, new LocalAppFileResponseReservation([responseSlot]), signal); } catch (error) { let failure = error; if (file != null) { try { await file.close(); } catch (closeError) { failure = new AggregateError([error, closeError], 'Local renderer file open and cleanup failed'); } } responseSlot.release(); throw failure; } } isIndexFile(filePath: string): boolean { return path.basename(filePath) === INDEX_HTML; } private async openBufferedBody( filePath: string, expectedSize: number, signal: AbortSignal, ): Promise> { const bufferPermit = await this.bufferedBytes.acquire(Math.max(1, expectedSize), signal); try { signal.throwIfAborted(); const contents = await readExactLocalRendererFile(filePath, expectedSize); return singleChunkResponseBody(contents, new LocalAppFileResponseReservation([bufferPermit]), signal); } catch (error) { bufferPermit.release(); throw error; } } private async resolveModuleFile(relativePath: string): Promise { const moduleIndex = this.moduleIndex; if (moduleIndex == null) { return null; } const modulePath = moduleIndex.files.get(getModuleIndexKey(relativePath)); if (modulePath == null) { return null; } const requestedPath = path.resolve(modulePath); if (!isPathInsideRoot(moduleIndex.root, requestedPath)) { return {type: LocalAppFileResolution.BLOCKED, reason: LocalAppFileBlockReason.PATH_ESCAPE}; } const requestedFile = await statFile(requestedPath); if (requestedFile == null) { return null; } return describeFile(requestedPath, requestedFile.size); } private rendererRootPath(): string { return ( this.configuredRendererRoot ?? resolveAsarUnpackedPath(path.join(app.getAppPath(), ...RENDERER_DIRECTORY_SEGMENTS)) ); } private resolveRendererRoot(): Promise { this.rendererRootInitialization ??= resolveOfflineRendererRoot(this.rendererRootPath()).catch((error: unknown) => { this.rendererRootInitialization = null; throw error; }); return this.rendererRootInitialization; } } async function readLocalRendererFile(filePath: string, maxBytes: number): Promise { const file = await fs.promises.open(filePath, readOnlyOpenFlags()); try { const stats = await file.stat(); if (!stats.isFile()) { throw new LocalRendererFileChangedDuringOpenError(); } if (!Number.isSafeInteger(stats.size) || stats.size < 0) { throw new InvalidLocalRendererFileSizeError(); } if (stats.size > maxBytes) { throw new LocalRendererFileTooLargeError({filePath, maxBytes}); } return await readFileContents(file, stats.size); } finally { await file.close(); } } async function readExactLocalRendererFile(filePath: string, expectedSize: number): Promise { const file = await fs.promises.open(filePath, readOnlyOpenFlags()); try { const stats = await file.stat(); if (!stats.isFile() || stats.size !== expectedSize) { throw new LocalRendererFileChangedDuringOpenError(); } return await readFileContents(file, expectedSize); } finally { await file.close(); } } async function readFileContents(file: fs.promises.FileHandle, size: number): Promise { const contents = Buffer.alloc(size); let offset = 0; while (offset < size) { const {bytesRead} = await file.read(contents, offset, size - offset, offset); if (bytesRead === 0) { throw new LocalRendererFileChangedDuringOpenError(); } offset += bytesRead; } return contents; } function singleChunkResponseBody( bytes: Uint8Array, reservation: LocalAppFileResponseReservation, signal: AbortSignal, ): ReadableStream { let enqueued = false; let settled = false; let controller: ReadableStreamDefaultController | null = null; const settle = (): void => { if (settled) { return; } settled = true; signal.removeEventListener('abort', onAbort); reservation.release(); }; const onAbort = (): void => { if (settled) { return; } try { controller?.error(signal.reason); } finally { settle(); } }; return new ReadableStream({ start(streamController) { controller = streamController; if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, {once: true}); }, pull(streamController) { if (settled) { return; } try { if (!enqueued) { enqueued = true; streamController.enqueue(bytes); return; } streamController.close(); } catch (error) { settle(); throw error; } settle(); }, cancel() { settle(); }, }); } function holdReservationForStream( stream: ReadableStream, reservation: LocalAppFileResponseReservation, signal: AbortSignal, ): ReadableStream { const reader = stream.getReader(); let settled = false; let controller: ReadableStreamDefaultController | null = null; const settle = (): void => { if (settled) { return; } settled = true; signal.removeEventListener('abort', onAbort); reservation.release(); }; const onAbort = (): void => { if (settled) { return; } try { controller?.error(signal.reason); } finally { settle(); } }; return new ReadableStream({ start(streamController) { controller = streamController; if (signal.aborted) { onAbort(); return; } signal.addEventListener('abort', onAbort, {once: true}); }, async pull(streamController) { if (settled) { return; } try { const chunk = await reader.read(); if (settled) { return; } if (chunk.done) { try { streamController.close(); } finally { settle(); } return; } streamController.enqueue(chunk.value); } catch (error) { if (!settled) { try { streamController.error(error); } finally { settle(); } } } }, async cancel(reason) { try { await reader.cancel(reason); } finally { settle(); } }, }); } function describeFile(filePath: string, size: number): LocalAppFile { return { type: LocalAppFileResolution.FILE, filePath, size, cacheControl: localAppCacheControl(filePath), contentType: localAppContentType(filePath), }; } function getModuleIndexKey(relativePath: string): string { return path.sep === '/' ? relativePath : relativePath.split(path.sep).join('/'); } function readOnlyOpenFlags(): number { const noFollow = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0; return fs.constants.O_RDONLY | noFollow; } function getSafeRelativePath(rawPathname: string): string | null { let decodedPathname: string; try { decodedPathname = decodeURIComponent(rawPathname); } catch { return null; } if (decodedPathname.includes('\0') || decodedPathname.includes('\\')) { return null; } const strippedPathname = decodedPathname.replace(/^\/+/u, ''); if (strippedPathname.length === 0) { return INDEX_HTML; } const normalized = path.normalize(strippedPathname); if (normalized === '.' || normalized === '..' || normalized.startsWith(`..${path.sep}`)) { return null; } if (path.isAbsolute(normalized)) { return null; } return normalized; } async function resolveOfflineRendererRoot(root: string): Promise { const indexFile = await statFile(path.join(root, INDEX_HTML)); return indexFile == null ? null : root; } async function statFile(filePath: string): Promise { let stats: fs.Stats; try { stats = await fs.promises.lstat(filePath); } catch (error) { if (isNotFoundError(error)) { return null; } throw error; } if (!stats.isFile() || stats.isSymbolicLink()) { return null; } if (!Number.isSafeInteger(stats.size) || stats.size < 0) { throw new InvalidLocalRendererFileSizeError(); } return stats; } function isNotFoundError(error: unknown): boolean { if (typeof error !== 'object' || error === null || !('code' in error)) { return false; } return error.code === NOT_FOUND_ERROR_CODE; } function isPathInsideRoot(root: string, candidate: string): boolean { const relative = path.relative(root, candidate); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); }