mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
Give snapshot capture, header admission, packed members, and container streams one file-handle owner. Serialize adoption with reader shutdown, retain failed closes for retry, and remove snapshots only after descriptor cleanup succeeds. Cover unread members, pending opens, zero-byte members, and paired format/cleanup failures. Keep intentionally unrecoverable test fixtures hygienic and strengthen managed identity and process cleanup-authority expectations. Validation: full npm run check; 99 targeted tests and temporary-directory hygiene; exact discovery of 877 test files. No real vendor-provider verification was performed.
762 lines
24 KiB
TypeScript
762 lines
24 KiB
TypeScript
import { constants, createWriteStream, type Stats } from "node:fs";
|
|
import {
|
|
lstat,
|
|
mkdtemp,
|
|
open,
|
|
realpath,
|
|
rm,
|
|
type FileHandle,
|
|
} from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { createHash } from "node:crypto";
|
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
import { Readable, Transform } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
|
|
import { listPackage, statFile, uncache } from "@electron/asar";
|
|
|
|
import {
|
|
ArtifactReaderFailure,
|
|
copyArtifactEntry,
|
|
sameArtifactEntry,
|
|
type ArtifactEntry,
|
|
type ArtifactReader,
|
|
} from "./ArtifactReader.js";
|
|
import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
|
import { admitAsarHeader } from "./AsarHeader.js";
|
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
|
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
|
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
|
|
|
/**
|
|
* Official Electron ASAR adapter with range-streamed member reads.
|
|
*
|
|
* An entry marked `unpacked` is metadata, not an integrity exemption: Electron
|
|
* stores its bytes beside the archive in `<archive>.unpacked`, and callers must
|
|
* hash those companion bytes against the archive's declared integrity value.
|
|
*/
|
|
export class AsarArtifactReader implements ArtifactReader {
|
|
readonly format = "asar" as const;
|
|
#archiveSize: number | undefined;
|
|
#headerSize: number | undefined;
|
|
readonly #entries = new Map<string, AsarEntryState>();
|
|
readonly #ownedHandles = new Map<SharedAsarFileHandle, OwnedAsarHandle>();
|
|
#snapshot: { readonly path: string; readonly sha256: string } | undefined;
|
|
#snapshotRoot: string | undefined;
|
|
#snapshotOperation: Promise<void> = Promise.resolve();
|
|
|
|
constructor(
|
|
private readonly path: string,
|
|
private readonly options: AsarArtifactReaderOptions = {},
|
|
) {}
|
|
|
|
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
|
let paths: readonly AsarListedPath[];
|
|
try {
|
|
await this.prepareContainer(undefined, signal);
|
|
paths = await this.#withSnapshotOperation(async () => {
|
|
const snapshotPath = this.#snapshotPath();
|
|
this.#resetArchiveState();
|
|
this.#entries.clear();
|
|
const admitted = await this.#admitHeader(snapshotPath);
|
|
this.#archiveSize = admitted.archiveBytes;
|
|
this.#headerSize = admitted.headerBytes;
|
|
abortIfNeeded(signal);
|
|
const ordered = orderAsarListing(
|
|
listPackage(snapshotPath, { isPack: false }),
|
|
);
|
|
const archiveMetadata = await lstat(snapshotPath);
|
|
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"ASAR container is not a regular file",
|
|
);
|
|
this.#archiveSize = archiveMetadata.size;
|
|
return ordered;
|
|
}, signal);
|
|
} catch (cause: unknown) {
|
|
throw asarFailure(this.path, "inventory", cause);
|
|
}
|
|
for (const { listed, path } of paths) {
|
|
abortIfNeeded(signal);
|
|
if (path.length === 0) continue;
|
|
const providerPath = listed.startsWith(sep)
|
|
? listed.slice(sep.length)
|
|
: listed;
|
|
let metadata: ReturnType<typeof statFile>;
|
|
try {
|
|
metadata = statFile(this.#snapshotPath(), providerPath, false);
|
|
} catch (cause: unknown) {
|
|
throw asarFailure(this.path, `stat ${path}`, cause);
|
|
}
|
|
const kind =
|
|
"files" in metadata
|
|
? "directory"
|
|
: "link" in metadata
|
|
? "symlink"
|
|
: "file";
|
|
const entry: ArtifactEntry = {
|
|
path,
|
|
kind,
|
|
declaredSize: "size" in metadata ? metadata.size : null,
|
|
compressedSize: null,
|
|
executable: "executable" in metadata && metadata.executable,
|
|
encrypted: false,
|
|
byteOffset: null,
|
|
declaredSha256:
|
|
"integrity" in metadata &&
|
|
metadata.integrity.algorithm === "SHA256" &&
|
|
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
|
? metadata.integrity.hash
|
|
: null,
|
|
unpacked: "unpacked" in metadata && metadata.unpacked === true,
|
|
limitations:
|
|
kind === "symlink"
|
|
? ["ASAR symlink target was not followed or disclosed."]
|
|
: [],
|
|
adapterKey: providerPath,
|
|
};
|
|
if (kind === "file" && isAsarFileMetadata(metadata))
|
|
this.#entries.set(providerPath, {
|
|
metadata,
|
|
entry: copyArtifactEntry(entry),
|
|
});
|
|
yield entry;
|
|
}
|
|
}
|
|
|
|
async #admitHeader(snapshotPath: string) {
|
|
const headerHandle = await this.#openOwned(
|
|
snapshotPath,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
);
|
|
let admission:
|
|
| {
|
|
readonly ok: true;
|
|
readonly value: Awaited<ReturnType<typeof admitAsarHeader>>;
|
|
}
|
|
| { readonly ok: false; readonly cause: unknown };
|
|
try {
|
|
admission = { ok: true, value: await admitAsarHeader(headerHandle) };
|
|
} catch (cause: unknown) {
|
|
admission = { ok: false, cause };
|
|
}
|
|
try {
|
|
await this.#closeOwnedHandle(headerHandle);
|
|
} catch (cleanupCause: unknown) {
|
|
if (!admission.ok)
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
admission.cause,
|
|
ArtifactReaderFailure.cleanupObservation(cleanupCause, snapshotPath),
|
|
);
|
|
throw cleanupCause;
|
|
}
|
|
if (!admission.ok) throw admission.cause;
|
|
return admission.value;
|
|
}
|
|
|
|
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
|
return this.#withSnapshotOperation(
|
|
() => this.#openEntry(entry, signal),
|
|
signal,
|
|
);
|
|
}
|
|
|
|
async #openEntry(
|
|
entry: ArtifactEntry,
|
|
signal?: AbortSignal,
|
|
): Promise<Readable> {
|
|
abortIfNeeded(signal);
|
|
const state = this.#entries.get(entry.adapterKey);
|
|
if (state === undefined && entry.kind !== "file")
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
"ASAR entry is not a regular file",
|
|
);
|
|
if (state === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`ASAR entry was not produced by this reader: ${entry.path}`,
|
|
);
|
|
if (!sameArtifactEntry(entry, state.entry))
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`ASAR entry metadata changed since inventory: ${state.entry.path}`,
|
|
);
|
|
if (state.entry.unpacked)
|
|
return this.#openUnpackedStream(state.entry, signal);
|
|
return this.#openPackedStream(state, signal);
|
|
}
|
|
|
|
async #openPackedStream(
|
|
state: AsarEntryState,
|
|
signal?: AbortSignal,
|
|
): Promise<Readable> {
|
|
const { metadata, entry: producedEntry } = state;
|
|
const archiveSize = this.#archiveSize;
|
|
const headerSize = this.#headerSize;
|
|
const offset =
|
|
metadata.offset === undefined
|
|
? undefined
|
|
: parseArchiveOffset(metadata.offset);
|
|
const start =
|
|
offset === undefined || headerSize === undefined
|
|
? undefined
|
|
: 8 + headerSize + offset;
|
|
if (
|
|
archiveSize === undefined ||
|
|
start === undefined ||
|
|
!Number.isSafeInteger(start) ||
|
|
metadata.size < 0 ||
|
|
!Number.isSafeInteger(metadata.size) ||
|
|
start > archiveSize ||
|
|
metadata.size > archiveSize - start
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"format",
|
|
`ASAR entry range is outside its container: ${producedEntry.path}`,
|
|
);
|
|
const snapshotPath = this.#snapshotPath();
|
|
let handle: SharedAsarFileHandle | undefined;
|
|
try {
|
|
const openedHandle = await this.#openOwned(
|
|
snapshotPath,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
);
|
|
handle = openedHandle;
|
|
const observed = await openedHandle.stat();
|
|
abortIfNeeded(signal);
|
|
if (!observed.isFile() || observed.size !== archiveSize)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`ASAR container changed before read: ${producedEntry.path}`,
|
|
);
|
|
if (metadata.size === 0) {
|
|
handle = undefined;
|
|
await closeAsarHandle(
|
|
() => this.#closeOwnedHandle(openedHandle),
|
|
signal,
|
|
);
|
|
return Readable.from([]);
|
|
}
|
|
const source = this.#ownedReadStream(openedHandle, {
|
|
start,
|
|
end: start + metadata.size - 1,
|
|
signal,
|
|
});
|
|
handle = undefined;
|
|
const output = readValidatedAsarEntry(
|
|
source,
|
|
metadata.size,
|
|
producedEntry.path,
|
|
this.path,
|
|
signal,
|
|
);
|
|
this.#setOwnedOutput(openedHandle, output);
|
|
return output;
|
|
} catch (cause: unknown) {
|
|
const failure = asarFailure(
|
|
this.path,
|
|
`read ${producedEntry.path}`,
|
|
cause,
|
|
);
|
|
if (handle !== undefined) {
|
|
try {
|
|
await this.#closeOwnedHandle(handle);
|
|
} catch (cleanupCause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
failure,
|
|
ArtifactReaderFailure.cleanupObservation(
|
|
cleanupCause,
|
|
snapshotPath,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
throw failure;
|
|
}
|
|
}
|
|
|
|
/** Capture once per reader lifetime; bind all interpretation to these exact bytes. */
|
|
async prepareContainer(
|
|
expectedSha256?: string,
|
|
signal?: AbortSignal,
|
|
): Promise<void> {
|
|
await this.#withSnapshotOperation(async () => {
|
|
abortIfNeeded(signal);
|
|
if (this.#snapshot === undefined && this.#snapshotRoot !== undefined)
|
|
await this.#closeSnapshot();
|
|
if (this.#snapshot === undefined) await this.#captureSnapshot(signal);
|
|
if (
|
|
expectedSha256 !== undefined &&
|
|
this.#snapshot?.sha256 !== expectedSha256
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`ASAR container changed before interpretation: ${this.path}`,
|
|
);
|
|
}, signal);
|
|
}
|
|
|
|
/** Read the same captured container that supplies headers and packed members. */
|
|
async openContainer(signal?: AbortSignal): Promise<Readable> {
|
|
await this.prepareContainer(undefined, signal);
|
|
return this.#withSnapshotOperation(async () => {
|
|
abortIfNeeded(signal);
|
|
const snapshotPath = this.#snapshotPath();
|
|
const handle = await this.#openOwned(
|
|
snapshotPath,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
);
|
|
return this.#ownedReadStream(handle, { signal });
|
|
}, signal);
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
await this.#withSnapshotOperation(() => this.#closeSnapshot());
|
|
}
|
|
|
|
async #captureSnapshot(signal?: AbortSignal): Promise<void> {
|
|
let inputHandle: SharedAsarFileHandle | undefined;
|
|
try {
|
|
this.#snapshotRoot = await mkdtemp(join(tmpdir(), "rea-asar-snapshot-"));
|
|
const snapshotPath = join(this.#snapshotRoot, "container.asar");
|
|
const hash = createHash("sha256");
|
|
inputHandle = await this.#openOwned(
|
|
this.path,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
);
|
|
const source = this.#ownedReadStream(inputHandle, {
|
|
signal,
|
|
releaseOnClose: false,
|
|
});
|
|
const digesting = new Transform({
|
|
transform(chunk: Buffer, _encoding, callback) {
|
|
hash.update(chunk);
|
|
callback(null, chunk);
|
|
},
|
|
});
|
|
await pipeline(
|
|
source,
|
|
digesting,
|
|
createWriteStream(snapshotPath, { flags: "wx", mode: 0o600 }),
|
|
signal === undefined ? {} : { signal },
|
|
);
|
|
const completedInput = inputHandle;
|
|
inputHandle = undefined;
|
|
await this.#closeOwnedHandle(completedInput);
|
|
this.#snapshot = { path: snapshotPath, sha256: hash.digest("hex") };
|
|
} catch (cause: unknown) {
|
|
let failure =
|
|
signal?.aborted === true
|
|
? new ArtifactReaderFailure("cancelled", "ASAR operation cancelled", {
|
|
cause,
|
|
})
|
|
: asarFailure(this.path, "snapshot", cause);
|
|
if (inputHandle !== undefined) {
|
|
const failedInput = inputHandle;
|
|
inputHandle = undefined;
|
|
try {
|
|
await this.#closeOwnedHandle(failedInput);
|
|
} catch (cleanupCause: unknown) {
|
|
failure = ArtifactReaderFailure.withCleanup(
|
|
failure,
|
|
ArtifactReaderFailure.cleanupObservation(cleanupCause, this.path),
|
|
);
|
|
}
|
|
}
|
|
try {
|
|
if (this.#ownedHandles.size === 0) await this.#closeSnapshot();
|
|
} catch (cleanupCause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
failure,
|
|
ArtifactReaderFailure.cleanupObservation(
|
|
cleanupCause,
|
|
this.#snapshotRoot ?? this.path,
|
|
),
|
|
);
|
|
}
|
|
throw failure;
|
|
}
|
|
}
|
|
|
|
async #closeSnapshot(): Promise<void> {
|
|
await this.#closeOwnedHandles();
|
|
const root = this.#snapshotRoot;
|
|
if (root !== undefined) {
|
|
try {
|
|
await (this.options.removeSnapshot ?? defaultRemoveSnapshot)(root);
|
|
if (this.#snapshotRoot === root) this.#snapshotRoot = undefined;
|
|
} catch (cause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(cause, {
|
|
reason: `Could not remove ASAR snapshot: ${root}`,
|
|
resources: [root],
|
|
});
|
|
}
|
|
}
|
|
if (root === undefined || this.#snapshotRoot === undefined) {
|
|
if (this.#snapshot !== undefined) uncache(this.#snapshot.path);
|
|
this.#snapshot = undefined;
|
|
this.#resetArchiveState();
|
|
this.#entries.clear();
|
|
}
|
|
}
|
|
|
|
async #withSnapshotOperation<Value>(
|
|
operation: () => Promise<Value>,
|
|
signal?: AbortSignal,
|
|
): Promise<Value> {
|
|
const previous = this.#snapshotOperation;
|
|
let release!: () => void;
|
|
this.#snapshotOperation = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
try {
|
|
await waitForOperation(previous, signal);
|
|
} catch (cause: unknown) {
|
|
void previous.then(release, release);
|
|
throw cause;
|
|
}
|
|
try {
|
|
abortIfNeeded(signal);
|
|
return await operation();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
#snapshotPath(): string {
|
|
if (this.#snapshot === undefined)
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
"ASAR snapshot is not available",
|
|
);
|
|
return this.#snapshot.path;
|
|
}
|
|
|
|
provenance(): readonly [] {
|
|
return [];
|
|
}
|
|
|
|
async #openUnpackedStream(
|
|
entry: ArtifactEntry,
|
|
signal?: AbortSignal,
|
|
): Promise<Readable> {
|
|
const unpackedRoot = `${this.path}.unpacked`;
|
|
let handle: SharedAsarFileHandle | undefined;
|
|
try {
|
|
const rootMetadata = await lstat(unpackedRoot);
|
|
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink())
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
"ASAR unpacked companion is not a regular directory",
|
|
);
|
|
const canonicalRoot = await realpath(unpackedRoot);
|
|
const candidate = join(unpackedRoot, entry.adapterKey);
|
|
const canonical = await realpath(candidate);
|
|
const relativePath = relative(canonicalRoot, canonical);
|
|
if (
|
|
relativePath === ".." ||
|
|
relativePath.startsWith(`..${sep}`) ||
|
|
isAbsolute(relativePath)
|
|
)
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
`ASAR unpacked entry escaped its companion directory: ${entry.path}`,
|
|
);
|
|
const pathMetadata = await lstat(candidate);
|
|
if (!pathMetadata.isFile() || pathMetadata.isSymbolicLink())
|
|
throw new ArtifactReaderFailure(
|
|
"path",
|
|
`ASAR unpacked entry is not a regular file: ${entry.path}`,
|
|
);
|
|
handle = await (this.options.openUnpackedFile ?? open)(
|
|
canonical,
|
|
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
);
|
|
const openedHandle = handle;
|
|
this.#adoptHandle(openedHandle, canonical);
|
|
const openedMetadata = await openedHandle.stat();
|
|
if (
|
|
!openedMetadata.isFile() ||
|
|
openedMetadata.dev !== pathMetadata.dev ||
|
|
openedMetadata.ino !== pathMetadata.ino
|
|
) {
|
|
throw new ArtifactReaderFailure(
|
|
"integrity",
|
|
`ASAR unpacked entry changed before read: ${entry.path}`,
|
|
{},
|
|
{
|
|
logicalPath: entry.path,
|
|
declaredSha256: entry.declaredSha256,
|
|
calculatedSha256: null,
|
|
unpacked: true,
|
|
},
|
|
);
|
|
}
|
|
abortIfNeeded(signal);
|
|
const source = this.#ownedReadStream(openedHandle, { start: 0 });
|
|
handle = undefined;
|
|
const output = readValidatedAsarEntry(
|
|
source,
|
|
undefined,
|
|
entry.path,
|
|
this.path,
|
|
signal,
|
|
);
|
|
this.#setOwnedOutput(openedHandle, output);
|
|
return output;
|
|
} catch (cause: unknown) {
|
|
let failure = asarUnpackedEntryFailure(this.path, entry, cause);
|
|
if (handle !== undefined && this.#ownedHandles.has(handle)) {
|
|
try {
|
|
await this.#closeOwnedHandle(handle);
|
|
} catch (cleanupCause: unknown) {
|
|
failure = ArtifactReaderFailure.withCleanup(
|
|
failure,
|
|
ArtifactReaderFailure.cleanupObservation(
|
|
cleanupCause,
|
|
this.#ownedHandles.get(handle)?.path ?? entry.path,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
throw failure;
|
|
}
|
|
}
|
|
|
|
async #closeOwnedHandle(handle: SharedAsarFileHandle): Promise<void> {
|
|
const owned = this.#ownedHandles.get(handle);
|
|
if (owned === undefined) return;
|
|
owned.output?.destroy();
|
|
owned.source?.destroy();
|
|
try {
|
|
await owned.owner.close();
|
|
} catch (cause: unknown) {
|
|
throw ArtifactReaderFailure.withCleanup(
|
|
cause,
|
|
ArtifactReaderFailure.cleanupObservation(cause, owned.path),
|
|
);
|
|
}
|
|
this.#ownedHandles.delete(handle);
|
|
}
|
|
|
|
async #closeOwnedHandles(): Promise<void> {
|
|
let failure: ArtifactReaderFailure | undefined;
|
|
for (const [handle, owned] of this.#ownedHandles) {
|
|
try {
|
|
await this.#closeOwnedHandle(handle);
|
|
} catch (cause: unknown) {
|
|
failure = ArtifactReaderFailure.withCleanup(
|
|
failure ?? cause,
|
|
ArtifactReaderFailure.cleanupObservation(cause, owned.path),
|
|
);
|
|
}
|
|
}
|
|
if (failure !== undefined) throw failure;
|
|
}
|
|
|
|
async #openOwned(path: string, flags: number): Promise<SharedAsarFileHandle> {
|
|
const handle = await (this.options.openFile ?? open)(path, flags);
|
|
this.#adoptHandle(handle, path);
|
|
return handle;
|
|
}
|
|
|
|
#adoptHandle(handle: SharedAsarFileHandle, path: string): void {
|
|
this.#ownedHandles.set(handle, {
|
|
path,
|
|
owner: new OwnedFileHandle(handle),
|
|
});
|
|
}
|
|
|
|
#ownedReadStream(
|
|
handle: SharedAsarFileHandle,
|
|
options: {
|
|
readonly start?: number;
|
|
readonly end?: number;
|
|
readonly signal?: AbortSignal | undefined;
|
|
readonly releaseOnClose?: boolean;
|
|
},
|
|
): Readable {
|
|
const { releaseOnClose, ...readOptions } = options;
|
|
const source = readFileHandleChunks(handle, readOptions);
|
|
const owned = this.#ownedHandles.get(handle);
|
|
if (owned === undefined)
|
|
throw new ArtifactReaderFailure("integrity", "ASAR handle has no owner");
|
|
owned.source = source;
|
|
owned.output = source;
|
|
if (releaseOnClose !== false)
|
|
source.once("close", () => {
|
|
void this.#closeOwnedHandle(handle).catch(() => undefined);
|
|
});
|
|
return source;
|
|
}
|
|
|
|
#setOwnedOutput(handle: SharedAsarFileHandle, output: Readable): void {
|
|
const owned = this.#ownedHandles.get(handle);
|
|
if (owned !== undefined) owned.output = output;
|
|
}
|
|
|
|
#resetArchiveState(): void {
|
|
this.#archiveSize = undefined;
|
|
this.#headerSize = undefined;
|
|
}
|
|
}
|
|
|
|
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
|
|
type AsarEntryState = {
|
|
readonly metadata: AsarFileMetadata;
|
|
readonly entry: ArtifactEntry;
|
|
};
|
|
type SharedAsarFileHandle = Pick<FileHandle, "fd" | "read" | "close"> & {
|
|
stat(): Promise<Stats>;
|
|
};
|
|
type OpenAsarFile = (
|
|
path: string,
|
|
flags: number,
|
|
) => Promise<SharedAsarFileHandle>;
|
|
type OwnedAsarHandle = {
|
|
readonly path: string;
|
|
readonly owner: OwnedFileHandle<SharedAsarFileHandle>;
|
|
source?: Readable;
|
|
output?: Readable;
|
|
};
|
|
type AsarArtifactReaderOptions = {
|
|
readonly openFile?: OpenAsarFile;
|
|
readonly openUnpackedFile?: OpenAsarFile;
|
|
readonly removeSnapshot?: (path: string) => Promise<void>;
|
|
};
|
|
|
|
const defaultRemoveSnapshot = (path: string): Promise<void> =>
|
|
rm(path, { recursive: true, force: true });
|
|
|
|
const asarUnpackedEntryFailure = (
|
|
archivePath: string,
|
|
entry: ArtifactEntry,
|
|
cause: unknown,
|
|
): ArtifactReaderFailure => {
|
|
if (cause instanceof ArtifactReaderFailure) return cause;
|
|
if (entry.unpacked && isMissingFile(cause))
|
|
return new ArtifactReaderFailure(
|
|
"unavailable",
|
|
`ASAR unpacked entry bytes are unavailable: ${entry.path}`,
|
|
{ cause },
|
|
{
|
|
logicalPath: entry.path,
|
|
declaredSha256: entry.declaredSha256,
|
|
calculatedSha256: null,
|
|
unpacked: true,
|
|
},
|
|
);
|
|
return asarFailure(archivePath, `read ${entry.path}`, cause);
|
|
};
|
|
|
|
const isAsarFileMetadata = (
|
|
metadata: ReturnType<typeof statFile>,
|
|
): metadata is AsarFileMetadata =>
|
|
"size" in metadata &&
|
|
typeof metadata.size === "number" &&
|
|
!("files" in metadata) &&
|
|
!("link" in metadata);
|
|
|
|
const parseArchiveOffset = (value: string): number | undefined => {
|
|
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
|
|
const offset = Number(value);
|
|
return Number.isSafeInteger(offset) ? offset : undefined;
|
|
};
|
|
|
|
const abortIfNeeded = (signal?: AbortSignal): void => {
|
|
if (signal?.aborted === true)
|
|
throw new ArtifactReaderFailure("cancelled", "ASAR operation cancelled");
|
|
};
|
|
|
|
const waitForOperation = async (
|
|
operation: Promise<void>,
|
|
signal?: AbortSignal,
|
|
): Promise<void> => {
|
|
if (signal === undefined) return operation;
|
|
abortIfNeeded(signal);
|
|
await new Promise<void>((resolve, reject) => {
|
|
const removeAbortListener = (): void =>
|
|
signal.removeEventListener("abort", onAbort);
|
|
const onAbort = (): void => {
|
|
removeAbortListener();
|
|
reject(
|
|
new ArtifactReaderFailure("cancelled", "ASAR operation cancelled"),
|
|
);
|
|
};
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
operation.then(
|
|
() => {
|
|
removeAbortListener();
|
|
resolve();
|
|
},
|
|
(cause: unknown) => {
|
|
removeAbortListener();
|
|
reject(cause);
|
|
},
|
|
);
|
|
});
|
|
};
|
|
|
|
interface AsarListedPath {
|
|
readonly listed: string;
|
|
readonly path: string;
|
|
}
|
|
|
|
/**
|
|
* Pair each host-spelled listing with its portable archive path, ordered by
|
|
* the portable path so Windows and POSIX hosts agree.
|
|
*
|
|
* @electron/asar returns paths assembled with the host path module. Keep that
|
|
* spelling for its API calls, but expose archive paths with portable
|
|
* separators. On POSIX, backslashes can be literal filename characters and
|
|
* must remain untouched.
|
|
*/
|
|
export const orderAsarListing = (
|
|
listed: readonly string[],
|
|
separator: string = sep,
|
|
): AsarListedPath[] =>
|
|
listed
|
|
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
|
|
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
|
|
|
const toArtifactPath = (listed: string, separator: string): string => {
|
|
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
|
|
return portable.replace(/^\/+|\/+$/gu, "");
|
|
};
|
|
|
|
const asarFailure = (
|
|
path: string,
|
|
operation: string,
|
|
cause: unknown,
|
|
): ArtifactReaderFailure => {
|
|
if (cause instanceof ArtifactReaderFailure) return cause;
|
|
if (isFilesystemFailure(cause))
|
|
return new ArtifactReaderFailure(
|
|
"io",
|
|
`Could not ${operation} ASAR at ${path}: ${cause.message}`,
|
|
{ cause },
|
|
);
|
|
return new ArtifactReaderFailure(
|
|
"format",
|
|
`Malformed ASAR during ${operation}: ${path}`,
|
|
{ cause },
|
|
);
|
|
};
|
|
|
|
const isFilesystemFailure = (
|
|
cause: unknown,
|
|
): cause is NodeJS.ErrnoException & Error =>
|
|
cause instanceof Error &&
|
|
"errno" in cause &&
|
|
typeof cause.errno === "number" &&
|
|
"syscall" in cause &&
|
|
typeof cause.syscall === "string";
|
|
|
|
const isMissingFile = (cause: unknown): boolean =>
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
cause.code === "ENOENT";
|