fix(artifacts): retain expanded ASAR container parents (#583)

This commit is contained in:
Kaoru0822 2026-10-05 08:39:12 -07:00 committed by GitHub
parent ebb4c45b68
commit 00fa97422f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 236 additions and 3 deletions

View File

@ -248,12 +248,18 @@ export const createArtifactEdges = (
export const nearestParent = (
path: string,
occurrences: ReadonlyMap<string, MutableOccurrence>,
expandedContainerIds: ReadonlySet<string>,
): MutableOccurrence | undefined => {
const parts = path.split("/");
while (parts.length > 1) {
parts.pop();
const candidate = occurrences.get(parts.join("/"));
if (candidate?.entry_kind === "directory") return candidate;
if (
candidate !== undefined &&
(candidate.entry_kind === "directory" ||
expandedContainerIds.has(candidate.occurrence_id))
)
return candidate;
}
return undefined;
};

View File

@ -50,6 +50,7 @@ interface ScanContext {
readonly pendingContradictions: PendingIntegrityContradiction[];
readonly occurrenceByPath: Map<string, MutableOccurrence>;
readonly registry: ArtifactPathRegistry;
readonly expandedContainerIds: Set<string>;
}
export const scanReader = async (
@ -73,14 +74,18 @@ export const scanReader = async (
pendingContradictions,
occurrenceByPath: new Map<string, MutableOccurrence>(),
registry: new ArtifactPathRegistry(),
expandedContainerIds: new Set<string>(),
};
await visitArtifactEntries(context, reader, "");
// Archive directories may appear after their children. Resolve containment
// against the complete index before directory identities are materialized.
for (const occurrence of occurrences)
occurrence.parent_occurrence_id =
nearestParent(occurrence.logical_path, context.occurrenceByPath)
?.occurrence_id ?? null;
nearestParent(
occurrence.logical_path,
context.occurrenceByPath,
context.expandedContainerIds,
)?.occurrence_id ?? null;
return { nodes, occurrences, pendingContradictions };
};
@ -154,6 +159,8 @@ const visitArtifactEntries = async (
context.occurrenceByPath.set(logicalPath, occurrence);
if (expandableAsar && digested?.mismatched !== true) {
const nested = new AsarArtifactReader(entry.adapterKey);
// Only traversed containers can own members, not opaque ASAR-named files.
context.expandedContainerIds.add(occurrence.occurrence_id);
stack.push({
reader: nested,
prefix: logicalPath,

View File

@ -0,0 +1,220 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createPackage } from "@electron/asar";
import {
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipWriter,
} from "@zip.js/zip.js";
import { describe, expect, it } from "vitest";
import { inventoryArtifact } from "../../../src/application/ArtifactInventory.js";
import {
artifactOccurrenceAt,
artifactParentPaths,
writeOrderedZip,
} from "../../fixtures/artifactEntryOrder.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const mainBytes = "module.exports = 'packed member';\n";
const createNestedAsar = async () => {
const root = await createTestTempDirectory("rea-asar-containment-");
const source = join(root, "source");
const application = join(root, "application");
await mkdir(join(source, "lib"), { recursive: true });
await mkdir(join(source, "assets.asar"));
await mkdir(join(application, "resources"), { recursive: true });
await writeFile(join(source, "main.js"), mainBytes);
await writeFile(join(source, "lib", "dep.js"), "module.exports = 1;");
await writeFile(join(source, "ordinary.asar"), "opaque archive member");
await writeFile(join(source, "assets.asar", "data.txt"), "directory member");
const archive = join(application, "resources", "app.asar");
await createPackage(source, archive);
return { root, application, archive };
};
const expectedNestedParents = {
".": null,
resources: ".",
"resources/app.asar": "resources",
"resources/app.asar/assets.asar": "resources/app.asar",
"resources/app.asar/assets.asar/data.txt": "resources/app.asar/assets.asar",
"resources/app.asar/lib": "resources/app.asar",
"resources/app.asar/lib/dep.js": "resources/app.asar/lib",
"resources/app.asar/main.js": "resources/app.asar",
"resources/app.asar/ordinary.asar": "resources/app.asar",
};
describe("expanded ASAR inventory containment", () => {
it("parents expanded members to their archive without changing byte identities", async () => {
const { application, archive } = await createNestedAsar();
const nested = await inventoryArtifact(application);
const direct = await inventoryArtifact(archive);
expect(artifactParentPaths(nested)).toEqual(expectedNestedParents);
const container = artifactOccurrenceAt(nested, "resources/app.asar");
expect(container.entry_kind).toBe("file");
expect(container.artifact_id).toBe(direct.manifest.root_artifact_id);
expect(
nested.nodes.find((node) => node.artifact_id === container.artifact_id),
).toMatchObject({
format: "asar",
content_state: "embedded",
sha256: createHash("sha256")
.update(await readFile(archive))
.digest("hex"),
});
for (const [path, parent] of Object.entries(expectedNestedParents)) {
if (parent === null) continue;
const child = artifactOccurrenceAt(nested, path);
expect(nested.edges).toContainEqual(
expect.objectContaining({
logical_path: path,
relation: "contains",
parent_artifact_id: artifactOccurrenceAt(nested, parent).artifact_id,
child_artifact_id: child.artifact_id,
}),
);
if (path.startsWith("resources/app.asar/"))
expect(child.artifact_id).toBe(
artifactOccurrenceAt(direct, path.slice("resources/app.asar/".length))
.artifact_id,
);
}
expect(artifactParentPaths(direct)).toEqual({
".": null,
"assets.asar": ".",
"assets.asar/data.txt": "assets.asar",
lib: ".",
"lib/dep.js": "lib",
"main.js": ".",
"ordinary.asar": ".",
});
expect(await inventoryArtifact(application)).toEqual(nested);
});
it("records the actual expanded archive as the integrity contradiction parent", async () => {
const { application, archive } = await createNestedAsar();
const bytes = await readFile(archive);
const offset = bytes.indexOf(Buffer.from(mainBytes));
expect(offset).toBeGreaterThanOrEqual(0);
bytes[offset] = "X".charCodeAt(0);
await writeFile(archive, bytes);
await expect(inventoryArtifact(application)).rejects.toMatchObject({
reason: "integrity",
});
const observed = await inventoryArtifact(application, {
integrity: { mode: "record-and-continue" },
});
const container = artifactOccurrenceAt(observed, "resources/app.asar");
const child = artifactOccurrenceAt(observed, "resources/app.asar/main.js");
expect(child.hash_status).toBe("mismatched");
expect(child.parent_occurrence_id).toBe(container.occurrence_id);
expect(observed.integrity_contradictions).toEqual([
expect.objectContaining({
logical_path: "resources/app.asar/main.js",
parent_artifact_id: container.artifact_id,
occurrence_id: child.occurrence_id,
trust: "observed-untrusted",
}),
]);
});
});
describe("unexpanded ASAR inventory controls", () => {
it("does not expand ZIP members from ASAR bytes or filenames", async () => {
const { root, archive } = await createNestedAsar();
const path = join(root, "opaque.zip");
const writer = new ZipWriter(new Uint8ArrayWriter());
await writer.add("resources/", undefined, { directory: true });
await writer.add(
"resources/app.asar",
new Uint8ArrayReader(await readFile(archive)),
);
await writer.add(
"resources/ordinary.asar",
new TextReader("ordinary bytes"),
);
await writer.add(
"resources/encrypted.asar",
new TextReader("encrypted bytes"),
{ password: "fixture-only" },
);
await writeFile(path, await writer.close());
const observed = await inventoryArtifact(path);
expect(artifactParentPaths(observed)).toEqual({
".": null,
resources: ".",
"resources/app.asar": "resources",
"resources/encrypted.asar": "resources",
"resources/ordinary.asar": "resources",
});
expect(
artifactOccurrenceAt(observed, "resources/encrypted.asar").hash_status,
).toBe("unavailable");
});
it("keeps ASAR-named directories as directory parents", async () => {
const root = await createTestTempDirectory("rea-asar-directory-");
const path = join(root, "directory.zip");
await writeOrderedZip(path, ["assets.asar/main.js", "assets.asar/"]);
const observed = await inventoryArtifact(path);
expect(artifactParentPaths(observed)).toEqual({
".": null,
"assets.asar": ".",
"assets.asar/main.js": "assets.asar",
});
expect(artifactOccurrenceAt(observed, "assets.asar").entry_kind).toBe(
"directory",
);
const directory = join(root, "filesystem");
await mkdir(join(directory, "assets.asar"), { recursive: true });
await writeFile(join(directory, "assets.asar", "main.js"), "evidence\n");
expect(artifactParentPaths(await inventoryArtifact(directory))).toEqual(
artifactParentPaths(observed),
);
});
it.each([
["ordinary.asar", "ordinary.asar/main.js"],
["ordinary.asar/main.js", "ordinary.asar"],
])(
"rejects opaque file-prefix conflicts in either order: %j",
async (...entries) => {
const root = await createTestTempDirectory("rea-asar-prefix-");
const path = join(root, "conflict.zip");
await writeOrderedZip(path, entries);
await expect(inventoryArtifact(path)).rejects.toMatchObject({
reason: "path",
});
},
);
it("keeps malformed filesystem archives rejected", async () => {
const root = await createTestTempDirectory("rea-malformed-asar-");
await writeFile(join(root, "ordinary.asar"), "ordinary bytes");
await expect(inventoryArtifact(root)).rejects.toMatchObject({
reason: "format",
});
});
it.skipIf(process.platform === "win32")(
"does not follow filesystem ASAR symlinks",
async () => {
const { root, archive } = await createNestedAsar();
const directory = join(root, "links");
await mkdir(directory);
await symlink(archive, join(directory, "app.asar"));
const observed = await inventoryArtifact(directory);
expect(artifactParentPaths(observed)).toEqual({
".": null,
"app.asar": ".",
});
expect(artifactOccurrenceAt(observed, "app.asar").entry_kind).toBe(
"symlink",
);
},
);
});