fix(artifacts): retain permission omissions through JavaScript lookups

This commit is contained in:
morluto committed 2026-10-24 03:10:43 +08:00
1 parent dd55e2c7d5
commit 693ecec082
14 files changed
+343 -30

No files matched your search

+16 -2
View File
@@ -97,8 +97,9 @@ Omit the selection for the existing complete
application workflow.
The result and summary retain `source_selection.requested_paths` and
`source_selection.unselected_files`. Every relevant file keeps its original
inventory identity; unselected files report `text_status:
`source_selection.unselected_files`. Selecting every relevant file leaves this
count at zero and adds no unselected-source caveat. Every relevant file keeps its
original inventory identity; unselected files report `text_status:
"outside-analysis-scope"`. Their relationships remain unknown, and application
coverage is partial while any file remains outside scope. Selection does not
claim whole-application completeness or change integrity policy. Summary detail
@@ -106,6 +107,19 @@ only reduces delivery; source selection reduces text acquisition and analysis.
Explicit native provider or snapshot routing rejects `--source-path` rather
than ignoring it.
For directory input, a permission-denied child file remains an unavailable
inventory occurrence, and an unreadable child directory records an unknown
descendant omission. Readable sources remain available; application coverage is
partial and retains those inventory limitations. An unreadable selected root
remains an error. Explicit readers that require selected child bytes keep their
access errors rather than treating denied paths as absent.
Incomplete traversal also qualifies negative path and package-metadata lookups:
an unobserved earlier loader candidate cannot be skipped in favor of a later
observed candidate. An explicit source selection whose discovery is unknown
returns an unavailable error with the retained inventory, rather than claiming
the selected source does not exist. Observed exact file candidates keep their
identity; unknown traversal cannot establish a unique application root.
ASAR header/index admission defaults to 16 MiB / 16777216 bytes. Set
`REA_ASAR_MAX_HEADER_BYTES` in the environment that starts REA to a decimal
byte count of at most 268435456 (256 MiB) to raise or lower it; restart REA
@@ -159,6 +159,7 @@ function* buildJavaScriptArtifactGraphInputSteps(
root,
filesByPath: new Map(fileSet.files.map((file) => [file.path, file])),
unanalyzedFiles: {
coverage: snapshot.coverage,
get: (path) =>
unavailableFilesByPath.get(path) ?? fileSet.unanalyzed_files?.get(path),
hasDirectory: (path, container) =>
@@ -118,6 +118,7 @@ export const applicationRootPath = (
context: JavaScriptArtifactGraphContext,
source: JavaScriptArtifactFile,
): string | null => {
if (context.snapshot.coverage.status !== "complete") return null;
// Unread launch metadata can name a main in another inventoried container.
// Archive occurrence identity does not prove isolation from the Node loader.
if (context.applicationOwnershipUnknownContainers.size > 0) return null;
@@ -329,6 +329,7 @@ it.each([
gap === "opaque"
? {
get: (path) => (path === target.path ? target : undefined),
coverage: base.snapshot.coverage,
hasDirectory: () => false,
}
: undefined,
@@ -10,6 +10,72 @@ import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js";
import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js";
import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js";
const completeInventoryCoverage = {
scope: "container-entries",
status: "complete",
} as const;
describe("incomplete inventory lookups", () => {
it.each(["complete", "unknown"] as const)(
"qualifies missing candidates before extension fallbacks under %s traversal",
(status) => {
const files = fileMap([
file("app/main.cjs", "root"),
file("app/target.js", "root"),
]);
const unanalyzedFiles = {
coverage: { scope: "container-entries" as const, status },
get: () => undefined,
hasDirectory: () => false,
};
expect(
resolve({
declaredPath: "./target",
sourcePath: "app/main.cjs",
context: "module-specifier",
moduleKind: "require",
files,
unanalyzedFiles,
}),
).toMatchObject(
status === "complete"
? { resolution_status: "resolved", resolved_path: "app/target.js" }
: {
resolution_status: "unavailable",
resolved_path: null,
candidate_path: "app/target",
},
);
expect(
resolve({
declaredPath: "./target.js",
sourcePath: "app/main.cjs",
context: "module-specifier",
moduleKind: "require",
files,
unanalyzedFiles,
}),
).toMatchObject({
resolution_status: "resolved",
resolved_path: "app/target.js",
});
expect(
resolve({
declaredPath: "./missing.js",
sourcePath: "app/main.cjs",
context: "module-specifier",
moduleKind: "require",
files,
unanalyzedFiles,
}),
).toMatchObject({
resolution_status: status === "complete" ? "not-found" : "unavailable",
resolved_path: null,
});
},
);
});
describe("Windows CommonJS request classification", () => {
it.each([
"..\\shared\\window.cjs",
@@ -578,6 +644,7 @@ describe("loader-specific module suffix lookup", () => {
const files = fileMap([file("app/entry.cjs", "root")]);
const unanalyzedFiles = {
get: (path: string) => opaque.get(path),
coverage: completeInventoryCoverage,
hasDirectory: () => false,
};
expect(
@@ -650,6 +717,7 @@ describe("unavailable inventoried native add-ons", () => {
files: fileMap([file("app/entry.cjs", "root")]),
unanalyzedFiles: {
get: (path) => (path === unavailable.path ? unavailable : undefined),
coverage: completeInventoryCoverage,
hasDirectory: () => false,
},
});
@@ -689,6 +757,7 @@ describe("unavailable package metadata", () => {
requestedPaths.push(path);
return path === manifest.path ? manifest : undefined;
},
coverage: completeInventoryCoverage,
hasDirectory: () => false,
};
const relativeRequire = resolve({
@@ -757,6 +826,7 @@ describe("unavailable package metadata", () => {
]);
const lookup = {
get: (path: string) => (path === manifest.path ? manifest : undefined),
coverage: completeInventoryCoverage,
hasDirectory: () => false,
};
for (const [sourcePath, moduleKind] of [
@@ -4128,6 +4198,7 @@ describe("Node launch package main ownership", () => {
limitations: ["main readback unavailable"],
}
: undefined,
coverage: completeInventoryCoverage,
hasDirectory: () => false,
},
}),
@@ -4376,6 +4447,7 @@ describe("package metadata at archive module boundaries", () => {
unanalyzedFiles: {
get: (requested) =>
opaque && requested === path ? target : undefined,
coverage: completeInventoryCoverage,
hasDirectory: () => false,
},
}),
@@ -1426,6 +1426,11 @@ const readPackageMetadata = (
unavailable.limitations?.[0] ??
`Directory package metadata ${path} was inventoried but its bytes are unavailable.`,
};
if (input.unanalyzedFiles?.coverage.status === "unknown")
return {
kind: "unavailable",
limitation: `Directory package metadata ${path} was not observed, and artifact traversal is incomplete; its absence is unknown.`,
};
return { kind: "absent" };
}
if (!admitsRuntimeContainer(input, path, file.container_sha256))
@@ -1750,6 +1755,15 @@ const findFileCandidate = (
: `${path} is an inventoried ${unanalyzed.kind} file, but its bytes are unavailable (hash status ${unanalyzed.hash_status ?? "unknown"}); resolution cannot be confirmed.`,
],
};
if (input.unanalyzedFiles?.coverage.status === "unknown")
return {
resolvedPath: null,
status: "unavailable",
candidatePath: path,
limitations: [
`Artifact path ${path} was not observed, and artifact traversal is incomplete; its absence is unknown.`,
],
};
return null;
};
+42 -13
View File
@@ -94,7 +94,10 @@ export class DirectoryArtifactReader implements ArtifactReader {
readonly #files = new Map<OwnedFileHandle, string>();
readonly #fileCleanup = new WeakMap<OwnedFileHandle, AnalysisCleanupLease>();
constructor(root: string) {
constructor(
root: string,
private readonly unreadableDirectories: "fail" | "omit" = "fail",
) {
this.#rootPath = root;
}
@@ -131,18 +134,36 @@ export class DirectoryArtifactReader implements ArtifactReader {
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory, admittedRoot.identity),
);
for await (const entry of this.#readDirectory(
directory,
pending,
signal,
options,
examine,
(rawHex) => {
unrepresentableNameCount += 1;
firstUnrepresentableNameHex ??= rawHex;
},
))
yield entry;
try {
for await (const entry of this.#readDirectory(
directory,
pending,
signal,
options,
examine,
(rawHex) => {
unrepresentableNameCount += 1;
firstUnrepresentableNameHex ??= rawHex;
},
))
yield entry;
} catch (cause: unknown) {
if (
this.unreadableDirectories !== "omit" ||
!(cause instanceof DirectoryArtifactEntryAccessDenied)
)
throw cause;
abortIfNeeded(signal);
options?.onOmission?.({
path: cause.entryPath,
kind: "directory-descendants",
});
options?.onCoverage?.({
status: "unknown",
limitations: [cause.message],
});
continue;
}
await withDirectoryIoContext("inspect directory", directory, () =>
assertContainedDirectory(root, directory, admittedRoot.identity),
);
@@ -204,6 +225,14 @@ export class DirectoryArtifactReader implements ArtifactReader {
const handle = new OwnedDirectoryHandle(
await opendir(directory, { encoding: "buffer" as BufferEncoding }).catch(
(cause: unknown) => {
const code = hostErrorCode(cause);
if (directory !== root && (code === "EACCES" || code === "EPERM"))
throw new DirectoryArtifactEntryAccessDenied(
code,
relative(root, directory).split(sep).join("/"),
directoryIoFailure("open directory", directory, cause).message,
{ cause },
);
throw directoryIoFailure(
"open directory",
directory,
@@ -176,8 +176,8 @@ const materializeArtifactInventoryInScope = async (
input.inputPath,
`Archive entry ${occurrence.logical_path} is encrypted; REA does not decrypt archive entries, so the archive cannot be extracted completely`,
);
// Inventory keeps an ASAR unpacked entry whose companion file is absent,
// but extraction cannot materialize bytes it never observed.
// Inventory retains unavailable source occurrences, but extraction cannot
// materialize bytes it never observed.
if (
occurrence.entry_kind === "file" &&
occurrence.artifact_id === null &&
@@ -185,13 +185,13 @@ const materializeArtifactInventoryInScope = async (
)
throw new ArtifactReaderFailure(
"unavailable",
`ASAR unpacked companion bytes are unavailable for ${occurrence.logical_path}`,
`Inventoried source bytes are unavailable for ${occurrence.logical_path}`,
undefined,
{
logicalPath: occurrence.logical_path,
declaredSha256: null,
calculatedSha256: null,
unpacked: true,
unpacked: occurrence.unpacked,
},
);
if (
+1 -1
View File
@@ -31,7 +31,7 @@ export const createReader = (
return new ZipArtifactReader(path, format, undefined, rootSource);
switch (format) {
case "directory":
return new DirectoryArtifactReader(path);
return new DirectoryArtifactReader(path, "omit");
case "asar":
return new AsarArtifactReader(
path,
+12 -3
View File
@@ -18,6 +18,7 @@ import {
type ArtifactReaderTraversal,
} from "../ArtifactReader.js";
import { ArtifactTraversalObservation } from "../ArtifactTraversalObservation.js";
import { DirectoryArtifactEntryAccessDenied } from "../DirectoryArtifactReader.js";
import { AsarArtifactReader } from "../AsarArtifactReader.js";
import type { ArtifactNode } from "../../domain/artifactGraph.js";
import {
@@ -34,7 +35,7 @@ import {
STRICT_INTEGRITY_POLICY,
type ArtifactIntegrityPolicy,
} from "./types.js";
import { hashReadable } from "../ArtifactHash.js";
import { abortIfNeeded, hashReadable } from "../ArtifactHash.js";
export interface PendingIntegrityContradiction {
readonly occurrence: MutableOccurrence;
@@ -214,9 +215,17 @@ const visitArtifactEntries = async (
occurrence,
);
} catch (cause: unknown) {
if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
let limitation = UNAVAILABLE_UNPACKED_LIMITATION;
if (cause instanceof DirectoryArtifactEntryAccessDenied) {
abortIfNeeded(context.signal);
limitation = cause.message;
context.traversal.onCoverage({
status: "unknown",
limitations: [limitation],
});
} else if (!isUnavailableUnpackedEntry(cause, entry)) throw cause;
occurrence.hash_status = "unavailable";
occurrence.limitations.push(UNAVAILABLE_UNPACKED_LIMITATION);
occurrence.limitations.push(limitation);
}
if (digested === undefined) noteUnreadContainerSuffix(occurrence);
else {
@@ -80,6 +80,23 @@ export const readJavaScriptArtifactFiles = async (
sourcePaths === undefined
? undefined
: new Map([...new Set(sourcePaths)].map((path, index) => [path, index]));
const undiscoveredPath = sourcePaths?.find(
(path) =>
relevantKind(path) !== undefined &&
!expected.has(path) &&
!inventory.unavailableFiles.has(path) &&
snapshot.occurrences[
lowerBound(snapshot.occurrences, path, (entry) => entry.logical_path)
]?.logical_path !== path,
);
if (undiscoveredPath !== undefined && snapshot.coverage.status === "unknown")
throw new ArtifactReaderFailure(
"unavailable",
`Selected source discovery is unavailable under incomplete artifact traversal: ${undiscoveredPath}`,
{
partialObservation: { kind: "artifact-inventory", inventory: snapshot },
},
);
const issues = (sourcePaths ?? []).flatMap((path, index) =>
expected.has(path) || inventory.unavailableFiles.has(path)
? []
@@ -229,11 +246,7 @@ const visitReader = async (
`Nested ASAR was not present in inventory: ${path}`,
);
if (container.hash_status === "mismatched") continue;
if (container.hash_status === "unavailable")
throw new ArtifactReaderFailure(
"unavailable",
`Nested ASAR bytes were unavailable during inventory: ${path}`,
);
if (container.hash_status === "unavailable") continue;
if (container.hash_status !== "verified")
throw new ArtifactReaderFailure(
"integrity",
@@ -512,6 +525,7 @@ const createUnanalyzedArtifactLookup = (
occurrence.artifact_id === null &&
relevantKind(occurrence.logical_path) !== undefined;
return {
coverage: snapshot.coverage,
get(path) {
if (requested.has(path)) return requested.get(path);
const occurrence =
@@ -16,4 +16,4 @@ export const createJavaScriptArtifactReader = (
? {}
: { headerAdmission: asarHeaderAdmission },
)
: new DirectoryArtifactReader(path);
: new DirectoryArtifactReader(path, "omit");
@@ -1,7 +1,10 @@
import { z } from "zod";
import { digestSchema } from "../digests.js";
import type { JavaScriptSourceSelection } from "./javascriptSourceSelection.js";
import type { ArtifactOccurrence } from "../artifactGraph.js";
import type {
ArtifactInventoryCoverage,
ArtifactOccurrence,
} from "../artifactGraph.js";
/** One physical file occurrence, independent of its graph representations. */
export const javaScriptFileOccurrenceSchema = z.strictObject({
@@ -81,6 +84,8 @@ export interface JavaScriptUnanalyzedArtifactFile {
/** Borrowed inventory lookup; metadata is materialized only for requested paths. */
export interface JavaScriptUnanalyzedArtifactLookup {
/** Inventory traversal coverage qualifies every negative lookup. */
readonly coverage: ArtifactInventoryCoverage;
get(path: string): JavaScriptUnanalyzedArtifactFile | undefined;
hasDirectory(path: string, containerSha256: string | undefined): boolean;
}
@@ -0,0 +1,153 @@
import { execFile } from "node:child_process";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { scanArtifactInventory } from "../../fixtures/artifactInventory.js";
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
import { DirectoryArtifactReader } from "../../../src/artifacts/DirectoryArtifactReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.skipIf(process.platform === "win32")(
"projects inventoried special-entry omissions into partial application coverage",
async () => {
const root = await createTestTempDirectory("rea-js-directory-fifo-");
await writeFile(join(root, "good.js"), "export const answer = 42;");
await promisify(execFile)("mkfifo", [join(root, "events.pipe")], {
timeout: 2_000,
});
const analyzed = await analyzeJavaScriptApplication({ input_path: root });
if (!analyzed.ok) throw analyzed.error;
const result = javascriptApplicationAnalysisResultSchema.parse(
analyzed.value.normalized_result,
);
expect(result.graph.coverage).toMatchObject({
status: "partial",
truncated: false,
});
expect(result.graph.limitations).toContain(
'Artifact traversal omitted fifo at "events.pipe".',
);
expect(
result.semantic_graph.nodes.some(
({ identity }) => identity.module_path === "good.js",
),
).toBe(true);
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"retains readable source facts and permission omissions while refusing an unreadable root",
async () => {
const root = await createTestTempDirectory("rea-js-directory-permissions-");
const deniedFile = join(root, "closed.js");
const deniedDirectory = join(root, "private");
await mkdir(deniedDirectory);
await writeFile(
join(root, "good.js"),
'require("./private/hidden.js"); const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("page.html"); module.exports = 42;',
);
await writeFile(
join(root, "page.html"),
"<!doctype html><title>Fixture</title>",
);
await writeFile(deniedFile, "export const unseen = true;");
await writeFile(
join(deniedDirectory, "hidden.js"),
"export const hidden = true;",
);
await chmod(deniedFile, 0);
await chmod(deniedDirectory, 0);
try {
const inventory = await scanArtifactInventory(root);
expect(inventory.coverage.status).toBe("unknown");
expect(
inventory.occurrences.find(
({ logical_path }) => logical_path === "closed.js",
),
).toMatchObject({ hash_status: "unavailable", artifact_id: null });
expect(
inventory.occurrences.some(
({ logical_path }) => logical_path === "private/hidden.js",
),
).toBe(false);
expect(inventory.limitations).toContainEqual(
expect.stringContaining('directory-descendants at "private"'),
);
const selectedReader = new DirectoryArtifactReader(root);
try {
await expect(
(async () => {
for await (const entry of selectedReader.entries())
expect(entry.path).not.toBe("private/hidden.js");
})(),
).rejects.toMatchObject({
reason: "io",
entryPath: "private",
});
} finally {
await selectedReader.close();
}
const analyzed = await analyzeJavaScriptApplication({ input_path: root });
if (!analyzed.ok) throw analyzed.error;
const result = javascriptApplicationAnalysisResultSchema.parse(
analyzed.value.normalized_result,
);
expect(result.graph.coverage).toMatchObject({
status: "partial",
truncated: false,
});
expect(result.graph.limitations).toEqual(
expect.arrayContaining([...inventory.limitations]),
);
expect(
result.semantic_graph.nodes.some(
({ identity }) => identity.module_path === "good.js",
),
).toBe(true);
const moduleLinks = result.graph.edges.filter(
({ relation, properties }) =>
relation === "imports" &&
properties.specifier === "./private/hidden.js" &&
properties.module_link_kind !== undefined,
);
expect(moduleLinks).toHaveLength(1);
expect(moduleLinks[0]?.properties).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
});
const renderer = result.graph.nodes
.flatMap(({ observations }) => observations)
.find(({ properties }) => properties.declared_path === "page.html");
expect(renderer?.properties).toMatchObject({
resolution_status: "unavailable",
resolved_path: null,
});
expect(
await analyzeJavaScriptApplication({
input_path: root,
source_paths: ["private/hidden.js"],
}),
).toMatchObject({
ok: false,
error: {
reason: "unavailable",
partialObservation: {
kind: "artifact-inventory",
inventory: { coverage: { status: "unknown" } },
},
},
});
await chmod(root, 0);
await expect(scanArtifactInventory(root)).rejects.toMatchObject({
reason: "io",
});
} finally {
await chmod(root, 0o700);
await chmod(deniedDirectory, 0o700);
await chmod(deniedFile, 0o600);
}
},
);