From 693ecec082b3c928da7702f038084e7de4d06587 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:00:21 +0800 Subject: [PATCH] fix(artifacts): retain permission omissions through JavaScript lookups --- docs/javascript-artifact-reconstruction.md | 18 ++- .../JavaScriptArtifactGraphBuilder.ts | 1 + .../JavaScriptArtifactGraphContext.ts | 1 + .../JavaScriptArtifactGraphStructure.test.ts | 1 + .../JavaScriptArtifactPathResolution.test.ts | 72 +++++++++ .../JavaScriptArtifactPathResolution.ts | 14 ++ src/artifacts/DirectoryArtifactReader.ts | 55 +++++-- .../extraction/ArtifactExtraction.ts | 8 +- src/artifacts/inventory/reader.ts | 2 +- src/artifacts/inventory/scanReader.ts | 15 +- .../javascript/JavaScriptArtifactFiles.ts | 24 ++- .../javascript/JavaScriptArtifactReader.ts | 2 +- .../javascript/javascriptArtifactFiles.ts | 7 +- .../javascriptDirectoryCoverage.test.ts | 153 ++++++++++++++++++ 14 files changed, 343 insertions(+), 30 deletions(-) create mode 100644 tests/boundary/filesystem/javascriptDirectoryCoverage.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index a19427344b..0aa0a9de8b 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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 diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index d3e6132059..6a58932c20 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -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) => diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index e1798d7314..e89b217bf8 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -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; diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index add737714f..4546877d7f 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -329,6 +329,7 @@ it.each([ gap === "opaque" ? { get: (path) => (path === target.path ? target : undefined), + coverage: base.snapshot.coverage, hasDirectory: () => false, } : undefined, diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 8b59d80144..929701e9bd 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -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, }, }), diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 52b4446521..1c160b1f50 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -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; }; diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 029043a57c..b96066adcb 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -94,7 +94,10 @@ export class DirectoryArtifactReader implements ArtifactReader { readonly #files = new Map(); readonly #fileCleanup = new WeakMap(); - 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, diff --git a/src/artifacts/extraction/ArtifactExtraction.ts b/src/artifacts/extraction/ArtifactExtraction.ts index d8e3ec2e11..1b72e1fca1 100644 --- a/src/artifacts/extraction/ArtifactExtraction.ts +++ b/src/artifacts/extraction/ArtifactExtraction.ts @@ -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 ( diff --git a/src/artifacts/inventory/reader.ts b/src/artifacts/inventory/reader.ts index 4951573d44..c483848676 100644 --- a/src/artifacts/inventory/reader.ts +++ b/src/artifacts/inventory/reader.ts @@ -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, diff --git a/src/artifacts/inventory/scanReader.ts b/src/artifacts/inventory/scanReader.ts index 6f00661a3f..0ae61b643d 100644 --- a/src/artifacts/inventory/scanReader.ts +++ b/src/artifacts/inventory/scanReader.ts @@ -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 { diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index d052c48d60..e5fd6dfba9 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -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 = diff --git a/src/artifacts/javascript/JavaScriptArtifactReader.ts b/src/artifacts/javascript/JavaScriptArtifactReader.ts index a5ab77d8d6..4f946b2ef5 100644 --- a/src/artifacts/javascript/JavaScriptArtifactReader.ts +++ b/src/artifacts/javascript/JavaScriptArtifactReader.ts @@ -16,4 +16,4 @@ export const createJavaScriptArtifactReader = ( ? {} : { headerAdmission: asarHeaderAdmission }, ) - : new DirectoryArtifactReader(path); + : new DirectoryArtifactReader(path, "omit"); diff --git a/src/domain/javascript/javascriptArtifactFiles.ts b/src/domain/javascript/javascriptArtifactFiles.ts index faefbc3b85..4fcc1ba6bf 100644 --- a/src/domain/javascript/javascriptArtifactFiles.ts +++ b/src/domain/javascript/javascriptArtifactFiles.ts @@ -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; } diff --git a/tests/boundary/filesystem/javascriptDirectoryCoverage.test.ts b/tests/boundary/filesystem/javascriptDirectoryCoverage.test.ts new file mode 100644 index 0000000000..d47ea4f6d7 --- /dev/null +++ b/tests/boundary/filesystem/javascriptDirectoryCoverage.test.ts @@ -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"), + "Fixture", + ); + 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); + } + }, +);