From 3ded799bc7b9a588404913b4064aff8c1412a045 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:12:36 -0700 Subject: [PATCH 1/5] fix(reference): never resolve a directory to an extensionless index (#3789) resolveInternalSpecifier offered posix.join(dir, "index") as the second candidate, ahead of every extension. With both dep.js and an extensionless dep/index present, require("./dep") therefore resolved to dep/index, a file that neither Node nor TypeScript ever selects. Directory candidates now carry an extension. File and index ordering is otherwise unchanged, so Node-validated cases such as dep/index.js winning over dep.cjs keep their meaning. Fixes #3381 Co-authored-by: Claude Opus 5.5 --- src/application/ReferenceSourceImportEntries.ts | 4 +++- .../boundary/cli/referenceSourceDirectoryImports.test.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index d2602df044..c53df6a9d1 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -110,9 +110,11 @@ const resolveInternalSpecifier = ( const directoryReference = specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier)); const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"]; + // A directory resolves only to an index with an extension; neither Node nor + // TypeScript selects an extensionless `index` file. const candidates = suffixes.flatMap((suffix) => [ ...(directoryReference ? [] : [`${normalized}${suffix}`]), - posix.join(normalized, `index${suffix}`), + ...(suffix === "" ? [] : [posix.join(normalized, `index${suffix}`)]), ]); // Retain present runtime files first; substitute only a missing counterpart. // Counterparts cover explicit filenames, not directory/index resolution. diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts index 6270855cbc..8fbd8a421c 100644 --- a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -29,11 +29,13 @@ describe.skipIf(process.platform === "win32")( ["direct.cjs", "./lib.js", "lib.js"], ["fallback.cjs", "./fallback", "fallback/index.js"], ["legacy-order.cjs", "./dep", "dep/index.js"], + ["bare-index.cjs", "./bare", "bare.js"], ] as const; const unresolved = [ ["missing.cjs", "./missing/", "missing/"], ["escape.cjs", "../outside/", "../outside/"], ["main.ts", "./folder.js/.", "folder.js"], + ["only-bare.cjs", "./only/", "only/"], ] as const; const files = new Map([ ["index.js", 'module.exports = "root index";\n'], @@ -48,6 +50,9 @@ describe.skipIf(process.platform === "win32")( 'export const value = "emitted filename counterpart";\n', ], ["missing.js", 'module.exports = "not a directory";\n'], + ["bare.js", 'module.exports = "file before directory";\n'], + ["bare/index", 'module.exports = "extensionless index";\n'], + ["only/index", 'module.exports = "extensionless index";\n'], ...[...imports, ...unresolved].map( ([path, specifier]) => [path, `require(${JSON.stringify(specifier)});\n`] as const, @@ -75,6 +80,9 @@ describe.skipIf(process.platform === "win32")( expect(() => createRequire(join(root, "main.ts")).resolve("./folder.js/."), ).toThrow(); + expect(() => + createRequire(join(root, "only-bare.cjs")).resolve("./only/"), + ).toThrow(); expect( createRequire(join(root, "escape.cjs")).resolve("../outside/"), ).toBe(outside); From 90c69229050cd251f86294a6f3ff5e22abf6912f Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:12:49 -0700 Subject: [PATCH 2/5] fix(javascript): keep empty URL path segments significant (#3792) * fix(javascript): keep empty URL path segments significant ESM imports and HTML script references were joined with POSIX path rules, which collapse '//' before applying '..'. URL resolution removes the empty segment instead, so './assets//../app.js' names assets/app.js, not app.js, and the graph linked the importer and renderer to the wrong file. URL references refused every '//' even when dot segments cancel it. Resolve ESM, HTML (including document base) and URL references through one URL dot-segment helper. A remaining empty segment stays unavailable, references above the artifact root are still rejected, and CommonJS keeps filesystem path semantics. Fixes #3307 Co-Authored-By: Claude Opus 5.5 * fix(javascript): resolve HTML base and reference as one URL path The base href was turned into a canonical path before the script reference was applied. A base empty segment that the reference's '..' would remove was rejected early, and an above-root '..' from the base became a removable segment that the reference's '..' could cancel. Keep one URL path state through base and reference resolution, with above-root escapes held apart from removable segments, and reject empty segments only in the final path. Remove the superseded htmlBaseDirectory. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 4 + .../JavaScriptArtifactPathResolution.test.ts | 51 ++++++- .../JavaScriptArtifactPathResolution.ts | 129 ++++++++++++++---- .../JavaScriptHtmlBaseResolution.test.ts | 21 +++ 4 files changed, 176 insertions(+), 29 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 18175ef8c9..07548202e9 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -629,6 +629,10 @@ href are read without surrounding whitespace or embedded tabs and newlines, and their percent-encoded path bytes are decoded; encoded dot and separator bytes are still rejected. CommonJS module lookups retain extension and directory resolution. +ESM, HTML, and URL references apply URL dot segments to empty path segments: +`./assets//../app.js` selects `assets/app.js`, while a remaining empty segment +such as `./assets//app.js` stays unavailable. CommonJS keeps filesystem path +semantics and selects `app.js` for the same text. Unresolved HTML references retain their declaration, source range, and resolution reason in the renderer observations. HTML script source ranges follow the HTML parser across LF, CRLF, and bare CR diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 77a448b4a3..dc0ccf5e61 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -2495,20 +2495,61 @@ describe("url-reference directory and empty-segment semantics", () => { }); }); - it.each(["./nested//worker.js", "./nested//../worker.js"])( - "does not collapse empty URL path segments: %s", - (declaredPath) => { + it.each([ + ["./nested//worker.js", null], + ["./nested//../worker.js", "dist/nested/worker.js"], + ])( + "keeps empty URL path segments significant: %s", + (declaredPath, resolvedPath) => { + // A dot segment removes the preceding empty segment, never `nested`. + expect(new URL(declaredPath, "file:///root/dist/app.mjs").pathname).toBe( + resolvedPath === null + ? "/root/dist/nested//worker.js" + : `/root/${resolvedPath}`, + ); + for (const context of ["url-reference", "html-reference"] as const) + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context, + files: fileMap([ + file("dist/worker.js", "root"), + file("dist/nested/worker.js", "root"), + ]), + }), + context, + ).toMatchObject({ resolved_path: resolvedPath }); expect( resolve({ declaredPath, sourcePath: "dist/app.mjs", - context: "url-reference", + context: "module-specifier", + moduleKind: "import", files: fileMap([ file("dist/worker.js", "root"), file("dist/nested/worker.js", "root"), ]), }), - ).toMatchObject({ resolved_path: null }); + ).toMatchObject({ resolved_path: resolvedPath }); + // CommonJS keeps filesystem path semantics. + expect( + resolve({ + declaredPath, + sourcePath: "dist/app.mjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file("dist/worker.js", "root"), + file("dist/nested/worker.js", "root"), + ]), + }), + ).toMatchObject({ + resolved_path: + declaredPath === "./nested//worker.js" + ? "dist/nested/worker.js" + : "dist/worker.js", + }); }, ); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index 7a3e4c89e0..94ffbc7725 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -551,22 +551,24 @@ const contextualCandidate = ( return unresolvedCandidate("rejected", [ "The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - // Empty URL segments are significant; canonical filesystem paths cannot - // preserve them, including their interaction with following dot segments. - if (decoded.includes("//")) - return unresolvedCandidate("unavailable", [ - "Repeated URL path separators cannot be mapped to an exact canonical artifact file.", - ]); - return exactUrlPathCandidate( + const candidate = urlRelativeCandidate( + posix.dirname(input.sourcePath), decoded, - relativeCandidate(input.sourcePath, decoded), - "URL", ); + return typeof candidate === "string" + ? exactUrlPathCandidate(decoded, candidate, "URL") + : candidate; } - const candidate = relativeCandidate(input.sourcePath, declared); - return context === "module-specifier" && input.moduleKind === "import" - ? exactUrlPathCandidate(declared, candidate, "module URL") - : candidate; + if (context === "module-specifier" && input.moduleKind === "import") { + const candidate = urlRelativeCandidate( + posix.dirname(input.sourcePath), + declared, + ); + return typeof candidate === "string" + ? exactUrlPathCandidate(declared, candidate, "module URL") + : candidate; + } + return relativeCandidate(input.sourcePath, declared); }; const decodeModuleUrlPath = ( @@ -593,6 +595,78 @@ const relativeCandidate = (sourcePath: string, declared: string): string => ? declared.slice(1) : posix.join(posix.dirname(sourcePath), declared); +/** + * A URL path during reference resolution. Above-root `..` markers are kept + * apart from segments, so a later `..` cannot cancel an escape, while empty + * segments stay removable until the final path is formed. + */ +interface UrlPathState { + readonly escapes: number; + readonly segments: readonly string[]; +} + +const urlDirectoryState = (directory: string): UrlPathState => ({ + escapes: 0, + segments: directory === "" || directory === "." ? [] : directory.split("/"), +}); + +/** + * Apply URL dot segments: `..` removes the previous segment even when it is + * empty, so `assets//../app.js` names `assets/app.js`. A final `.` or `..` + * leaves directory form, as the URL parser does. + */ +const applyUrlReference = ( + base: UrlPathState, + reference: string, +): UrlPathState => { + const absolute = reference.startsWith("/"); + const segments = absolute ? [] : [...base.segments]; + let escapes = absolute ? 0 : base.escapes; + const parts = (absolute ? reference.slice(1) : reference).split("/"); + for (const [index, part] of parts.entries()) { + if (part !== "." && part !== "..") { + segments.push(part); + continue; + } + if (part === "..") + if (segments.length > 0) segments.pop(); + else escapes += 1; + if (index === parts.length - 1) segments.push(""); + } + return { escapes, segments }; +}; + +/** A relative reference replaces the base URL's final path segment. */ +const urlBaseDirectory = (base: UrlPathState): UrlPathState => ({ + escapes: base.escapes, + segments: base.segments.slice(0, -1), +}); + +/** + * Form the artifact candidate. A remaining empty segment cannot name a + * canonical artifact file; a trailing one is directory form. Confinement + * rejects a candidate that keeps an above-root marker. + */ +const urlPathCandidate = (path: UrlPathState): string | CandidateResolution => { + const segments = + path.segments.at(-1) === "" ? path.segments.slice(0, -1) : path.segments; + if (segments.includes("")) + return unresolvedCandidate("unavailable", [ + "Repeated URL path separators cannot be mapped to an exact canonical artifact file.", + ]); + return [ + ...Array.from({ length: path.escapes }, () => ".."), + ...segments, + ].join("/"); +}; + +/** Resolve a URL path reference against a canonical directory. */ +const urlRelativeCandidate = ( + directory: string, + reference: string, +): string | CandidateResolution => + urlPathCandidate(applyUrlReference(urlDirectoryState(directory), reference)); + const bareModuleCandidate = ( input: ResolveArtifactPathInput, ): CandidateResolution => { @@ -1066,11 +1140,12 @@ const htmlCandidate = ( return unresolvedCandidate("rejected", [ "The HTML reference path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - if (declaredPath.startsWith("/")) return declaredPath.slice(1); + if (declaredPath.startsWith("/")) + return urlRelativeCandidate("", declaredPath); if (base === undefined || base === null || base === "") return declaredPath === "" ? input.sourcePath - : posix.join(posix.dirname(input.sourcePath), declaredPath); + : urlRelativeCandidate(posix.dirname(input.sourcePath), declaredPath); // A local base href is a second untrusted path input; apply the same // admission rules a declared path gets so it cannot smuggle traversal or // separator syntax past canonicalization. @@ -1083,12 +1158,21 @@ const htmlCandidate = ( return unresolvedCandidate("rejected", [ "The document base href path percent-decodes to NUL or to bytes that are not UTF-8.", ]); - const basePath = decodedBase.startsWith("/") - ? decodedBase.slice(1) - : posix.join(posix.dirname(input.sourcePath), decodedBase); - if (declaredPath === "") - return exactUrlPathCandidate(decodedBase, basePath, "HTML"); - return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath); + // Keep the base's empty segments and escapes until the reference is + // applied; only the final path must name a canonical artifact file. + const baseState = applyUrlReference( + urlDirectoryState(posix.dirname(input.sourcePath)), + decodedBase, + ); + if (declaredPath === "") { + const basePath = urlPathCandidate(baseState); + return typeof basePath === "string" + ? exactUrlPathCandidate(decodedBase, basePath, "HTML") + : basePath; + } + return urlPathCandidate( + applyUrlReference(urlBaseDirectory(baseState), declaredPath), + ); }; const exactUrlPathCandidate = ( @@ -1112,9 +1196,6 @@ const urlPathIsDirectory = (path: string): boolean => path === "." || path === ".."; -const htmlBaseDirectory = (base: string, basePath: string): string => - urlPathIsDirectory(base) ? basePath : posix.dirname(basePath); - const confineCandidate = (candidate: string): string | CandidateResolution => { const normalized = normalizeJoinedLogicalPath(candidate); if (logicalPathEscapesRoot(normalized)) diff --git a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts index 1da6539a03..1a4bbd8503 100644 --- a/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts +++ b/src/application/javascript/JavaScriptHtmlBaseResolution.test.ts @@ -6,6 +6,7 @@ const paths = [ "assets/app.js", "renderer/app.js", "renderer/a/app.js", + "renderer/assets/app.js", "app.js", ]; const files = new Map( @@ -105,6 +106,26 @@ describe("HTML base href dot segments", () => { }, ); }); +describe("HTML base href empty segments", () => { + it("lets the script reference remove an empty segment from the base", () => { + // URL resolution keeps the base's empty segment until `..` removes it. + expect( + new URL("../app.js", new URL("./assets//", documentUrl)).pathname, + ).toBe("/renderer/assets/app.js"); + expect(resolve("../app.js", "./assets//")).toMatchObject({ + resolution_status: "resolved", + resolved_path: "renderer/assets/app.js", + }); + }); + it("keeps an above-root base escape when the reference also climbs", () => { + expect(resolve("../app.js", "../../")).toMatchObject({ + resolution_status: "rejected", + limitations: [ + "The resolved candidate escapes the canonical artifact root.", + ], + }); + }); +}); describe("HTML base href path syntax", () => { it.each(["%2e%2e/secret/", "/a/%2e%2e/", "/a\\b/", "a\0b"])( "rejects base href %j with the same admission rules a declared path gets", From 7a66470385636e57191f964ca584484e4bd01d34 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:13:01 -0700 Subject: [PATCH 3/5] fix(apple): report plist dates the decoder cannot represent as unknown (#3793) plutil writes years past 9999 and negative years in a form the pinned XML decoder cannot parse, and binary plists can hold dates outside the JavaScript Date range. The decoder returns an Invalid Date. Its projection's toISOString() call then threw, so inspect_plist failed the whole plist as "unreadable_output" with a `rea doctor` remediation. Keyed archive inspection and Interface Builder decoding rejected the archive in the same way. projectPlistValue, the shared projection, now reports such a date as { "$plist_type": "date", "iso8601": null } and counts it. inspect_plist, keyed archive inspection and Interface Builder decoding each add a counted limitation, as they already do for non-finite reals. Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 6 +++ .../apple/InterfaceBuilderAnalysis.ts | 33 ++++++++++--- src/artifacts/apple/KeyedArchiveReader.ts | 16 +++++-- src/domain/apple/plistValue.test.ts | 14 ++++++ src/domain/apple/plistValue.ts | 17 +++++-- src/native/parsers/plist.ts | 4 ++ tests/boundary/cli/nativePlistCli.test.ts | 4 ++ .../filesystem/interfaceBuilderBundle.test.ts | 27 +++++++++++ .../filesystem/keyedArchiveDateRange.test.ts | 48 +++++++++++++++++++ 9 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 tests/boundary/filesystem/keyedArchiveDateRange.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index b9b42dcb8b..ecda41a465 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -570,6 +570,12 @@ Exact and ambiguous value counts describe emitted root/object value instances; a root and an object may contain the same value. Archive digests identify the original bytes. +Dates are reported as `{ "$plist_type": "date", "iso8601": "" }`. A +date the decoder cannot represent, such as an XML date with a year beyond 9999 +or a negative year, is reported as `{ "$plist_type": "date", "iso8601": null }` +with a counted limitation; the rest of the plist or archive is still decoded. +`inspect_plist` and Interface Builder decoding use the same projection. + ## Locate a byte signature Use `search_bytes` after opening a target with Ghidra to locate a known constant diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 00db7f03c7..32760e0820 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -7,7 +7,10 @@ import { isUIKitRuntimeConnectionClass, } from "../../domain/apple/interfaceBuilderKeyedArchive.js"; import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js"; -import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { + projectPlistValue, + unknownDateLimitation, +} from "../../domain/apple/plistValue.js"; import { omittedPrototypeKeysLimitation, parseXmlPropertyList, @@ -58,6 +61,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { const incompleteHierarchies = new Map(); const unresolvedConnections = new Map(); const prototypeKeyOmissions = new Map(); + const unknownDates = new Map(); let omitted = 0; let attempted = 0; let aggregateInputBytes = 0; @@ -131,16 +135,21 @@ export const analyzeInterfaceBuilderBundle = async (input: { binaryPrototypeKeyObserved = decodedReservation.binaryPrototypeKeyObserved; } - const { value: raw, omittedPrototypeKeys } = - nib === null - ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved) - : { value: nib.raw, omittedPrototypeKeys: 0 }; + const { + value: raw, + omittedPrototypeKeys, + unknownDateCount, + } = nib === null + ? decodePlist(bytes, xmlText, binaryPrototypeKeyObserved) + : { value: nib.raw, omittedPrototypeKeys: 0, unknownDateCount: 0 }; if (nib !== null && nib.omitted > 0) incompleteHierarchies.set(entry.path, nib.omitted); if (nib !== null && nib.unresolvedConnections > 0) unresolvedConnections.set(entry.path, nib.unresolvedConnections); if (omittedPrototypeKeys !== 0) prototypeKeyOmissions.set(entry.path, omittedPrototypeKeys); + if (unknownDateCount !== 0) + unknownDates.set(entry.path, unknownDateCount); const documentHash = createHash("sha256").update(bytes).digest("hex"); documents.push({ relativePath: entry.path, @@ -178,6 +187,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { incompleteHierarchies, unresolvedConnections, prototypeKeyOmissions, + unknownDates, invalid, omittedArchives, unreportedOmittedArchives, @@ -246,6 +256,7 @@ const finalizeHierarchyCoverage = ( incompleteHierarchies: ReadonlyMap; unresolvedConnections: ReadonlyMap; prototypeKeyOmissions: ReadonlyMap; + unknownDates: ReadonlyMap; invalid: readonly string[]; omittedArchives: readonly { path: string; reason: string }[]; unreportedOmittedArchives: number; @@ -257,6 +268,7 @@ const finalizeHierarchyCoverage = ( incompleteHierarchies, unresolvedConnections, prototypeKeyOmissions, + unknownDates, invalid, omittedArchives, unreportedOmittedArchives, @@ -342,6 +354,9 @@ const finalizeHierarchyCoverage = ( ...[...prototypeKeyOmissions].map( ([path, count]) => `${path}: ${omittedPrototypeKeysLimitation(count)}`, ), + ...[...unknownDates].map( + ([path, count]) => `${path}: ${unknownDateLimitation(count)}`, + ), ...omittedArchives.map( ({ path, reason }) => `${path}: omitted because ${reason.replaceAll("_", " ")}.`, @@ -693,6 +708,7 @@ const decodePlist = ( ): { readonly value: JsonValue; readonly omittedPrototypeKeys: number | null; + readonly unknownDateCount: number; } => { const binary = hasBinaryPlistSignature(bytes); const { value, omittedPrototypeKeys } = binary @@ -701,5 +717,10 @@ const decodePlist = ( omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0, } : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); - return { value: projectPlistValue(value).value, omittedPrototypeKeys }; + const projected = projectPlistValue(value); + return { + value: projected.value, + omittedPrototypeKeys, + unknownDateCount: projected.unknownDateCount, + }; }; diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 7bd64511e4..ff1bdf99cd 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -6,7 +6,10 @@ import { AnalysisInputError, AnalysisUnsupportedTargetError, } from "../../domain/analysisErrorCore.js"; -import { projectPlistValue } from "../../domain/apple/plistValue.js"; +import { + projectPlistValue, + unknownDateLimitation, +} from "../../domain/apple/plistValue.js"; import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js"; import { binaryPlistNumberLiterals, @@ -75,9 +78,11 @@ export const decodeKeyedArchiveBytes = ( : 0, } : parseXmlPropertyList(xmlText ?? ""); - const { value: plistValue, unknownRealCount } = projectPlistValue( - parsed.value, - ); + const { + value: plistValue, + unknownRealCount, + unknownDateCount, + } = projectPlistValue(parsed.value); const graph = projectKeyedArchive(plistValue, selection); const numbers = createPlistNumberProjection( binary @@ -112,6 +117,9 @@ export const decodeKeyedArchiveBytes = ( : [ `${String(unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because JSON evidence cannot preserve NaN vs infinity.`, ]), + ...(unknownDateCount === 0 + ? [] + : [unknownDateLimitation(unknownDateCount)]), ], }; }; diff --git a/src/domain/apple/plistValue.test.ts b/src/domain/apple/plistValue.test.ts index 4a3465a15b..65ce87ba92 100644 --- a/src/domain/apple/plistValue.test.ts +++ b/src/domain/apple/plistValue.test.ts @@ -2,6 +2,20 @@ import { expect, it } from "vitest"; import { projectPlistValue } from "./plistValue.js"; +// Binary decoders build dates from seconds; beyond the Date range they are invalid. +it("projects an unrepresentable date as unknown and counts it", () => { + expect( + projectPlistValue({ far: new Date(Number.NaN), near: new Date(0) }), + ).toEqual({ + value: { + far: { $plist_type: "date", iso8601: null }, + near: { $plist_type: "date", iso8601: "1970-01-01T00:00:00.000Z" }, + }, + unknownRealCount: 0, + unknownDateCount: 1, + }); +}); + it("rejects excessive plist nesting without exhausting the stack", () => { const deep: unknown[] = []; let cursor = deep; diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index 418688dd72..3ea0c87331 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -14,8 +14,14 @@ export interface ProjectedPlistValue { readonly value: JsonValue; /** Reals decoded as NaN, which the decoder also uses for ±infinity. */ readonly unknownRealCount: number; + /** Dates the decoder could not represent, reported without a timestamp. */ + readonly unknownDateCount: number; } +/** Limitation for results whose plist held dates the decoder could not represent. */ +export const unknownDateLimitation = (count: number): string => + `${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent them, such as XML dates with years beyond 9999 or negative years, or dates outside the JavaScript date range.`; + /** * Project a decoded plist into JSON. Data and dates become explicit * `$plist_type` objects; a non-finite real becomes an explicit unknown @@ -23,6 +29,7 @@ export interface ProjectedPlistValue { */ export const projectPlistValue = (value: unknown): ProjectedPlistValue => { let unknownRealCount = 0; + let unknownDateCount = 0; const project = (item: unknown, depth: number): JsonValue => { if (depth > MAX_PLIST_DEPTH) throw new RangeError( @@ -33,8 +40,12 @@ export const projectPlistValue = (value: unknown): ProjectedPlistValue => { $plist_type: "data", base64: Buffer.from(item).toString("base64"), }; - if (item instanceof Date) - return { $plist_type: "date", iso8601: item.toISOString() }; + if (item instanceof Date) { + if (!Number.isNaN(item.getTime())) + return { $plist_type: "date", iso8601: item.toISOString() }; + unknownDateCount += 1; + return { $plist_type: "date", iso8601: null }; + } if (typeof item === "number" && !Number.isFinite(item)) { unknownRealCount += 1; return { $plist_type: "real", value: null }; @@ -51,5 +62,5 @@ export const projectPlistValue = (value: unknown): ProjectedPlistValue => { return z.union([z.string(), z.number(), z.boolean(), z.null()]).parse(item); }; const projected = project(value, 0); - return { value: projected, unknownRealCount }; + return { value: projected, unknownRealCount, unknownDateCount }; }; diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index b27f67b1ee..a3b6c4fa5e 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -4,6 +4,7 @@ import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { projectPlistValue, + unknownDateLimitation, type ProjectedPlistValue, } from "../../domain/apple/plistValue.js"; import { @@ -178,6 +179,9 @@ export const parsePlistXml = ( : [ `${String(projected.unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the XML decoder does not distinguish NaN from infinity.`, ]), + ...(projected.unknownDateCount === 0 + ? [] + : [unknownDateLimitation(projected.unknownDateCount)]), ...numberLimitations(exactIntegerCount, ambiguousNumberCount), ...prototypeKeyLimitations(omittedPrototypeKeys), ], diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 324d4e20c5..62ec9ce985 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -96,6 +96,8 @@ describe.skipIf(process.platform !== "darwin")( "CFBundleIdentifiercom.example.types", "BlobAAEC", "Built2020-01-01T00:00:00Z", + // plutil writes years past 9999 unsigned, which the decoder rejects. + "Far11507-08-17T05:20:00Z", "Rationan", "Nested", "Ratio0.5", @@ -130,6 +132,7 @@ describe.skipIf(process.platform !== "darwin")( $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z", }, + Far: { $plist_type: "date", iso8601: null }, Ratio: { $plist_type: "real", value: null }, Nested: [ { @@ -148,6 +151,7 @@ describe.skipIf(process.platform !== "darwin")( limitations: [ expect.stringContaining("XML conversion"), expect.stringContaining("2 non-finite real"), + expect.stringContaining("1 date value(s)"), ], }, }); diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 543248e635..1b2954b7b6 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -961,6 +961,33 @@ describe("bounded Interface Builder archive decoding", () => { ); }); + it("decodes an archive whose date the decoder cannot represent", async () => { + const root = await createTestTempDirectory("rea-ib-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // plutil writes years past 9999 unsigned, which the decoder rejects. + await writeFile( + join(resources, "Far.nib"), + '$archiverNSKeyedArchiver$objects$null11507-08-17T05:20:00Z$top', + ); + + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "f".repeat(64), + }); + + expect(result.documents).toHaveLength(1); + expect(result.limitations).toContainEqual( + expect.stringMatching( + /^Contents\/Resources\/Far\.nib: 1 date value\(s\)/u, + ), + ); + expect(result.limitations).not.toContainEqual( + expect.stringContaining("could not be decoded"), + ); + }); + it.skipIf(!xcodeIbtoolAvailable)( "decodes an Xcode-compiled storyboard NIB and recovers its UI routes", async () => { diff --git a/tests/boundary/filesystem/keyedArchiveDateRange.test.ts b/tests/boundary/filesystem/keyedArchiveDateRange.test.ts new file mode 100644 index 0000000000..9a45bbaa61 --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveDateRange.test.ts @@ -0,0 +1,48 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { build } from "plist"; +import { expect } from "vitest"; + +import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js"; +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "built CLI reports a date the decoder cannot represent as unknown", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-keyed-archive-date-"); + const path = join(root, "archive.plist"); + // plutil writes years past 9999 unsigned, which the decoder rejects. + const xml = build({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: ["$null", "__FAR__", new Date("2020-01-01T00:00:00Z")], + $top: { far: { CF$UID: 1 }, near: { CF$UID: 2 } }, + }).replace( + "__FAR__", + "11507-08-17T05:20:00Z", + ); + await writeFile(path, xml); + const output = await cli.run({ + arguments: ["inspect-keyed-archive", path, "--json"], + environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" }, + }); + expect(output.exitCode).toBe(0); + const result = keyedArchiveResultSchema.parse( + parseEvidence(output.json).normalized_result, + ); + expect(result.objects.map(({ id, value }) => ({ id, value }))).toEqual([ + { id: 0, value: "$null" }, + { id: 1, value: { $plist_type: "date", iso8601: null } }, + { + id: 2, + value: { $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z" }, + }, + ]); + expect(result.limitations).toContainEqual( + expect.stringContaining("1 date value(s)"), + ); + }, +); From af6a9980669129aae882c9216766fc8d9f544079 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:13:17 -0700 Subject: [PATCH 4/5] fix(javascript): keep string Worker paths unresolved without a document base (#3797) A plain new Worker("./w.js") or navigator.serviceWorker.register("./sw.js") resolves against the document base URL in a browser, and a Node worker_threads string resolves against the working directory. Static analysis was resolving browser strings against the calling script, which recorded a resolved mapping to the wrong file. Only new Worker(new URL(path, import.meta.url)) now produces a specifier. Other worker and service-worker strings keep their expression and stay unresolved, matching the existing worker_threads string handling. Fixes #3309 Co-authored-by: Claude Opus 5.5 --- docs/javascript-artifact-reconstruction.md | 5 +- ...scriptStaticAnalysisCalleeBoundary.test.ts | 5 +- .../javascriptStaticAnalysisCalls.ts | 18 ++++-- .../applicationWorkflowCli.test.ts | 54 ++++++++++------- .../javascriptWorkerUrlCli.test.ts | 60 ++++++++++++++++--- ...javascriptApplicationEmptyLiterals.test.ts | 4 +- ...ascriptApplicationTemplateLiterals.test.ts | 2 +- .../javascriptSourceMapLocations.test.ts | 20 +++---- .../javascriptRuntimeReconciliation.ts | 2 +- 9 files changed, 119 insertions(+), 51 deletions(-) diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 07548202e9..e66d52d724 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -223,7 +223,10 @@ Selected bounded text is then parsed as inert data to recover: - Webpack and Rspack chunk registrations and module factories represented as AST literals; - static imports, dynamic imports, CommonJS `require` calls, workers, and - service workers; + service workers (only `new Worker(new URL(path, import.meta.url))` resolves + relative to the calling module; string worker and service-worker paths use + the document base URL or Node's working directory, which static analysis + does not establish, so they keep their expression and stay unresolved); - route, network endpoint, storage, and vendor-marker observations; - local source-map declarations and original source names and content digests; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index 477bef1ac4..cb602ca0f1 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -27,11 +27,12 @@ describe("static callee boundary matching", () => { ["./bare.html", "call:loadFile"], ["https://optional.example.test", "call:win.loadURL"], ]); + // The document base URL is unknown, so the script path stays unresolved. expect( analysis.references .filter(({ kind }) => kind === "service-worker") - .map(({ specifier }) => specifier), - ).toEqual(["./worker.js"]); + .map(({ specifier, expression }) => [specifier, expression]), + ).toEqual([[null, 'navigator.serviceWorker.register("./worker.js")']]); expect( analysis.endpoints .filter(({ mechanism }) => mechanism.endsWith("loadURL")) diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 816ba3eaf2..16a123b8b5 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -95,19 +95,25 @@ export const inspectCall = ( addReference(context, { node, kind: "worker", - // Node string paths use the process cwd or filesystem root, neither of - // which is established by the analyzed source's artifact location. + // A string path is relative to the document base URL in a browser and + // to the process cwd in Node; the analyzed source's artifact location + // establishes neither. Only `new URL(path, import.meta.url)` names a + // location relative to this module. specifier: - (workerConstructor === "node" ? undefined : first) ?? - (workerConstructor === undefined || worker === undefined + workerConstructor === undefined || worker === undefined ? undefined - : workerUrlSpecifier(worker, bindings)), + : workerUrlSpecifier(worker, bindings), }); + // Service worker script URLs are also relative to the document base URL. if ( name === "serviceWorker.register" || name.endsWith(".serviceWorker.register") ) - addReference(context, { node, kind: "service-worker", specifier: first }); + addReference(context, { + node, + kind: "service-worker", + specifier: undefined, + }); inspectEndpointCall(node, name, first, context); inspectStorageCall(node, name, first, context); if (first === "audio" || first === "video") diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index eefa8a7f95..da514f2921 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -541,7 +541,7 @@ it("resolves source-map URL paths once without changing their declarations", asy await writeFile( join(root, "app.js"), cases.map(([url]) => `//# sourceMappingURL=${url}\n`).join("") + - 'new Worker("./worker%20file.js");\n' + + 'new Worker(new URL("./worker%20file.js", import.meta.url));\n' + 'navigator.serviceWorker.register("./service%20worker.js");\n', ); await writeFile(join(root, "worker file.js"), "export const worker = 1;\n"); @@ -598,27 +598,41 @@ it("resolves source-map URL paths once without changing their declarations", asy }), ); } - for (const [kind, specifier, path] of [ - ["worker", "./worker%20file.js", "worker file.js"], - ["service-worker", "./service%20worker.js", "service worker.js"], - ]) { - const worker = graph.nodes.find((node) => node.kind === kind); - expect(worker?.observations).toContainEqual( - expect.objectContaining({ - properties: expect.objectContaining({ - declared_specifier: specifier, - resolved_path: path, - }), + const worker = graph.nodes.find((node) => node.kind === "worker"); + expect(worker?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: "./worker%20file.js", + resolved_path: "worker file.js", }), - ); - expect(graph.edges).toContainEqual( - expect.objectContaining({ - source_node_id: worker?.node_id, - relation: "maps_to", - properties: { resolved_path: path }, + }), + ); + expect(graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: worker?.node_id, + relation: "maps_to", + properties: { resolved_path: "worker file.js" }, + }), + ); + // A service worker script URL is relative to the unestablished document base. + const serviceWorker = graph.nodes.find( + (node) => node.kind === "service-worker", + ); + expect(serviceWorker?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: null, + expression: 'navigator.serviceWorker.register("./service%20worker.js")', + resolved_path: null, }), - ); - } + }), + ); + expect( + graph.edges.some( + ({ source_node_id, relation }) => + source_node_id === serviceWorker?.node_id && relation === "maps_to", + ), + ).toBe(false); expect((await analyzeThroughStdioMcp(root)).graph).toEqual(graph); }, 20_000); diff --git a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts index 977083c760..1039f5d3a1 100644 --- a/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts +++ b/tests/acceptance/applications/javascriptWorkerUrlCli.test.ts @@ -279,12 +279,6 @@ cliTest( specifier: "./worker.js", resolved: "bundled/worker.js", }, - { - source: "nested/literal.mjs", - expression: 'new Worker("./literal-worker.js");', - specifier: "./literal-worker.js", - resolved: "nested/literal-worker.js", - }, ]; await writeFixtureFiles(root, { ...Object.fromEntries( @@ -431,9 +425,6 @@ cliTest( "directory-dot.mjs": "./worker.js/.", "directory-parent.mjs": "./worker.js/child/..", "empty-segment.mjs": "./child//../worker.js", - "empty-worker-url.mjs": "", - "query-only-worker-url.mjs": "?cache=1", - "fragment-only-worker-url.mjs": "#worker", "package.mjs": "./package-worker", "external.mjs": "https://example.test/worker.js", }; @@ -478,6 +469,57 @@ cliTest( }, ); +cliTest( + "keeps string Worker paths unresolved because the document base is unknown", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-worker-document-base-"); + // A browser resolves these strings against the document base URL, not the + // calling script; Node resolves them against the working directory. + const expressions = { + "scripts/main.js": 'new Worker("./worker.js")', + "scripts/service.js": 'navigator.serviceWorker.register("./sw.js")', + }; + await writeFixtureFiles(root, { + "index.html": '', + ...Object.fromEntries( + Object.entries(expressions).map(([path, code]) => [path, `${code};`]), + ), + "worker.js": "self.postMessage('document-relative worker');", + "scripts/worker.js": "self.postMessage('script-relative decoy');", + "scripts/sw.js": "self.postMessage('script-relative decoy');", + }); + + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + for (const [source, expression] of Object.entries(expressions)) { + const node = result.graph.nodes.find( + ({ kind, observations }) => + (kind === "worker" || kind === "service-worker") && + observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === source, + ), + ); + expect(node?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + declared_specifier: null, + expression, + resolved_path: null, + }), + }), + ); + expect( + result.graph.edges.some( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === node?.node_id, + ), + ).toBe(false); + } + }, +); + cliTest( "bounds repeated ambient alias branches through the compiled CLI", async ({ cli }) => { diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 8387acdaae..a575e41a8a 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -148,7 +148,9 @@ it("analyzes a plain long-line loader without package metadata", async () => { for (const node of nodes) { expect(node.identity).toMatchObject({ strategy: "artifact-local-key", - key: node.kind === "endpoint" ? literalEndpointKey("") : "", + // A Worker string is relative to the unestablished document base, so + // its identity is the retained expression rather than a specifier. + key: node.kind === "endpoint" ? literalEndpointKey("") : 'new Worker("")', }); expect(node.observations.map(({ label }) => label)).toEqual([null]); } diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts index 55807d6de7..1e1f4d970b 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationTemplateLiterals.test.ts @@ -55,7 +55,7 @@ const graphIdentities = async (source: string) => { it.each([ "fetch('https://api.example.com/v1/users');", "localStorage.setItem('token', 1);", - "new Worker('./worker.js');", + "new Worker(new URL('./worker.js', import.meta.url));", "const dep = require('./dep.js'); dep();", "import('./dep.js');", ])( diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index d9d4c8ffb5..21d5719e06 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -168,13 +168,13 @@ it("associates each source-map URL with its edge when aliases share one physical }); }); -it("resolves a bare source-map file name and worker URL next to the script", async () => { +it("resolves a bare source-map file name and module worker URL next to the script", async () => { const root = await createTestTempDirectory("rea-source-map-relative-"); await mkdir(join(root, "dist")); await writeFile( join(root, "dist", "app.js"), [ - 'new Worker("worker.js");', + 'new Worker(new URL("worker.js", import.meta.url));', 'navigator.serviceWorker.register("sw.js");', "//# sourceMappingURL=app.js.map", ].join("\n"), @@ -228,12 +228,12 @@ it("resolves a bare source-map file name and worker URL next to the script", asy expect(workerEdge?.properties).toMatchObject({ resolved_path: "dist/worker.js", }); - const serviceWorkerEdge = result.graph.edges.find( - ({ relation, source_node_id }) => - relation === "maps_to" && - nodes.get(source_node_id)?.kind === "service-worker", - ); - expect(serviceWorkerEdge?.properties).toMatchObject({ - resolved_path: "dist/sw.js", - }); + // A service worker URL is relative to the unestablished document base. + expect( + result.graph.edges.some( + ({ relation, source_node_id }) => + relation === "maps_to" && + nodes.get(source_node_id)?.kind === "service-worker", + ), + ).toBe(false); }); diff --git a/tests/fixtures/javascriptRuntimeReconciliation.ts b/tests/fixtures/javascriptRuntimeReconciliation.ts index baca10aeb6..2f0310ce46 100644 --- a/tests/fixtures/javascriptRuntimeReconciliation.ts +++ b/tests/fixtures/javascriptRuntimeReconciliation.ts @@ -7,7 +7,7 @@ import { createWebTextArtifact } from "../../src/domain/webContentArtifact.js"; import { analyzeJavaScriptApplication } from "../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "./temporaryDirectory.js"; -export const SOURCE = `const worker = new Worker("./worker.js");\nexport const observed = worker;\n`; +export const SOURCE = `const worker = new Worker(new URL("./worker.js", import.meta.url));\nexport const observed = worker;\n`; export const applicationFixture = async (): Promise => { const root = await createTestTempDirectory("rea-runtime-reconciliation-"); From 4692bb33300c7cb77b095b6cc0c33da861c1b860 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Wed, 14 Oct 2026 19:23:06 -0700 Subject: [PATCH 5/5] fix(reference): stop attributing overwritten require members to Node (#3801) * fix(reference): stop attributing overwritten require members to Node import-reference-source treated require.resolve(...) and require.main(...) as Node's resolver even after the file replaced that member, so a literal argument became a parsed internal dependency that the source never loads. Attribute member calls only while the loader's resolve and main members keep Node's identity: no write to either member or a computed member (assignment, update, delete, destructuring) and no use of require as a value where a mutator or alias could replace them. Bare require(...) calls are unaffected. Fixes #3393 Co-Authored-By: Claude Opus 5.5 * fix(reference): keep optional and wrapped loader uses intact An optional call require?.(...) or a TypeScript or parenthesis wrapper around require was read as require escaping as a value, which disabled every member-call link in the file. Look through inert wrappers and accept optional calls before deciding the loader escaped, and add controls for bare calls after a member overwrite, a local loader's member write, and optional calls. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/domain/referenceSourceRequireBindings.ts | 87 +++++++++++++++++++ .../referenceSourceShadowedRequire.test.ts | 64 ++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index f2f8d1ef54..234121a512 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -1,5 +1,11 @@ import * as t from "@babel/types"; +import { + semanticStaticPropertyKey, + semanticWriteSiteTarget, + semanticWriteTargets, + unwrapJavaScriptExpression, +} from "./javascript/javascriptAstValues.js"; import { analyzeParsedJavaScriptReferences } from "./javascript/javascriptSemanticAnalysis.js"; import { semanticBindingId, @@ -49,6 +55,22 @@ export const unshadowedReferenceSourceRequireCalls = ( (reference.resolution === "unbound" || reference.bindingId === programRequireBinding), ); + const isLoaderReference = (identifier: t.Identifier): boolean => { + const start = identifier.loc?.start; + const reference = + start === undefined + ? undefined + : references.get(`${start.line}:${start.column}`); + return ( + (allowsUnboundRequire && reference?.resolution === "unbound") || + (reference?.resolution === "resolved" && + reference.bindingId === programRequireBinding) + ); + }; + const loaderMembersIntact = requireLoaderMembersIntact( + file.program, + isLoaderReference, + ); const calls = new Set(); traverseJavaScriptAst(file.program, { @@ -64,6 +86,9 @@ export const unshadowedReferenceSourceRequireCalls = ( ) ) return; + // `require.resolve` and `require.main` denote Node's members only + // while no visible write or escape can have replaced them. + if (!t.isIdentifier(node.callee) && !loaderMembersIntact) return; const reference = references.get(`${start.line}:${start.column}`); if ( (allowsUnboundRequire && @@ -80,6 +105,68 @@ export const unshadowedReferenceSourceRequireCalls = ( return calls; }; +/** + * Whether the loader's `resolve` and `main` members keep Node's identity. A + * write to either member or to a computed member, or `require` used as a + * value other than a call or member read (where a mutator or alias can + * replace members), leaves that identity unknown. + */ +const requireLoaderMembersIntact = ( + program: t.Program, + isLoaderReference: (identifier: t.Identifier) => boolean, +): boolean => { + let intact = true; + const isLoaderMemberTarget = (target: t.Node): boolean => { + if (!t.isMemberExpression(target) && !t.isOptionalMemberExpression(target)) + return false; + const object = unwrapJavaScriptExpression(target.object).node; + if (!t.isIdentifier(object, { name: "require" })) return false; + if (!isLoaderReference(object)) return false; + const key = semanticStaticPropertyKey(target.property, target.computed); + return key === null || key === "resolve" || key === "main"; + }; + traverseJavaScriptAst(program, { + enter: (node, parent, readAncestors) => { + if (!intact) return; + const site = semanticWriteSiteTarget(node); + if (site !== null) { + for (const target of semanticWriteTargets(site)) + if (isLoaderMemberTarget(target)) intact = false; + return; + } + if ( + !t.isIdentifier(node, { name: "require" }) || + parent === null || + !isLoaderReference(node) + ) + return; + // Inert parentheses and TypeScript wrappers do not change how the + // loader value is used. + let used: t.Node = node; + let user: t.Node | undefined = parent; + const read = readAncestors(); + const ancestors = read.at(-1) === parent ? read : [...read, parent]; + for (let index = ancestors.length - 1; index >= 0; index -= 1) { + user = ancestors[index]; + if ( + user === undefined || + unwrapJavaScriptExpression(user).node !== node + ) + break; + used = user; + } + const usedAsLoader = + ((t.isCallExpression(user) || t.isOptionalCallExpression(user)) && + user.callee === used) || + ((t.isMemberExpression(user) || t.isOptionalMemberExpression(user)) && + user.object === used) || + t.isUnaryExpression(user, { operator: "typeof" }); + if (!usedAsLoader) intact = false; + }, + }); + return intact; +}; + const contains = (container: t.Node, node: t.Node): boolean => typeof container.start === "number" && typeof container.end === "number" && diff --git a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts index 8c438e6473..0c88a38014 100644 --- a/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts +++ b/tests/boundary/filesystem/referenceSourceShadowedRequire.test.ts @@ -119,3 +119,67 @@ it("resolves require calls by lexical binding and source mode", async () => { ["top-level-var.cjs", "loaded.cjs"], ]); }); + +it("does not attribute overwritten require members to Node's resolver", async () => { + const root = await createTestTempDirectory("rea-reference-require-member-"); + const sources: Record = { + "assigned.cjs": + "require.resolve = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));", + "defined.cjs": + "Object.defineProperty(require, 'resolve', { value: () => 'custom' });\nconsole.log(require.resolve('./loaded.cjs'));", + "computed.cjs": + "const key = 'resolve';\nrequire[key] = () => 'custom';\nconsole.log(require.resolve('./loaded.cjs'));", + "control.cjs": "console.log(typeof require.resolve('./loaded.cjs'));", + // A member overwrite does not change bare loader calls. + "bare-after-overwrite.cjs": + "require.resolve = () => 'custom';\nconsole.log(require('./loaded.cjs'));", + // A local loader's member write does not touch Node's loader. + "local-loader.cjs": + "function patch(require) { require.resolve = () => 'custom'; }\npatch({});\nconsole.log(typeof require.resolve('./loaded.cjs'));", + // Optional calls and inert wrappers do not let the loader escape. + "optional-call.cjs": + "require?.('./loaded.cjs');\nconsole.log(typeof (require).resolve('./loaded.cjs'));", + "loaded.cjs": "module.exports = 'real';\n", + }; + await Promise.all( + Object.entries(sources).map(([path, source]) => + writeFile(join(root, path), source), + ), + ); + const runNode = promisify(execFile); + for (const [path, stdout] of [ + ["assigned.cjs", "custom\n"], + ["defined.cjs", "custom\n"], + ["computed.cjs", "custom\n"], + ["control.cjs", "string\n"], + ["bare-after-overwrite.cjs", "real\n"], + ["local-loader.cjs", "string\n"], + ["optional-call.cjs", "string\n"], + ] as const) + expect( + ( + await runNode(process.execPath, [join(root, path)], { + cwd: root, + timeout: 5_000, + }) + ).stdout, + ).toBe(stdout); + + const result = await importReferenceSource({ + root, + caller: "reference-require-member-test", + policy: { secretPatterns: [] }, + }); + if (!result.ok) throw result.error; + expect( + result.value.relationships + .filter(({ kind }) => kind === "requires") + .map(({ from_path, to }) => [from_path, to]) + .sort((left, right) => (left[0] ?? "").localeCompare(right[0] ?? "")), + ).toEqual([ + ["bare-after-overwrite.cjs", "loaded.cjs"], + ["control.cjs", "loaded.cjs"], + ["local-loader.cjs", "loaded.cjs"], + ["optional-call.cjs", "loaded.cjs"], + ]); +});