diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 4816be96c..c15437b08 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -65,6 +65,20 @@ it("projects closure and direct interprocedural flow without execution", () => { ); }); +it("projects a destructuring assignment member target as a property write", () => { + const graph = graphFor(` + const source = { count: 1 }; + [source.count] = [2]; + `); + const operations = graph.nodes.filter( + ({ properties }) => properties.operation_kind !== undefined, + ); + expect(operations.map(({ label }) => label)).toEqual(["write:/count"]); + expect(graph.relations).not.toContainEqual( + expect.objectContaining({ relation: "reads-property" }), + ); +}); + it("keeps dynamic calls explicit and returns complete deterministic results", () => { const graph = graphFor(` const handlers = { ready() { return 1; } }; diff --git a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts index 62771c042..29e2ad64d 100644 --- a/src/application/javascript/JavaScriptSemanticGraphResources.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphResources.test.ts @@ -351,6 +351,7 @@ const emptyAnalysis = (): JavaScriptArtifactAnalysis => ({ packages: [], json_modules: [], html_scripts: [], + html_inline_scripts: [], source_maps: [], visited_ast_nodes: 0, findings: 0, diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts new file mode 100644 index 000000000..6b963965a --- /dev/null +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +// Webpack 5 standalone bootstrap with an Electron external, as emitted for +// `target: "electron-main"`. +const bootstrap = (external: string, consumer: string, entry = "n(100);") => + `(()=>{var m={45840:${external},100:(e,t,n)=>{"use strict";${consumer}}},c={};` + + `function n(t){var r=c[t];if(void 0!==r)return r.exports;var o=c[t]={exports:{}};` + + `return m[t](o,o.exports,n),o.exports}${entry}})();`; + +const EXTERNAL = 'e=>{"use strict";e.exports=require("electron")}'; +const CONSUMER = + 'const s=n(45840);new s.BrowserWindow({webPreferences:{preload:"./preload.js",contextIsolation:!1}});' + + 's.ipcMain.handle("get-connections",async()=>[]);'; + +const electronOf = (source: string) => + analyzeJavaScriptStaticSource(source).electron; + +describe("Electron identity through Webpack externals", () => { + it("follows the loader passed to a module factory", () => { + const electron = electronOf(bootstrap(EXTERNAL, CONSUMER)); + expect(electron.browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + expect(electron.ipc).toMatchObject([ + { channel: "get-connections", side: "main" }, + ]); + }); + + it("follows a direct member read from the loader call", () => { + expect( + electronOf( + bootstrap( + EXTERNAL, + 'new(n(45840).BrowserWindow)({webPreferences:{preload:"./p.js"}});', + ), + ).browser_windows, + ).toMatchObject([{ preload_path: "./p.js" }]); + }); + + it("follows the runtime loader called from the bootstrap body", () => { + expect( + electronOf( + bootstrap( + EXTERNAL, + "", + 'const s=n(45840);new s.BrowserWindow({webPreferences:{preload:"./p.js"}});', + ), + ).browser_windows, + ).toMatchObject([{ preload_path: "./p.js" }]); + }); + + it.each([ + [ + "an external overwritten after the require", + bootstrap('e=>{e.exports=require("electron");e.exports=other}', CONSUMER), + ], + [ + "a local module table entry", + bootstrap("e=>{e.exports={BrowserWindow:class{}}}", CONSUMER), + ], + [ + "a shadowed require inside the external", + bootstrap( + 'e=>{const require=x=>x;e.exports=require("electron")}', + CONSUMER, + ), + ], + [ + "a reassigned loader parameter", + bootstrap(EXTERNAL, `n=other;${CONSUMER}`), + ], + [ + "a loader-shaped call outside a proven bootstrap", + `var m={45840:${EXTERNAL}};function n(t){return other(t)}${CONSUMER}`, + ], + [ + "a different table key", + bootstrap(EXTERNAL, CONSUMER.replace("n(45840)", "n(45841)")), + ], + ])("keeps %s unresolved", (_name, source) => { + const electron = electronOf(source); + expect(electron.browser_windows).toEqual([]); + // Only direct observations come from this identity; the separate + // wrapper-substitution path keeps its own resolution basis. + expect( + electron.ipc.filter( + ({ resolution_basis: basis }) => basis !== "wrapper-substitution", + ), + ).toEqual([]); + }); +}); + +describe("Webpack externals with a tampered runtime", () => { + it.each([ + [ + "a replaced bootstrap loader", + bootstrap( + EXTERNAL, + CONSUMER, + "const o=n;n=k=>k===100?o(k):local;n(100);", + ), + ], + [ + "a generator external", + bootstrap('function*(e){e.exports=require("electron")}', CONSUMER), + ], + [ + "an async external", + bootstrap('async e=>{e.exports=require("electron")}', CONSUMER), + ], + [ + "a loader that returns something else", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n),o.exports}", + "return m[t](o,o.exports,n),{BrowserWindow:class{}}}", + ), + ], + [ + "a table slot replaced before loading", + bootstrap( + EXTERNAL, + CONSUMER, + "m[45840]=e=>{e.exports={BrowserWindow:class{}}};n(100);", + ), + ], + [ + "a table slot replaced through an alias", + bootstrap(EXTERNAL, CONSUMER, "const a=m;a[45840]=local;n(100);"), + ], + [ + "a slot replaced through the runtime exposure", + bootstrap(EXTERNAL, CONSUMER, "n.m=m;n.m[45840]=local;n(100);"), + ], + [ + "a cache entry planted before loading", + bootstrap(EXTERNAL, CONSUMER, "c[45840]={exports:local};n(100);"), + ], + [ + "a computed table key that may collide", + bootstrap(EXTERNAL, CONSUMER).replace( + "c={};", + "c={};m={...m,[key]:e=>{e.exports=local}};", + ), + ], + [ + "an unresolved computed key in the table", + bootstrap(EXTERNAL, CONSUMER).replace( + `45840:${EXTERNAL},`, + `45840:${EXTERNAL},[key]:e=>{e.exports=local},`, + ), + ], + ])("keeps %s unresolved", (_name, source) => { + const electron = electronOf(source); + expect(electron.browser_windows).toEqual([]); + // Only direct observations come from this identity; the separate + // wrapper-substitution path keeps its own resolution basis. + expect( + electron.ipc.filter( + ({ resolution_basis: basis }) => basis !== "wrapper-substitution", + ), + ).toEqual([]); + }); +}); + +describe("Webpack externals with a tampered loader or module object", () => { + it.each([ + [ + "cached exports rewritten through the runtime exposure", + bootstrap( + EXTERNAL, + CONSUMER, + "n.c=c;n(45840);n.c[45840].exports=local;n(100);", + ), + ], + [ + "a pre-populated cache", + bootstrap(EXTERNAL, CONSUMER).replace( + "c={};", + "c={45840:{exports:local}};", + ), + ], + [ + "exports rewritten in the returned sequence", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n),o.exports}", + "return m[t](o,o.exports,n),o.exports=local,o.exports}", + ), + ], + [ + "exports rewritten before the return", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n),o.exports}", + "m[t](o,o.exports,n);o.exports=local;return o.exports}", + ), + ], + [ + "an alias of the exposed table", + bootstrap(EXTERNAL, CONSUMER, "n.m=m;const a=n.m;a[45840]=local;n(100);"), + ], + [ + "a rewritten module key", + bootstrap(EXTERNAL, CONSUMER).replace( + "function n(t){", + "function n(t){if(t===45840)t=100;", + ), + ], + [ + "a dispatch behind a guard", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n),o.exports}", + "if(t===100)m[t](o,o.exports,n);return o.exports}", + ), + ], + [ + "duplicate factory parameter names", + bootstrap('function(e,e,n){e.exports=require("electron")}', CONSUMER), + ], + [ + "an accessor for module exports", + bootstrap(EXTERNAL, CONSUMER).replace( + "var o=c[t]={exports:{}}", + "var o=c[t]={get exports(){return local},set exports(v){}}", + ), + ], + ])("keeps %s unresolved", (_name, source) => { + const electron = electronOf(source); + expect(electron.browser_windows).toEqual([]); + // Only direct observations come from this identity; the separate + // wrapper-substitution path keeps its own resolution basis. + expect( + electron.ipc.filter( + ({ resolution_basis: basis }) => basis !== "wrapper-substitution", + ), + ).toEqual([]); + }); + + it.each([ + [ + "a cache cleared when the factory throws", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n),o.exports}", + "var i=!0;try{m[t].call(o.exports,o,o.exports,n),i=!1}finally{i&&delete c[t]}return o.exports}", + ), + ], + [ + "the runtime exposing its cache", + bootstrap(EXTERNAL, CONSUMER, "n.c=c;n(100);"), + ], + [ + "chunk installation through the runtime table", + bootstrap( + EXTERNAL, + CONSUMER, + "n.m=m;n.i=e=>{for(var k in e)n.m[k]=e[k]};n(100);", + ), + ], + [ + "a loaded flag set before returning", + bootstrap(EXTERNAL, CONSUMER).replace( + "return m[t](o,o.exports,n),o.exports}", + "return m[t].call(o.exports,o,o.exports,n),o.loaded=!0,o.exports}", + ), + ], + ])("still resolves with %s", (_name, source) => { + expect(electronOf(source).browser_windows).toMatchObject([ + { preload_path: "./preload.js" }, + ]); + }); +}); diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 26ff26a49..7a4386524 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -94,6 +94,41 @@ export const semanticObjectPatternKeys = ( return key === null ? [] : [key]; }); +/** Return the syntactic node whose target this construct writes. */ +export const semanticWriteSiteTarget = (node: t.Node): t.Node | null => { + if (t.isAssignmentExpression(node)) return node.left; + if (t.isUpdateExpression(node)) return node.argument; + if (t.isUnaryExpression(node, { operator: "delete" })) return node.argument; + if (t.isForOfStatement(node) || t.isForInStatement(node)) return node.left; + return null; +}; + +/** Whether a write site reads the previous target value before writing. */ +export const semanticWriteSiteReadsFirst = (site: t.Node): boolean => + (t.isAssignmentExpression(site) && site.operator !== "=") || + t.isUpdateExpression(site); + +/** Find the owning write site through inert TypeScript and parenthesis wrappers. */ +export const semanticWrappedWriteSite = ( + node: t.Node, + parent: t.Node, + ancestors: readonly t.Node[], +): t.Node | null => { + const chain = + ancestors.at(-1) === parent ? ancestors : [...ancestors, parent]; + for (let index = chain.length - 1; index >= 0; index -= 1) { + const candidate = chain[index]; + if (candidate === undefined) return null; + const target = semanticWriteSiteTarget(candidate); + if (target !== null) + return unwrapJavaScriptExpression(target).node === node + ? candidate + : null; + if (unwrapJavaScriptExpression(candidate).depth === 0) return null; + } + return null; +}; + /** Visit binding and member leaves in direct and destructuring write targets. */ export function* semanticWriteTargets( root: t.Node, diff --git a/src/domain/javascript/javascriptBootstrapIntegrity.ts b/src/domain/javascript/javascriptBootstrapIntegrity.ts new file mode 100644 index 000000000..1e748df41 --- /dev/null +++ b/src/domain/javascript/javascriptBootstrapIntegrity.ts @@ -0,0 +1,527 @@ +import * as t from "@babel/types"; + +import type { javascriptBootstrapAt } from "./javascriptBootstrap.js"; +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + resolveSemanticBindingState, + semanticStableInitializer, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; +import { argumentValue } from "./javascriptStaticAnalysisHelpers.js"; + +type Bootstrap = NonNullable>; + +/** Slots a visible literal write may have replaced; the rest stay as declared. */ +export interface JavaScriptBootstrapIntegrity { + readonly replacedKeys: ReadonlySet; +} + +interface LoaderShape { + readonly parameterId: string; + /** The single unconditional `table[key](...)` call. */ + readonly dispatch: t.CallExpression; + readonly dispatches: ReadonlySet; + readonly moduleId: string; + readonly cacheId: string | undefined; + /** Cache reads, cache writes and deletes the loader itself performs. */ + readonly cacheSites: ReadonlySet; +} + +/** + * Prove that `loader(key)` returns the exports of the factory declared at + * `table[key]`: the loader returns only the dispatched module's exports or a + * cache entry it created for that module, the table has only literal keys, + * and the table and cache are referenced only by the loader or through the + * runtime's `loader.m`/`loader.c` exposure. Literal-key writes through that + * exposure replace their slot. Returns undefined when any of this is unproven. + */ +export const proveJavaScriptBootstrapIntegrity = ( + call: t.CallExpression, + bootstrap: Bootstrap, + loaderIds: ReadonlySet, + state: JavaScriptSemanticAnalysisState, +): JavaScriptBootstrapIntegrity | undefined => { + if ( + bootstrap.table.properties.some((property) => + t.isSpreadElement(property) + ? true + : semanticStaticPropertyKey(property.key, property.computed) === null, + ) + ) + return undefined; + const loader = bootstrap.body.body.find( + (statement): statement is t.FunctionDeclaration => + t.isFunctionDeclaration(statement) && + statement.id !== null && + statement.id !== undefined && + bindingOf(state, statement.id) === bootstrap.loaderBindingId, + ); + const shape = + loader === undefined + ? undefined + : loaderShape(loader, bootstrap.tableBindingId, state); + if (shape === undefined) return undefined; + return referencesAreContained(call, { bootstrap, shape, loaderIds }, state); +}; + +const loaderShape = ( + loader: t.FunctionDeclaration, + tableId: string, + state: JavaScriptSemanticAnalysisState, +): LoaderShape | undefined => { + const [parameter] = loader.params; + const parameterId = t.isIdentifier(parameter) + ? bindingOf(state, parameter) + : undefined; + // A rewritten key would dispatch a different slot than the caller named. + if ( + parameterId === undefined || + state.bindingsById.get(parameterId)?.definitions.length !== 1 || + loader.async || + loader.generator + ) + return undefined; + const dispatches = new Set(); + const calls: t.CallExpression[] = []; + const modules = new Set(); + const returns: (t.Expression | null | undefined)[] = []; + traverseJavaScriptAst(loader.body, { + skip: (node) => t.isFunction(node), + enter: (node) => { + if (t.isReturnStatement(node)) returns.push(node.argument); + if (!t.isCallExpression(node)) return; + const dispatch = tableDispatch(node, tableId, parameterId, state); + if (dispatch === undefined) return; + dispatches.add(dispatch.table); + calls.push(node); + if (dispatch.moduleId !== undefined) modules.add(dispatch.moduleId); + }, + }); + const [moduleId] = modules; + const [dispatch] = calls; + if ( + calls.length !== 1 || + dispatch === undefined || + modules.size !== 1 || + moduleId === undefined || + !runsUnconditionally(dispatch, loader, state) + ) + return undefined; + const cache = moduleCache(moduleId, parameterId, state); + if ( + cache === null || + !moduleObjectContained(loader, moduleId, dispatch, state) + ) + return undefined; + const shape: LoaderShape = { + parameterId, + dispatch, + dispatches, + moduleId, + cacheId: cache?.cacheId, + cacheSites: new Set(cache === undefined ? [] : [cache.site]), + }; + return returns.every((argument) => + returnsModuleExports(argument, shape, state), + ) + ? withCacheReads(loader, shape, state) + : undefined; +}; + +/** `table[key](module, ...)` or `table[key].call(thisArg, module, ...)`. */ +const tableDispatch = ( + call: t.CallExpression, + tableId: string, + parameterId: string, + state: JavaScriptSemanticAnalysisState, +): { readonly table: t.Node; readonly moduleId?: string } | undefined => { + const viaCall = + t.isMemberExpression(call.callee) && + semanticStaticPropertyKey(call.callee.property, call.callee.computed) === + "call"; + const slot = + viaCall && t.isMemberExpression(call.callee) + ? call.callee.object + : call.callee; + if ( + !t.isMemberExpression(slot) || + !slot.computed || + !t.isIdentifier(slot.object) || + !t.isIdentifier(slot.property) || + bindingOf(state, slot.object) !== tableId || + bindingOf(state, slot.property) !== parameterId + ) + return undefined; + const module = call.arguments[viaCall ? 1 : 0]; + const moduleId = t.isIdentifier(module) + ? bindingOf(state, module) + : undefined; + return moduleId === undefined + ? { table: slot.object } + : { table: slot.object, moduleId }; +}; + +/** + * The module object is a fresh literal, optionally stored as `cache[key]`. + * Undefined means no cache; null means the module binding is not proven. + */ +const moduleCache = ( + moduleId: string, + parameterId: string, + state: JavaScriptSemanticAnalysisState, +): { readonly cacheId: string; readonly site: t.Node } | undefined | null => { + const binding = state.bindingsById.get(moduleId); + const initializer = semanticStableInitializer(binding, state); + if ( + binding?.definitions.length !== 1 || + initializer === undefined || + initializer.projection.length !== 0 + ) + return null; + const value = initializer.node; + if (isPlainObject(value)) return undefined; + if ( + !t.isAssignmentExpression(value, { operator: "=" }) || + !isPlainObject(value.right) + ) + return null; + const cacheId = keyedSlot(value.left, parameterId, state); + return cacheId !== undefined && startsEmpty(cacheId, state) + ? { cacheId, site: value.left } + : null; +}; + +/** A pre-populated cache could answer the early return without a dispatch. */ +const startsEmpty = ( + cacheId: string, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const binding = state.bindingsById.get(cacheId); + const initializer = semanticStableInitializer(binding, state); + return ( + binding?.definitions.length === 1 && + initializer?.projection.length === 0 && + t.isObjectExpression(initializer.node) && + initializer.node.properties.length === 0 + ); +}; + +/** An object literal of ordinary data properties, so `exports` stores plainly. */ +const isPlainObject = (node: t.Node): boolean => + t.isObjectExpression(node) && + node.properties.every( + (property) => + t.isObjectProperty(property) && + !property.computed && + semanticStaticPropertyKey(property.key, false) !== "__proto__", + ); + +/** Every ancestor up to the loader body executes whenever the loader runs. */ +const runsUnconditionally = ( + dispatch: t.Node, + loader: t.FunctionDeclaration, + state: JavaScriptSemanticAnalysisState, +): boolean => { + let child: t.Node = dispatch; + let parent = state.parentsByNode.get(child); + while (parent !== undefined && child !== loader.body) { + const ordered = + t.isSequenceExpression(parent) || + t.isExpressionStatement(parent) || + t.isReturnStatement(parent) || + t.isBlockStatement(parent) || + (t.isMemberExpression(parent) && parent.object === child) || + (t.isCallExpression(parent) && parent.callee === child) || + (t.isTryStatement(parent) && parent.block === child); + if (!ordered) return false; + child = parent; + parent = state.parentsByNode.get(parent); + } + return child === loader.body; +}; + +/** + * The module object reaches only the dispatch, `.exports` reads after it, and + * writes to members other than `exports`, so nothing swaps its exports. + */ +const moduleObjectContained = ( + loader: t.FunctionDeclaration, + moduleId: string, + dispatch: t.CallExpression, + state: JavaScriptSemanticAnalysisState, +): boolean => { + let contained = true; + traverseJavaScriptAst(loader.body, { + enter: (node, parent, readAncestors) => { + if (!contained || !t.isIdentifier(node) || parent === null) return; + const ancestors = readAncestors(); + if (!t.isReferenced(node, parent, ancestors.at(-2))) return; + if (bindingOf(state, node) !== moduleId) return; + contained = + !ancestors.some((ancestor) => t.isFunction(ancestor)) && + moduleUse(node, [parent, ancestors.at(-2)], dispatch); + }, + }); + return contained; +}; + +const moduleUse = ( + node: t.Identifier, + [parent, grandparent]: readonly [t.Node, t.Node | undefined], + dispatch: t.CallExpression, +): boolean => { + if (parent === dispatch) return dispatch.arguments.includes(node); + if (!t.isMemberExpression(parent) || parent.object !== node) return false; + const key = semanticStaticPropertyKey(parent.property, parent.computed); + const written = + (t.isAssignmentExpression(grandparent) && grandparent.left === parent) || + t.isUpdateExpression(grandparent) || + t.isUnaryExpression(grandparent, { operator: "delete" }); + if (written) return key !== null && key !== "exports"; + if (key !== "exports") return false; + // `module.exports` passed to the factory, or read after the dispatch ran. + return ( + grandparent === dispatch || + (parent.start ?? -1) > (dispatch.end ?? Number.POSITIVE_INFINITY) + ); +}; + +/** Record `cache[key]` reads that initialize the loader's early-return binding. */ +const withCacheReads = ( + loader: t.FunctionDeclaration, + shape: LoaderShape, + state: JavaScriptSemanticAnalysisState, +): LoaderShape | undefined => { + if (shape.cacheId === undefined) return shape; + const sites = new Set(shape.cacheSites); + let contained = true; + traverseJavaScriptAst(loader.body, { + skip: (node) => t.isFunction(node), + enter: (node, parent) => { + if ( + !t.isMemberExpression(node) || + keyedSlot(node, shape.parameterId, state) !== shape.cacheId + ) + return; + const read = t.isVariableDeclarator(parent) && parent.init === node; + const deleted = t.isUnaryExpression(parent, { operator: "delete" }); + if (read || deleted || sites.has(node)) sites.add(node); + else contained = false; + }, + }); + return contained ? { ...shape, cacheSites: sites } : undefined; +}; + +/** Accept `module.exports` or `cache[key].exports` read through a stable binding. */ +const returnsModuleExports = ( + argument: t.Expression | null | undefined, + shape: LoaderShape, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const value = t.isSequenceExpression(argument) + ? argument.expressions.at(-1) + : argument; + const before = t.isSequenceExpression(argument) + ? argument.expressions.slice(0, -1) + : []; + if ( + !before.every((expression) => + sideEffectBeforeReturn(expression, shape, state), + ) + ) + return false; + if ( + !t.isMemberExpression(value) || + !t.isIdentifier(value.object) || + semanticStaticPropertyKey(value.property, value.computed) !== "exports" + ) + return false; + const id = bindingOf(state, value.object); + if (id === shape.moduleId) return true; + if (id === undefined || shape.cacheId === undefined) return false; + const binding = state.bindingsById.get(id); + const initializer = semanticStableInitializer(binding, state); + return ( + binding?.definitions.length === 1 && + initializer?.projection.length === 0 && + keyedSlot(initializer.node, shape.parameterId, state) === shape.cacheId + ); +}; + +/** The dispatch itself, or a write to a module member other than `exports`. */ +const sideEffectBeforeReturn = ( + expression: t.Expression, + shape: LoaderShape, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (expression === shape.dispatch) return true; + if ( + !t.isAssignmentExpression(expression) || + !t.isMemberExpression(expression.left) || + !t.isIdentifier(expression.left.object) || + bindingOf(state, expression.left.object) !== shape.moduleId + ) + return false; + const key = semanticStaticPropertyKey( + expression.left.property, + expression.left.computed, + ); + return key !== null && key !== "exports"; +}; + +/** The binding of `owner` in `owner[key]` when `key` is the loader parameter. */ +const keyedSlot = ( + node: t.Node, + parameterId: string, + state: JavaScriptSemanticAnalysisState, +): string | undefined => + t.isMemberExpression(node) && + node.computed && + t.isIdentifier(node.object) && + t.isIdentifier(node.property) && + bindingOf(state, node.property) === parameterId + ? bindingOf(state, node.object) + : undefined; + +const referencesAreContained = ( + call: t.CallExpression, + { + bootstrap, + shape, + loaderIds, + }: { + readonly bootstrap: Bootstrap; + readonly shape: LoaderShape; + readonly loaderIds: ReadonlySet; + }, + state: JavaScriptSemanticAnalysisState, +): JavaScriptBootstrapIntegrity | undefined => { + const tracked = new Map([ + [bootstrap.tableBindingId, "table"], + ...(shape.cacheId === undefined ? [] : [[shape.cacheId, "cache"] as const]), + ...[...loaderIds].map((id) => [id, "loader"] as const), + ]); + const names = new Set( + [...tracked.keys()].flatMap((id) => state.bindingsById.get(id)?.name ?? []), + ); + const replacedKeys = new Set(); + let contained = true; + traverseJavaScriptAst(call, { + skip: () => !contained, + enter: (node, parent, readAncestors) => { + if (!t.isIdentifier(node) || !names.has(node.name) || parent === null) + return; + const ancestors = readAncestors(); + if (!t.isReferenced(node, parent, ancestors.at(-2))) return; + const id = bindingOf(state, node); + const role = id === undefined ? undefined : tracked.get(id); + if (role === undefined) return; + const use: Use = { node, parent, ancestors, shape, loaderIds, state }; + if (role !== "loader") contained = containedUse(role, use); + else if (exposedTableUse(use) === "escaped") contained = false; + else exposedSlotWrite(use, replacedKeys); + }, + }); + return contained ? { replacedKeys } : undefined; +}; + +interface Use { + readonly node: t.Identifier; + readonly parent: t.Node; + readonly ancestors: readonly t.Node[]; + readonly shape: LoaderShape; + readonly loaderIds: ReadonlySet; + readonly state: JavaScriptSemanticAnalysisState; +} + +/** The table is read only by the dispatch; the cache only by the loader's own slot operations. */ +const containedUse = (role: "table" | "cache", use: Use): boolean => { + if (role === "table" && use.shape.dispatches.has(use.node)) return true; + if (role === "cache" && use.shape.cacheSites.has(use.parent)) return true; + return isRuntimeExposure(use, role === "table" ? "m" : "c"); +}; + +/** `loader.m = table` or `loader.c = cache`, the runtime's own exposure. */ +const isRuntimeExposure = (use: Use, key: "m" | "c"): boolean => { + const { parent, node, loaderIds, state } = use; + return ( + t.isAssignmentExpression(parent, { operator: "=" }) && + parent.right === node && + t.isMemberExpression(parent.left) && + t.isIdentifier(parent.left.object) && + semanticStaticPropertyKey(parent.left.property, parent.left.computed) === + key && + loaderIds.has(bindingOf(state, parent.left.object) ?? "") + ); +}; + +/** + * `loader.m`/`loader.c` may only be assigned (the exposure) or indexed + * directly; any other use, such as `const a = loader.m`, lets later writes + * escape this proof. Writing a member of an indexed entry, such as + * `loader.c[id].exports = value`, can replace what the loader returns. + */ +const exposedTableUse = (use: Use): "other" | "contained" | "escaped" => { + const { parent, ancestors } = use; + if ( + !t.isMemberExpression(parent) || + parent.object !== use.node || + !["m", "c"].includes( + semanticStaticPropertyKey(parent.property, parent.computed) ?? "", + ) + ) + return "other"; + const outer = ancestors.at(-2); + const assigned = + t.isAssignmentExpression(outer, { operator: "=" }) && outer.left === parent; + const indexed = + t.isMemberExpression(outer) && outer.object === parent && outer.computed; + if (assigned) return "contained"; + if (!indexed) return "escaped"; + const member = ancestors.at(-3); + const target = ancestors.at(-4); + const entryWritten = + t.isMemberExpression(member) && + member.object === outer && + ((t.isAssignmentExpression(target) && target.left === member) || + t.isUpdateExpression(target) || + t.isUnaryExpression(target, { operator: "delete" })); + return entryWritten ? "escaped" : "contained"; +}; + +/** + * `loader.m[key] = factory` or `delete loader.c[key]` replaces a literal slot. + * Writes with runtime keys are chunk installation, which adds modules under + * their own ids; they are not treated as replacing a declared slot. + */ +const exposedSlotWrite = (use: Use, replacedKeys: Set): void => { + const { parent, ancestors } = use; + const slot = ancestors.at(-2); + const write = ancestors.at(-3); + if ( + !t.isMemberExpression(parent) || + parent.object !== use.node || + !["m", "c"].includes( + semanticStaticPropertyKey(parent.property, parent.computed) ?? "", + ) || + !t.isMemberExpression(slot) || + slot.object !== parent || + !( + (t.isAssignmentExpression(write) && write.left === slot) || + t.isUnaryExpression(write, { operator: "delete" }) || + t.isUpdateExpression(write) + ) + ) + return; + const key = slot.computed + ? argumentValue(slot.property) + : semanticStaticPropertyKey(slot.property, false); + if (key !== undefined && key !== null) replacedKeys.add(key); +}; + +const bindingOf = ( + state: JavaScriptSemanticAnalysisState, + identifier: t.Identifier, +): string | undefined => + resolveSemanticBindingState(state, identifier, identifier.name)?.bindingId; diff --git a/src/domain/javascript/javascriptBuiltinResourceMethods.ts b/src/domain/javascript/javascriptBuiltinResourceMethods.ts new file mode 100644 index 000000000..8034b902f --- /dev/null +++ b/src/domain/javascript/javascriptBuiltinResourceMethods.ts @@ -0,0 +1,11 @@ +/** Supported native resource receivers, shared by acquisition and validity owners. */ +export const BUILTIN_RESOURCE_METHODS = { + fs: ["open", "openSync", "createReadStream", "createWriteStream"], + "fs/promises": ["open"], + net: ["connect", "createConnection"], + tls: ["connect", "createConnection"], +} as const; + +export const BUILTIN_RESOURCE_SPECIFIERS = Object.keys( + BUILTIN_RESOURCE_METHODS, +); diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts new file mode 100644 index 000000000..4bec81042 --- /dev/null +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -0,0 +1,225 @@ +import * as t from "@babel/types"; + +import { javascriptBootstrapAt } from "./javascriptBootstrap.js"; +import { proveJavaScriptBootstrapIntegrity } from "./javascriptBootstrapIntegrity.js"; +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; +import { + semanticMemberChain, + semanticRequireOrigin, + type JavaScriptRequireOrigin, +} from "./javascriptSemanticRequireOrigin.js"; +import { + resolveSemanticBindingState, + semanticResolutionBlocked, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import { + argumentValue, + moduleFactory, + modulePropertyName, +} from "./javascriptStaticAnalysisHelpers.js"; + +/** Loader bindings and literal externals proven for one standalone bootstrap. */ +interface BootstrapExternals { + /** The runtime loader and every factory parameter it is passed as. */ + readonly loaderBindingIds: ReadonlySet; + /** Table keys whose factory only re-exports one unshadowed literal require; null when a key repeats. */ + readonly externals: ReadonlyMap; +} + +const proofs = new WeakMap< + JavaScriptSemanticAnalysisState, + WeakMap +>(); + +/** + * Recover a literal require reached through a proven Webpack bootstrap. + * + * Webpack emits externals as `{45840: e => { e.exports = require("electron") }}` + * and consumers call the runtime loader, `n(45840)`, instead of `require`. The + * origin is accepted only when the loader binding is the bootstrap's own loader + * or a factory parameter that the bootstrap proves it passes the loader as, and + * the keyed factory consists solely of that re-export. + */ +export const semanticBundledRequireOrigin = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptRequireOrigin | undefined => { + const chain = semanticMemberChain(node); + const current = chain?.root; + if ( + chain === undefined || + !t.isCallExpression(current) || + !t.isIdentifier(current.callee) || + current.arguments.length !== 1 + ) + return undefined; + const key = argumentValue(current.arguments[0]); + if (key === undefined) return undefined; + const loader = current.callee; + if (semanticResolutionBlocked(state, loader, loader.name)) return undefined; + const binding = resolveSemanticBindingState(state, loader, loader.name); + if ( + binding === undefined || + binding.definitions.length !== 1 || + binding.definitions[0]?.kind === "assignment" + ) + return undefined; + for ( + let ancestor = state.parentsByNode.get(current); + ancestor !== undefined; + ancestor = state.parentsByNode.get(ancestor) + ) { + if (!t.isCallExpression(ancestor)) continue; + const proof = bootstrapExternals(ancestor, state); + if (proof === null || !proof.loaderBindingIds.has(binding.bindingId)) + continue; + const specifier = proof.externals.get(key); + return typeof specifier === "string" + ? { + origin: { + specifier, + importedPath: chain.members, + resolutionMode: "require", + }, + call: current, + } + : undefined; + } + return undefined; +}; + +const bootstrapExternals = ( + call: t.CallExpression, + state: JavaScriptSemanticAnalysisState, +): BootstrapExternals | null => { + let byCall = proofs.get(state); + if (byCall === undefined) { + byCall = new WeakMap(); + proofs.set(state, byCall); + } + const cached = byCall.get(call); + if (cached !== undefined) return cached; + const proof = proveBootstrapExternals(call, state); + byCall.set(call, proof); + return proof; +}; + +const proveBootstrapExternals = ( + call: t.CallExpression, + state: JavaScriptSemanticAnalysisState, +): BootstrapExternals | null => { + const bootstrap = javascriptBootstrapAt(call, state); + // A replaced loader no longer dispatches the declared table. + if ( + bootstrap === undefined || + !singleDefinition(state, bootstrap.loaderBindingId, "function") + ) + return null; + const loaderBindingIds = new Set([bootstrap.loaderBindingId]); + const factories = bootstrap.table.properties.flatMap((property) => { + const factory = moduleFactory(property); + return factory === undefined ? [] : [{ property, factory }]; + }); + for (const { factory } of factories) { + // The bootstrap proves `table[key](module, module.exports, loader)`. + const loaderParameter = factory.params[2]; + const bindingId = t.isIdentifier(loaderParameter) + ? resolveSemanticBindingState( + state, + loaderParameter, + loaderParameter.name, + )?.bindingId + : undefined; + if ( + bindingId !== undefined && + singleDefinition(state, bindingId, "parameter") + ) + loaderBindingIds.add(bindingId); + } + const integrity = proveJavaScriptBootstrapIntegrity( + call, + bootstrap, + loaderBindingIds, + state, + ); + if (integrity === undefined) return null; + const externals = new Map(); + for (const { property, factory } of factories) { + const key = modulePropertyName(property); + const specifier = integrity.replacedKeys.has(key) + ? undefined + : externalSpecifier(factory, state); + if (externals.has(key)) externals.set(key, null); + else externals.set(key, specifier ?? null); + } + return { loaderBindingIds, externals }; +}; + +/** + * Accept only `module.exports = require("specifier")` as the whole body of an + * ordinary factory; generator, async and accessor factories do not run it. + */ +const externalSpecifier = ( + factory: t.FunctionExpression | t.ArrowFunctionExpression | t.ObjectMethod, + state: JavaScriptSemanticAnalysisState, +): string | undefined => { + const moduleParameter = factory.params[0]; + const names = factory.params.map((parameter) => + t.isIdentifier(parameter) ? parameter.name : null, + ); + if ( + !t.isIdentifier(moduleParameter) || + // A duplicated name binds the later argument, not the module object. + new Set(names).size !== names.length || + factory.generator || + factory.async || + (t.isObjectMethod(factory) && factory.kind !== "method") + ) + return undefined; + let expression: t.Node | undefined; + if (t.isBlockStatement(factory.body)) { + const [statement] = factory.body.body; + if (factory.body.body.length !== 1 || !t.isExpressionStatement(statement)) + return undefined; + expression = statement.expression; + } else expression = factory.body; + if ( + !t.isAssignmentExpression(expression, { operator: "=" }) || + !t.isMemberExpression(expression.left) || + !t.isIdentifier(expression.left.object) || + semanticStaticPropertyKey( + expression.left.property, + expression.left.computed, + ) !== "exports" + ) + return undefined; + const moduleBinding = resolveSemanticBindingState( + state, + moduleParameter, + moduleParameter.name, + ); + const target = resolveSemanticBindingState( + state, + expression.left.object, + expression.left.object.name, + ); + if ( + moduleBinding === undefined || + target?.bindingId !== moduleBinding.bindingId + ) + return undefined; + const required = semanticRequireOrigin(expression.right, state)?.origin; + return required !== undefined && required.importedPath.length === 0 + ? required.specifier + : undefined; +}; + +const singleDefinition = ( + state: JavaScriptSemanticAnalysisState, + bindingId: string, + kind: "function" | "parameter", +): boolean => { + const definitions = state.bindingsById.get(bindingId)?.definitions; + return definitions?.length === 1 && definitions[0]?.kind === kind; +}; diff --git a/src/domain/javascript/javascriptElectronMemberWrites.ts b/src/domain/javascript/javascriptElectronMemberWrites.ts index 14741af36..9ca7c2d89 100644 --- a/src/domain/javascript/javascriptElectronMemberWrites.ts +++ b/src/domain/javascript/javascriptElectronMemberWrites.ts @@ -2,6 +2,7 @@ import * as t from "@babel/types"; import { semanticStaticPropertyKey, + semanticWriteSiteTarget, semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -62,6 +63,7 @@ export function* collectJavaScriptIdentityWritesSteps( origin: ( root: t.Node, kind: "binding" | "member" | "opaque-call", + writeSite: t.Node, ) => readonly (readonly (string | null)[])[], intrinsicInvocation: ( node: t.CallExpression | t.OptionalCallExpression, @@ -88,7 +90,8 @@ export function* collectJavaScriptIdentityWritesSteps( if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { const invocation = intrinsicInvocation(node); if (invocation?.kind === "opaque") { - for (const scope of origin(node, "opaque-call")) retain(node, scope); + for (const scope of origin(node, "opaque-call", node)) + retain(node, scope); return; } const callee = invocation?.path; @@ -117,11 +120,11 @@ export function* collectJavaScriptIdentityWritesSteps( // A known mutator with an opaque argument list can overwrite any // captured intrinsic. Do not turn an unexamined target into proof. if (target === undefined || t.isSpreadElement(target)) { - for (const scope of origin(node, "opaque-call")) + for (const scope of origin(node, "opaque-call", node)) retain(node, scope); } if (t.isNode(target) && !t.isSpreadElement(target)) { - const identities = origin(target, "member"); + const identities = origin(target, "member", node); if (identities.length > 0) { const key = keyedMutators.has(method ?? "") ? [staticString(arguments_?.[1]) ?? null] @@ -129,26 +132,18 @@ export function* collectJavaScriptIdentityWritesSteps( for (const identity of identities) retain(node, [...identity, ...key]); } else if (!freshObject(target)) { - for (const scope of origin(node, "opaque-call")) + for (const scope of origin(node, "opaque-call", node)) retain(node, scope); } } } } } - const target = t.isAssignmentExpression(node) - ? node.left - : t.isUpdateExpression(node) - ? node.argument - : t.isUnaryExpression(node, { operator: "delete" }) - ? node.argument - : t.isForOfStatement(node) || t.isForInStatement(node) - ? node.left - : undefined; - if (target === undefined) return; + const target = semanticWriteSiteTarget(node); + if (target === null) return; for (const leaf of semanticWriteTargets(target)) { if (t.isIdentifier(leaf)) { - for (const identity of origin(leaf, "binding")) + for (const identity of origin(leaf, "binding", node)) retain(node, identity); continue; } @@ -159,7 +154,7 @@ export function* collectJavaScriptIdentityWritesSteps( ) continue; const key = semanticStaticPropertyKey(member.property, member.computed); - for (const identity of origin(member.object, "member")) + for (const identity of origin(member.object, "member", node)) retain(node, [...identity, key]); } }, diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.test.ts b/src/domain/javascript/javascriptIntrinsicInvocation.test.ts new file mode 100644 index 000000000..66c58bff6 --- /dev/null +++ b/src/domain/javascript/javascriptIntrinsicInvocation.test.ts @@ -0,0 +1,54 @@ +import { expect, it } from "vitest"; +import * as t from "@babel/types"; + +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { + completeSemanticSteps, + traverseJavaScriptAst, +} from "./javascriptSemanticTraversal.js"; + +const invocationKinds = (source: string) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("invocation fixture did not parse"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + const interop = createSemanticModuleInterop(state); + const invocations: (ReturnType | null)[] = + []; + traverseJavaScriptAst(parsed.program, { + enter: (node) => { + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + invocations.push(interop.intrinsicInvocation(node) ?? null); + }, + }); + return invocations; +}; + +it.each([ + 'const change = Object.defineProperty.call; change(null,Object,"create",{value:customCreate});', + 'const {apply:change} = Object.defineProperty; change(null,[Object,"create",{value:customCreate}]);', + 'const change = Reflect.apply.call; change(null,Object.defineProperty,null,[Object,"create",{value:customCreate}]);', +])( + "keeps a bare invocation member alias unknown, not effect-free: %s", + (source) => { + expect(invocationKinds(source)).toEqual([ + expect.objectContaining({ kind: "opaque" }), + ]); + }, +); + +it("keeps a comma-expression intrinsic call unknown when its receiver is erased", () => { + expect( + invocationKinds( + '(0, Object.defineProperty.call)(null, Object, "create", {value: customCreate});', + ), + ).toEqual([ + expect.objectContaining({ + kind: "opaque", + path: ["intrinsic", "Object", "defineProperty", "call"], + }), + ]); +}); diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index b46f28b42..6aeb5ab78 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -81,6 +81,15 @@ export const resolveJavaScriptIntrinsicInvocation = ( let explicitReceiver = false; for (;;) { callee = unwrapJavaScriptExpression(callee).node; + if (t.isSequenceExpression(callee)) { + const finalExpression = callee.expressions.at(-1); + if (finalExpression === undefined) return undefined; + // The comma operator returns the final value without its member receiver. + // Keep that lost receiver explicit so intrinsic call/apply stays unknown. + explicitReceiver = true; + callee = finalExpression; + continue; + } if (visited.has(callee)) return undefined; visited.add(callee); let path = intrinsicPath(callee); @@ -129,7 +138,8 @@ export const resolveJavaScriptIntrinsicInvocation = ( : t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) ? callee.object : undefined; - if (!t.isExpression(callable)) return undefined; + if (!t.isExpression(callable)) + return { kind: "opaque", path, arguments: undefined }; callee = callable; receiver = arguments_?.[0]; explicitReceiver = true; diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index dd24f3541..f7c4a8e7a 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -16,6 +16,7 @@ import { type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js"; +import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"]; @@ -51,8 +52,8 @@ const createAmbientPathResolver = ( possibleWrites: boolean, ) => { const cached = new WeakMap(); - return (node: t.Node): readonly AmbientPath[] => { - const prior = cached.get(node); + return (node: t.Node, capture?: t.Node): readonly AmbientPath[] => { + const prior = possibleWrites ? undefined : cached.get(node); if (prior !== undefined) return prior; const result = new Map(); const pending: { node: t.Node; suffix: AmbientProjection }[] = [ @@ -126,7 +127,13 @@ const createAmbientPathResolver = ( if (binding === undefined) continue; const stable = semanticStableInitializer(binding, state); const initializers = possibleWrites - ? binding.initializers + ? capture === undefined + ? binding.initializers + : semanticMutationInitializers( + binding, + capture, + state.parentsByNode, + ).initializers : stable === undefined ? [] : [stable]; @@ -221,7 +228,7 @@ const createAmbientPathResolver = ( } } const paths = [...result.values()]; - cached.set(node, paths); + if (!possibleWrites) cached.set(node, paths); return paths; }; }; @@ -248,7 +255,7 @@ export function* indexJavaScriptAmbientWritesSteps( }); yield* collectJavaScriptIdentityWritesSteps( program, - (root, kind) => { + (root, kind, writeSite) => { if (kind === "opaque-call") return []; // Reassigning a local alias changes the binding, not the referenced object. if (kind === "binding") @@ -256,13 +263,14 @@ export function* indexJavaScriptAmbientWritesSteps( isUnshadowedGlobal(root, state, root.name) ? [["global", root.name]] : []; - return ambientWrites(root); + return ambientWrites(root, writeSite); }, interop.intrinsicInvocation, interop.staticString, interop.isFreshObject, - (_node, path) => { + (node, path) => { state.ambientWrites.retain(path); + state.ambientWriteEffects.push({ path, node }); }, ); } diff --git a/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts index 15a4619c3..42c7cce6e 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.builtin_defaults.test.ts @@ -2,6 +2,428 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +it("shares required process writes with globals while keeping earlier observations", () => { + const ir = analyzeJavaScriptSemantics(`const proc=require('process'); + process.cwd(); const before=proc.env.BEFORE; + proc.env={}; const after=process.env.AFTER;`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["BEFORE"]); +}); + +it("does not propagate replacement-object writes from import-equals direct origins", () => { + const ir = analyzeJavaScriptSemantics(`import fs = require('fs'); + const other=require('fs'); fs={}; fs.readFileSync=custom; other.readFileSync('kept');`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it("retains original import-equals aliases captured before reassignment", () => { + const ir = analyzeJavaScriptSemantics(`import fs = require('fs'); + const alias=fs; const other=require('fs'); fs={}; alias.readFileSync=custom; other.readFileSync('wrong');`); + expect(ir.configurationOperations).toEqual([]); +}); + +it("keeps computed namespace calls as unknown receiver escapes", () => { + const ir = analyzeJavaScriptSemantics( + `const fs=require('fs'); fs.hook=custom; fs[key](); fs.readFileSync('wrong');`, + ); + expect(ir.configurationOperations).toEqual([]); +}); + +it.each(["(0, fs.readFileSync)", "(assigned = fs.readFileSync)"])( + "checks captured function validity through %s", + (value) => { + const ir = analyzeJavaScriptSemantics(`const fs=require('fs'); let assigned; + const saved=${value}; fs.readFileSync=custom; const late=${value}; saved('kept'); late('wrong');`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); + }, +); + +it.each(['process.env={MODE:"local"};', "process.argv=[];", "patch(process);"])( + "shares unbound process object effects with required aliases: %s", + (write) => { + const ir = + analyzeJavaScriptSemantics(`${write} const proc=require('process'); + ${write.includes("argv") ? "const arg=proc.argv[2];" : "const mode=proc.env.MODE;"}`); + expect(ir.configurationOperations).toEqual([]); + }, +); + +it.each([ + "function local(process) {process.env={};}", + "process={}; process.env={};", +])("keeps unrelated process replacements separate: %s", (write) => { + const ir = analyzeJavaScriptSemantics( + `${write} const proc=require('process'); const mode=proc.env.MODE;`, + ); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["MODE"]); +}); + +it("preserves exports captured before a later loader overwrite", () => { + const ir = analyzeJavaScriptSemantics(` + const fs=require('node:fs'); fs.readFileSync('before'); + const read=fs.readFileSync; + require('fs').readFileSync('direct-before'); + require=replacement; + fs.readFileSync('after'); read('saved'); + const late=require('fs'); late.readFileSync('wrong'); + require('fs').readFileSync('also-wrong'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual([ + "before", + "direct-before", + "after", + "saved", + ]); +}); + +it.each([ + `for (;;) { require('fs').readFileSync('wrong'); require=replacement; }`, + `function run() { require('fs').readFileSync('wrong'); require=replacement; }`, + `require=replacement; const fs=require('fs'); fs.readFileSync('wrong'); require=again;`, +])( + "keeps repeated or already replaced loader captures uncertain: %s", + (source) => { + expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual( + [], + ); + }, +); + +it.each([ + ["fs.readFileSync = custom;", "other.readFileSync('wrong');"], + ["other.readFileSync = custom;", "fs.readFileSync('wrong');"], + ["patch(fs);", "other.readFileSync('wrong');"], +])( + "shares require-mode import-equals effects in both directions: %s", + (write, call) => { + const ir = analyzeJavaScriptSemantics(`import fs = require('node:fs'); + const other = require('fs'); other.readFileSync('before'); ${write} ${call}`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual([ + "before", + ]); + }, +); + +it.each([ + "import {readFileSync as read} from 'node:fs';", + "const {readFileSync:read}=require('node:fs');", + "const fs=require('fs'); const read=fs.readFileSync;", +])( + "retains captured native function calls after argument escape: %s", + (capture) => { + const ir = analyzeJavaScriptSemantics( + `${capture} observe(read); read('kept');`, + ); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); + }, +); + +it("distinguishes escaping a method value from escaping its namespace", () => { + const ir = analyzeJavaScriptSemantics(`const fs=require('fs'); + observe(fs.readFileSync); fs.readFileSync('kept'); + observe(fs); fs.readFileSync('wrong');`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it("still invalidates escaped process configuration containers", () => { + const ir = analyzeJavaScriptSemantics(`const proc=require('process'); + observe(proc.env); const mode=proc.env.MODE;`); + expect(ir.configurationOperations).toEqual([]); +}); + +it("retains direct require effects without poisoning later cached receivers", () => { + const ir = analyzeJavaScriptSemantics(` + require('node:fs').readFileSync('first'); + const fs = require('fs'); fs.readFileSync('second'); + require('node:fs').createReadStream('stream'); + require('node:child_process').spawn('tool', []); + require('node:timers').setTimeout(callback, 1); + require('node:http').get('http://example.test'); + require('fs').readFileSync = custom; + require('node:fs').readFileSync('wrong'); fs.readFileSync('wrong'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual([ + "first", + "second", + ]); + expect(ir.resourceOperations).toHaveLength(1); + expect(ir.childProcessSpawns).toHaveLength(1); + expect(ir.timerOperations).toHaveLength(1); + expect(ir.requestOperations).toHaveLength(1); +}); + +it.each([ + "const fs = require('fs'); fs.promises.open = custom;", + "require('node:fs').promises.open = custom;", + "const fsp = require('fs/promises'); fsp.open = custom;", +])("shares fs.promises object writes with fs/promises: %s", (write) => { + const ir = analyzeJavaScriptSemantics(` + const before = require('node:fs/promises'); before.open('before'); + ${write} + const after = require('node:fs/promises'); after.open('wrong'); + `); + expect(ir.resourceOperations).toHaveLength(1); +}); + +it.each([ + "require = () => ({ readFileSync() {} });", + "({ loader: require } = other);", + "for (require of loaders) {}", +])("rejects provenance from an overwritten ambient loader: %s", (write) => { + const ir = analyzeJavaScriptSemantics(`${write} + const fs = require('node:fs'); fs.readFileSync('wrong'); + require('node:fs').readFileSync('also-wrong'); + `); + expect(ir.configurationOperations).toEqual([]); + expect( + ir.bindings.find(({ name }) => name === "fs")?.provenance.status, + ).not.toBe("module"); +}); + +it("keeps local loader reassignment separate from the ambient loader", () => { + const ir = analyzeJavaScriptSemantics(` + function local(require) { require = replacement; } + const fs = require('node:fs'); fs.readFileSync('kept'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it("keeps wrapper require separate from global-object property writes", () => { + const ir = analyzeJavaScriptSemantics(` + globalThis.require = replacement; + function polyfill(key) { globalThis[key] = replacement; } + require('node:fs').readFileSync('kept'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it.each(["import fs from 'node:fs';", "const fs = require('node:fs');"])( + "retains reads and saved methods before a later write: %s", + (declaration) => { + const ir = analyzeJavaScriptSemantics(`${declaration} + fs.readFileSync('before'); + const saved = fs.readFileSync; + const { readFileSync: destructured } = fs; + const namespace = fs; + fs.readFileSync = custom; + saved('saved'); destructured('destructured'); + namespace.readFileSync('wrong'); fs.readFileSync('wrong'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual([ + "before", + "saved", + "destructured", + ]); + }, +); + +it.each([ + "const { readFileSync: read } = require('fs');", + "const read = require('fs').readFileSync;", +])("checks cached module writes at the projected capture: %s", (capture) => { + const ir = analyzeJavaScriptSemantics(` + const fs = require('node:fs'); + const saved = require('fs').readFileSync; + fs.readFileSync = custom; + ${capture} + read('wrong'); saved('saved'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["saved"]); +}); + +it("retains captured methods across later escapes without freezing a namespace alias", () => { + const ir = analyzeJavaScriptSemantics(` + const fs = require('node:fs'); const saved = require('fs').readFileSync; + const namespace = require('fs'); patch(fs); + const late = require('fs').readFileSync; + saved('saved'); late('wrong'); namespace.readFileSync('wrong'); + `); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["saved"]); +}); + +it("keeps writes in repeated callable and loop activations conservative", () => { + const ir = analyzeJavaScriptSemantics(` + const fs = require('node:fs'); + function run() { fs.readFileSync('wrong'); fs.readFileSync = custom; } + for (;;) { const read = require('fs').readFileSync; read('wrong'); fs.readFileSync = custom; } + `); + expect(ir.configurationOperations).toEqual([]); +}); + +it("does not propagate a reassigned local object's writes into cached exports", () => { + const ir = analyzeJavaScriptSemantics( + `let a = require('node:fs'); const b = require('node:fs'); a = {}; a.readFileSync = custom; b.readFileSync('kept');`, + ); + expect(ir.configurationOperations).toMatchObject([{ key: "kept" }]); +}); + +it("shares repeated require mutations for every built-in effect family", () => { + const ir = analyzeJavaScriptSemantics(` + const fs = require('fs'); const otherFs = require('node:fs'); fs.createReadStream = custom; otherFs.createReadStream('wrong'); + const cp = require('child_process'); const otherCp = require('node:child_process'); cp.spawn = custom; otherCp.spawn('wrong', []); + const timers = require('timers'); const otherTimers = require('node:timers'); timers.setTimeout = custom; otherTimers.setTimeout(callback, 1); + const http = require('http'); const otherHttp = require('node:http'); http.get = custom; otherHttp.get('http://wrong.test'); + const proc = require('process'); const otherProc = require('node:process'); proc.env = replacement; const mode = otherProc.env.MODE; + `); + expect(ir.configurationOperations).toEqual([]); + expect(ir.resourceOperations).toEqual([]); + expect(ir.childProcessSpawns).toEqual([]); + expect(ir.timerOperations).toEqual([]); + expect(ir.requestOperations).toEqual([]); +}); + +it.each(["node:fs", "fs"])( + "shares repeated built-in require identity across %s", + (specifier) => { + const ir = analyzeJavaScriptSemantics( + `const a = require('node:fs'); const b = require('${specifier}'); const alias = b; a.readFileSync = custom; b.createReadStream('kept'); alias.readFileSync('wrong');`, + ); + expect(ir.configurationOperations).toEqual([]); + expect(ir.resourceOperations).toMatchObject([ + { method: "createReadStream" }, + ]); + }, +); + +it("shares unknown escapes across repeated requires without losing earlier effects", () => { + const ir = analyzeJavaScriptSemantics( + `const a = require('node:fs'); const b = require('fs'); b.readFileSync('before'); patch(a); b.readFileSync('after');`, + ); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["before"]); +}); + +it("preserves exact default ESM import mutation paths", () => { + const ir = analyzeJavaScriptSemantics( + `import fs from 'node:fs'; fs.writeFileSync = custom; fs.readFileSync('kept'); fs.writeFileSync('replaced');`, + ); + expect(ir.configurationOperations).toMatchObject([ + { kind: "file", key: "kept" }, + ]); +}); + +it("retains built-in effects before later top-level escapes", () => { + const ir = analyzeJavaScriptSemantics(` + const fs = require('node:fs'); const cp = require('node:child_process'); const timers = require('node:timers'); const http = require('node:http'); + fs.readFileSync('before'); fs.createReadStream('before'); cp.spawn('tool', []); timers.setTimeout(callback, 1); http.get('http://example.test'); + patch(fs); patch(cp); patch(timers); patch(http); + fs.readFileSync('after'); fs.createReadStream('after'); cp.spawn('wrong', []); timers.setTimeout(callback, 1); http.get('http://wrong.test'); + `); + expect(ir.configurationOperations).toMatchObject([{ key: "before" }]); + expect(ir.resourceOperations).toHaveLength(1); + expect(ir.childProcessSpawns).toMatchObject([{ command: "tool" }]); + expect(ir.timerOperations).toHaveLength(1); + expect(ir.requestOperations).toMatchObject([ + { endpoint: "http://example.test" }, + ]); +}); + +it.each(["env", "argv"])( + "does not treat a replaced process %s as configuration", + (family) => { + const ir = analyzeJavaScriptSemantics( + `const proc = require('node:process'); proc.${family} = replacement; const value = proc.${family}[${family === "env" ? "'MODE'" : "2"}];`, + ); + expect(ir.configurationOperations).toEqual([]); + }, +); + +it("does not infer temporal safety across repeated callable or loop activations", () => { + const ir = analyzeJavaScriptSemantics( + `const fs = require('node:fs'); function run() { fs.readFileSync('callable'); patch(fs); } for (;;) { fs.readFileSync('loop'); patch(fs); }`, + ); + expect(ir.configurationOperations).toEqual([]); +}); + +it("does not recover a replaced method through a subsequent alias", () => { + const ir = analyzeJavaScriptSemantics( + `const fs = require('node:fs'); fs.readFileSync = custom; const read = fs.readFileSync; read('config.json');`, + ); + expect(ir.configurationOperations).toEqual([]); +}); + +it.each([ + "fs.readFileSync = custom; fs.createReadStream = custom; cp.spawn = custom; timers.setTimeout = custom; http.get = custom;", + "patch(fs); patch(cp); patch(timers); patch(http);", + "const fsAlias = fs; fsAlias.readFileSync = custom; fsAlias.createReadStream = custom; const cpAlias = cp; cpAlias.spawn = custom; timers[key] = custom; delete http.get;", +])("excludes replaced or escaped CommonJS methods: %s", (mutation) => { + const ir = analyzeJavaScriptSemantics(` + const fs = require('node:fs'); const cp = require('node:child_process'); + const timers = require('node:timers'); const http = require('node:http'); + ${mutation} + fs.readFileSync('config.json'); fs.createReadStream('config.json'); + cp.spawn('tool', []); timers.setTimeout(callback, 1); http.get('http://example.test'); + `); + expect(ir.configurationOperations).toEqual([]); + expect(ir.resourceOperations).toEqual([]); + expect(ir.childProcessSpawns).toEqual([]); + expect(ir.timerOperations).toEqual([]); + expect(ir.requestOperations).toEqual([]); +}); + +it("preserves unaffected CommonJS methods after sibling property mutation", () => { + const ir = analyzeJavaScriptSemantics( + `const fs = require('node:fs'); fs.writeFileSync = custom; fs.readFileSync('config.json');`, + ); + expect(ir.configurationOperations).toMatchObject([ + { kind: "file", key: "config.json" }, + ]); +}); + +it.each([ + `const fs = require("node:fs"); const cp = require("node:child_process"); + const timers = require("node:timers"); const http = require("node:http"); + const read = fs.readFileSync; const launch = cp.spawn; + const schedule = timers.setTimeout; const get = http.get;`, + `const fs = require("fs"); const { readFileSync: read } = fs; + const { spawn: launch } = require("child_process"); + const { setTimeout: schedule } = require("timers"); + const { get } = require("http");`, +])("recovers builtin effects from CommonJS provenance: %s", (declarations) => { + const ir = analyzeJavaScriptSemantics(`${declarations} + const text = read("config.json", "utf8"); + const stream = fs.createReadStream("config.json"); stream.destroy(); + const child = launch("tool", []); child.kill("SIGTERM"); + const timer = schedule(callback, 10); clearTimeout(timer); + get("http://example.test"); + `); + expect(ir.configurationOperations).toMatchObject([ + { kind: "file", key: "config.json" }, + ]); + expect( + ir.resourceOperations.map(({ kind, method }) => ({ kind, method })), + ).toEqual([ + { kind: "acquire", method: "createReadStream" }, + { kind: "release", method: "destroy" }, + ]); + expect(ir.childProcessSpawns).toMatchObject([ + { method: "spawn", command: "tool" }, + ]); + expect(ir.childProcessInteractions).toMatchObject([ + { kind: "signal", signalName: "SIGTERM" }, + ]); + expect( + ir.timerOperations.map(({ kind, method }) => ({ kind, method })), + ).toEqual([ + { kind: "schedule", method: "setTimeout" }, + { kind: "cancel", method: "clearTimeout" }, + ]); + expect(ir.requestOperations).toMatchObject([ + { method: "get", endpoint: "http://example.test" }, + ]); +}); + +it("does not promote ambiguous or non-namespace CommonJS origins to builtin effects", () => { + const ir = analyzeJavaScriptSemantics(` + const fake = require("./fs.js"); fake.readFileSync("external"); + const absentDefault = require("node:fs").default; absentDefault.readFileSync("default"); + const { constants } = require("node:fs"); constants.createReadStream("nested"); + let replaced = require("node:fs"); replaced = other; replaced.readFileSync("replaced"); + const mixed = condition ? require("node:fs") : other; mixed.readFileSync("mixed"); + function shadowed(require) { const fs = require("node:fs"); fs.readFileSync("shadowed"); } + `); + expect(ir.configurationOperations).toEqual([]); + expect(ir.resourceOperations).toEqual([]); +}); + it("does not assign built-in APIs to unrelated or shadowed bindings", () => { const ir = analyzeJavaScriptSemantics(` import fs from "./fs.js"; @@ -13,3 +435,244 @@ it("does not assign built-in APIs to unrelated or shadowed bindings", () => { expect(ir.resourceOperations).toEqual([]); }); + +it.each([ + `const fs = require('fs'); class Reader { text = fs.readFileSync('wrong'); } fs.readFileSync = custom; new Reader();`, + `const fs = require('fs'); class Reader { #text = fs.readFileSync('wrong'); } fs.readFileSync = custom; new Reader();`, + `const fs = require('fs'); fs.__defineGetter__('readFileSync', () => custom); fs.readFileSync('wrong');`, + `import proc from 'node:process'; proc.env = {}; const value = process.env.MODE;`, + `import fs from 'node:fs'; fs.readFileSync = custom; const other = require('fs'); other.readFileSync('wrong');`, +])("keeps delayed or replaced built-in identities unknown: %s", (source) => { + expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual( + [], + ); +}); + +it.each([ + `const fs = require('fs'); const before = fs.readFileSync('kept'), patch = (fs.readFileSync = custom);`, + `globalThis.process = {env: {}}; process.env = {}; const proc = require('process'); const value = proc.env.kept;`, + `import fs from 'node:fs'; fs.writeFileSync = custom; const other = require('fs'); other.readFileSync('kept');`, +])("keeps earlier and independent built-in observations: %s", (source) => { + expect( + analyzeJavaScriptSemantics(source).configurationOperations.map( + ({ key }) => key, + ), + ).toEqual(["kept"]); +}); + +it("shares equivalent alias subproblems and repeated intact receiver facts", () => { + const aliases = Array.from({ length: 30 }, (_, index) => { + const previous = index === 0 ? "fs" : `a${index - 1}`; + return `const a${index} = flag ? ${previous} : ${previous};`; + }); + const calls = Array.from( + { length: 128 }, + (_, index) => `a29.readFileSync('file-${index}');`, + ); + const ir = analyzeJavaScriptSemantics( + ["const fs = require('fs');", ...aliases, ...calls].join("\n"), + ); + expect(ir.configurationOperations).toHaveLength(128); +}); + +it.each([ + `import tls from 'node:tls'; const socket = tls.connect(443, 'example.test'); socket.end();`, + `import * as tls from 'node:tls'; const socket = tls.connect(443, 'example.test'); socket.end();`, + `const tls = require('tls'); const socket = tls.connect(443, 'example.test'); socket.end();`, +])("retains TLS acquisitions and their release: %s", (source) => { + expect( + analyzeJavaScriptSemantics(source).resourceOperations.map( + ({ kind, method }) => ({ kind, method }), + ), + ).toEqual([ + { kind: "acquire", method: "connect" }, + { kind: "release", method: "end" }, + ]); +}); + +it.each([ + `globalThis.process.env = {}; const proc = require('process'); const value = proc.env.MODE;`, + `const host = globalThis; host.process.env = {}; const proc = require('process'); const value = proc.env.MODE;`, + `import * as procNs from 'node:process'; procNs.default.env = {}; const value = process.env.MODE;`, + `globalThis.process.env = { MODE: 'local' }; const proc = require('process'); const value = proc.env.MODE;`, + `const proc = require('process'); proc.env.MODE = 'local'; const value = proc.env.MODE;`, +])("shares writes through the original process object: %s", (source) => { + expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual( + [], + ); +}); + +it.each([ + [ + `const fs = require('fs'); const result = (fs.readFileSync('kept'), fs.readFileSync = custom);`, + "kept", + ], + [ + `const fs = require('fs'); const read = fs.readFileSync; const result = (read('kept'), fs.readFileSync = custom);`, + "kept", + ], + [ + `const fs = require('fs'); consume(fs.readFileSync('before'), fs.readFileSync = custom);`, + "before", + ], +])( + "retains reads before a later sequence operand: %s", + (source, expectedKey) => { + expect( + analyzeJavaScriptSemantics(source).configurationOperations.map( + ({ key }) => key, + ), + ).toEqual([expectedKey]); + }, +); + +it("does not use source offsets for destructuring assignment evaluation order", () => { + const ir = + analyzeJavaScriptSemantics(`const fs = require('fs'); const target = {}; + [target[fs.readFileSync('wrong')]] = (fs.readFileSync = custom, []);`); + expect(ir.configurationOperations).toEqual([]); +}); + +it("keeps process reads after an alias is reassigned away from the global", () => { + const ir = + analyzeJavaScriptSemantics(`let host = globalThis; host = {process: {env: {}}}; + host.process.env = {}; const proc = require('process'); const value = proc.env.MODE;`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["MODE"]); +}); + +it("rejects CommonJS require after the wrapper module replaces its delegate", () => { + const ir = + analyzeJavaScriptSemantics(`module.require = () => ({ readFileSync() {} }); + const fs = require('node:fs'); fs.readFileSync('unknown');`); + expect(ir.configurationOperations).toEqual([]); +}); + +it.each([ + `module.require.extra = true; const fs = require('fs'); fs.readFileSync('kept');`, + `const owner = module; owner.require = () => ({}); const fs = require('fs'); fs.readFileSync('wrong');`, + `Object.defineProperty(module, 'require', {value: () => ({})}); const fs = require('fs'); fs.readFileSync('wrong');`, +])("tracks only actual wrapper require replacements: %s", (source) => { + const ir = analyzeJavaScriptSemantics(source); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual( + source.includes("extra") ? ["kept"] : [], + ); +}); + +it("preserves a saved promises object after deleting its parent export", () => { + const ir = + analyzeJavaScriptSemantics(`const fs = require('fs'); const fsp = fs.promises; + delete fs.promises; const handle = fsp.open('kept'); handle.close();`); + expect( + ir.resourceOperations.map(({ kind, method }) => ({ kind, method })), + ).toEqual([ + { kind: "acquire", method: "open" }, + { kind: "release", method: "close" }, + ]); +}); + +it("does not infer network acquisition methods from another built-in", () => { + const ir = analyzeJavaScriptSemantics( + `const { open } = require('node:net'); open();`, + ); + expect(ir.resourceOperations).toEqual([]); +}); + +it("recognizes nested ESM namespace receivers as original built-ins", () => { + const ir = + analyzeJavaScriptSemantics(`import * as ns from 'node:fs'; import fs from 'node:fs'; + ns.default.readFileSync('first'); fs.readFileSync('second');`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual([ + "first", + "second", + ]); +}); + +it("keeps intact fs/promises identity after deleting the fs.promises slot", () => { + const ir = + analyzeJavaScriptSemantics(`const fs=require('fs'); delete fs.promises; + const fsp=require('fs/promises'); const handle=fsp.open('kept'); handle.close();`); + expect( + ir.resourceOperations.map(({ kind, method }) => ({ kind, method })), + ).toEqual([ + { kind: "acquire", method: "open" }, + { kind: "release", method: "close" }, + ]); +}); + +it.each([ + `import fs from 'node:fs'; fs.promises = { open() {} }; fs.promises.open('wrong');`, + `const fs = require('fs'); patch(fs.promises); require('fs/promises').open('wrong');`, + `const fs = require('fs'); patch(fs.promises); require('node:fs/promises').open('wrong');`, +])("keeps replaced or escaped promises receivers unknown: %s", (source) => { + expect(analyzeJavaScriptSemantics(source).resourceOperations).toEqual([]); +}); + +it("follows final sequence expressions for direct built-in method calls", () => { + const ir = analyzeJavaScriptSemantics(`const fs = require('fs'); + (0, fs.readFileSync)('kept'); fs.readFileSync = custom; + (0, fs.readFileSync)('wrong');`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it.each([ + `import { env } from 'node:process'; patch(env); process.env.MODE;`, + `import { argv } from 'node:process'; patch(argv); process.argv[2];`, +])( + "shares named process containers with their namespace aliases: %s", + (source) => { + expect(analyzeJavaScriptSemantics(source).configurationOperations).toEqual( + [], + ); + }, +); + +it("retains uncertainty after a computed write to module's require delegate", () => { + const ir = + analyzeJavaScriptSemantics(`const key = 'require'; module[key] = replacement; + const fs = require('fs'); fs.readFileSync('wrong');`); + expect(ir.configurationOperations).toEqual([]); +}); + +it("retains a module namespace captured before its binding is reassigned", () => { + const ir = + analyzeJavaScriptSemantics(`let fs = require('fs'); const saved = fs; + fs = {}; saved.readFileSync('kept');`); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it("does not let own fs probe calls hide later builtin reads", () => { + const ir = analyzeJavaScriptSemantics( + `const fs=require('fs'); fs.existsSync('probe'); fs.readFileSync('kept');`, + ); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it("resolves nested ESM default namespace methods", () => { + const ir = analyzeJavaScriptSemantics( + `import * as ns from 'node:fs'; ns.default.readFileSync('kept');`, + ); + expect(ir.configurationOperations.map(({ key }) => key)).toEqual(["kept"]); +}); + +it("tracks process objects passed through qualified global references", () => { + const ir = + analyzeJavaScriptSemantics(`patch(globalThis.process); const proc = require('process'); + const mode = proc.env.MODE;`); + expect(ir.configurationOperations).toEqual([]); +}); + +it("checks a saved child object's method when that function is selected", () => { + const ir = + analyzeJavaScriptSemantics(`const fs = require('fs'); const fsp = fs.promises; + fs.promises.open = custom; const open = fsp.open; open('wrong');`); + expect(ir.resourceOperations).toEqual([]); +}); + +it("retains an earlier captured function after its object method is replaced", () => { + const ir = + analyzeJavaScriptSemantics(`const fs = require('fs'); const fsp = fs.promises; + const open = fsp.open; fs.promises.open = custom; open('kept');`); + expect(ir.resourceOperations).toMatchObject([ + { kind: "acquire", method: "open" }, + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index 15738d5b4..2e32b0fe3 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -210,6 +210,64 @@ describe("JavaScript semantic analysis: read-modify-write", () => { ]); }); + it.each([ + ["destructuring assignment", "[source.count] = [2];"], + ["destructuring assignment with default", "[source.count = 2] = [];"], + [ + "nested destructuring assignment", + "({ value: { count: source.count } } = {});", + ], + ["rest destructuring assignment", "[...source.items] = [];"], + ["for-of head", "for (source.count of [4]) {}"], + ["for-in head", "for (source.count in { key: true }) {}"], + ["delete", "delete source.count;"], + ])("records a member target in %s as a write", (_name, statement) => { + const ir = analyzeJavaScriptSemantics(` + const source = { count: 1, items: [] }; + ${statement} + `); + expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["write"]); + }); + + it.each([ + ["non-null update", "source.count!++;"], + ["non-null compound assignment", "source.count! += 1;"], + ["cast compound assignment", "(source.count as number) += 1;"], + ["parenthesized update", "(source.count)++;"], + ])( + "retains the property read in a wrapped read-modify-write: %s", + (_name, statement) => { + const ir = analyzeJavaScriptSemantics(` + const source = { count: 1 }; + ${statement} + `); + expect(ir.objectOperations.map(({ kind }) => kind)).toEqual([ + "read", + "write", + ]); + }, + ); + + it.each([ + ["cast plain assignment", "(source.count as number) = 2;"], + ["non-null delete", "delete source.count!;"], + ])("keeps a wrapped %s write-only", (_name, statement) => { + const ir = analyzeJavaScriptSemantics(` + const source = { count: 1 }; + ${statement} + `); + expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["write"]); + }); + + it("keeps a member in a destructuring default value a read", () => { + const ir = analyzeJavaScriptSemantics(` + const source = { count: 1 }; + let value; + [value = source.count] = []; + `); + expect(ir.objectOperations.map(({ kind }) => kind)).toEqual(["read"]); + }); + it("does not model a compound binding write as a competing replacement", () => { const ir = analyzeJavaScriptSemantics(` let count = 1; @@ -222,6 +280,37 @@ describe("JavaScript semantic analysis: read-modify-write", () => { }); }); +describe("JavaScript semantic analysis: wrapped binding targets", () => { + it("records a wrapped identifier update as a read before a write", () => { + const ir = analyzeJavaScriptSemantics(` + let count = 1; + count! += 1; + (count as number)++; + const copy = count!; + `); + expect( + ir.references + .filter(({ name }) => name === "count") + .map(({ role }) => role), + ).toEqual(["read", "write", "read", "write", "read"]); + expect(topLevelBinding(ir, "count").value).toMatchObject({ + status: "unknown", + }); + }); + + it("records a wrapped identifier assignment as a write of its value", () => { + const ir = analyzeJavaScriptSemantics(` + let count = 1; + count! = 2; + `); + expect( + ir.references + .filter(({ name }) => name === "count") + .map(({ role }) => role), + ).toEqual(["write"]); + }); +}); + describe("JavaScript semantic analysis: dataflow 2", () => { it("retains partial property coverage and empty returns", () => { const partial = analyzeJavaScriptSemantics(` diff --git a/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts index 1ef0d72af..e97b9e284 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.empty_keys.test.ts @@ -115,3 +115,18 @@ it("retains exact empty request field names without dynamic fields", () => { ), ).toEqual(["", "body"]); }); + +it("retains the exact empty CommonJS export name", () => { + const ir = analyzeJavaScriptSemantics(` + exports[''] = 1; + module.exports[''] = 2; + exports['ok'] = 3; + exports[key] = 4; + `); + expect(ir.moduleLinks.map(({ exportedName }) => exportedName)).toEqual([ + "", + "", + "ok", + "*", + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts index 3c137cc0a..784a5a5b6 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts @@ -170,3 +170,39 @@ describe("JavaScript semantic analysis: native bindings 2", () => { ); }); }); + +describe("JavaScript semantic analysis: child-process signals", () => { + it("keeps unresolved kill signals distinct from default and literal signals", () => { + const ir = analyzeJavaScriptSemantics(` + import { spawn } from "node:child_process"; + function run(signal) { + const child = spawn("/bin/tool"); + child.kill(signal); + child.kill(); + child.kill("SIGINT"); + child.kill(undefined); + child.kill(undefined as NodeJS.Signals | undefined); + child.kill(void 0); + child.kill(void signal); + } + function shadowed(undefined) { + const child = spawn("/bin/tool"); + child.kill(undefined); + } + `); + + expect(ir.childProcessSpawns).toHaveLength(2); + expect( + ir.childProcessInteractions.map(({ signalName }) => signalName), + ).toEqual([ + null, + "SIGTERM", + "SIGINT", + "SIGTERM", + "SIGTERM", + "SIGTERM", + "SIGTERM", + null, + ]); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 578789ad5..dcb913afd 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -21,6 +21,7 @@ import { runtimeFunctionParameters, semanticStaticPropertyKey, semanticWriteTargets, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import type { JavaScriptSemanticAnalysisState, @@ -42,6 +43,7 @@ import { traverseJavaScriptAstSteps, } from "./javascriptSemanticTraversal.js"; import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js"; import { hasUncertainTypeOnlyModuleSideEffect } from "./javascriptModuleDeclaration.js"; @@ -157,6 +159,7 @@ export function* analyzeParsedJavaScriptSemanticsSteps( ): Generator { const state = createState(file.program, staticImportResolutionMode); yield* collectDefinitionsSteps(file.program, state); + yield* indexJavaScriptAmbientWritesSteps(file.program, state); yield* collectSemanticMemberMutationsSteps(file.program, state); yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => collectSemanticModuleLink(node, state), @@ -258,7 +261,8 @@ export type JavaScriptOpenReceiverFact = | "local" | "local-indexed-db" | "local-cache-storage" - | "configuration"; + | "configuration" + | "extension-state"; /** * Resolve only the lexical receiver identity needed to distinguish browser @@ -278,6 +282,7 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( const state = createState(file.program); yield* collectDefinitionsSteps(file.program, state); const facts = new Map(); + const extensionStateReads: number[] = []; yield* traverseJavaScriptAstSteps(file.program, { enter: (node) => { if ( @@ -293,6 +298,8 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( if (method === "get") { if (isConfigurationReceiver(state, node.callee.object)) facts.set(node.start ?? -1, "configuration"); + else if (isExtensionStateMember(node.callee.object)) + extensionStateReads.push(node.start ?? -1); return; } if (method !== "open" || !t.isIdentifier(node.callee.object)) return; @@ -358,9 +365,31 @@ export function* classifyParsedJavaScriptOpenReceiversSteps( if (aliasFact !== undefined) facts.set(node.start ?? -1, aliasFact); }, }); + if (extensionStateReads.length > 0 && importsVsCodeModule(state)) + for (const start of extensionStateReads) + facts.set(start, "extension-state"); return facts; } +/** VS Code passes extension state through ExtensionContext, not an import. */ +const isExtensionStateMember = (receiver: t.Node): boolean => + (t.isMemberExpression(receiver) || t.isOptionalMemberExpression(receiver)) && + (() => { + const key = semanticStaticPropertyKey(receiver.property, receiver.computed); + return key === "globalState" || key === "workspaceState"; + })(); + +/** Require module evidence before treating a familiar member as VS Code state. */ +const importsVsCodeModule = (state: JavaScriptSemanticAnalysisState): boolean => + [...state.bindingsById.values()].some( + (binding) => + binding.directOrigins.some(({ specifier }) => specifier === "vscode") || + semanticRequireOrigin( + semanticStableInitializer(binding, state)?.node, + state, + )?.origin.specifier === "vscode", + ); + /** * Whether a call spells VS Code's `workspace.getConfiguration(section)`, whose * result `.get` reads setting keys rather than request paths. The `workspace` @@ -522,7 +551,7 @@ export function* indexJavaScriptElectronIdentitySteps( const wrapped = interop.callOrigin(root); const origin = wrapped === undefined - ? semanticRequireOrigin(root, state)?.origin + ? electronRequireOrigin(root, state)?.origin : collectedWrites && interop.invalidatedCall(root) ? undefined : wrapped.origin; @@ -606,6 +635,13 @@ const electronExport = ( : null; }; +const electronRequireOrigin = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +) => + semanticRequireOrigin(node, state) ?? + semanticBundledRequireOrigin(node, state); + const bindingOrigin = ( binding: JavaScriptSemanticBindingState, state: JavaScriptSemanticAnalysisState, @@ -616,7 +652,7 @@ const bindingOrigin = ( : undefined; const initializer = semanticStableInitializer(binding, state); if (initializer === undefined) return undefined; - const origin = semanticRequireOrigin(initializer.node, state)?.origin; + const origin = electronRequireOrigin(initializer.node, state)?.origin; const projection = initializer.projection; return origin === undefined || !projection.every((key): key is string => typeof key === "string") @@ -637,6 +673,7 @@ const createState = ( bindings: new Map(), }; return { + program, staticImportResolutionMode, parentsByNode: new WeakMap(), scopes: [root], @@ -649,7 +686,10 @@ const createState = ( linkedRequireCalls: new WeakSet(), moduleLinkBindings: new WeakMap(), conditionalInitializers: new WeakSet(), + requiredModuleEffects: new Map(), + ambientBindingWrites: new Map(), ambientWrites: new SemanticPropertyPathCoverage(), + ambientWriteEffects: [], }; }; @@ -940,12 +980,13 @@ const bindAssignment = ( state: JavaScriptSemanticAnalysisState, ): void => { if (t.isAssignmentExpression(node)) { - if (t.isIdentifier(node.left)) + const left = unwrapJavaScriptExpression(node.left).node; + if (t.isIdentifier(left)) addAssignment( - node.left, + left, node.operator === "=" || ((node.operator === "||=" || node.operator === "??=") && - !hasIncomingValue(node.left, scope, state)) + !hasIncomingValue(left, scope, state)) ? node.right : node, scope, @@ -955,9 +996,10 @@ const bindAssignment = ( for (const identifier of semanticWriteTargets(node.left)) if (t.isIdentifier(identifier)) addAssignment(identifier, node, scope, state); - } else if (t.isUpdateExpression(node) && t.isIdentifier(node.argument)) - addAssignment(node.argument, node, scope, state); - else if (t.isForOfStatement(node) || t.isForInStatement(node)) + } else if (t.isUpdateExpression(node)) { + const argument = unwrapJavaScriptExpression(node.argument).node; + if (t.isIdentifier(argument)) addAssignment(argument, node, scope, state); + } else if (t.isForOfStatement(node) || t.isForInStatement(node)) for (const pattern of t.isVariableDeclaration(node.left) ? node.left.declarations.map(({ id }) => id) : [node.left]) { @@ -1339,7 +1381,7 @@ const createBinding = ( name, kind, mutable, - mutatedPaths: [], + memberWrites: [], escapedPaths: [], definitions: [], initializers: [], @@ -1359,7 +1401,14 @@ const addAssignment = ( identifier.name, state, ); - if (binding === undefined) return; + if (binding === undefined) { + // Unbound identifiers can denote host wrapper bindings (such as require), + // which are separate from properties written through the global object. + const writes = state.ambientBindingWrites.get(identifier.name) ?? []; + writes.push(identifier); + state.ambientBindingWrites.set(identifier.name, writes); + return; + } binding.mutable = true; binding.definitions.push({ kind: "assignment", location: range(identifier) }); binding.initializers.push({ node: initializer, projection: [] }); diff --git a/src/domain/javascript/javascriptSemanticAsyncEffects.ts b/src/domain/javascript/javascriptSemanticAsyncEffects.ts index 5b3068101..6ab62bd59 100644 --- a/src/domain/javascript/javascriptSemanticAsyncEffects.ts +++ b/src/domain/javascript/javascriptSemanticAsyncEffects.ts @@ -1,7 +1,5 @@ import * as t from "@babel/types"; -import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; - import type { JavaScriptSemanticCallable, JavaScriptSemanticEventOperation, @@ -18,6 +16,7 @@ import { import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { + builtinDataEffectMethod, dataEffectLiteralString, dataEffectMemberCallee, assignedSemanticResultBindings, @@ -308,22 +307,11 @@ const timerMethod = ( ? null : method; } - return timerMethodFromBinding(binding); } - if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) - return null; - const method = admittedTimerMethod( - semanticStaticPropertyName(callee.property, callee.computed), + return admittedTimerMethod( + builtinDataEffectMethod(callee, state, ["timers", "node:timers"]) ?? "", ); - return method !== null && - t.isIdentifier(callee.object) && - timerNamespaceBinding( - resolveSemanticBindingState(state, callee.object, callee.object.name), - ) - ? method - : null; }; - const admittedTimerMethod = ( value: string, ): JavaScriptSemanticTimerOperation["method"] | null => @@ -331,28 +319,6 @@ const admittedTimerMethod = ( (candidate) => candidate === value, ) ?? null; -const timerMethodFromBinding = ( - binding: JavaScriptSemanticBindingState, -): JavaScriptSemanticTimerOperation["method"] | null => { - for (const { specifier, importedPath } of binding.directOrigins) { - if (!timerModule(specifier)) continue; - const method = admittedTimerMethod(importedPath.at(-1) ?? ""); - if (method !== null) return method; - } - return null; -}; - -const timerNamespaceBinding = ( - binding: JavaScriptSemanticBindingState | undefined, -): boolean => - binding?.directOrigins.some( - ({ specifier, importedPath }) => - timerModule(specifier) && semanticBuiltinNamespacePath(importedPath), - ) ?? false; - -const timerModule = (specifier: string): boolean => - specifier === "timers" || specifier === "node:timers"; - const bindingId = ( node: | t.Expression diff --git a/src/domain/javascript/javascriptSemanticChildProcesses.ts b/src/domain/javascript/javascriptSemanticChildProcesses.ts index 1cffb5164..c9aec78e7 100644 --- a/src/domain/javascript/javascriptSemanticChildProcesses.ts +++ b/src/domain/javascript/javascriptSemanticChildProcesses.ts @@ -1,15 +1,17 @@ import * as t from "@babel/types"; -import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; - import type { JavaScriptSemanticCallable, JavaScriptSemanticChildProcessInteraction, JavaScriptSemanticChildProcessSpawn, } from "./javascriptSemanticIr.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; -import { semanticStaticPropertyName } from "./javascriptAstValues.js"; import { + semanticStaticPropertyName, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + isUnshadowedGlobal, resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, @@ -18,6 +20,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { + builtinDataEffectMethod, dataEffectLiteralString, dataEffectMemberCallee, assignedSemanticResultBindings, @@ -222,9 +225,7 @@ const childInteraction = ( .sort(compareUnicodeCodePoints), eventName: kind === "listener" ? eventName : null, signalName: - kind === "signal" - ? (dataEffectLiteralString(node.arguments[0]) ?? "SIGTERM") - : null, + kind === "signal" ? killSignalName(node.arguments, state) : null, listenerLocation: kind === "listener" && t.isNode(listener) ? range(listener) : null, resolution: @@ -237,6 +238,23 @@ const childInteraction = ( }; }; +// Only an omitted argument or the unshadowed global undefined selects the +// SIGTERM default; an unresolved argument leaves the signal unknown. +const killSignalName = ( + args: t.CallExpression["arguments"], + state: JavaScriptSemanticAnalysisState, +): string | null => { + const [signal] = args; + if (signal === undefined) return "SIGTERM"; + const expression = unwrapJavaScriptExpression(signal).node; + if ( + t.isUnaryExpression(expression, { operator: "void" }) || + isUnshadowedGlobal(expression, state, "undefined") + ) + return "SIGTERM"; + return dataEffectLiteralString(signal); +}; + const classifyInteraction = ( method: string, argument: @@ -266,52 +284,15 @@ const childProcessMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticChildProcessSpawn["method"] | null => { - if (t.isIdentifier(callee)) { - const binding = resolveSemanticBindingState(state, callee, callee.name); - return binding === undefined ? null : methodFromBinding(binding); - } - if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) - return null; - const method = admittedChildMethod( - semanticStaticPropertyName(callee.property, callee.computed), + const method = builtinDataEffectMethod(callee, state, [ + "child_process", + "node:child_process", + ]); + return ( + CHILD_PROCESS_METHODS.find((candidate) => candidate === method) ?? null ); - if (method === null || !t.isIdentifier(callee.object)) return null; - const binding = resolveSemanticBindingState( - state, - callee.object, - callee.object.name, - ); - return childProcessNamespace(binding) ? method : null; }; -const methodFromBinding = ( - binding: JavaScriptSemanticBindingState, -): JavaScriptSemanticChildProcessSpawn["method"] | null => { - for (const { specifier, importedPath } of binding.directOrigins) { - if (!childProcessModule(specifier)) continue; - const method = admittedChildMethod(importedPath.at(-1) ?? ""); - if (method !== null) return method; - } - return null; -}; - -const admittedChildMethod = ( - value: string, -): JavaScriptSemanticChildProcessSpawn["method"] | null => - CHILD_PROCESS_METHODS.find((candidate) => candidate === value) ?? null; - -const childProcessNamespace = ( - binding: JavaScriptSemanticBindingState | undefined, -): boolean => - binding?.directOrigins.some( - ({ specifier, importedPath }) => - childProcessModule(specifier) && - semanticBuiltinNamespacePath(importedPath), - ) ?? false; - -const childProcessModule = (specifier: string): boolean => - specifier === "child_process" || specifier === "node:child_process"; - const resolveChildCandidates = ( node: t.Node, binding: JavaScriptSemanticBindingState | undefined, diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index 0f7a6d942..99ae17097 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -1,19 +1,36 @@ import * as t from "@babel/types"; +import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"; import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; +import { + semanticRequireOrigin, + semanticRequireExportsIdentity, + semanticGlobalProcessOrigin, + semanticMutableBuiltinOrigin, +} from "./javascriptSemanticRequireOrigin.js"; +import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; +import { + evaluateSemanticProvenance, + evaluateSemanticProvenanceAtRead, +} from "./javascriptSemanticValues.js"; +import { + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; +import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; import { resolveSemanticBindingState, - semanticResolutionBlocked, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, + type JavaScriptSemanticMemberEffect, + type JavaScriptSemanticRequiredModuleEffect, } from "./javascriptSemanticState.js"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; import { readExactJavaScriptLiteral, semanticStaticPropertyKey, - semanticStaticPropertyName, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -137,59 +154,715 @@ export interface DataEffectTraversalContext { readonly ancestors: t.Node[]; } -/** Resolve a direct or namespace-imported built-in method. */ +/** Resolve built-in methods from the shared, unambiguous binding provenance. */ export const builtinDataEffectMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, specifiers: readonly string[], ): string | null => { + const expression = unwrapJavaScriptExpression(callee).node; + if (expression !== callee) + return builtinDataEffectMethod(expression, state, specifiers); + if (t.isSequenceExpression(expression)) { + const final = expression.expressions.at(-1); + return final === undefined + ? null + : builtinDataEffectMethod(final, state, specifiers); + } + const required = semanticRequireOrigin(callee, state); + if (required !== undefined) { + const origin = builtinNamespaceOrigin(required.origin); + return origin.importedPath.length === 1 && + specifiers.some( + (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, + ) && + !requiredModuleChanged( + required.origin.specifier, + required.origin.importedPath, + callee, + state, + true, + ) + ? (origin.importedPath[0] ?? null) + : null; + } if (t.isIdentifier(callee)) { const binding = resolveSemanticBindingState(state, callee, callee.name); - return moduleMethod(binding, specifiers); + const path = builtinBindingPath( + binding, + state, + specifiers, + callee, + [], + true, + ); + return path?.length === 1 ? (path[0] ?? null) : null; } if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return null; - if (!t.isIdentifier(callee.object)) return null; + if (!t.isIdentifier(callee.object)) { + const method = semanticStaticPropertyKey(callee.property, callee.computed); + const receiver = semanticBoundMemberPath(callee.object, state); + if (method !== null && receiver !== undefined) { + const provenance = evaluateSemanticProvenance(receiver.binding, state); + if (provenance.status === "module") { + const origin = builtinNamespaceOrigin({ + ...provenance.origins[0], + importedPath: [ + ...provenance.origins[0].importedPath, + ...receiver.path, + ], + }); + if ( + specifiers.some( + (specifier) => + specifier.replace(/^node:/u, "") === origin.specifier, + ) + ) + return builtinBindingPath( + receiver.binding, + state, + specifiers, + callee, + [...receiver.path, method], + true, + )?.length === 0 + ? method + : null; + } + } + const source = semanticExpressionModuleOrigin(callee.object, state); + if (method === null || source === undefined) return null; + const origin = builtinNamespaceOrigin(source); + return origin.importedPath.length === 0 && + specifiers.some( + (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, + ) && + !requiredModuleChanged( + origin.specifier, + [...origin.importedPath, method], + callee, + state, + true, + callee.object, + ) + ? method + : null; + } const binding = resolveSemanticBindingState( state, callee.object, callee.object.name, ); - const namespace = binding?.directOrigins.some( - ({ specifier, importedPath }) => - specifiers.includes(specifier) && - semanticBuiltinNamespacePath(importedPath), + const method = semanticStaticPropertyKey(callee.property, callee.computed); + if (method === null) return null; + const resolvedPath = builtinBindingPath( + binding, + state, + specifiers, + callee, + [method], + true, ); - return namespace - ? semanticStaticPropertyName(callee.property, callee.computed) + if (resolvedPath?.length === 0) return method; + const initializer = binding?.initializers[0]; + if ( + initializer === undefined || + binding?.initializers.length !== 1 || + (!t.isMemberExpression(initializer.node) && + !t.isOptionalMemberExpression(initializer.node)) + ) { + return null; + } + const provenance = evaluateSemanticProvenance(binding, state); + if (provenance.status !== "module") return null; + const origin = builtinNamespaceOrigin(provenance.origins[0]); + const selected = [...origin.importedPath, method]; + return specifiers.some( + (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, + ) && + builtinBindingPath( + binding, + state, + specifiers, + initializer.node, + [method], + true, + )?.length === 0 && + !requiredModuleChanged( + provenance.origins[0].specifier, + selected, + callee, + state, + true, + initializer.node, + ) + ? method : null; }; -const moduleMethod = ( - binding: JavaScriptSemanticBindingState | undefined, - specifiers: readonly string[], -): string | null => { - for (const { specifier, importedPath } of binding?.directOrigins ?? []) { - if (!specifiers.includes(specifier)) continue; - return importedPath.at(-1) ?? null; +const semanticBoundMemberPath = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): + | { + readonly binding: JavaScriptSemanticBindingState; + readonly path: string[]; + } + | undefined => { + const path: string[] = []; + let current = unwrapJavaScriptExpression(node).node; + while ( + t.isMemberExpression(current) || + t.isOptionalMemberExpression(current) + ) { + const key = semanticStaticPropertyKey(current.property, current.computed); + if (key === null || !t.isNode(current.object)) return undefined; + path.unshift(key); + current = unwrapJavaScriptExpression(current.object).node; } - return null; + if (!t.isIdentifier(current)) return undefined; + const binding = resolveSemanticBindingState(state, current, current.name); + return binding === undefined ? undefined : { binding, path }; +}; + +type BuiltinBindingMemo = Map>; + +const builtinBindingPath = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, + specifiers: readonly string[], + capture: t.Node, + selectedPath: readonly string[] = [], + callableValue = false, + visited: ReadonlySet = new Set(), + memo: BuiltinBindingMemo = new Map(), + provenanceCapture?: t.Identifier, + savedObjectCapture?: t.Node, +): readonly string[] | null => { + if (binding === undefined) return null; + const key = JSON.stringify([ + binding.bindingId, + selectedPath, + callableValue, + provenanceCapture?.start, + provenanceCapture?.end, + savedObjectCapture?.start, + savedObjectCapture?.end, + ]); + let captured = memo.get(capture); + if (captured?.has(key)) return captured.get(key) ?? null; + if (visited.has(binding.bindingId)) return null; + if (captured === undefined) { + captured = new Map(); + memo.set(capture, captured); + } + const result = computeBuiltinBindingPath( + binding, + state, + specifiers, + capture, + selectedPath, + callableValue, + visited, + memo, + provenanceCapture, + savedObjectCapture, + ); + captured.set(key, result); + return result; +}; + +const computeBuiltinBindingPath = ( + binding: JavaScriptSemanticBindingState | undefined, + state: JavaScriptSemanticAnalysisState, + specifiers: readonly string[], + capture: t.Node, + selectedPath: readonly string[] = [], + callableValue = false, + visited: ReadonlySet, + memo: BuiltinBindingMemo, + provenanceCapture?: t.Identifier, + savedObjectCapture?: t.Node, +): readonly string[] | null => { + if (binding === undefined || visited.has(binding.bindingId)) return null; + // Provenance identifies the module, independently of whether a selected slot + // still refers to its original built-in after member mutation or escape. + if ( + binding.memberWrites.some( + ({ path, node }) => + affectsBuiltinPath(path, selectedPath) && + !( + semanticTopLevelEffectFollowsCapture( + path.length === 0 && provenanceCapture !== undefined + ? provenanceCapture + : capture, + node, + state.parentsByNode, + ) || + (savedObjectCapture !== undefined && + path.length < selectedPath.length && + semanticTopLevelEffectFollowsCapture( + savedObjectCapture, + node, + state.parentsByNode, + )) + ), + ) || + builtinEscapes(binding, state).some( + ({ path, node }) => + affectsBuiltinPath(path, selectedPath) && + // A function-value escape cannot replace its native [[Call]]. An + // escaped containing object can still replace the selected method. + (!callableValue || path.length < selectedPath.length) && + !semanticTopLevelEffectFollowsCapture( + capture, + node, + state.parentsByNode, + ), + ) + ) + return null; + const provenance = + provenanceCapture === undefined + ? evaluateSemanticProvenance(binding, state) + : evaluateSemanticProvenanceAtRead(binding, provenanceCapture, state); + if (provenance.status !== "module") return null; + const { importedPath } = provenance.origins[0]; + const namespace = builtinNamespaceOrigin(provenance.origins[0]); + if ( + !specifiers.some((item) => { + const name = item.replace(/^node:/u, ""); + return ( + name === namespace.specifier || + (namespace.specifier === "fs" && + selectedPath[0] === "promises" && + name === "fs/promises") + ); + }) + ) + return null; + let resolvedPath: readonly string[] = importedPath; + for (const origin of binding.directOrigins) { + const shared = semanticMutableBuiltinOrigin(origin, selectedPath); + if ( + shared !== undefined && + requiredModuleChanged( + shared.specifier, + shared.path, + capture, + state, + callableValue, + ) + ) + return null; + } + const ancestors = new Set([...visited, binding.bindingId]); + for (const initializer of binding.initializers) { + const pending = [ + { + node: initializer.node, + path: [...initializer.projection.map(String), ...selectedPath], + savedObjectCapture: + selectedPath.length > 0 && + (t.isMemberExpression(initializer.node) || + t.isOptionalMemberExpression(initializer.node)) + ? initializer.node + : savedObjectCapture, + capture: + callableValue && + selectedPath.length === 0 && + initializer.projection.length > 0 + ? initializer.node + : capture, + }, + ]; + while (pending.length > 0) { + const current = pending.pop()!; + const node = unwrapJavaScriptExpression(current.node).node; + if (t.isIdentifier(node)) { + const source = resolveSemanticBindingState(state, node, node.name); + const sourcePath = builtinBindingPath( + source, + state, + specifiers, + current.capture, + current.path, + callableValue, + ancestors, + memo, + node, + current.savedObjectCapture, + ); + if (sourcePath === null) return null; + // Normalize ESM default namespaces at their owner before projecting an alias. + resolvedPath = [...sourcePath, ...current.path].slice( + 0, + sourcePath.length + current.path.length - selectedPath.length, + ); + } else if ( + t.isMemberExpression(node) || + t.isOptionalMemberExpression(node) + ) { + const key = semanticStaticPropertyKey(node.property, node.computed); + if (key === null) return null; + pending.push({ + node: node.object, + path: [key, ...current.path], + savedObjectCapture: current.savedObjectCapture, + // Freeze a selected function, not later member reads through a saved object. + capture: + callableValue && selectedPath.length === 0 ? node : current.capture, + }); + } else if (t.isSequenceExpression(node)) { + const final = node.expressions.at(-1); + if (final === undefined) return null; + pending.push({ ...current, node: final }); + } else if (t.isAssignmentExpression(node) && node.operator === "=") { + pending.push({ ...current, node: node.right }); + } else if (t.isConditionalExpression(node)) { + pending.push( + { ...current, node: node.consequent }, + { ...current, node: node.alternate }, + ); + } else if (t.isLogicalExpression(node)) { + pending.push( + { ...current, node: node.left }, + { ...current, node: node.right }, + ); + } else { + const required = semanticRequireOrigin(node, state); + if ( + required !== undefined && + requiredModuleChanged( + required.origin.specifier, + [...required.origin.importedPath, ...current.path], + current.capture, + state, + callableValue, + current.savedObjectCapture, + ) + ) + return null; + } + } + } + // Only a direct ESM default import denotes the built-in namespace; + // require("node:fs").default is a literal property, not interop syntax. + return binding.directOrigins.length > 0 && + semanticBuiltinNamespacePath(importedPath) + ? [] + : builtinNamespaceOrigin({ + ...provenance.origins[0], + importedPath: resolvedPath, + }).importedPath; +}; + +/** Interpret known Node namespace aliases without changing source provenance. */ +const builtinNamespaceOrigin = ( + origin: JavaScriptModuleOrigin, +): JavaScriptModuleOrigin => { + const specifier = origin.specifier.replace(/^node:/u, ""); + const importedPath = + origin.resolutionMode !== "require" && origin.importedPath[0] === "default" + ? origin.importedPath.slice(1) + : origin.importedPath; + return specifier === "fs" && importedPath[0] === "promises" + ? { + ...origin, + specifier: "fs/promises", + importedPath: importedPath.slice(1), + } + : { ...origin, specifier, importedPath }; +}; + +const affectsBuiltinPath = ( + path: JavaScriptSemanticPropertyPath, + selected: readonly string[], +): boolean => + path.length <= selected.length && + path.every((key, index) => + semanticPropertyPathKeyMatches(key, selected[index]!), + ); + +const BUILTIN_RECEIVER_METHODS: Readonly>> = + { + fs: new Set([ + "readFile", + "readFileSync", + "writeFile", + "writeFileSync", + "close", + "closeSync", + "existsSync", + ...BUILTIN_RESOURCE_METHODS.fs, + ]), + "fs/promises": new Set([ + "readFile", + "writeFile", + ...BUILTIN_RESOURCE_METHODS["fs/promises"], + ]), + net: new Set(BUILTIN_RESOURCE_METHODS.net), + tls: new Set(BUILTIN_RESOURCE_METHODS.tls), + http: new Set(["request", "get"]), + https: new Set(["request", "get"]), + child_process: new Set(["spawn", "exec", "execFile", "fork"]), + process: new Set(["cwd", "uptime", "hrtime", "nextTick"]), + timers: new Set([ + "setTimeout", + "setInterval", + "setImmediate", + "clearTimeout", + "clearInterval", + "clearImmediate", + ]), + }; + +interface BuiltinEffectIndex { + readonly writesByModule: Map< + string, + readonly JavaScriptSemanticRequiredModuleEffect[] + >; + readonly moduleEffects: Map< + string, + readonly JavaScriptSemanticRequiredModuleEffect[] + >; + readonly bindingEscapes: Map< + string, + readonly JavaScriptSemanticMemberEffect[] + >; + readonly receivers: WeakMap; +} + +// Derived passes run after mutation collection. These indexes share that state's +// lifetime and never cache facts while its mutation owner is still producing them. +const builtinEffectIndexes = new WeakMap< + JavaScriptSemanticAnalysisState, + BuiltinEffectIndex +>(); + +const builtinEffectIndex = ( + state: JavaScriptSemanticAnalysisState, +): BuiltinEffectIndex => { + let index = builtinEffectIndexes.get(state); + if (index === undefined) { + index = { + writesByModule: new Map( + [...state.requiredModuleEffects].map(([name, effects]) => [ + name, + effects.filter(({ kind }) => kind === "write"), + ]), + ), + moduleEffects: new Map(), + bindingEscapes: new Map(), + receivers: new WeakMap(), + }; + builtinEffectIndexes.set(state, index); + } + return index; +}; + +const builtinEscapes = ( + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): readonly JavaScriptSemanticMemberEffect[] => { + const cache = builtinEffectIndex(state).bindingEscapes; + let effects = cache.get(binding.bindingId); + if (effects === undefined) { + effects = binding.escapedPaths.filter( + ({ node }) => !originalBuiltinReceiver(node, state), + ); + cache.set(binding.bindingId, effects); + } + return effects; +}; + +const builtinModuleEffects = ( + specifier: string, + state: JavaScriptSemanticAnalysisState, +): readonly JavaScriptSemanticRequiredModuleEffect[] => { + const cache = builtinEffectIndex(state).moduleEffects; + let effects = cache.get(specifier); + if (effects === undefined) { + effects = (state.requiredModuleEffects.get(specifier) ?? []).filter( + ({ kind, node }) => + kind === "write" || !originalBuiltinReceiver(node, state), + ); + if (specifier === "fs/promises") { + const sharedFsEscapes = (state.requiredModuleEffects.get("fs") ?? []) + .filter( + ({ kind, path, node }) => + kind === "escape" && + (path.length === 0 || path[0] === "promises") && + !originalBuiltinReceiver(node, state), + ) + .map((effect) => ({ + ...effect, + path: + effect.path[0] === "promises" ? effect.path.slice(1) : effect.path, + })); + effects = [...effects, ...sharedFsEscapes]; + } + cache.set(specifier, effects); + } + return effects; +}; + +/** Check shared exports at the use or method-capture point, not at every later call. */ +const requiredModuleChanged = ( + specifier: string, + selected: readonly string[], + capture: t.Node, + state: JavaScriptSemanticAnalysisState, + callableValue = false, + savedObjectCapture?: t.Node, +): boolean => { + const identity = semanticRequireExportsIdentity(specifier, selected); + return builtinModuleEffects(identity.specifier, state).some( + ({ path, node, kind }) => + !( + savedObjectCapture !== undefined && + path.length < identity.path.length && + semanticTopLevelEffectFollowsCapture( + savedObjectCapture, + node, + state.parentsByNode, + ) + ) && + affectsBuiltinPath(path, identity.path) && + !semanticTopLevelEffectFollowsCapture( + capture, + node, + state.parentsByNode, + ) && + (kind === "write" || + !callableValue || + path.length < identity.path.length), + ); +}; + +/** Calling an unchanged built-in as a member is not an escape to unknown code. */ +const originalBuiltinReceiver = ( + node: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (node === undefined) return false; + const receivers = builtinEffectIndex(state).receivers; + const previous = receivers.get(node); + if (previous !== undefined) return previous; + const result = computeOriginalBuiltinReceiver(node, state); + receivers.set(node, result); + return result; +}; + +const computeOriginalBuiltinReceiver = ( + node: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return false; + const callee = node.callee; + if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) + return false; + const receiver = t.isIdentifier(callee.object) + ? resolveSemanticBindingState(state, callee.object, callee.object.name) + : undefined; + const provenance = + receiver === undefined + ? undefined + : evaluateSemanticProvenance(receiver, state); + const sourceOrigin = + provenance?.status === "module" + ? provenance.origins[0] + : (semanticExpressionModuleOrigin(callee.object, state) ?? + semanticRequireOrigin(callee.object, state)?.origin ?? + semanticGlobalProcessOrigin(callee.object, state)); + if (sourceOrigin === undefined) return false; + const origin = builtinNamespaceOrigin(sourceOrigin); + if (origin.importedPath.length !== 0) return false; + const method = semanticStaticPropertyKey(callee.property, callee.computed); + const identity = semanticRequireExportsIdentity(origin.specifier, [ + method ?? "", + ]); + if ( + method === null || + !BUILTIN_RECEIVER_METHODS[origin.specifier]?.has(method) || + receiver?.memberWrites.some( + ({ path, node: write }) => + affectsBuiltinPath(path, [method]) && + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), + ) || + ( + builtinEffectIndex(state).writesByModule.get(identity.specifier) ?? [] + ).some( + ({ path, node: write }) => + affectsBuiltinPath(path, identity.path) && + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), + ) + ) + return false; + return !node.arguments.some((argument) => { + if (!t.isIdentifier(argument)) return false; + const passed = resolveSemanticBindingState(state, argument, argument.name); + if (passed === undefined) return false; + const passedOrigin = evaluateSemanticProvenance(passed, state); + return ( + passedOrigin.status === "module" && + builtinNamespaceOrigin(passedOrigin.origins[0]).specifier === + origin.specifier && + builtinNamespaceOrigin(passedOrigin.origins[0]).importedPath.length === 0 + ); + }); +}; + +const semanticExpressionModuleOrigin = ( + expression: t.Node, + state: JavaScriptSemanticAnalysisState, + visited = new Set(), +): JavaScriptModuleOrigin | undefined => { + const node = unwrapJavaScriptExpression(expression).node; + const required = semanticRequireOrigin(node, state); + if (required !== undefined) return required.origin; + if (t.isIdentifier(node)) { + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined || visited.has(binding.bindingId)) + return undefined; + const provenance = evaluateSemanticProvenance(binding, state); + return provenance.status === "module" ? provenance.origins[0] : undefined; + } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + const base = semanticExpressionModuleOrigin(node.object, state, visited); + const key = semanticStaticPropertyKey(node.property, node.computed); + return base === undefined || key === null + ? undefined + : { ...base, importedPath: [...base.importedPath, key] }; + } + return undefined; }; /** Recognize global or node:process aliases without accepting shadowing. */ export const isSemanticProcessObject = ( node: t.Expression | t.Super, state: JavaScriptSemanticAnalysisState, + family: "env" | "argv", + key?: string | null, ): boolean => { if (!t.isIdentifier(node)) return false; const binding = resolveSemanticBindingState(state, node, node.name); + const selected = key === undefined || key === null ? [family] : [family, key]; if (binding === undefined) return ( - node.name === "process" && - !semanticResolutionBlocked(state, node, node.name) + semanticGlobalProcessOrigin(node, state) !== undefined && + !requiredModuleChanged("process", selected, node, state) ); - return binding.directOrigins.some( - ({ specifier }) => specifier === "process" || specifier === "node:process", + return ( + builtinBindingPath( + binding, + state, + ["process", "node:process"], + node, + selected, + )?.length === 0 ); }; diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index 607843aab..c6d47dfd2 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -1,7 +1,5 @@ import * as t from "@babel/types"; -import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; - import type { JavaScriptSemanticBoundaryOperation, JavaScriptSemanticCallable, @@ -140,12 +138,12 @@ const processConfigurationMember = ( const object = node.object; if (!t.isMemberExpression(object) && !t.isOptionalMemberExpression(object)) return null; - if (!isProcessObject(object.object, state)) return null; const family = semanticStaticPropertyName(object.property, object.computed); if (family !== "env" && family !== "argv") return null; // `process.env[""]` names an exact (empty) key; only a dynamic key is unknown. const key = semanticStaticPropertyKey(node.property, node.computed); if (family === "argv" && key !== null && !isArrayIndexKey(key)) return null; + if (!isProcessObject(object.object, state, family, key)) return null; return { kind: family === "env" ? "environment" : "argv", key, @@ -375,22 +373,13 @@ const requestMethod = ( if (t.isNewExpression(node) && unbound && name === "WebSocket") return "WebSocket"; } - const member = memberCallee(node); - if (member === null || !t.isIdentifier(member.object)) return null; - const method = semanticStaticPropertyName(member.property, member.computed); - if (method !== "request" && method !== "get") return null; - const binding = resolveSemanticBindingState( - state, - member.object, - member.object.name, - ); - return binding?.directOrigins.some( - ({ specifier, importedPath }) => - ["http", "https", "node:http", "node:https"].includes(specifier) && - semanticBuiltinNamespacePath(importedPath), - ) - ? method - : null; + const method = builtinMethod(node.callee, state, [ + "http", + "https", + "node:http", + "node:https", + ]); + return method === "request" || method === "get" ? method : null; }; const requestFields = ( diff --git a/src/domain/javascript/javascriptSemanticGraphSchemas.ts b/src/domain/javascript/javascriptSemanticGraphSchemas.ts index 4048ae698..115d3762e 100644 --- a/src/domain/javascript/javascriptSemanticGraphSchemas.ts +++ b/src/domain/javascript/javascriptSemanticGraphSchemas.ts @@ -184,7 +184,6 @@ const relativePathSchema = z .refine( (path) => !path.startsWith("/") && - !path.includes("\\") && path.split("/").every((part) => !["", ".", ".."].includes(part)), "Expected a normalized relative POSIX path without traversal", ); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 167e8899e..f3d7063b7 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -1,5 +1,14 @@ import * as t from "@babel/types"; +import { + semanticGlobalProcessOrigin, + semanticGlobalProcessPathOrigin, + semanticOriginalGlobalProcessAt, + semanticMutableBuiltinOrigin, + semanticRequireExportsIdentity, + semanticRequireOrigin, +} from "./javascriptSemanticRequireOrigin.js"; + import { resolveSemanticBindingState, type JavaScriptSemanticBindingState, @@ -35,6 +44,7 @@ import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticStaticPropertyKey, + semanticWriteSiteTarget, semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; @@ -50,7 +60,41 @@ export function* collectSemanticMemberMutationsSteps( const parents = state.parentsByNode; const observablePaths = observableMutationPaths(state); const returnedReferences = createSemanticReturnedReferences(state); - const recordedEffects = new Set(); + const recordedRequiredEffects = new Set(); + let recordedEffectCount = 0; + const recordRequiredEffect = ( + specifier: string, + path: PropertyPath, + effect: ValueEffect, + node: t.Node | undefined, + ): void => { + const identity = semanticRequireExportsIdentity(specifier, path); + const key = JSON.stringify([ + "required", + identity.specifier, + effect, + identity.path, + node?.start, + node?.end, + ]); + if (recordedRequiredEffects.has(key)) return; + recordedRequiredEffects.add(key); + recordedEffectCount += 1; + const effects = state.requiredModuleEffects.get(identity.specifier) ?? []; + effects.push({ path: identity.path, node, kind: effect }); + state.requiredModuleEffects.set(identity.specifier, effects); + }; + // Ambient indexing owns aliases of global objects. Consume its canonical + // writes instead of reparsing qualified or aliased process assignments. + for (const { path, node } of state.ambientWriteEffects) + if ( + path[0] === "global" && + path[1] === "process" && + path.length > 2 && + node !== undefined && + semanticOriginalGlobalProcessAt(node, state) + ) + recordRequiredEffect("node:process", path.slice(2), "write", node); const selections = new Map(); const coveringPaths = new Map(); let arrayIterationUnknown = false; @@ -171,9 +215,37 @@ export function* collectSemanticMemberMutationsSteps( const current = pending[cursor]; if (current === undefined) break; const expression = unwrapJavaScriptExpression(current.node).node; + const required = semanticRequireOrigin(expression, state); + if (required !== undefined) { + recordRequiredEffect( + required.origin.specifier, + [...required.origin.importedPath, ...current.path], + effect, + mutation, + ); + continue; + } + const globalProcess = semanticGlobalProcessPathOrigin(expression, state); + if (globalProcess !== undefined) { + recordRequiredEffect( + globalProcess.specifier, + [...globalProcess.importedPath, ...current.path], + effect, + mutation, + ); + continue; + } if (t.isIdentifier(expression)) { const binding = resolveBinding(expression, expression.name); if (binding === undefined) { + const processOrigin = semanticGlobalProcessOrigin(expression, state); + if (processOrigin !== undefined) + recordRequiredEffect( + processOrigin.specifier, + current.path, + effect, + mutation, + ); if ( (expression.name === "Array" && (current.path[0] === "prototype" || current.path[0] === null)) || @@ -270,52 +342,6 @@ export function* collectSemanticMemberMutationsSteps( index === selectorIndex ? { ...selector, excludedKeys } : key, ); } - // Keep an antichain of exact paths. Widening distinct member keys would - // also invalidate unrelated siblings that never reach this effect. - const pathState = JSON.stringify([ - binding.bindingId, - effect, - // Short writes stop at owned literal slots, whereas longer writes - // reach shared children. Only equal-depth writes cover one another. - effect === "write" ? path.length : null, - mutation?.start, - mutation?.end, - current.originAt?.start, - current.originAt?.end, - current.ownerInitializer?.start, - current.ownerInitializer?.end, - ]); - let covering = coveringPaths.get(pathState); - if (covering === undefined) { - covering = new SemanticPropertyPathCoverage(); - coveringPaths.set(pathState, covering); - } - if (!covering.retain(path)) continue; - const identity = JSON.stringify([ - binding.bindingId, - effect, - path, - mutation?.start, - mutation?.end, - current.originAt?.start, - current.originAt?.end, - current.ownerInitializer?.start, - current.ownerInitializer?.end, - ]); - if (recordedEffects.has(identity)) continue; - // A recorded write invalidates the binding to a non-primitive value, so - // only its first write can target a primitive. Re-evaluating it would - // replay every earlier write and make repeated writes superlinear. - const value = - effect === "write" && binding.mutatedPaths.length === 0 - ? evaluateSemanticBinding(binding, state) - : undefined; - const primitiveWrite = - value?.status === "literal" || value?.status === "union"; - recordedEffects.add(identity); - const nested = new Set([...current.bindings, lifetime, captureKey]); - // Returning a literal's receiver reaches that allocation, not the - // binding's older values read while evaluating its initializer. const origins = current.ownerInitializer !== undefined ? { @@ -323,6 +349,7 @@ export function* collectSemanticMemberMutationsSteps( ({ node }) => node === current.ownerInitializer, ), referenceInitializers: [], + directOrigins: [], } : current.originAt === undefined ? binding @@ -331,10 +358,57 @@ export function* collectSemanticMemberMutationsSteps( current.originAt, parents, ); + // Keep an antichain of exact paths. Widening distinct member keys would + // also invalidate unrelated siblings that never reach this effect. + // This map is local to the file scan. Escaping the binding id keeps a + // field separator injective while avoiding a fresh JSON array encode. + const pathState = `${JSON.stringify(binding.bindingId)}\u0001${ + effect + }\u0001${ + // Short writes stop at owned literal slots, whereas longer writes + // reach shared children. Only equal-depth writes cover one another. + effect === "write" ? path.length : -1 + }\u0001${mutation?.start ?? -1}\u0001${mutation?.end ?? -1}\u0001${ + current.originAt?.start ?? -1 + }\u0001${current.originAt?.end ?? -1}\u0001${ + current.ownerInitializer?.start ?? -1 + }\u0001${current.ownerInitializer?.end ?? -1}`; + let covering = coveringPaths.get(pathState); + if (covering === undefined) { + covering = new SemanticPropertyPathCoverage(); + coveringPaths.set(pathState, covering); + } + if (!covering.retain(path)) continue; + // A recorded write invalidates the binding to a non-primitive value, so + // only its first write can target a primitive. Re-evaluating it would + // replay every earlier write and make repeated writes superlinear. + const value = + effect === "write" && binding.memberWrites.length === 0 + ? evaluateSemanticBinding(binding, state) + : undefined; + const primitiveWrite = + value?.status === "literal" || value?.status === "union"; + // The coverage index already owns binding-effect deduplication. + recordedEffectCount += 1; + const nested = new Set(current.bindings); + nested.add(lifetime); + nested.add(captureKey); + // Returning a literal's receiver reaches that allocation, not the + // binding's older values read while evaluating its initializer. if (!primitiveWrite) { + for (const origin of origins.directOrigins) { + const shared = semanticMutableBuiltinOrigin(origin, path); + if (shared !== undefined) + recordRequiredEffect( + shared.specifier, + shared.path, + effect, + mutation, + ); + } if (effect === "escape") binding.escapedPaths.push({ path, node: mutation }); - else binding.mutatedPaths.push(path); + else binding.memberWrites.push({ path, node: mutation }); clearSemanticValueCaches(state); for (const initializer of origins.initializers) pending.push({ @@ -514,12 +588,8 @@ export function* collectSemanticMemberMutationsSteps( originAt: source.mutation, iterationOnly: source.kind === "iteration", }); - if (t.isAssignmentExpression(node)) markTarget(node.left, node); - else if (t.isUpdateExpression(node)) markTarget(node.argument, node); - else if (t.isUnaryExpression(node, { operator: "delete" })) - markTarget(node.argument, node); - else if (t.isForOfStatement(node) || t.isForInStatement(node)) - markTarget(node.left, node); + const writeTarget = semanticWriteSiteTarget(node); + if (writeTarget !== null) markTarget(writeTarget, node); else if ( t.isCallExpression(node) || t.isOptionalCallExpression(node) || @@ -552,7 +622,7 @@ export function* collectSemanticMemberMutationsSteps( // A later effect can make a previously defined destructuring source uncertain. // Reconsider defaults and iterable origins until their effects are stable. while (pendingReferences.length > 0 || iterableReferences.length > 0) { - const effectsBefore = recordedEffects.size; + const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; const iterationBefore: boolean = arrayIterationUnknown; for (const [index, reference] of pendingReferences.splice(0).entries()) { @@ -631,7 +701,7 @@ export function* collectSemanticMemberMutationsSteps( ); } if ( - recordedEffects.size === effectsBefore && + recordedEffectCount === effectsBefore && iterableReferences.length === iterablesBefore && arrayIterationUnknown === iterationBefore ) @@ -881,6 +951,15 @@ const observableMutationPaths = ( for (const binding of state.bindingsById.values()) { const observer = bindingNodes.get(binding.bindingId); if (observer === undefined) continue; + // Imported values are external objects with observable member slots even + // when their syntax has no local object-allocation node (for example a + // CommonJS namespace from `require("node:fs")`). Keep those writes at their + // exact member paths instead of widening the first path to a wildcard. + const hasParsedRequireOrigin = binding.initializers.some( + ({ node }) => semanticRequireOrigin(node, state) !== undefined, + ); + if (binding.directOrigins.length > 0 || hasParsedRequireOrigin) + (observer.origins ??= new Set()).add("opaque"); for (const initializer of binding.initializers) { link( project(expressionNode(initializer.node), initializer.projection), diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts index 8b5364578..8e1c92d5f 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.test.ts @@ -48,7 +48,7 @@ describe("parameter references copied into function body bindings", () => { name: "arg", kind: "variable", mutable: true, - mutatedPaths: [], + memberWrites: [], escapedPaths: [], definitions: [], initializers: [entry, assigned], diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index 414d3e26d..fba388226 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -73,6 +73,94 @@ const positionPrecedes = ( left.declaratorIndex >= 0 && left.declaratorIndex < right.declaratorIndex)); +/** Order effects only in a non-repeating top-level activation. */ +export const semanticTopLevelEffectFollowsCapture = ( + capture: t.Node, + effect: t.Node | undefined, + parents: WeakMap, +): boolean => { + const position = (node: t.Node | undefined): StatementPosition | null => { + let selected: StatementPosition | null = null; + let declarator: t.VariableDeclarator | undefined; + let child: t.Node | undefined; + while (node !== undefined) { + if (t.isFunction(node) || t.isLoop(node)) return null; + if ( + (t.isClassProperty(node) || + t.isClassPrivateProperty(node) || + t.isClassAccessorProperty(node)) && + !node.static && + node.value === child + ) + return null; + if (t.isVariableDeclarator(node)) declarator = node; + if (selected === null && t.isStatement(node)) + selected = statementPosition(node, parents, declarator); + if (t.isProgram(node)) return selected; + child = node; + node = parents.get(node); + } + return null; + }; + const before = position(capture); + const after = position(effect); + if (before === null || after === null) return false; + if (positionPrecedes(before, after)) return true; + if ( + before.body !== after.body || + before.index !== after.index || + before.declaratorIndex !== after.declaratorIndex + ) + return false; + return expressionEvaluationPrecedes(capture, effect, parents); +}; + +/** Prove ordering only where JavaScript evaluates sibling expressions left-to-right. */ +const expressionEvaluationPrecedes = ( + earlier: t.Node, + later: t.Node | undefined, + parents: WeakMap, +): boolean => { + if (later === undefined) return false; + const earlierAncestors = new Set(); + let current: t.Node | undefined = earlier; + while (current !== undefined) { + earlierAncestors.add(current); + current = parents.get(current); + } + const laterPath: t.Node[] = []; + current = later; + while (current !== undefined && !earlierAncestors.has(current)) { + laterPath.push(current); + current = parents.get(current); + } + const common = current; + if (common === undefined || common === earlier || common === later) + return false; + let earlierChild: t.Node = earlier; + while (parents.get(earlierChild) !== common) { + const parent = parents.get(earlierChild); + if (parent === undefined) return false; + earlierChild = parent; + } + const laterChild = laterPath.at(-1); + if (laterChild === undefined) return false; + const orderedChildren = ((): readonly t.Node[] | null => { + if (t.isSequenceExpression(common)) return common.expressions; + if (t.isCallExpression(common) || t.isOptionalCallExpression(common)) + return [common.callee, ...common.arguments]; + if (t.isNewExpression(common)) return [common.callee, ...common.arguments]; + if (t.isBinaryExpression(common) || t.isLogicalExpression(common)) + return [common.left, common.right]; + if (t.isConditionalExpression(common)) return [common.test]; + return null; + })(); + if (orderedChildren === null) return false; + const earlierIndex = orderedChildren.indexOf(earlierChild); + const laterIndex = orderedChildren.indexOf(laterChild); + return earlierIndex >= 0 && laterIndex > earlierIndex; +}; + const computeDirectInitializerPosition = ( node: t.Node, parents: WeakMap, @@ -211,12 +299,64 @@ export const semanticMutationInitializers = ( parents: WeakMap, ): Pick< JavaScriptSemanticBindingState, - "initializers" | "referenceInitializers" + "initializers" | "referenceInitializers" | "directOrigins" > => { const mutationPosition = directMutationPosition(mutation, parents) ?? referenceInitializerPosition(mutation, parents); - return selectInitializers(binding, mutationPosition, parents); + if (mutationPosition === null) return binding; + const origins = [ + ...binding.initializers.map((initializer) => ({ + initializer, + position: + initializer.entryBody === undefined + ? directInitializerPosition(initializer.node, parents) + : { + body: initializer.entryBody.body, + index: -1, + declaratorIndex: -1, + }, + })), + ...binding.referenceInitializers.map((initializer) => ({ + initializer, + position: referenceInitializerPosition(initializer.node, parents), + })), + ]; + if ( + origins.some( + ({ position }) => + position === null || !positionPrecedes(position, mutationPosition), + ) + ) + return binding; + const latest = origins.reduce( + (latest, { position }) => + position !== null && + (latest === null || positionPrecedes(latest, position)) + ? position + : latest, + null, + ); + const retained = new Set( + origins + .filter( + ({ position }) => + position?.index === latest?.index && + position?.declaratorIndex === latest?.declaratorIndex, + ) + .map(({ initializer }) => initializer), + ); + return { + // A proven earlier assignment replaces even an import-equals value. A + // capture before or inside an uncertain assignment retains the full binding. + directOrigins: origins.length === 0 ? binding.directOrigins : [], + initializers: binding.initializers.filter((initializer) => + retained.has(initializer), + ), + referenceInitializers: binding.referenceInitializers.filter((initializer) => + retained.has(initializer), + ), + }; }; /** Select a local alias at its read, before any later straight-line reassignment. */ @@ -309,6 +449,16 @@ const selectInitializers = ( ) .map(({ initializer }) => initializer), ); + // Preserve the owner's identity when selection removes no facts. Consumers + // can then reuse the binding's dependency-aware provenance memo rather than + // treating every equivalent read as a new temporal snapshot. + if ( + binding.initializers.every((initializer) => retained.has(initializer)) && + binding.referenceInitializers.every((initializer) => + retained.has(initializer), + ) + ) + return binding; return { initializers: binding.initializers.filter((initializer) => retained.has(initializer), diff --git a/src/domain/javascript/javascriptSemanticObjects.ts b/src/domain/javascript/javascriptSemanticObjects.ts index 08acf25f8..8c0b7af8b 100644 --- a/src/domain/javascript/javascriptSemanticObjects.ts +++ b/src/domain/javascript/javascriptSemanticObjects.ts @@ -4,12 +4,12 @@ import type { JavaScriptSemanticCallable, JavaScriptSemanticObjectOperation, } from "./javascriptSemanticIr.js"; -import { - semanticCallableIdForNode, - semanticReadsBeforeWrite, -} from "./javascriptSemanticProjection.js"; +import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; import { semanticStaticPropertyKey, + semanticWriteSiteReadsFirst, + semanticWriteSiteTarget, + semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { @@ -28,13 +28,21 @@ export const collectJavaScriptSemanticObjects = ( const output: JavaScriptSemanticObjectOperation[] = []; const callableIds = new Set(callables.map(({ callableId }) => callableId)); const callableStack: string[] = []; - const context: ObjectCollectionContext = { state, output }; + const context: ObjectCollectionContext = { + state, + output, + writeTargets: new Map(), + }; traverseJavaScriptAst(program, { enter: (node, parent) => { const callableId = semanticCallableIdForNode(node); if (callableId !== null && callableIds.has(callableId)) callableStack.push(callableId); const ownerCallableId = callableStack.at(-1) ?? null; + const writeTarget = semanticWriteSiteTarget(node); + if (writeTarget !== null) + for (const leaf of semanticWriteTargets(writeTarget)) + if (!t.isIdentifier(leaf)) context.writeTargets.set(leaf, node); if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) collectMember(node, parent, ownerCallableId, context); else if ( @@ -73,6 +81,7 @@ export const collectJavaScriptSemanticObjects = ( interface ObjectCollectionContext { readonly state: JavaScriptSemanticAnalysisState; readonly output: JavaScriptSemanticObjectOperation[]; + readonly writeTargets: Map; } const collectMember = ( @@ -90,10 +99,11 @@ const collectMember = ( ) return; const identity = expressionObjectIdentity(node, state); - const write = - (t.isAssignmentExpression(parent) && parent.left === node) || - (t.isUpdateExpression(parent) && parent.argument === node); - const readsBeforeWrite = write && semanticReadsBeforeWrite(node, parent); + const writeSite = context.writeTargets.get(node); + context.writeTargets.delete(node); + const write = writeSite !== undefined; + const readsBeforeWrite = + writeSite !== undefined && semanticWriteSiteReadsFirst(writeSite); if (readsBeforeWrite) addObjectOperation( { diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index b477bb5af..59e92b98d 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -27,6 +27,10 @@ import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName, semanticStaticPropertyKey, + semanticWrappedWriteSite, + semanticWriteSiteReadsFirst, + semanticWriteSiteTarget, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { isTypeOnlyExportDeclaration, @@ -175,12 +179,17 @@ export function* collectSemanticReferencesSteps( !patterns.some((pattern) => bindsIdentifier(pattern, parent, node))) ) return; - const role = semanticIdentifierRole(node, parent, readAncestors()); + const ancestors = readAncestors(); + const role = semanticIdentifierRole(node, parent, ancestors); if (role === null) return; // A compound assignment or update reads before it writes; emit both so // reference consumers see the read they actually execute. const roles = - role === "write" && semanticReadsBeforeWrite(node, parent) + role === "write" && + semanticReadsBeforeWrite( + node, + semanticWrappedWriteSite(node, parent, ancestors) ?? parent, + ) ? (["read", "write"] as const) : ([role] as const); for (const resolvedRole of roles) { @@ -594,12 +603,15 @@ const declarationCallableId = ( */ export const semanticReadsBeforeWrite = ( node: t.Node, - parent: t.Node, -): boolean => - (t.isAssignmentExpression(parent) && - parent.left === node && - parent.operator !== "=") || - (t.isUpdateExpression(parent) && parent.argument === node); + site: t.Node, +): boolean => { + const target = semanticWriteSiteTarget(site); + return ( + target !== null && + unwrapJavaScriptExpression(target).node === node && + semanticWriteSiteReadsFirst(site) + ); +}; /** * Role of one identifier occurrence, given the node's ancestry. @@ -620,6 +632,8 @@ export const semanticIdentifierRole = ( if (isPatternOwnerDeclaration(patternAncestor)) return null; return "write"; } + const site = semanticWrappedWriteSite(node, parent, ancestors); + if (site !== null && site !== parent && !isDeleteSite(site)) return "write"; return semanticReferenceRole(node, parent); }; @@ -684,6 +698,9 @@ const isPatternNode = (node: t.Node): boolean => t.isAssignmentPattern(node) || t.isRestElement(node); +const isDeleteSite = (site: t.Node): boolean => + t.isUnaryExpression(site, { operator: "delete" }); + const isPatternOwnerDeclaration = (node: t.Node): boolean => (t.isVariableDeclarator(node) && t.isPattern(node.id)) || (t.isFunction(node) && @@ -815,16 +832,15 @@ const commonJsExportName = ( if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node)) return undefined; const key = semanticStaticPropertyKey(node.property, node.computed); - // `exports[key]`/`module.exports[key]` assign an export whose name is not - // knowable. Report the wildcard rather than the variable name, and never - // collapse it into `default`, which would claim a real default export. + // A computed nonliteral key is unknown, but the empty string is a real exact + // CommonJS export name and must not collapse into the wildcard/default. if (isUnshadowedGlobal(node.object, state, "exports")) - return key === null ? "*" : key || "*"; + return key === null ? "*" : key; if ( t.isMemberExpression(node.object) && isCommonJsModuleExports(node.object, state) ) - return key === null ? "*" : key || "default"; + return key === null ? "*" : key; if (isCommonJsModuleExports(node, state)) return "default"; return undefined; }; diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 63fde3a95..a4a153597 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -1,5 +1,6 @@ import * as t from "@babel/types"; +import { BUILTIN_RESOURCE_METHODS } from "./javascriptBuiltinResourceMethods.js"; import type { JavaScriptModuleOrigin } from "./javascriptSemanticIr.js"; import { resolveSemanticBindingState, @@ -10,18 +11,243 @@ import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; +import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; -interface JavaScriptRequireOrigin { +export interface JavaScriptRequireOrigin { readonly origin: JavaScriptModuleOrigin; readonly call: t.CallExpression; } -/** Recover an unshadowed literal require origin and its exact member path. */ -export const semanticRequireOrigin = ( - node: t.Node | null | undefined, +// Ambient indexing finishes before these derived facts are consumed. Keep only +// replacement sites, rather than rescanning unrelated global member writes. +const globalProcessReplacements = new WeakMap< + JavaScriptSemanticAnalysisState, + readonly (t.Node | undefined)[] +>(); +const moduleRequireWrites = new WeakMap< + JavaScriptSemanticAnalysisState, + readonly t.Node[] +>(); + +const writesModuleRequire = ( state: JavaScriptSemanticAnalysisState, -): JavaScriptRequireOrigin | undefined => { +): readonly t.Node[] => { + const cached = moduleRequireWrites.get(state); + if (cached !== undefined) return cached; + // Interop invocation resolution may ask for require provenance while this + // index is being built. Publish a temporary empty result to break that cycle. + moduleRequireWrites.set(state, []); + const writes: t.Node[] = []; + const interop = createSemanticModuleInterop(state); + const isModuleObject = ( + node: t.Node, + visited = new Set(), + ): boolean => { + const value = unwrapJavaScriptExpression(node).node; + if (!t.isIdentifier(value)) return false; + if (value.name === "module") + return ( + resolveSemanticBindingState(state, value, "module") === undefined && + !semanticResolutionBlocked(state, value, "module") + ); + const binding = resolveSemanticBindingState(state, value, value.name); + if (binding === undefined || visited.has(binding.bindingId)) return false; + const next = new Set(visited).add(binding.bindingId); + return ( + binding.initializers.length > 0 && + binding.initializers.every(({ node: init }) => isModuleObject(init, next)) + ); + }; + const pending: t.Node[] = [state.program]; + while (pending.length > 0) { + const node = pending.pop(); + if (node === undefined) break; + const target = t.isAssignmentExpression(node) + ? node.left + : t.isUpdateExpression(node) || + t.isUnaryExpression(node, { operator: "delete" }) + ? node.argument + : undefined; + if (target !== undefined) { + const members: string[] = []; + let root: t.Node = target; + while (t.isMemberExpression(root) || t.isOptionalMemberExpression(root)) { + const key = semanticStaticPropertyKey(root.property, root.computed); + if (key === null) break; + members.unshift(key); + root = root.object; + } + if ( + members.length === 1 && + members[0] === "require" && + isModuleObject(root) + ) + writes.push(node); + else if ( + members.length === 0 && + (t.isMemberExpression(target) || + t.isOptionalMemberExpression(target)) && + target.computed && + semanticStaticPropertyKey(target.property, true) === null && + isModuleObject(target.object) + ) + // An unknown computed key may be "require"; retain uncertainty rather + // than treating this direct delegate write as a proven unrelated slot. + writes.push(node); + } + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + const invocation = interop.intrinsicInvocation(node); + const owner = + invocation?.path?.length === 3 ? invocation.path[1] : undefined; + const method = invocation?.path?.at(-1); + const args = invocation?.arguments; + if ( + (owner === "Object" || owner === "Reflect") && + method === "defineProperty" && + isModuleObject(args?.[0] ?? t.nullLiteral()) + ) { + const key = interop.staticString(args?.[1]); + if (key === "require" || key === undefined) writes.push(node); + } else if ( + owner === "Object" && + method === "defineProperties" && + isModuleObject(args?.[0] ?? t.nullLiteral()) + ) { + const descriptors = args?.[1]; + if ( + !t.isObjectExpression(descriptors) || + descriptors.properties.some( + (property) => + t.isSpreadElement(property) || + (t.isObjectProperty(property) && + semanticStaticPropertyKey(property.key, property.computed) === + "require"), + ) + ) + writes.push(node); + } else if ( + owner === "Object" && + method === "assign" && + args?.some((arg) => isModuleObject(arg)) + ) { + const target = args?.[0]; + if (target !== undefined && isModuleObject(target)) { + const sourceHasRequire = args + ?.slice(1) + .some( + (arg) => + t.isObjectExpression(arg) && + arg.properties.some( + (property) => + t.isObjectProperty(property) && + semanticStaticPropertyKey( + property.key, + property.computed, + ) === "require", + ), + ); + if ( + sourceHasRequire || + args?.slice(1).some((arg) => !t.isObjectExpression(arg)) + ) + writes.push(node); + } + } + } + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + const value = (node as unknown as Record)[key]; + if (Array.isArray(value)) { + for (const child of value) if (t.isNode(child)) pending.push(child); + } else if (t.isNode(value)) pending.push(value); + } + } + moduleRequireWrites.set(state, writes); + return writes; +}; + +/** Whether the original global process object can still own effects at this source site. */ +export const semanticOriginalGlobalProcessAt = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + let replacements = globalProcessReplacements.get(state); + if (replacements === undefined) { + replacements = [ + ...state.ambientWriteEffects + .filter( + ({ path }) => + path.length <= 2 && + path.every((key, index) => + semanticPropertyPathKeyMatches( + key, + ["global", "process"][index]!, + ), + ), + ) + .map(({ node: write }) => write), + ...(state.ambientBindingWrites.get("process") ?? []), + ]; + globalProcessReplacements.set(state, replacements); + } + return !replacements.some( + (write) => + !semanticTopLevelEffectFollowsCapture(node, write, state.parentsByNode), + ); +}; + +/** Node's unbound process reference shares the cached process exports object. */ +export const semanticGlobalProcessOrigin = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptModuleOrigin | undefined => + t.isIdentifier(node, { name: "process" }) && + resolveSemanticBindingState(state, node, "process") === undefined && + !semanticResolutionBlocked(state, node, "process") && + semanticOriginalGlobalProcessAt(node, state) + ? { specifier: "node:process", importedPath: [], resolutionMode: "require" } + : undefined; + +/** Resolve qualified global-object process references, preserving their selected path. */ +export const semanticGlobalProcessPathOrigin = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptModuleOrigin | undefined => { + const members: string[] = []; + let current = unwrapJavaScriptExpression(node).node; + while ( + t.isMemberExpression(current) || + t.isOptionalMemberExpression(current) + ) { + const key = semanticStaticPropertyKey(current.property, current.computed); + if (key === null) return undefined; + members.unshift(key); + current = unwrapJavaScriptExpression(current.object).node; + } + if ( + !t.isIdentifier(current) || + !["globalThis", "global", "window", "self"].includes(current.name) || + resolveSemanticBindingState(state, current, current.name) !== undefined || + semanticResolutionBlocked(state, current, current.name) || + members[0] !== "process" || + !semanticOriginalGlobalProcessAt(node, state) + ) + return undefined; + return { + specifier: "node:process", + importedPath: members.slice(1), + resolutionMode: "require", + }; +}; + +/** Strip a static member chain, returning its root expression and member path. */ +export const semanticMemberChain = ( + node: t.Node | null | undefined, +): + | { readonly root: t.Node | null | undefined; readonly members: string[] } + | undefined => { const members: string[] = []; let current = node; while (current !== null && current !== undefined) { @@ -39,15 +265,45 @@ export const semanticRequireOrigin = ( members.push(member); current = current.object; } + return { root: current, members: members.reverse() }; +}; + +/** Recover an unshadowed literal require origin and its exact member path. */ +export const semanticRequireOrigin = ( + node: t.Node | null | undefined, + state: JavaScriptSemanticAnalysisState, +): JavaScriptRequireOrigin | undefined => { + const chain = semanticMemberChain(node); + const current = chain?.root; if ( + chain === undefined || !t.isCallExpression(current) || !t.isIdentifier(current.callee, { name: "require" }) ) return undefined; + if ( + writesModuleRequire(state).some( + (write) => + !semanticTopLevelEffectFollowsCapture( + current, + write, + state.parentsByNode, + ), + ) + ) + return undefined; if ( resolveSemanticBindingState(state, current.callee, "require") !== undefined || - semanticResolutionBlocked(state, current.callee, "require") + semanticResolutionBlocked(state, current.callee, "require") || + (state.ambientBindingWrites.get("require") ?? []).some( + (write) => + !semanticTopLevelEffectFollowsCapture( + current, + write, + state.parentsByNode, + ), + ) ) return undefined; const specifier = stringValue(current.arguments[0]); @@ -56,9 +312,50 @@ export const semanticRequireOrigin = ( : { origin: { specifier, - importedPath: members.reverse(), + importedPath: chain.members, resolutionMode: "require", }, call: current, }; }; + +/** Node's fs.promises export and fs/promises require share one object. */ +export const semanticRequireExportsIdentity = ( + specifier: string, + path: readonly Key[], +): { readonly specifier: string; readonly path: readonly (Key | string)[] } => { + const name = specifier.replace(/^node:/u, ""); + if (name === "fs/promises") return { specifier: name, path }; + return name === "fs" && path[0] === "promises" && path.length > 1 + ? { specifier: "fs/promises", path: path.slice(1) } + : { specifier: name, path }; +}; + +const MUTABLE_BUILTIN_DEFAULTS = new Set([ + ...Object.keys(BUILTIN_RESOURCE_METHODS), + "http", + "https", + "child_process", + "timers", + "process", +]); + +/** Mutable default/object selections share CommonJS identity; namespace slots do not. */ +export const semanticMutableBuiltinOrigin = ( + origin: JavaScriptModuleOrigin, + selected: readonly Key[] = [], +): + | { readonly specifier: string; readonly path: readonly (Key | string)[] } + | undefined => { + const path = [...origin.importedPath, ...selected]; + if (origin.resolutionMode === "require") + return { specifier: origin.specifier, path }; + const specifier = origin.specifier.replace(/^node:/u, ""); + if (!MUTABLE_BUILTIN_DEFAULTS.has(specifier)) return undefined; + if (path[0] === "default") return { specifier, path: path.slice(1) }; + if (specifier === "process" && (path[0] === "env" || path[0] === "argv")) + return { specifier, path }; + return specifier === "fs" && path[0] === "promises" + ? { specifier, path } + : undefined; +}; diff --git a/src/domain/javascript/javascriptSemanticResources.ts b/src/domain/javascript/javascriptSemanticResources.ts index 0a080fc90..4cfd066e9 100644 --- a/src/domain/javascript/javascriptSemanticResources.ts +++ b/src/domain/javascript/javascriptSemanticResources.ts @@ -1,7 +1,9 @@ import * as t from "@babel/types"; -import { semanticBuiltinNamespacePath } from "./javascriptSemanticProvenance.js"; - +import { + BUILTIN_RESOURCE_SPECIFIERS, + BUILTIN_RESOURCE_METHODS, +} from "./javascriptBuiltinResourceMethods.js"; import type { JavaScriptSemanticCallable, JavaScriptSemanticResourceOperation, @@ -13,6 +15,7 @@ import { type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; import { + builtinDataEffectMethod, dataEffectMemberCallee, outerDataEffectBinding, traverseDataEffects, @@ -20,14 +23,6 @@ import { } from "./javascriptSemanticDataEffectHelpers.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; -const ACQUIRE_METHODS = [ - "open", - "openSync", - "createReadStream", - "createWriteStream", - "connect", - "createConnection", -] as const; const RELEASE_METHODS = ["close", "destroy", "end"] as const; interface ResourceCandidate { @@ -137,58 +132,19 @@ const acquisitionMethod = ( callee: t.Node, state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticResourceOperation["method"] | null => { - if (t.isIdentifier(callee)) { - const binding = resolveSemanticBindingState(state, callee, callee.name); - return methodFromBinding(binding); - } - if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) - return null; - const method = ACQUIRE_METHODS.find( - (candidate) => - candidate === - semanticStaticPropertyName(callee.property, callee.computed), - ); - if (method === undefined || !t.isIdentifier(callee.object)) return null; - const binding = resolveSemanticBindingState( - state, - callee.object, - callee.object.name, - ); - return resourceNamespace(binding) ? method : null; -}; - -const methodFromBinding = ( - binding: JavaScriptSemanticBindingState | undefined, -): JavaScriptSemanticResourceOperation["method"] | null => { - for (const { specifier, importedPath } of binding?.directOrigins ?? []) { - if (!resourceModule(specifier)) continue; - const method = ACQUIRE_METHODS.find( - (candidate) => candidate === importedPath.at(-1), - ); - if (method !== undefined) return method; + for (const specifier of BUILTIN_RESOURCE_SPECIFIERS) { + const method = builtinDataEffectMethod(callee, state, [specifier]); + if ( + method !== null && + ( + BUILTIN_RESOURCE_METHODS[ + specifier as keyof typeof BUILTIN_RESOURCE_METHODS + ] as readonly string[] + ).includes(method) + ) + return method as JavaScriptSemanticResourceOperation["method"]; } return null; }; - -const resourceNamespace = ( - binding: JavaScriptSemanticBindingState | undefined, -): boolean => - binding?.directOrigins.some( - ({ specifier, importedPath }) => - resourceModule(specifier) && semanticBuiltinNamespacePath(importedPath), - ) ?? false; - -const resourceModule = (specifier: string): boolean => - [ - "fs", - "fs/promises", - "net", - "node:fs", - "node:fs/promises", - "node:net", - "node:tls", - "tls", - ].includes(specifier); - const resourceCandidateId = (candidate: ResourceCandidate): string => `resource:acquire:${String(candidate.node.start ?? -1)}:${String(candidate.node.end ?? -1)}`; diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index fbf1dbb56..bdaf2af58 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -74,10 +74,35 @@ export const createSemanticReturnedReferences = ( const pending: Candidate[] = [ { node: callee, path: [], bindings: new Set() }, ]; + // A shared initializer can be reached through both arms of every alias + // diamond. Expand an expression/path again only when its cycle context + // permits a binding that an earlier traversal blocked. + const expanded = new WeakMap< + t.Node, + Map[]> + >(); for (let cursor = 0; cursor < pending.length; cursor++) { const item = pending[cursor]; if (item === undefined) break; const node = unwrapJavaScriptExpression(item.node).node; + const pathKey = JSON.stringify(item.path); + const paths = + expanded.get(node) ?? new Map[]>(); + const contexts = paths.get(pathKey) ?? []; + if ( + contexts.some((context) => + [...context].every((binding) => item.bindings.has(binding)), + ) + ) + continue; + paths.set(pathKey, [ + ...contexts.filter( + (context) => + ![...item.bindings].every((binding) => context.has(binding)), + ), + item.bindings, + ]); + expanded.set(node, paths); if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); if (binding === undefined || item.bindings.has(binding.bindingId)) diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index d01a4be52..7dac75c97 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -32,17 +32,23 @@ interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticIniti } /** Mutable binding state used only while constructing the immutable IR. */ +export interface JavaScriptSemanticMemberEffect { + readonly path: JavaScriptSemanticPropertyPath; + readonly node: t.Node | undefined; +} + +export interface JavaScriptSemanticRequiredModuleEffect extends JavaScriptSemanticMemberEffect { + readonly kind: "write" | "escape"; +} + export interface JavaScriptSemanticBindingState { readonly bindingId: string; readonly scopeId: string; readonly name: string; kind: JavaScriptSemanticDefinition["kind"]; mutable: boolean; - readonly mutatedPaths: JavaScriptSemanticPropertyPath[]; - readonly escapedPaths: { - readonly path: JavaScriptSemanticPropertyPath; - readonly node: t.Node | undefined; - }[]; + readonly memberWrites: JavaScriptSemanticMemberEffect[]; + readonly escapedPaths: JavaScriptSemanticMemberEffect[]; readonly definitions: JavaScriptSemanticDefinition[]; readonly initializers: JavaScriptSemanticInitializer[]; readonly referenceInitializers: JavaScriptSemanticReferenceInitializer[]; @@ -63,6 +69,7 @@ export interface JavaScriptSemanticScopeState { /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { + readonly program: t.Program; /** Effective loader for static imports in this source artifact. */ readonly staticImportResolutionMode: JavaScriptModuleResolutionMode; readonly parentsByNode: WeakMap; @@ -71,6 +78,11 @@ export interface JavaScriptSemanticAnalysisState { /** Scope boundaries and descendant scopes resolved on demand. */ readonly scopeByNode: WeakMap; readonly bindingsById: Map; + /** Effects on a cached require exports object, independent of local projections. */ + readonly requiredModuleEffects: Map< + string, + JavaScriptSemanticRequiredModuleEffect[] + >; readonly callables: JavaScriptSemanticCallable[]; readonly callableNodesById: Map; readonly moduleLinks: JavaScriptSemanticModuleLink[]; @@ -81,8 +93,12 @@ export interface JavaScriptSemanticAnalysisState { JavaScriptSemanticBindingState >; readonly conditionalInitializers: WeakSet; - /** Source-local writes, tagged by global-object or import.meta identity. */ + /** Unbound identifier writes retain their source sites for capture ordering. */ + readonly ambientBindingWrites: Map; + /** Source-local writes, tagged by global object or import.meta identity. */ readonly ambientWrites: SemanticPropertyPathCoverage; + /** Source sites distinguish replacement before an ambient identity capture from later writes. */ + readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[]; } /** diff --git a/src/domain/javascript/javascriptSemanticValues.memo.test.ts b/src/domain/javascript/javascriptSemanticValues.memo.test.ts index 8f32a723c..5a9395cdb 100644 --- a/src/domain/javascript/javascriptSemanticValues.memo.test.ts +++ b/src/domain/javascript/javascriptSemanticValues.memo.test.ts @@ -86,7 +86,7 @@ it("invalidates retained alias values at the producing mutation boundary", () => expect( property(property(evaluateSemanticBinding(root, state), "alias"), "value"), ).toEqual({ status: "literal", value: 1 }); - target.mutatedPaths.push(["value"]); + target.memberWrites.push({ path: ["value"], node: undefined }); clearSemanticValueCaches(state); expect( property(property(evaluateSemanticBinding(root, state), "alias"), "value") diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index d15c5edfc..276131f08 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -9,6 +9,7 @@ import { semanticCapturePosition, semanticEscapeFollowsCapture, semanticBindingPrecedesCapture, + semanticReadInitializers, } from "./javascriptSemanticMutationInitializers.js"; import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; @@ -228,6 +229,21 @@ export const evaluateSemanticProvenance = ( memo: createEvaluationMemo(state), }); +/** Follow a namespace binding as it was read at a specific source position. */ +export const evaluateSemanticProvenanceAtRead = ( + binding: JavaScriptSemanticBindingState, + read: t.Identifier, + state: JavaScriptSemanticAnalysisState, +): JavaScriptBindingProvenance => + provenanceForBindingAtRead(binding, read, { + state, + bindings: new Map(), + expressionDepth: 0, + primitiveBindingValues: primitiveBindingValuesFor(state), + dependence: { lowest: CALLER_INDEPENDENT }, + memo: createEvaluationMemo(state), + }); + const readMemoizedEvaluation = ( entry: MemoizedEvaluation | undefined, context: EvaluationContext, @@ -383,7 +399,10 @@ const evaluateBinding = ( ? callerDependence : Math.min(callerDependence, context.dependence.lowest); } - let projected = invalidateSemanticMutationPaths(value, binding.mutatedPaths); + let projected = invalidateSemanticMutationPaths( + value, + binding.memberWrites.map(({ path }) => path), + ); for (const escape of binding.escapedPaths) { // Escapes only invalidate object references; other lattice values survive. if (projected.status !== "object" && projected.status !== "array") break; @@ -912,7 +931,7 @@ const provenanceForExpression = ( "unknown", `Unbound identifier ${node.name}.`, ) - : provenanceForBinding(binding, nestedContext(context)); + : provenanceForBindingAtRead(binding, node, nestedContext(context)); } if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { const members: string[] = []; @@ -983,6 +1002,40 @@ const provenanceForExpression = ( ); }; +/** Resolve an alias from the value visible at this read, before later rebinds. */ +const provenanceForBindingAtRead = ( + binding: JavaScriptSemanticBindingState, + read: t.Identifier, + context: EvaluationContext, +): JavaScriptBindingProvenance => { + const selected = semanticReadInitializers( + binding, + read, + context.state.parentsByNode, + ); + if (selected.initializers === binding.initializers) + return provenanceForBinding(binding, context); + if (context.bindings.has(binding.bindingId)) + return semanticUnresolvedProvenance( + "cycle", + `Alias cycle at ${binding.name}.`, + ); + const snapshot: JavaScriptSemanticBindingState = { + ...binding, + ...selected, + // Assignment facts after the read cannot change the captured value. + definitions: binding.definitions.filter( + ({ kind }) => kind !== "assignment", + ), + }; + context.bindings.set(binding.bindingId, context.bindings.size); + try { + return provenanceForBindingValue(snapshot, context); + } finally { + context.bindings.delete(binding.bindingId); + } +}; + const isLocallyCreatedValue = (node: t.Node): boolean => readExactJavaScriptLiteral(node).found || t.isObjectExpression(node) || diff --git a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts index 6dbf855fc..36cb537ae 100644 --- a/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts +++ b/tests/acceptance/applications/javascriptBuiltinDefaultsCli.test.ts @@ -8,9 +8,252 @@ import { expect, it } from "vitest"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; const execute = promisify(execFile); +it("preserves earlier and sibling effects without inventing replaced cached-module or process effects", async () => { + const root = await createTestTempDirectory("rea-builtin-identity-"); + await writeFile( + join(root, "app.cjs"), + ` + throw new Error('parse only'); + const a = require('node:fs'); const b = require('fs'); + b.readFileSync('before.json'); const stream = b.createReadStream('before.json'); stream.destroy(); + patch(a); b.readFileSync('wrong-after.json'); + const proc = require('node:process'); proc.env = {}; proc.argv = []; const mode = proc.env.MODE; const arg = proc.argv[2]; + `, + ); + await writeFile( + join(root, "sibling.mjs"), + `import fs from 'node:fs'; fs.writeFileSync = custom; fs.readFileSync('sibling.json'); fs.readFileSync = custom;`, + ); + await writeFile( + join(root, "captures.cjs"), + `const fs = require('node:fs'); + const saved = require('fs').readFileSync; + const { readFileSync: savedDestructured } = fs; + fs.readFileSync('before-write.json'); fs.readFileSync = custom; + const { readFileSync: late } = require('fs'); + const lateMember = require('fs').readFileSync; + saved('saved.json'); savedDestructured('saved-destructured.json'); + late('wrong-late.json'); lateMember('wrong-member.json');`, + ); + await writeFile( + join(root, "direct.cjs"), + `require('node:fs').readFileSync('direct.json'); + const fs = require('fs'); fs.readFileSync('later.json'); + require('fs/promises').open('before'); + fs.promises.open = custom; require('node:fs/promises').open('wrong');`, + ); + await writeFile( + join(root, "loader.cjs"), + `require = () => ({ readFileSync() {} }); + const fs = require('node:fs'); fs.readFileSync('wrong-loader.json'); + require('node:fs').readFileSync('wrong-direct-loader.json');`, + ); + await writeFile( + join(root, "imports.ts"), + `import fs = require('node:fs'); const other = require('fs'); + const read = other.readFileSync; observe(read); read('escaped.json'); + fs.readFileSync = custom; other.readFileSync('wrong-import-equals.json');`, + ); + await writeFile( + join(root, "late-loader.cjs"), + `const fs=require('fs'); fs.readFileSync('loader-before.json'); + require=replacement; fs.readFileSync('loader-after.json'); + require('fs').readFileSync('wrong-late-loader.json');`, + ); + await writeFile( + join(root, "replacement.ts"), + `import fs = require('fs'); const other=require('fs'); fs={}; fs.readFileSync=custom; other.readFileSync('replacement-kept.json');`, + ); + await writeFile( + join(root, "dynamic.cjs"), + `const fs=require('fs'); fs.hook=custom; fs[key](); fs.readFileSync('wrong-dynamic.json');`, + ); + await writeFile( + join(root, "wrapped.cjs"), + `const fs=require('fs'); let assigned; const saved=(0, fs.readFileSync); fs.readFileSync=custom; const late=(assigned=fs.readFileSync); saved('wrapped-kept.json'); late('wrong-wrapped.json');`, + ); + await writeFile( + join(root, "global-process.cjs"), + `process.env={MODE:'local'}; const proc=require('process'); const mode=proc.env.MODE;`, + ); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(stdout)); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect( + result.semantic_graph.nodes + .filter(({ kind }) => kind === "config-source") + .map(({ properties }) => properties.key) + .sort(), + ).toEqual([ + "before-write.json", + "before.json", + "direct.json", + "escaped.json", + "later.json", + "loader-after.json", + "loader-before.json", + "replacement-kept.json", + "saved-destructured.json", + "saved.json", + "sibling.json", + "wrapped-kept.json", + ]); + expect( + result.semantic_graph.nodes.filter(({ kind }) => kind === "resource"), + ).toHaveLength(2); + expect( + result.semantic_graph.relations.filter( + ({ relation }) => relation === "releases", + ), + ).toHaveLength(1); + const { client, call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + format: "auto", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + await client.ping(); +}, 60000); + +it("excludes monkey-patched CommonJS effects from CLI and MCP evidence", async () => { + const root = await createTestTempDirectory("rea-builtin-mutated-"); + await writeFile( + join(root, "app.cjs"), + ` + throw new Error("parse only"); + const fs = require("node:fs"); fs.readFileSync = custom; fs.createReadStream = custom; + const cp = require("node:child_process"); cp.spawn = custom; + const timers = require("node:timers"); patch(timers); + const http = require("node:http"); delete http.get; + fs.readFileSync("config.json"); const stream = fs.createReadStream("config.json"); stream.destroy(); + const child = cp.spawn("tool", []); child.kill(); + const timer = timers.setTimeout(callback, 1); clearTimeout(timer); + http.get("http://example.test"); + `, + ); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(stdout)); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + expect( + result.semantic_graph.nodes.filter(({ kind }) => + [ + "config-source", + "resource", + "child-process", + "timer", + "request", + ].includes(kind), + ), + ).toEqual([]); + expect( + result.semantic_graph.relations.filter(({ relation }) => + ["releases", "forwards-signal", "cancels-timer"].includes(relation), + ), + ).toEqual([]); + const { client, call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + format: "auto", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + await client.ping(); +}, 60000); + +it("retains CommonJS builtin effects and handle links through CLI and MCP", async () => { + const root = await createTestTempDirectory("rea-builtin-commonjs-"); + await writeFile( + join(root, "app.cjs"), + ` + throw new Error("This source must only be parsed, never executed"); + const fs = require("node:fs"); + const {spawn: launch} = require("node:child_process"); + const timers = require("timers"); + const {get} = require("http"); + const proc = require("node:process"); + const text = fs.readFileSync("config.json", "utf8"); + const stream = fs.createReadStream("config.json"); stream.destroy(); + const child = launch("tool", []); child.kill("SIGTERM"); + const timer = timers.setTimeout(callback, 10); timers.clearTimeout(timer); + get("http://example.test"); + const mode = proc.env.MODE; + const absent = require("node:fs").default; absent.readFileSync("wrong-default"); + const external = require("./fs.js"); external.readFileSync("wrong-external"); + function shadowed(require) { const fs = require("node:fs"); fs.readFileSync("wrong"); } + `, + ); + const { stdout } = await execute( + process.execPath, + ["scripts/rea.mjs", "analyze-javascript-application", root, "--json"], + { cwd: process.cwd(), maxBuffer: 16 * 1024 * 1024 }, + ); + const evidence = parseEvidence(JSON.parse(stdout)); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + const nodes = result.semantic_graph.nodes; + expect( + nodes.filter(({ kind }) => + [ + "config-source", + "resource", + "child-process", + "timer", + "request", + ].includes(kind), + ), + ).toHaveLength(6); + expect(nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "config-source", + properties: expect.objectContaining({ key: "config.json" }), + }), + expect.objectContaining({ + kind: "config-source", + properties: expect.objectContaining({ key: "MODE" }), + }), + ...["createReadStream", "spawn", "setTimeout", "get"].map((method) => + expect.objectContaining({ + properties: expect.objectContaining({ method }), + }), + ), + ]), + ); + const relations = result.semantic_graph.relations; + for (const relation of ["releases", "forwards-signal", "cancels-timer"]) + expect(relations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relation, resolution: "resolved" }), + ]), + ); + const { client, call } = await connectLocalToolsMcp(); + const response = await call("analyze_javascript_application", { + input_path: root, + format: "auto", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + expect(response.structuredContent).toEqual(evidence); + await client.ping(); +}, 60000); + it.each([ "import fs from 'node:fs';", "import {default as fs} from 'node:fs';", diff --git a/tests/fixtures/javascriptSemanticGraph.ts b/tests/fixtures/javascriptSemanticGraph.ts index 0615b5419..04e7e009e 100644 --- a/tests/fixtures/javascriptSemanticGraph.ts +++ b/tests/fixtures/javascriptSemanticGraph.ts @@ -26,6 +26,7 @@ export const graphForJavaScript = ( packages: [], json_modules: [], html_scripts: [], + html_inline_scripts: [], source_maps: [], visited_ast_nodes: 0, findings: 0,