diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 18175ef8c9..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; @@ -629,6 +632,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/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/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/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", 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/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/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/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/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/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/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); 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/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/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)"), + ); + }, +); 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"], + ]); +}); 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-");