diff --git a/src/artifacts/AsarArtifactReader.ts b/src/artifacts/AsarArtifactReader.ts index 5a872a95ab..2a79e318b9 100644 --- a/src/artifacts/AsarArtifactReader.ts +++ b/src/artifacts/AsarArtifactReader.ts @@ -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 { - 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; readonly entry: ArtifactEntry; }; type SharedAsarFileHandle = Pick & { @@ -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, signal?: AbortSignal, diff --git a/src/artifacts/AsarListing.ts b/src/artifacts/AsarListing.ts index 9988567017..46fd7612c6 100644 --- a/src/artifacts/AsarListing.ts +++ b/src/artifacts/AsarListing.ts @@ -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; -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) { 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, diff --git a/tests/boundary/filesystem/asarListingAdmission.test.ts b/tests/boundary/filesystem/asarListingAdmission.test.ts index 349e3b0343..7fdb0bb6c3 100644 --- a/tests/boundary/filesystem/asarListingAdmission.test.ts +++ b/tests/boundary/filesystem/asarListingAdmission.test.ts @@ -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(); + } +});