mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
702 lines
20 KiB
TypeScript
702 lines
20 KiB
TypeScript
// 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<string> = 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<string, string>;
|
|
}
|
|
|
|
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<LocalAppResourceWaiter> = [];
|
|
|
|
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<LocalAppResourcePermit> {
|
|
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<LocalAppResourcePermit>((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<LocalAppResourcePermit> | null;
|
|
|
|
constructor(permits: ReadonlyArray<LocalAppResourcePermit>) {
|
|
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<Uint8Array> | 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<string | null> | 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<LocalAppResolvedFile> {
|
|
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<LocalAppBufferedResponseFile> {
|
|
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<ReadableStream<Uint8Array>> {
|
|
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<Uint8Array>;
|
|
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<ReadableStream<Uint8Array>> {
|
|
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<LocalAppResolvedFile | null> {
|
|
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<string | null> {
|
|
this.rendererRootInitialization ??= resolveOfflineRendererRoot(this.rendererRootPath()).catch((error: unknown) => {
|
|
this.rendererRootInitialization = null;
|
|
throw error;
|
|
});
|
|
return this.rendererRootInitialization;
|
|
}
|
|
}
|
|
|
|
async function readLocalRendererFile(filePath: string, maxBytes: number): Promise<Buffer> {
|
|
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<Buffer> {
|
|
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<Buffer> {
|
|
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<Uint8Array> {
|
|
let enqueued = false;
|
|
let settled = false;
|
|
let controller: ReadableStreamDefaultController<Uint8Array> | 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<Uint8Array>({
|
|
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<Uint8Array>,
|
|
reservation: LocalAppFileResponseReservation,
|
|
signal: AbortSignal,
|
|
): ReadableStream<Uint8Array> {
|
|
const reader = stream.getReader();
|
|
let settled = false;
|
|
let controller: ReadableStreamDefaultController<Uint8Array> | 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<Uint8Array>({
|
|
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<string | null> {
|
|
const indexFile = await statFile(path.join(root, INDEX_HTML));
|
|
return indexFile == null ? null : root;
|
|
}
|
|
|
|
async function statFile(filePath: string): Promise<fs.Stats | null> {
|
|
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));
|
|
}
|