diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e35e443e5..cb7e620678 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -305,6 +305,12 @@ jobs: sudo apt-get install --no-install-recommends -y g++-mingw-w64-x86-64 - run: npm run build:windows-native - run: npm run verify:windows-native:artifact + - run: node scripts/build-windows-native.mjs --inspection-fixture + - uses: actions/upload-artifact@v4 + with: + name: windows-native-inspection-fixture + path: .cache/windows-native-inspection-fixture/ + if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: windows-native-controls @@ -505,6 +511,12 @@ jobs: tests/process-global/updateMaintenance.test.ts tests/conformance/providers/ghidra/realGhidraWindowsWorkflow.test.ts - run: npm run verify:windows-native + - uses: actions/download-artifact@v4 + with: + name: windows-native-inspection-fixture + path: .cache/windows-native-inspection-fixture/ + - name: Verify native inspection failure ownership and recovery + run: node scripts/verify-windows-native-inspection.mjs - name: Verify real Node Inspector discovery and observation timeout-minutes: 10 env: diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 1c61520ea8..34ce403445 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -323,8 +323,16 @@ substitute version-dependent Electron defaults for omitted `webPreferences`. Dynamic option objects, bridge keys, API objects, and IPC channel expressions remain unknown and make coverage partial. -Electron API identity follows proven lexical module aliases. Reassigned bindings, -dynamic selections, and visibly overwritten namespace members are unresolved. +Electron API identity follows proven lexical module aliases. Namespace interop +also preserves identity when the complete Rolldown helper operations, lexical +roles, live getters, and captured intrinsic origins match, or when a stable +helper simply returns its module parameter. Recognition uses structure rather +than emitted names; other helper bodies and ambiguous runtime fallbacks remain +unresolved. Captured function identities survive later global-property +replacement. Visible intrinsic writes before capture or conversion are checked +in their owning callable scope. Arbitrary cross-call side effects are not +evaluated. Other reassignments, dynamic selections, and visibly overwritten +namespace members are unresolved. Direct and destructuring assignment, loop, update, and delete targets are checked through namespace aliases; unrelated member writes do not discard observed API identity. This static check does diff --git a/docs/testing.md b/docs/testing.md index 548b1867d5..dbbdb0b3c4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -482,6 +482,13 @@ not require Ghidra or Java. It includes Job Object commit-limit controls (a limited child that reaches the limit, a control that does not, and invalid limit rejection). An optional independently compiled Windows fixture adds in-place reparse, breakaway, environment, and token observations. +The separate `scripts/verify-windows-native-inspection.mjs` lane uses the +`.cache/windows-native-inspection-fixture` artifact built on Linux with +`node scripts/build-windows-native.mjs --inspection-fixture`. Its controlled +first deletion failure checks retained NTFS ownership, later-query recovery, +successful-admission caching, and environment-teardown cleanup. It neither +establishes the cause of the intermittent #1701 host failure nor replaces +verification with the shipped production artifact. `npm run verify:ghidra:windows:package` additionally packs and installs REA into an isolated prefix and runs ordinary-user CLI/MCP operations against their canonical schemas. Run the controlled fixture generator before that lane, or diff --git a/native/windows/README.md b/native/windows/README.md index 288fd03b19..82dd5526ba 100644 --- a/native/windows/README.md +++ b/native/windows/README.md @@ -106,6 +106,30 @@ npm run verify:windows-native npm run verify:ghidra:windows ``` +The native inspection probe retains its directory owner if deletion fails. A +later availability query retries that cleanup before creating another probe; +successful admission and static artifact failures remain cached. Environment +teardown makes one final cleanup attempt and reports a persistent failure to +stderr rather than claiming the directory was removed. + +CI separately builds and verifies a test-only artifact: + +```sh +node scripts/build-windows-native.mjs --inspection-fixture +``` + +On Windows, after compiling the runtime: + +```powershell +node scripts/verify-windows-native-inspection.mjs +``` + +This artifact injects one access-denied disposition result while using a real +NTFS directory. The verifier checks retained ownership, recovery on a later +query, cached successful admission, and cleanup on environment teardown. It +does not reproduce or identify the host-dependent cause reported in #1701. +The fixture is stored under `.cache/` and is excluded from the shipped addon. + The native lane checks DACL readback independently, source write/replacement rejection, unrelated sibling renames, reparse rejection, immutable snapshots, asynchronous cancellation, cleanup containment, diff --git a/native/windows/src/addon.cc b/native/windows/src/addon.cc index 4d332aad74..0e39b84c0b 100644 --- a/native/windows/src/addon.cc +++ b/native/windows/src/addon.cc @@ -1,6 +1,7 @@ #include "authority.hpp" #include +#include namespace rea { @@ -89,17 +90,45 @@ napi_value failureValue(napi_env env, const Failure& failure) { return error; } +struct InspectionContext { std::unique_ptr probe; }; + +static void cleanupInspection(void* data) noexcept { + std::unique_ptr context(static_cast(data)); + if (!context->probe) return; + try { closeRuntime(*context->probe); } + catch (const Failure& failure) { + // Environment teardown cannot retain an owner or return an error. Report + // terminal cleanup failure instead of claiming the directory was removed. + std::fprintf(stderr, "REA Windows probe cleanup failed (Win32 %lu) path=\"", + static_cast(failure.win32)); + // Emit reversible UTF-16 escapes without allocation or CRT locale conversion. + for (wchar_t unit : failure.path) { + if (unit == L'"' || unit == L'\\') std::fputc('\\', stderr); + if (unit >= 0x20 && unit <= 0x7e) std::fputc(static_cast(unit), stderr); + else std::fprintf(stderr, "\\u%04x", static_cast(unit)); + } + std::fprintf(stderr, "\"\n"); + } + catch (const std::exception& failure) { + std::fprintf(stderr, "REA Windows probe cleanup failed: %s\n", failure.what()); + } + catch (...) { + std::fprintf(stderr, "REA Windows probe cleanup failed: unknown native exception\n"); + } +} + static napi_value call(napi_env env, napi_callback_info info) { try { std::array args; size_t count = args.size(); - check(napi_get_cb_info(env, info, &count, args.data(), nullptr, nullptr)); + void* data = nullptr; + check(napi_get_cb_info(env, info, &count, args.data(), nullptr, &data)); require(count == 2, "Native call requires operation and arguments", L"", ERROR_INVALID_PARAMETER); const auto operation = wide(env, args[0]); const auto values = array(env, args[1]); if (operation == L"inspect") { require(values.empty(), "Inspect takes no arguments", L"", ERROR_INVALID_PARAMETER); - return inspect(env); + return inspect(env, static_cast(data)->probe); } if (operation.rfind(L"process_", 0) == 0) return processCall(env, operation, values); return filesystemCall(env, operation, values); @@ -114,8 +143,13 @@ static napi_value call(napi_env env, napi_callback_info info) { } static napi_value initialize(napi_env env, napi_value exports) { + auto context = std::make_unique(); + if (napi_add_env_cleanup_hook(env, cleanupInspection, context.get()) != napi_ok) return nullptr; + // The environment owns callback data before a function can expose it. Failed + // initialization still leaves a valid context for the registered cleanup hook. + auto* data = context.release(); napi_value function; - if (napi_create_function(env, "call", NAPI_AUTO_LENGTH, call, nullptr, &function) != napi_ok) return nullptr; + if (napi_create_function(env, "call", NAPI_AUTO_LENGTH, call, data, &function) != napi_ok) return nullptr; if (napi_set_named_property(env, exports, "call", function) != napi_ok) return nullptr; return exports; } diff --git a/native/windows/src/authority.hpp b/native/windows/src/authority.hpp index c003aae621..cc713ce0bd 100644 --- a/native/windows/src/authority.hpp +++ b/native/windows/src/authority.hpp @@ -132,6 +132,6 @@ std::unique_ptr spawnProcess(const std::wstring& command, const std::ws bool protocolStdin = false, ULONGLONG memoryLimit = 0); napi_value processCall(napi_env env, const std::wstring& operation, const std::vector& args); -napi_value inspect(napi_env env); +napi_value inspect(napi_env env, std::unique_ptr& probe); } // namespace rea diff --git a/native/windows/src/process.cc b/native/windows/src/process.cc index 8ebf06fb2e..ede2b56223 100644 --- a/native/windows/src/process.cc +++ b/native/windows/src/process.cc @@ -412,13 +412,17 @@ napi_value processCall(napi_env env, const std::wstring& operation, const std::v throw Failure("Unknown native process operation", L"", ERROR_INVALID_PARAMETER); } -napi_value inspect(napi_env env) { +napi_value inspect(napi_env env, std::unique_ptr& probe) { + // Settle the previous owner before admitting another probe. Failed cleanup + // retains its handles, including when identity construction itself failed. + if (probe) { closeRuntime(*probe); probe.reset(); } wchar_t temp[32768]; const auto length = GetTempPathW(32768, temp); require(length > 0 && length < 32768, "Read temporary directory coordinate failed"); - auto root = createRuntime(std::wstring(temp, length), L"rea-native-probe-"); - auto filesystem = identity(env, root->directory.get(), root->path); - closeRuntime(*root); + probe = createRuntime(std::wstring(temp, length), L"rea-native-probe-"); + auto filesystem = identity(env, probe->directory.get(), probe->path); + closeRuntime(*probe); + probe.reset(); auto job = createJob(); Attributes attributes(1); HANDLE raw = job.get(); diff --git a/scripts/build-windows-native.mjs b/scripts/build-windows-native.mjs index 152f3eddb3..e5eae61115 100644 --- a/scripts/build-windows-native.mjs +++ b/scripts/build-windows-native.mjs @@ -11,7 +11,12 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const nodeVersion = "22.19.0"; const compiler = "x86_64-w64-mingw32-g++"; const cache = join(homedir(), ".cache", "rea-windows-native"); -const output = join(root, "native", "windows", "build"); +const inspectionFixture = process.argv[2] === "--inspection-fixture"; +if (process.argv.length > 3 || (process.argv.length > 2 && !inspectionFixture)) + throw new Error("Unknown Windows native build argument"); +const output = inspectionFixture + ? join(root, ".cache", "windows-native-inspection-fixture") + : join(root, "native", "windows", "build"); const run = (command, args) => { const result = spawnSync(command, args, { cwd: root, @@ -120,7 +125,9 @@ run(compiler, [ "-Wl,--no-insert-timestamp", `-I${headers}`, "native/windows/src/addon.cc", - "native/windows/src/filesystem.cc", + inspectionFixture + ? "tests/fixtures/windows-native-inspection-failure.cc" + : "native/windows/src/filesystem.cc", "native/windows/src/process.cc", library, "-ladvapi32", diff --git a/scripts/verify-windows-native-inspection.mjs b/scripts/verify-windows-native-inspection.mjs new file mode 100644 index 0000000000..e5f807da31 --- /dev/null +++ b/scripts/verify-windows-native-inspection.mjs @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readdir, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +if (process.platform !== "win32") + throw new Error("Native inspection ownership requires real Windows NTFS"); +const root = resolve(process.argv[2] ?? "."); +const artifact = resolve( + process.argv[3] ?? ".cache/windows-native-inspection-fixture", +); +const workspace = await mkdtemp(join(tmpdir(), "rea-inspection-conformance-")); +try { + // Isolate the fixture from the packaged production artifact and loader cache. + await mkdir(join(workspace, "dist", "windows"), { recursive: true }); + await mkdir(join(workspace, "native", "windows", "build"), { + recursive: true, + }); + await writeFile(join(workspace, "package.json"), '{"type":"module"}'); + await copyFile( + join(root, "dist", "windows", "WindowsNativeLoader.js"), + join(workspace, "dist", "windows", "WindowsNativeLoader.js"), + ); + await copyFile( + join(root, "dist", "generatedPackageMetadata.js"), + join(workspace, "dist", "generatedPackageMetadata.js"), + ); + for (const name of ["manifest.json", "rea-windows-x64.node"]) + await copyFile( + join(artifact, name), + join(workspace, "native", "windows", "build", name), + ); + const require = createRequire(join(root, "package.json")); + await mkdir(join(workspace, "node_modules")); + await symlink( + dirname(require.resolve("zod/package.json")), + join(workspace, "node_modules", "zod"), + "junction", + ); + const loader = pathToFileURL( + join(workspace, "dist", "windows", "WindowsNativeLoader.js"), + ).href; + const source = `import assert from 'node:assert/strict'; +import {readdirSync,renameSync,statSync} from 'node:fs'; +import {join} from 'node:path'; +import {systemWindowsNativeAuthority} from ${JSON.stringify(loader)}; +const temp=process.env.TEMP; +const first=systemWindowsNativeAuthority(); +assert.equal(first.available,false); +assert.equal(first.retryable,true); +assert.match(first.reason,/Delete owned object by handle failed.*Win32 5/s); +const [name,...extra]=readdirSync(temp);assert.ok(name);assert.equal(extra.length,0); +const path=join(temp,name);const identity=statSync(path,{bigint:true}); +assert.throws(()=>renameSync(path,path+'-replacement')); +assert.equal(statSync(path,{bigint:true}).ino,identity.ino); +if(process.argv[2]!=='teardown'){ + const second=systemWindowsNativeAuthority();assert.equal(second.available,true); + assert.equal(readdirSync(temp).length,0); + assert.equal(systemWindowsNativeAuthority(),second); +} +console.log(JSON.stringify({mode:process.argv[2],firstFailed:true,retainedOwner:true,inode:identity.ino.toString()})); +`; + const fixture = join(workspace, "probe.mjs"); + await writeFile(fixture, source); + for (const mode of ["retry", "teardown"]) { + const temp = join(workspace, mode); + await mkdir(temp); + const observed = JSON.parse( + execFileSync(process.execPath, [fixture, mode], { + encoding: "utf8", + timeout: 20_000, + windowsHide: true, + env: { ...process.env, TEMP: temp, TMP: temp }, + }), + ); + assert.equal(observed.retainedOwner, true); + assert.deepEqual(await readdir(temp), []); + } + process.stdout.write( + `${JSON.stringify({ ok: true, controlledDispositionFailure: true, retainedOwner: true, retryRecovers: true, successfulAdmissionCached: true, environmentTeardownCleansProbe: true, issue1701HostCauseReproduced: false })}\n`, + ); +} finally { + await rm(workspace, { recursive: true, force: true }); +} diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 1e70c0e0e4..cbbbbba69e 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -14,6 +14,7 @@ import { type ArtifactEntry, type ArtifactReader, } from "./ArtifactReader.js"; +import { hostExecutableMarking } from "./hostExecutableMarking.js"; import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; @@ -270,7 +271,7 @@ const artifactEntry = ( kind, declaredSize: kind === "file" ? metadata.size : null, compressedSize: null, - executable: (metadata.mode & 0o111) !== 0, + executable: hostExecutableMarking(absolute, metadata), encrypted: false, byteOffset: null, declaredSha256: null, diff --git a/src/artifacts/hostExecutableMarking.test.ts b/src/artifacts/hostExecutableMarking.test.ts new file mode 100644 index 0000000000..7aff5bbdac --- /dev/null +++ b/src/artifacts/hostExecutableMarking.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { hostExecutableMarking } from "./hostExecutableMarking.js"; + +const file = (mode: number) => ({ mode, isFile: () => true }); +const directory = (mode: number) => ({ mode, isFile: () => false }); + +describe("hostExecutableMarking", () => { + it("marks Windows launchable files by extension because NTFS has no execute bit", () => { + expect( + hostExecutableMarking("C:\\scratch\\whoami.exe", file(0o666), "win32"), + ).toBe(true); + expect( + hostExecutableMarking("C:\\scratch\\SETUP.CMD", file(0o444), "win32"), + ).toBe(true); + expect( + hostExecutableMarking("C:\\scratch\\library.dll", file(0o666), "win32"), + ).toBe(false); + expect( + hostExecutableMarking( + "C:\\scratch\\tools.exe", + directory(0o666), + "win32", + ), + ).toBe(false); + }); + + it("keeps POSIX permission bits authoritative regardless of extension", () => { + expect(hostExecutableMarking("/scratch/tool", file(0o755), "linux")).toBe( + true, + ); + expect( + hostExecutableMarking("/scratch/whoami.exe", file(0o644), "darwin"), + ).toBe(false); + }); +}); diff --git a/src/artifacts/hostExecutableMarking.ts b/src/artifacts/hostExecutableMarking.ts new file mode 100644 index 0000000000..dcc4b4fb24 --- /dev/null +++ b/src/artifacts/hostExecutableMarking.ts @@ -0,0 +1,22 @@ +import type { Stats } from "node:fs"; +import { extname } from "node:path"; + +/** Extensions Windows launches directly through CreateProcess or cmd.exe. */ +const windowsLaunchableExtensions = new Set([".bat", ".cmd", ".com", ".exe"]); + +/** + * Whether a host filesystem entry carries the host's executable marking. + * + * POSIX hosts mark executability with permission bits. Windows has no execute + * bit (Node reports file modes from the read-only attribute alone), so it marks + * launchable files by extension instead. + */ +export const hostExecutableMarking = ( + path: string, + metadata: Pick, + platform: NodeJS.Platform = process.platform, +): boolean => + platform === "win32" + ? metadata.isFile() && + windowsLaunchableExtensions.has(extname(path).toLowerCase()) + : (metadata.mode & 0o111) !== 0; diff --git a/src/artifacts/inventory/scanCanonical.ts b/src/artifacts/inventory/scanCanonical.ts index 1eb3a74d2b..ef4df6d713 100644 --- a/src/artifacts/inventory/scanCanonical.ts +++ b/src/artifacts/inventory/scanCanonical.ts @@ -34,6 +34,7 @@ import { artifactManifestId, } from "../../domain/artifactIdentity.js"; import { classifyAndHashRootForInventory } from "./classify.js"; +import { hostExecutableMarking } from "../hostExecutableMarking.js"; import type { HashResult } from "../ArtifactHash.js"; import { hashStableRootArtifact, @@ -211,7 +212,7 @@ const buildInventorySnapshot = async ( rekeyOccurrences(rootNode.artifact_id, input.occurrences); const rootOccurrence = rootOccurrenceFor(rootNode, { size: metadata.size, - executable: (metadata.mode & 0o111) !== 0, + executable: hostExecutableMarking(path, metadata), format: rootFormat, path, }); diff --git a/src/domain/javascript/electronModuleInterop.test.ts b/src/domain/javascript/electronModuleInterop.test.ts index fc7b5ef793..fa9a856721 100644 --- a/src/domain/javascript/electronModuleInterop.test.ts +++ b/src/domain/javascript/electronModuleInterop.test.ts @@ -168,6 +168,32 @@ describe("Electron module interop uncertainty", () => { }); }); +describe("Electron module interop helper results", () => { + it.each([ + "const __toESM = () => ({BrowserWindow:FakeWindow});", + "const __toESM = async (mod) => mod;", + "function* __toESM(mod) { return mod; }", + "const __toESM = (this && this.__toESM) || ((mod) => mod);", + ])("keeps an unproven fresh result unresolved: %s", (helper) => { + const analysis = analyzeJavaScriptStaticSource(`${helper} + const electron = __toESM(require("electron"), 1); ${WINDOW} + new (__toESM(require("electron"), 1)).BrowserWindow({}); + `); + expect(analysis.electron.browser_windows).toEqual([]); + }); + + it("preserves a proven fresh namespace and a pure identity helper", () => { + const analysis = analyzeJavaScriptStaticSource(`${ROLLDOWN_MODULE_INTEROP} + const electron = __toESM(require("electron"), 1); ${WINDOW} + new (__toESM(require("electron"), 1)).BrowserWindow({}); + function passthrough(value) {return value;} + const plain = passthrough(require("electron")); + new plain.BrowserWindow({}); + `); + expect(analysis.electron.browser_windows).toHaveLength(3); + }); +}); + describe("Electron module interop capture and scope", () => { it("does not reject unrelated intrinsic property definitions", () => { const analysis = analyzeJavaScriptStaticSource(` diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 78e4b314ca..1f1d9ab3f6 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -73,4 +73,51 @@ describe("Electron API identity", () => { }, ]); }); + + it("follows bundler namespace interop helpers without accepting other writes", () => { + const analysis = analyzeJavaScriptStaticSource(` + var __toESM = (mod) => mod; + var __importStar = (mod) => mod; + let electron = require("electron"); + electron = __toESM(electron); + new electron.BrowserWindow({ webPreferences: { preload: "./rolldown.js" } }); + var import_electron = __toESM(require("electron")); + new import_electron.BrowserWindow({ webPreferences: { preload: "./esbuild.js" } }); + function _interopRequireWildcard(mod) { return mod; } + const Electron = __importStar(require("electron")); + new Electron.BrowserWindow({ webPreferences: { preload: "./tsc.js" } }); + + let replaced = require("electron"); + replaced = __toESM(require("./fake")); + new replaced.BrowserWindow({ webPreferences: { preload: "./replaced.js" } }); + let rewritten = require("electron"); + rewritten = __toESM(rewritten); + rewritten = fake; + new rewritten.BrowserWindow({ webPreferences: { preload: "./rewritten.js" } }); + const babel = _interopRequireWildcard(require("electron")); + new babel.BrowserWindow({ webPreferences: { preload: "./babel.js" } }); + function nested() { + const __toESM = (mod) => mod; + const scoped = __toESM(require("electron")); + new scoped.BrowserWindow({ webPreferences: { preload: "./nested.js" } }); + } + `); + + expect( + analysis.electron.browser_windows.map(({ preload_path }) => preload_path), + ).toEqual(["./rolldown.js", "./esbuild.js", "./tsc.js", "./babel.js"]); + }); + + it("does not trust unbound helper names or non-function helper values", () => { + const analysis = analyzeJavaScriptStaticSource(` + const __toESM = makeLookalike(); + const electron = __toESM(require("electron")); + new electron.BrowserWindow({}); + let unbound = require("electron"); + unbound = __importStar(unbound); + new unbound.BrowserWindow({}); + `); + + expect(analysis.electron.browser_windows).toEqual([]); + }); }); diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 3e8f300c7f..d654b470e6 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -55,6 +55,7 @@ interface InteropOrigin { interface InteropHelper { readonly dependencies: readonly IntrinsicDependency[]; readonly readyAfter: number; + readonly identity: boolean; } /** Prove one inert namespace conversion without relaxing general assignment semantics. */ @@ -63,6 +64,7 @@ export const createSemanticModuleInterop = ( ) => { const helpers = new Map(); const origins = new Map(); + const calls = new WeakMap(); const intrinsicBindings = new Map(); const writes: { readonly scope: string; @@ -70,6 +72,7 @@ export const createSemanticModuleInterop = ( readonly coverage: SemanticPropertyPathCoverage; }[] = []; const invalidatedBindings = new Map(); + const invalidatedCalls = new WeakMap(); const opaqueMutationPath = (node: t.Node): readonly string[] => { let current: t.Node | undefined = node; while (current !== undefined) { @@ -134,8 +137,21 @@ export const createSemanticModuleInterop = ( const cached = helpers.get(binding.bindingId); if (cached !== undefined) return cached ?? undefined; helpers.set(binding.bindingId, null); + if (state.scopesById.get(binding.scopeId)?.kind !== "program") + return undefined; const wrapper = stableInitializer(binding, state); if (wrapper === undefined) return undefined; + if (identityFunction(wrapper, state)) { + const proof = { + dependencies: [], + readyAfter: t.isFunctionDeclaration(wrapper) + ? -1 + : (wrapper.end ?? Infinity), + identity: true, + }; + helpers.set(binding.bindingId, proof); + return proof; + } const wrapperRoles = canonicalRoles(wrapper, TO_ESM, state); const copyBinding = wrapperRoles?.get("__copyProps"); const copy = stableInitializer(copyBinding, state); @@ -171,6 +187,7 @@ export const createSemanticModuleInterop = ( ); const proof = { dependencies, + identity: false, readyAfter: Math.max( wrapper.end ?? Infinity, copy.end ?? Infinity, @@ -181,9 +198,62 @@ export const createSemanticModuleInterop = ( return proof; }; + const provenCall = (node: t.Node) => { + if (!t.isCallExpression(node) || !t.isIdentifier(node.callee)) + return undefined; + const helper = resolveSemanticBindingState( + state, + node.callee, + node.callee.name, + ); + const proof = helper === undefined ? undefined : helperProof(helper); + const input = node.arguments[0]; + if ( + proof === undefined || + proof.readyAfter >= (node.start ?? -1) || + !t.isExpression(input) || + (!proof.identity && + (node.arguments.length !== 2 || + !t.isNumericLiteral(node.arguments[1], { value: 1 }))) + ) + return undefined; + return { + input, + dependencies: proof.dependencies.map((dependency) => ({ + ...dependency, + capturedAt: dependency.capturedAt ?? node, + })), + }; + }; + + const callOrigin = (node: t.Node): InteropOrigin | undefined => { + const cached = calls.get(node); + if (cached !== undefined) return cached; + const converted = provenCall(node); + const required = + converted === undefined + ? undefined + : semanticRequireOrigin(converted.input, state)?.origin; + if ( + converted === undefined || + required === undefined || + required.importedPath.length !== 0 + ) + return undefined; + const result = { origin: required, dependencies: converted.dependencies }; + calls.set(node, result); + return result; + }; + const origin = ( binding: JavaScriptSemanticBindingState, ): InteropOrigin | undefined => { + const single = stableInitializer(binding, state); + if (single !== undefined) { + const result = callOrigin(single); + if (result !== undefined) origins.set(binding.bindingId, result); + return result; + } const [initial, converted] = binding.initializers; if ( binding.definitions.length !== 2 || @@ -219,12 +289,11 @@ export const createSemanticModuleInterop = ( !t.isVariableDeclaration(declarationStatement) || state.parentsByNode.get(statement) !== state.parentsByNode.get(declarationStatement) || - (initial.node.start ?? Infinity) >= (call.start ?? -1) || - call.arguments.length !== 2 || - !t.isNumericLiteral(call.arguments[1], { value: 1 }) + (initial.node.start ?? Infinity) >= (call.start ?? -1) ) return undefined; - const input = call.arguments[0]; + const convertedCall = provenCall(call); + const input = convertedCall?.input; if ( !t.isIdentifier(input) || resolveSemanticBindingState(state, input, input.name) !== binding @@ -233,26 +302,30 @@ export const createSemanticModuleInterop = ( const required = semanticRequireOrigin(initial.node, state)?.origin; if (required === undefined || required.importedPath.length !== 0) return undefined; - const helper = resolveSemanticBindingState( - state, - call.callee, - call.callee.name, - ); - const proof = helper === undefined ? undefined : helperProof(helper); - if (proof === undefined || proof.readyAfter >= (call.start ?? -1)) - return undefined; + if (convertedCall === undefined) return undefined; const result = { origin: required, - dependencies: proof.dependencies.map((dependency) => ({ - ...dependency, - capturedAt: dependency.capturedAt ?? call, - })), + dependencies: convertedCall.dependencies, }; origins.set(binding.bindingId, result); return result; }; + const invalidatedOrigin = (record: InteropOrigin | undefined): boolean => + record?.dependencies.some(({ path, capturedAt }) => { + if (capturedAt === undefined) return true; + const scope = opaqueMutationPath(capturedAt).join("."); + // Captured function values survive later replacement of global + // properties. Other callable bodies are not execution evidence. + return writes.some( + (write) => + (write.scope === scope || write.scope === "intrinsic") && + write.offset < (capturedAt.start ?? -1) && + write.coverage.covers(path), + ); + }) ?? false; return { origin, + callOrigin, intrinsicPath, opaqueMutationPath, retainWrite: (node: t.Node, path: readonly (string | null)[]) => { @@ -267,27 +340,47 @@ export const createSemanticModuleInterop = ( invalidated: (binding: JavaScriptSemanticBindingState): boolean => { const cached = invalidatedBindings.get(binding.bindingId); if (cached !== undefined) return cached; - const result = - origins - .get(binding.bindingId) - ?.dependencies.some(({ path, capturedAt }) => { - if (capturedAt === undefined) return true; - const scope = opaqueMutationPath(capturedAt).join("."); - // Captured function values survive later replacement of global - // properties. Other callable bodies are not execution evidence. - return writes.some( - (write) => - (write.scope === scope || write.scope === "intrinsic") && - write.offset < (capturedAt.start ?? -1) && - write.coverage.covers(path), - ); - }) ?? false; + const result = invalidatedOrigin(origins.get(binding.bindingId)); invalidatedBindings.set(binding.bindingId, result); return result; }, + invalidatedCall: (node: t.Node): boolean => { + const cached = invalidatedCalls.get(node); + if (cached !== undefined) return cached; + const result = invalidatedOrigin(calls.get(node)); + invalidatedCalls.set(node, result); + return result; + }, }; }; +const identityFunction = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if ( + !t.isFunction(node) || + node.async || + node.generator || + node.params.length !== 1 + ) + return false; + const parameter = node.params[0]; + const result = t.isBlockStatement(node.body) + ? node.body.body.length === 1 && t.isReturnStatement(node.body.body[0]) + ? node.body.body[0].argument + : undefined + : node.body; + return ( + t.isIdentifier(parameter) && + t.isIdentifier(result) && + resolveSemanticBindingState(state, parameter, parameter.name) !== + undefined && + resolveSemanticBindingState(state, parameter, parameter.name) === + resolveSemanticBindingState(state, result, result.name) + ); +}; + const stableInitializer = ( binding: JavaScriptSemanticBindingState | undefined, state: JavaScriptSemanticAnalysisState, diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index d654d75923..2aef924606 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -391,7 +391,13 @@ export function* classifyParsedJavaScriptElectronBindingsSteps( ? undefined : (exported ?? undefined); } - const origin = semanticRequireOrigin(root, state)?.origin; + const wrapped = interop.callOrigin(root); + const origin = + wrapped === undefined + ? semanticRequireOrigin(root, state)?.origin + : collectedWrites && interop.invalidatedCall(root) + ? undefined + : wrapped.origin; return origin !== undefined && ELECTRON_MODULE.test(origin.specifier) ? electronExportPath(origin.importedPath) : undefined; @@ -442,7 +448,7 @@ const electronExport = ( const reassigned = binding.definitions.some( ({ kind }) => kind === "assignment", ); - const converted = reassigned ? interop.origin(binding) : undefined; + const converted = interop.origin(binding); if (reassigned && converted === undefined) return null; const directOrigin = converted?.origin ?? bindingOrigin(binding, state); const [initializer] = binding.initializers; diff --git a/src/windows/WindowsNativeLoader.test.ts b/src/windows/WindowsNativeLoader.test.ts index 9e8cfe6b46..1aa8d73759 100644 --- a/src/windows/WindowsNativeLoader.test.ts +++ b/src/windows/WindowsNativeLoader.test.ts @@ -169,17 +169,18 @@ describe("packaged Windows native admission", () => { }), ).toMatchObject({ available: false, + retryable: true, reason: expect.stringContaining("private DACL: access denied"), }); }); it("requires every inspected native fact", () => { const value = fixture(); - expect( - loadWindowsNativeAuthority({ - ...value.host, - loadArtifact: () => ({ call: () => ({ privateDacl: true }) }), - }).available, - ).toBe(false); + const loaded = loadWindowsNativeAuthority({ + ...value.host, + loadArtifact: () => ({ call: () => ({ privateDacl: true }) }), + }); + expect(loaded.available).toBe(false); + expect(loaded).not.toHaveProperty("retryable"); }); }); diff --git a/src/windows/WindowsNativeLoader.ts b/src/windows/WindowsNativeLoader.ts index ba58359774..7719a0a854 100644 --- a/src/windows/WindowsNativeLoader.ts +++ b/src/windows/WindowsNativeLoader.ts @@ -67,7 +67,12 @@ export interface WindowsNativeAuthority { /** Availability of the bundled native implementation, including load failures. */ export type WindowsNativeLoadResult = | { readonly available: true; readonly authority: WindowsNativeAuthority } - | { readonly available: false; readonly reason: string }; + | { + readonly available: false; + readonly reason: string; + /** Only a failed native invocation can recover on a later query. */ + readonly retryable?: true; + }; /** Trusted package reads and module loading, replaceable in loader tests. */ export interface WindowsNativeLoadHost { @@ -106,7 +111,17 @@ export const loadWindowsNativeAuthority = ( ); assertWindowsX64Artifact(artifact); const binding = bindingSchema.parse(host.loadArtifact()); - const inspection = inspectionSchema.parse(binding.call("inspect", [])); + let observed: unknown; + try { + observed = binding.call("inspect", []); + } catch (cause: unknown) { + return { + available: false, + retryable: true, + reason: `REA Windows native self-test failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }; + } + const inspection = inspectionSchema.parse(observed); return { available: true, authority: { call: binding.call, inspection } }; } catch (cause: unknown) { return { @@ -131,8 +146,10 @@ const assertWindowsX64Artifact = (bytes: Buffer): void => { let cached: WindowsNativeLoadResult | undefined; -/** Load only the package-owned artifact, lazily and once per process. */ +/** Cache stable admission; let a later query retry failed owned inspection. */ export const systemWindowsNativeAuthority = (): WindowsNativeLoadResult => { + if (cached?.available === false && cached.retryable === true) + cached = undefined; cached ??= loadWindowsNativeAuthority({ platform: process.platform, architecture: process.arch, diff --git a/tests/acceptance/applications/electronModuleInteropCli.test.ts b/tests/acceptance/applications/electronModuleInteropCli.test.ts index 7bc31c5ba1..1e72a18b5e 100644 --- a/tests/acceptance/applications/electronModuleInteropCli.test.ts +++ b/tests/acceptance/applications/electronModuleInteropCli.test.ts @@ -29,6 +29,14 @@ cliTest.for(["CLI", "stdio MCP"] as const)( new electron.BrowserWindow({webPreferences:{preload:runtimePath}}); const direct = require("electron"); new direct.BrowserWindow({}); + const fresh = __toESM(require("electron"), 1); + new fresh.BrowserWindow({}); + function identity(mod) {return mod;} + const passthrough = identity(require("electron")); + new passthrough.BrowserWindow({}); + const __importStar = () => ({BrowserWindow:FakeWindow}); + const fake = __importStar(require("electron")); + new fake.BrowserWindow({}); function decoy(electron) {new electron.BrowserWindow({});} throw new Error("must not execute main"); `; @@ -52,14 +60,30 @@ cliTest.for(["CLI", "stdio MCP"] as const)( .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) .parse(document).normalized_result; expect(result.summary).toMatchObject({ - browser_windows: 3, + browser_windows: 5, preload_entrypoints: 1, }); expect(result.statistics.parse_failures).toBe(0); const windows = result.graph.nodes.filter( ({ kind }) => kind === "browser-window", ); - expect(windows).toHaveLength(3); + expect(windows).toHaveLength(5); + const expectedLines = source + .split("\n") + .flatMap((line, index) => + /^\s*new (?:electron|direct|fresh|passthrough)\.BrowserWindow/u.test( + line, + ) + ? [index + 1] + : [], + ); + const observedLines = windows.flatMap((window) => { + const location = window.observations[0]?.evidence.location; + return location?.available && location.value.kind === "source-range" + ? [location.value.start.line] + : []; + }); + expect(observedLines.toSorted()).toEqual(expectedLines.toSorted()); for (const window of windows) { expect(window.observations[0]?.evidence.location).toMatchObject({ available: true, diff --git a/tests/fixtures/windows-native-inspection-failure.cc b/tests/fixtures/windows-native-inspection-failure.cc new file mode 100644 index 0000000000..df93e48535 --- /dev/null +++ b/tests/fixtures/windows-native-inspection-failure.cc @@ -0,0 +1,20 @@ +// Only the separate conformance artifact uses this translation unit. The +// shipped addon always compiles filesystem.cc directly, without interposition. +#include "../../native/windows/src/authority.hpp" +#include + +static BOOL inspectionDisposition(HANDLE handle, FILE_INFO_BY_HANDLE_CLASS kind, + LPVOID value, DWORD size) { + // A real NTFS probe is created normally. Fail its first disposition call so + // the verifier can observe ownership after failure, independently of host + // filter drivers or the intermittent error reported in issue #1701. + static std::atomic_flag failed = ATOMIC_FLAG_INIT; + if (kind == FileDispositionInfo && !failed.test_and_set()) { + SetLastError(ERROR_ACCESS_DENIED); + return FALSE; + } + return SetFileInformationByHandle(handle, kind, value, size); +} + +#define SetFileInformationByHandle inspectionDisposition +#include "../../native/windows/src/filesystem.cc"