refactor(asar): let listing own parsed member facts

This commit is contained in:
morluto committed 2026-10-26 14:53:42 +08:00
1 parent 7cd65278ea
commit 85075fa979
3 files changed
+146 -56

No files matched your search

+16 -49
View File
@@ -34,11 +34,7 @@ import {
asarCancellationFailure,
asarFailure,
} from "./AsarFailure.js";
import {
readAsarListing,
type AsarListedEntry,
type AsarMetadata,
} from "./AsarListing.js";
import { readAsarListing, type AsarListedEntry } from "./AsarListing.js";
/**
* Official Electron ASAR adapter with range-streamed member reads.
@@ -90,39 +86,29 @@ export class AsarArtifactReader implements ArtifactReader {
} catch (cause: unknown) {
throw asarFailure(this.path, "inventory", cause);
}
for (const { metadata, path } of paths) {
for (const facts of paths) {
assertAsarReadActive(signal);
const { path, kind } = facts;
const providerPath = path.split("/").join(sep);
const kind =
metadata.files !== undefined
? "directory"
: metadata.link !== undefined
? "symlink"
: "file";
const entry: ArtifactEntry = {
path,
kind,
declaredSize: metadata.size ?? null,
declaredSize: facts.size,
compressedSize: null,
executable: metadata.executable === true,
executable: facts.executable,
encrypted: false,
byteOffset: null,
declaredIntegrity:
metadata.integrity !== undefined &&
metadata.integrity.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? { algorithm: "sha256", digest: metadata.integrity.hash }
: null,
unpacked: "unpacked" in metadata && metadata.unpacked === true,
declaredIntegrity: facts.declaredIntegrity,
unpacked: facts.unpacked,
limitations:
kind === "symlink"
? ["ASAR symlink target was not followed or disclosed."]
: [],
adapterKey: providerPath,
};
if (kind === "file" && isAsarFileMetadata(metadata))
if (facts.kind === "file")
this.#entries.set(providerPath, {
metadata,
facts,
entry: copyArtifactEntry(entry),
});
yield entry;
@@ -203,13 +189,10 @@ export class AsarArtifactReader implements ArtifactReader {
state: AsarEntryState,
signal?: AbortSignal,
): Promise<Readable> {
const { metadata, entry: producedEntry } = state;
const { facts, entry: producedEntry } = state;
const archiveSize = this.#archiveSize;
const headerSize = this.#headerSize;
const offset =
metadata.offset === undefined
? undefined
: parseArchiveOffset(metadata.offset);
const offset = facts.packedOffset;
const start =
offset === undefined || headerSize === undefined
? undefined
@@ -218,10 +201,8 @@ export class AsarArtifactReader implements ArtifactReader {
archiveSize === undefined ||
start === undefined ||
!Number.isSafeInteger(start) ||
metadata.size < 0 ||
!Number.isSafeInteger(metadata.size) ||
start > archiveSize ||
metadata.size > archiveSize - start
facts.size > archiveSize - start
)
throw new ArtifactReaderFailure(
"format",
@@ -242,7 +223,7 @@ export class AsarArtifactReader implements ArtifactReader {
"integrity",
`ASAR container changed before read: ${producedEntry.path}`,
);
if (metadata.size === 0) {
if (facts.size === 0) {
handle = undefined;
await closeAsarHandle(
() => this.#closeOwnedHandle(openedHandle),
@@ -252,13 +233,13 @@ export class AsarArtifactReader implements ArtifactReader {
}
const source = this.#ownedReadStream(openedHandle, {
start,
end: start + metadata.size - 1,
end: start + facts.size - 1,
signal,
});
handle = undefined;
const output = readValidatedAsarEntry(
source,
metadata.size,
facts.size,
producedEntry.path,
this.path,
signal,
@@ -609,9 +590,8 @@ export class AsarArtifactReader implements ArtifactReader {
}
}
type AsarFileMetadata = AsarMetadata & { readonly size: number };
type AsarEntryState = {
readonly metadata: AsarFileMetadata;
readonly facts: Extract<AsarListedEntry, { readonly kind: "file" }>;
readonly entry: ArtifactEntry;
};
type SharedAsarFileHandle = Pick<FileHandle, "fd" | "read" | "close"> & {
@@ -654,19 +634,6 @@ const asarUnpackedEntryFailure = (
return asarFailure(archivePath, `read ${entry.path}`, cause);
};
const isAsarFileMetadata = (
metadata: AsarMetadata,
): metadata is AsarFileMetadata =>
typeof metadata.size === "number" &&
metadata.files === undefined &&
metadata.link === undefined;
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 waitForOperation = async (
operation: Promise<void>,
signal?: AbortSignal,
+44 -7
View File
@@ -1,7 +1,7 @@
import { setImmediate } from "node:timers/promises";
import { z } from "zod";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import { ArtifactReaderFailure, type ArtifactEntry } from "./ArtifactReader.js";
import { assertAsarReadActive } from "./AsarFailure.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
@@ -23,11 +23,26 @@ const nodeSchema = z.looseObject({
.looseObject({ algorithm: z.string(), hash: z.string() })
.optional(),
});
export type AsarMetadata = z.infer<typeof nodeSchema>;
export interface AsarListedEntry {
readonly path: string;
readonly metadata: AsarMetadata;
}
type AsarListedFacts = Pick<
ArtifactEntry,
"declaredIntegrity" | "executable" | "unpacked"
> & { readonly path: string };
export type AsarListedEntry = AsarListedFacts &
(
| { readonly kind: "directory" | "symlink"; readonly size: number | null }
| {
readonly kind: "file";
readonly size: number;
readonly packedOffset: number | undefined;
}
);
const parseArchiveOffset = (value: string | undefined): number | undefined => {
if (value === undefined || !/^(?:0|[1-9][0-9]*)$/u.test(value))
return undefined;
const offset = Number(value);
return Number.isSafeInteger(offset) ? offset : undefined;
};
function* ownChildren(files: Record<string, unknown>) {
for (const name in files)
@@ -94,7 +109,29 @@ export const readAsarListing = async (
"format",
"ASAR entry has no directory, link or file metadata",
);
entries.push({ path, metadata });
const facts: AsarListedFacts = {
path,
executable: metadata.executable === true,
unpacked: metadata.unpacked === true,
declaredIntegrity:
metadata.integrity?.algorithm === "SHA256" &&
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
? { algorithm: "sha256", digest: metadata.integrity.hash }
: null,
};
if (metadata.files !== undefined || metadata.link !== undefined)
entries.push({
...facts,
kind: metadata.files !== undefined ? "directory" : "symlink",
size: metadata.size ?? null,
});
else if (metadata.size !== undefined)
entries.push({
...facts,
kind: "file",
size: metadata.size,
packedOffset: parseArchiveOffset(metadata.offset),
});
if (metadata.files !== undefined)
pending.push({
parent: path,
@@ -139,3 +139,89 @@ it("preserves POSIX literal backslashes and rejects Windows component separators
await reader.close();
}
});
it.each([undefined, "01", "-1", "9007199254740992"])(
"retains inventory facts for an unreadable packed offset %s",
async (offset) => {
const digest = "a".repeat(64);
const bytes = asarHeaderBytes(
JSON.stringify({
files: {
member: {
size: 0,
...(offset === undefined ? {} : { offset }),
executable: true,
integrity: { algorithm: "SHA256", hash: digest },
},
},
}),
);
const path = await archiveWith(bytes);
const reader = new AsarArtifactReader(path);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries).toHaveLength(1);
const entry = entries[0]!;
expect(entry).toMatchObject({
path: "member",
kind: "file",
declaredSize: 0,
executable: true,
declaredIntegrity: { algorithm: "sha256", digest },
});
await expect(reader.open(entry)).rejects.toMatchObject({
reason: "format",
});
expect(Buffer.compare(await readFile(path), bytes)).toBe(0);
} finally {
await reader.close();
}
},
);
it("preserves directory and link priority with unrecognized integrity declarations", async () => {
const bytes = asarHeaderBytes(
JSON.stringify({
files: {
directory: {
files: {},
link: "target",
size: 3,
integrity: { algorithm: "SHA512", hash: "source-digest" },
},
link: {
link: "target",
size: 4,
offset: "invalid",
integrity: { algorithm: "SHA256", hash: "INVALID" },
},
},
}),
);
const path = await archiveWith(bytes);
const reader = new AsarArtifactReader(path);
try {
const entries = [];
for await (const entry of reader.entries()) entries.push(entry);
expect(entries).toMatchObject([
{
path: "directory",
kind: "directory",
declaredSize: 3,
declaredIntegrity: null,
limitations: [],
},
{
path: "link",
kind: "symlink",
declaredSize: 4,
declaredIntegrity: null,
limitations: ["ASAR symlink target was not followed or disclosed."],
},
]);
expect(Buffer.compare(await readFile(path), bytes)).toBe(0);
} finally {
await reader.close();
}
});