diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 3b52f2ccca..65bef2a9a4 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -289,27 +289,28 @@ export function* indexJavaScriptAmbientWritesSteps( } /** - * Recognize an ambient name or direct global-object property without a visible + * Recognize an ambient property path, optionally through a global object, without a visible * source-local overwrite. This is a syntax-local identity fact, not proof that * the runtime's globals have never been patched by other code. */ export const isUnmodifiedGlobalReference = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, - name: string, + name: string | readonly [string, ...string[]], ): boolean => { - const node = unwrapJavaScriptExpression(callee).node; - if (state.ambientWrites.covers(["global", name])) return false; - if (isUnshadowedGlobal(node, state, name)) return true; - if ( - !t.isMemberExpression(node) || - semanticStaticPropertyKey(node.property, node.computed) !== name - ) - return false; - const root = unwrapJavaScriptExpression(node.object).node; + const path: readonly [string, ...string[]] = + typeof name === "string" ? [name] : name; + if (state.ambientWrites.covers(["global", ...path])) return false; + const { root, members } = electronMemberPath(callee); + const matches = (expected: readonly string[]) => + members.length === expected.length && + members.every((key, index) => key === expected[index]); + if (isUnshadowedGlobal(root, state, path[0]) && matches(path.slice(1))) + return true; const namespace = globalObjectName(root, state); return ( namespace !== undefined && + matches(path) && !state.ambientWrites.covers(["global", namespace]) ); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 008da7dd47..5c5b7442f6 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -102,17 +102,22 @@ export const inspectCall = ( specifier: workerConstructor === undefined || worker === undefined ? undefined - : workerUrlSpecifier(worker, bindings), + : moduleUrlSpecifier(worker, bindings), }); - // Service worker script URLs are also relative to the document base URL. + // Service worker strings also use the document base URL. The complete called + // path must retain its platform identity, including the register method. if ( - name === "serviceWorker.register" || - name.endsWith(".serviceWorker.register") + !t.isNewExpression(node) && + isUnmodifiedGlobalReference(node.callee, bindings, [ + "navigator", + "serviceWorker", + "register", + ]) ) addReference(context, { node, kind: "service-worker", - specifier: undefined, + specifier: moduleUrlSpecifier(node, bindings), }); inspectEndpointCall(node, name, first, context); inspectStorageCall(node, name, first, context); @@ -151,11 +156,11 @@ export const inspectCall = ( }); }; -const workerUrlSpecifier = ( - worker: t.NewExpression, +const moduleUrlSpecifier = ( + call: JavaScriptCallLike, bindings: JavaScriptSemanticAnalysisState, ): string | undefined => { - const argumentExpression = argumentNode(worker.arguments[0]); + const argumentExpression = argumentNode(call.arguments[0]); if (argumentExpression === undefined) return undefined; const node = unwrapJavaScriptExpression(argumentExpression).node; if (!t.isNewExpression(node)) return undefined; diff --git a/src/domain/javascript/javascriptStaticAnalysisServiceWorkers.test.ts b/src/domain/javascript/javascriptStaticAnalysisServiceWorkers.test.ts new file mode 100644 index 0000000000..952cb62c5c --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisServiceWorkers.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const serviceWorkers = (source: string) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + return analysis.references + .filter(({ kind }) => kind === "service-worker") + .map(({ specifier, expression }) => ({ specifier, expression })); +}; + +describe("service-worker API identity", () => { + it.each([ + "navigator.serviceWorker.register", + "window.navigator.serviceWorker.register", + "self.navigator.serviceWorker.register", + "globalThis.navigator.serviceWorker.register", + "window?.navigator?.serviceWorker?.register", + 'navigator["serviceWorker"]["register"]', + ])("resolves a module-relative URL for %s", (callee) => { + const expression = `${callee}(new URL("./sw.js?version=2", import.meta.url))`; + expect(serviceWorkers(expression)).toEqual([ + { specifier: "./sw.js?version=2", expression: null }, + ]); + }); + + it.each([ + 'const navigator = vendor; navigator.serviceWorker.register("./sw.js");', + 'import navigator from "vendor"; navigator.serviceWorker.register("./sw.js");', + 'function register(navigator) { navigator.serviceWorker.register("./sw.js"); }', + 'vendor.serviceWorker.register("./sw.js");', + 'serviceWorker.register("./sw.js");', + 'navigator["serviceWorker.register"]("./sw.js");', + 'navigator.serviceWorker[method]("./sw.js");', + 'navigator.serviceWorker.register = load; navigator.serviceWorker.register(new URL("./ordinary.js", import.meta.url));', + 'const sw = navigator.serviceWorker; sw.register = load; navigator.serviceWorker.register("./sw.js");', + 'Object.assign(navigator.serviceWorker, { register: load }); navigator.serviceWorker.register("./sw.js");', + 'Reflect.set(navigator.serviceWorker, "register", load); navigator.serviceWorker.register("./sw.js");', + 'navigator.serviceWorker = vendor; navigator.serviceWorker.register("./sw.js");', + 'window.navigator = vendor; navigator.serviceWorker.register("./sw.js");', + 'window = vendor; window.navigator.serviceWorker.register("./sw.js");', + ])("does not attribute a replaced or unproven API: %s", (source) => { + expect(serviceWorkers(source)).toEqual([]); + }); + + it.each([ + '"./sw.js"', + 'new URL("./sw.js", document.baseURI)', + 'new URL("/sw.js", import.meta.url)', + 'new URL("./sw.js", import.meta.url)', + ])( + "retains the expression when its module-relative URL is unknown: %s", + (argument) => { + // The last case is a replaced URL constructor, not the platform intrinsic. + const prefix = + argument === 'new URL("./sw.js", import.meta.url)' + ? "const URL = vendor; " + : ""; + const expression = `navigator.serviceWorker.register(${argument})`; + expect(serviceWorkers(prefix + expression)).toEqual([ + { specifier: null, expression }, + ]); + }, + ); + + it("does not invalidate registration for an unrelated member write", () => { + const expression = + 'navigator.serviceWorker.register(new URL("./sw.js", import.meta.url))'; + expect( + serviceWorkers( + `navigator.serviceWorker.unrelated = value; ${expression}`, + ), + ).toEqual([{ specifier: "./sw.js", expression: null }]); + }); +}); diff --git a/tests/acceptance/applications/javascriptServiceWorkerCli.test.ts b/tests/acceptance/applications/javascriptServiceWorkerCli.test.ts new file mode 100644 index 0000000000..c362a773b7 --- /dev/null +++ b/tests/acceptance/applications/javascriptServiceWorkerCli.test.ts @@ -0,0 +1,56 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest( + "attributes service-worker module URLs through the compiled CLI", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-service-worker-identity-"); + await writeFixtureFiles(root, { + "app/module.mjs": + 'navigator.serviceWorker.register(new URL("./sw.js?version=2", import.meta.url));', + "app/document.mjs": 'navigator.serviceWorker.register("./sw.js");', + "app/replaced.mjs": + 'navigator.serviceWorker.register = load; navigator.serviceWorker.register(new URL("./ordinary.js", import.meta.url));', + "app/vendor.mjs": + 'import navigator from "vendor"; navigator.serviceWorker.register(new URL("./ordinary.js", import.meta.url));', + "app/sw.js": "self.addEventListener('fetch', () => {});", + "app/ordinary.js": "export const ordinary = true;", + }); + const result = await analyzeJavaScriptDirectory("CLI", cli, root); + expect(result.statistics.parse_failures).toBe(0); + const workers = result.graph.nodes.filter( + ({ kind }) => kind === "service-worker", + ); + expect(workers).toHaveLength(2); + expect( + workers.flatMap(({ observations }) => + observations.map(({ properties }) => properties), + ), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + declared_specifier: "./sw.js?version=2", + resolved_path: "app/sw.js", + resolution_status: "resolved", + }), + expect.objectContaining({ + declared_specifier: null, + resolved_path: null, + expression: 'navigator.serviceWorker.register("./sw.js")', + }), + ]), + ); + const mapped = result.graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && + workers.some(({ node_id }) => node_id === source_node_id), + ); + expect(mapped.map(({ properties }) => properties.resolved_path)).toEqual([ + "app/sw.js", + ]); + }, +);