From 58736e9f0c15f614909f0db75417a61a73cc812d Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 23 Oct 2026 03:31:08 -0400 Subject: [PATCH] refactor(apps): drop the in-process local execution guard in favor of Node's permission model Local execution's in-process JavaScript guard patched fs, child_process, worker_threads and process.chdir to refuse writes and subprocesses on the paths where a call can't run under Node's permission model. The Node backend runtime itself allows those operations, and comparable local runners don't build a JavaScript file-system guard, so the guard blocked code that runs in production while not being a security boundary. The guard, its tests and its wiring are removed. On a Node without the permission model, or without --allow-inspector while debugging, a call's process runs unrestricted and the dev server warns once that production's limits aren't enforced locally and that dev:verify runs functions on the real runtime. A call's own process under the permission model keeps Node's limits. A refusal from Node's permission model that fails the call is reported with a message naming what was refused, the refused path for a file refusal or Node's own reason for an API it refuses without a scope, such as fs.symlink on Node 24, and the dev:verify hint, keeping the call's own stack frames. The unused graceful-fs devDependency goes with the guard's tests. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/README.md | 4 +- packages/plugins/apps/package.json | 1 - packages/plugins/apps/src/index.test.ts | 2 - packages/plugins/apps/src/vite/index.test.ts | 5 - .../apps/src/vite/local-execution-child.ts | 28 +- .../src/vite/local-execution-limits.test.ts | 130 + .../apps/src/vite/local-execution-limits.ts | 74 + ...ocal-execution-process.integration.test.ts | 141 +- .../apps/src/vite/local-execution-process.ts | 13 +- .../apps/src/vite/local-execution-protocol.ts | 1 - .../apps/src/vite/local-execution-sandbox.ts | 2 +- .../apps/src/vite/network-guard.test.ts | 2449 ----------------- .../plugins/apps/src/vite/network-guard.ts | 1106 -------- ...uard.fixtures.ts => probe-dir.fixtures.ts} | 2 +- .../childSandbox.backend.ts | 33 +- yarn.lock | 3 +- 16 files changed, 373 insertions(+), 3621 deletions(-) create mode 100644 packages/plugins/apps/src/vite/local-execution-limits.test.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-limits.ts delete mode 100644 packages/plugins/apps/src/vite/network-guard.test.ts delete mode 100644 packages/plugins/apps/src/vite/network-guard.ts rename packages/plugins/apps/src/vite/{network-guard.fixtures.ts => probe-dir.fixtures.ts} (88%) diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 8b67669a5..6b9f6e155 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -103,11 +103,11 @@ When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script - **Fresh state**: module-level variables and globals start over on every call. - **Concurrency**: calls run in parallel, with no local limit, so a large burst starts that many processes. Production's concurrency limits vary by org, and `dev:verify` runs against them. A call that starts while another call's runtime context (`$`) query is in flight shares that query instead of sending its own. - **Timeout**: a call that runs past its time budget is killed, even inside a synchronous loop. -- **Sandbox**: Node's [permission model](https://nodejs.org/api/permissions.html) lets a call read the project, its `node_modules` folders and the packages linked from them, Vite and Vite's [`server.fs.allow`](https://vite.dev/config/server-options#server-fs-allow) folders, and write only to its own temp dir (`os.tmpdir()`), which is also its working directory, and `/tmp`, so a project inside `/tmp` is writable too. A refused read or write throws `ERR_ACCESS_DENIED`, not `ENOENT`, so code that tolerates only a missing file fails locally. Modules loaded through Vite, though not `fs` reads or a native `require`, refuse files matching Vite's [`server.fs.deny`](https://vite.dev/config/server-options#server-fs-deny), such as `.env`, and, unless `server.fs.strict` is `false`, files outside those folders. It refuses subprocesses, worker threads and native addons, allows the network, and caps the heap at 256 MB. On a Node without [`module.registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions), added in Node 22.15, modules get no `import.meta.resolve`, which Vite resolves on a worker thread there, and the dev server warns once. A known gap, from Node's [documented limitations](https://nodejs.org/api/permissions.html#limitations-and-known-issues): "Symbolic links will be followed even to locations outside of the set of paths that access has been granted to." Here, a symlink in a writable folder (the call's temp dir or `/tmp`) lets a call read or write wherever it points, project files included. Local execution is a parity aid, not a security boundary. +- **Sandbox**: Node's [permission model](https://nodejs.org/api/permissions.html) lets a call read the project, its `node_modules` folders and the packages linked from them, Vite and Vite's [`server.fs.allow`](https://vite.dev/config/server-options#server-fs-allow) folders, and write only to its own temp dir (`os.tmpdir()`), which is also its working directory, and `/tmp`, so a project inside `/tmp` is writable too. A refused read or write throws `ERR_ACCESS_DENIED`, not `ENOENT`, so code that tolerates only a missing file fails locally. Modules loaded through Vite, though not `fs` reads or a native `require`, refuse files matching Vite's [`server.fs.deny`](https://vite.dev/config/server-options#server-fs-deny), such as `.env`, and, unless `server.fs.strict` is `false`, files outside those folders. It refuses subprocesses, worker threads and native addons, allows the network, and caps the heap at 256 MB. A refusal the function lets reach its result fails the call with a message that points to `dev:verify`, since production may allow it, and names the path for a file refusal, or Node's reason for an API that needs full access, such as `fs.symlink` on Node 24. On a Node without [`module.registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions), added in Node 22.15, modules get no `import.meta.resolve`, which Vite resolves on a worker thread there, and the dev server warns once. A known gap, from Node's [documented limitations](https://nodejs.org/api/permissions.html#limitations-and-known-issues): "Symbolic links will be followed even to locations outside of the set of paths that access has been granted to." Here, a symlink in a writable folder (the call's temp dir or `/tmp`) lets a call read or write wherever it points, project files included. Local execution is a parity aid, not a security boundary. - **Environment**: a call sees no variables from the dev server's environment, keys included, except Node's TLS and time zone settings (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `TZ`), plus `SystemRoot` on Windows, and no Node options from `NODE_OPTIONS`. Vite still inlines `import.meta.env` and `define` values into the code it serves. `$.Actions` calls go back to the dev server, which holds the credentials and checks each call's connection against the function's allowed connections. - **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, each prefixed with its call, for example `[getGreeting#3]`. - **Debugging**: when the dev server runs under a debugger (`--inspect`), each call's process listens on a random local port and prints its `Debugger listening on ws://…` URL with its prefix. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger. -- **Older Node**: on a Node without the permission model, or without `--allow-inspector` while debugging, a call's process runs under in-process guards instead. They refuse writes outside the temp dirs, subprocesses and worker threads, from the module's top-level code on. +- **Older Node**: on a Node without the permission model, or without `--allow-inspector` while debugging, a call's process runs unrestricted, and the dev server warns once that production's limits aren't enforced locally. Local execution doesn't support Yarn Plug'n'Play, whose packages load through hooks a call's process doesn't get: each call fails with a hint to set `nodeLinker: node-modules` or use `dev:verify`. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 315df84a4..b945f5574 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -54,7 +54,6 @@ "@types/estree": "1.0.8", "@types/node": "^20", "@types/picomatch": "4.0.2", - "graceful-fs": "4.2.11", "rollup": "4.60.4", "typescript": "5.4.3", "vite": "6.4.3" diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index 91b31890f..4c3325f3e 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -28,8 +28,6 @@ import type { AppsOptionsWithDefaults } from './types'; import { BACKEND_OUT_DIR_PREFIX } from './vite/build-backend-functions'; import { buildAppPackage, MANIFEST_DIR_PREFIX } from './vite/build-package'; -// Another test file in this Jest worker can install the guards, which makes fs.promises.mkdtemp -// non-configurable for jest.spyOn, so the mock comes from the module registry instead. jest.mock('fs/promises', () => { const actual = jest.requireActual('fs/promises'); return { ...actual, mkdtemp: jest.fn(actual.mkdtemp) }; diff --git a/packages/plugins/apps/src/vite/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 053975560..c1b37f21c 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -59,7 +59,6 @@ import { import * as buildPackage from './build-package'; import * as devServer from './dev-server'; import * as localExecutionProcess from './local-execution-process'; -import * as networkGuard from './network-guard'; import { MIN_VITE_VERSION, unsupportedViteMessage } from './vite-version'; type TransformHandler = (code: string, id: string, transformOptions?: { ssr?: boolean }) => unknown; @@ -1488,7 +1487,6 @@ describe('Backend Functions - getVitePlugin', () => { 'Should run backend functions in child processes and check local Node only when local execution can run ($scenario)', ({ mode, withAuth, runsLocally }) => { const plugin = getVitePlugin(defaultOptions); - const installGuards = jest.spyOn(networkGuard, 'installGuards'); const createMiddleware = jest.spyOn(devServer, 'createDevServerMiddleware'); const createExecutor = jest.spyOn(localExecutionProcess, 'createChildProcessExecutor'); const runnerPath = '/build/node_modules/vite/dist/node/module-runner.js'; @@ -1524,14 +1522,11 @@ describe('Backend Functions - getVitePlugin', () => { })); const expectedOptions = { invokeChannel: hot, moduleRunnerPath: runnerPath }; expect(executorOptions).toEqual(runsLocally ? [expectedOptions] : []); - // The dev server's own fs and subprocess APIs stay unpatched: calls run elsewhere. - expect(installGuards).not.toHaveBeenCalled(); const warning = localNodeWarning(NODE_WITHOUT_WEBSOCKET); const warnings = mockLogFn.mock.calls.filter(([message]) => message === warning); expect(warnings).toEqual(runsLocally ? [[warning, 'warn']] : []); } finally { restoreEnv(); - installGuards.mockRestore(); createMiddleware.mockRestore(); createExecutor.mockRestore(); resolveRunner.mockRestore(); diff --git a/packages/plugins/apps/src/vite/local-execution-child.ts b/packages/plugins/apps/src/vite/local-execution-child.ts index 1115824aa..6fdd05806 100644 --- a/packages/plugins/apps/src/vite/local-execution-child.ts +++ b/packages/plugins/apps/src/vite/local-execution-child.ts @@ -10,6 +10,7 @@ import nodeModule from 'node:module'; import type { Writable } from 'node:stream'; +import { withPermissionHint } from './local-execution-limits'; import type { ChildMessage, ChildStartConfig, ParentMessage } from './local-execution-protocol'; import { isParentMessage } from './local-execution-protocol'; import type { ActionCallArgs } from './local-execution-shared'; @@ -25,7 +26,6 @@ import { serializeActionInputs, validateActionCallShape, } from './local-execution-shared'; -import { installGuards, runBlocked } from './network-guard'; type ModuleRunnerInstance = { import(url: string): Promise> }; type CreateImportMeta = (modulePath: string) => unknown; @@ -172,7 +172,7 @@ async function load(config: ChildStartConfig): Promise { Reflect.set(globalThis, '$', undefined); const evaluator = new ESModulesEvaluator(); // Without `module.registerHooks`, Vite resolves it on a hooks thread, which the permission model refuses. - const resolvesImportMeta = config.installGuards || 'registerHooks' in nodeModule; + const resolvesImportMeta = !('permission' in process) || 'registerHooks' in nodeModule; // Without it, Vite's runner gives modules an import.meta.resolve that always throws. const runnerOptions: ModuleRunnerOptions = { transport: { invoke }, @@ -195,30 +195,28 @@ async function load(config: ChildStartConfig): Promise { Reflect.set(globalThis, '$', $); await registerSdkAdapters(runner, config, $); await sendMessage({ type: 'loaded' }); - const returned = await callBackendFunction(() => fn(...config.args)); + const returned = await callBackendFunction(async () => { + try { + return await fn(...config.args); + } catch (error) { + throw withPermissionHint(error); + } + }); const subject = returnValueSubject(config.functionName); return assertJsonRoundTrippable(returned, subject); } -// Without the permission model, the guards cover the module's top-level code as well as the call. -function execute(config: ChildStartConfig): Promise { - if (!config.installGuards) { - return load(config); - } - installGuards(); - return runBlocked(() => load(config)); -} - async function run(config: ChildStartConfig): Promise { let outcome: ChildMessage; try { - const data = await execute(config); + const data = await load(config); outcome = { type: 'result', data }; } catch (error) { + const failure = withPermissionHint(error); outcome = { type: 'error', - message: describeError(error), - ...(error instanceof FunctionThrewError && { detail: error.detail }), + message: describeError(failure), + ...(failure instanceof FunctionThrewError && { detail: failure.detail }), }; } const stdoutFlushed = flush(process.stdout, writeStdout, config.flushTimeoutMs); diff --git a/packages/plugins/apps/src/vite/local-execution-limits.test.ts b/packages/plugins/apps/src/vite/local-execution-limits.test.ts new file mode 100644 index 000000000..2f480e81d --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-limits.test.ts @@ -0,0 +1,130 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { + NATIVE_ADDON_BLOCKED_MESSAGE, + OUTSIDE_READ_BLOCKED_MESSAGE, + OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE, + PERMISSION_BLOCKED_MESSAGE, + SUBPROCESS_BLOCKED_MESSAGE, + WORKER_THREAD_BLOCKED_MESSAGE, + withPermissionHint, +} from './local-execution-limits'; + +const refusal = (fields: Record) => + Object.assign(new Error('Access to this API has been restricted'), fields); + +describe('withPermissionHint', () => { + const realPermission = Object.getOwnPropertyDescriptor(process, 'permission'); + + beforeEach(() => { + Object.defineProperty(process, 'permission', { + value: { has: () => false }, + configurable: true, + }); + }); + + afterEach(() => { + if (realPermission) { + Object.defineProperty(process, 'permission', realPermission); + } else { + Reflect.deleteProperty(process, 'permission'); + } + }); + + test.each([ + { + refused: 'a subprocess', + fields: { code: 'ERR_ACCESS_DENIED', permission: 'ChildProcess', resource: 'echo' }, + message: SUBPROCESS_BLOCKED_MESSAGE, + }, + { + refused: 'a worker thread, whose resource is empty', + fields: { code: 'ERR_ACCESS_DENIED', permission: 'WorkerThreads', resource: '' }, + message: WORKER_THREAD_BLOCKED_MESSAGE, + }, + { + refused: 'a write, naming its path', + fields: { code: 'ERR_ACCESS_DENIED', permission: 'FileSystemWrite', resource: '/x' }, + message: `${OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE} Refused: /x`, + }, + { + refused: 'a read, naming its path', + fields: { code: 'ERR_ACCESS_DENIED', permission: 'FileSystemRead', resource: '/y' }, + message: `${OUTSIDE_READ_BLOCKED_MESSAGE} Refused: /y`, + }, + { + refused: 'another permission', + fields: { + code: 'ERR_ACCESS_DENIED', + permission: 'Inspector', + resource: 'StartIoThread', + }, + message: PERMISSION_BLOCKED_MESSAGE, + }, + { + refused: 'an API that needs full access, naming the reason', + fields: { + message: 'fs.symlink API requires full fs.read and fs.write permissions.', + code: 'ERR_ACCESS_DENIED', + permission: '', + resource: '', + }, + message: `${PERMISSION_BLOCKED_MESSAGE} Refused: fs.symlink API requires full fs.read and fs.write permissions.`, + }, + { + refused: 'a native addon', + fields: { code: 'ERR_DLOPEN_DISABLED' }, + message: NATIVE_ADDON_BLOCKED_MESSAGE, + }, + ])('Should hint at dev:verify for $refused', ({ fields, message }) => { + const original = refusal(fields); + const hinted = withPermissionHint(original); + + expect(hinted).toBeInstanceOf(Error); + expect(hinted).toMatchObject({ message }); + expect(hinted).toHaveProperty( + 'stack', + expect.stringContaining(`Error: ${message}\n at `), + ); + }); + + const throwingCode = new Error('custom'); + Object.defineProperty(throwingCode, 'code', { + get() { + throw new Error('getter'); + }, + }); + + test.each([ + { + kind: 'an ERR_ACCESS_DENIED without a permission', + error: refusal({ code: 'ERR_ACCESS_DENIED' }), + }, + { + kind: 'an ERR_ACCESS_DENIED with an empty scope and a resource', + error: refusal({ code: 'ERR_ACCESS_DENIED', permission: '', resource: 'users' }), + }, + { kind: 'an unrelated error', error: new Error('boom') }, + { + kind: "an ERR_ACCESS_DENIED with a scope that isn't Node's", + error: refusal({ code: 'ERR_ACCESS_DENIED', permission: 'read:users' }), + }, + { kind: 'a thrown string', error: 'boom' }, + { kind: 'an error whose code getter throws', error: throwingCode }, + ])('Should keep $kind as it is', ({ error }) => { + const result = withPermissionHint(error); + + expect(result).toBe(error); + }); + + test('Should keep a refusal as it is without the permission model', () => { + Reflect.deleteProperty(process, 'permission'); + const original = refusal({ code: 'ERR_ACCESS_DENIED', permission: 'ChildProcess' }); + + const result = withPermissionHint(original); + + expect(result).toBe(original); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution-limits.ts b/packages/plugins/apps/src/vite/local-execution-limits.ts new file mode 100644 index 000000000..61c1fb227 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-limits.ts @@ -0,0 +1,74 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +const DEV_VERIFY = '`dev:verify` (`npx datadog-apps dev --verify`)'; +const LIMITS_DOC = + 'Rules: https://github.com/DataDog/build-plugins/tree/master/packages/plugins/apps#local-execution'; +const LOCALLY = 'is blocked when running backend functions locally.'; +const SUFFIX = `It may work in production; run ${DEV_VERIFY} to check it on the real runtime. ${LIMITS_DOC}`; +export const SUBPROCESS_BLOCKED_MESSAGE = `Spawning a subprocess ${LOCALLY} ${SUFFIX}`; +export const WORKER_THREAD_BLOCKED_MESSAGE = `Starting a worker thread ${LOCALLY} ${SUFFIX}`; +export const OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE = `Writing outside the call's temp dirs ${LOCALLY} ${SUFFIX}`; +export const OUTSIDE_READ_BLOCKED_MESSAGE = `Reading outside the folders a call can read ${LOCALLY} ${SUFFIX}`; +export const NATIVE_ADDON_BLOCKED_MESSAGE = `Loading a native addon ${LOCALLY} ${SUFFIX}`; +export const PERMISSION_BLOCKED_MESSAGE = `This operation ${LOCALLY} ${SUFFIX}`; +export const UNRESTRICTED_LOCAL_EXECUTION_WARNING = `Backend functions run locally without Node's permission model, so production's limits aren't enforced; ${DEV_VERIFY} runs them on the real runtime. ${LIMITS_DOC}`; + +const PERMISSION_REFUSAL_MESSAGES = new Map([ + ['ChildProcess', SUBPROCESS_BLOCKED_MESSAGE], + ['WorkerThreads', WORKER_THREAD_BLOCKED_MESSAGE], + ['FileSystemWrite', OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE], + ['FileSystemRead', OUTSIDE_READ_BLOCKED_MESSAGE], + ['Inspector', PERMISSION_BLOCKED_MESSAGE], + ['WASI', PERMISSION_BLOCKED_MESSAGE], + ['Net', PERMISSION_BLOCKED_MESSAGE], + ['Addons', PERMISSION_BLOCKED_MESSAGE], +]); + +function permissionRefusalMessage(error: Error): string | undefined { + const code = 'code' in error ? error.code : undefined; + if (code === 'ERR_DLOPEN_DISABLED') { + return NATIVE_ADDON_BLOCKED_MESSAGE; + } + // Node's refusals carry a `permission` field; any other error is the function's own. + if (code !== 'ERR_ACCESS_DENIED' || !('permission' in error)) { + return undefined; + } + const permission = String(error.permission); + const resource = 'resource' in error ? error.resource : undefined; + // Refusals of APIs that need full access, such as fs.symlink on Node 24, leave both fields empty. + if (permission === '' && resource === '') { + return `${PERMISSION_BLOCKED_MESSAGE} Refused: ${error.message}`; + } + const message = PERMISSION_REFUSAL_MESSAGES.get(permission); + if (message === undefined) { + return undefined; + } + const isPath = permission.startsWith('FileSystem'); + return isPath && typeof resource === 'string' && resource + ? `${message} Refused: ${resource}` + : message; +} + +// Node's own refusal suggests a permission flag, which the developer can't pass to a call's process. +// The refused call's frames stay, so the reported stack still points at the code that made it. +export function withPermissionHint(error: unknown): unknown { + // Only the permission model raises these codes; without it, a matching error is the function's own. + if (!process.permission) { + return error; + } + try { + const hint = error instanceof Error ? permissionRefusalMessage(error) : undefined; + if (!(error instanceof Error) || hint === undefined) { + return error; + } + const hinted = new Error(hint); + const lines = typeof error.stack === 'string' ? error.stack.split('\n') : []; + const frames = lines.filter((line) => line.startsWith(' at ')); + hinted.stack = [`Error: ${hint}`, ...frames].join('\n'); + return hinted; + } catch { + return error; + } +} diff --git a/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts index 418763b4b..ab5ce9e5a 100644 --- a/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts +++ b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts @@ -4,6 +4,7 @@ import { getVitePlugin } from '@dd/apps-plugin/vite/index'; import { getContextMock, getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import { build as esbuild } from 'esbuild'; import fs from 'fs'; import http from 'http'; import { type ChildProcess, fork } from 'node:child_process'; @@ -23,6 +24,13 @@ import { import type { BackendFunction } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { + NATIVE_ADDON_BLOCKED_MESSAGE, + OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE, + PERMISSION_BLOCKED_MESSAGE, + SUBPROCESS_BLOCKED_MESSAGE, + UNRESTRICTED_LOCAL_EXECUTION_WARNING, +} from './local-execution-limits'; import { buildLocalExecutionChild, FIXTURE_ROOT, @@ -48,9 +56,8 @@ import { type ExecuteAction, withTimeout, } from './local-execution'; -import { FS_WRITE_BLOCKED_MESSAGE } from './network-guard'; +import { makeProbeDirOutsideTmp } from './probe-dir.fixtures'; -// Spying on the shared core module fails once a sandbox guard makes `fork` non-configurable. jest.mock('node:child_process', () => { const actual = jest.requireActual('node:child_process'); return { ...actual, fork: jest.fn(actual.fork) }; @@ -129,6 +136,24 @@ const virtualModulePlugin: Plugin = { }, }; +// The published child resolves a package from where the plugin is installed, which its sandbox may not read. +test('Should import only Node built-ins from the child entry', async () => { + const result = await esbuild({ + entryPoints: [path.join(__dirname, 'local-execution-child.ts')], + bundle: true, + format: 'esm', + platform: 'node', + packages: 'external', + write: false, + metafile: true, + logLevel: 'silent', + }); + const outputs = Object.values(result.metafile.outputs); + const imports = outputs.flatMap((output) => output.imports.map((imported) => imported.path)); + const packageImports = imports.filter((specifier) => !nodeModule.isBuiltin(specifier)); + expect(packageImports).toEqual([]); +}); + describe('Local execution in a child process', () => { let server: ViteDevServer; let childEntryPath: string; @@ -615,7 +640,6 @@ describe('Local execution in a child process', () => { runtimeContext: previewRuntimeContext, actionCatalogInstalled: false, appsBackendInstalled: false, - installGuards: false, flushTimeoutMs: 1000, }; const child = fork(childEntryPath, [], { @@ -748,7 +772,12 @@ describe('Local execution in a child process', () => { test( 'Should refuse project writes, subprocesses and workers, and allow temp writes and fetch', async () => { - const execute = makeExecutor(); + const warnings: string[] = []; + const execute = makeExecutor({ + isInspectorActive: () => false, + hasRegisterHooks: true, + log: getMockLogger({ warn: (text: string) => warnings.push(text) }), + }); const result = await execute( makeRequest(fixtureFunction('childSandbox', 'runSandboxProbes'), [ projectFile, @@ -765,47 +794,100 @@ describe('Local execution in a child process', () => { fetch: 'ok: 200 pong', }); expect(fs.existsSync(projectFile)).toBe(false); + expect(warnings).toEqual([]); }, CALL_TIMEOUT_MS, ); test( - 'Should fall back to the in-process guards while debugging on a Node without --allow-inspector', + 'Should run calls unrestricted, warning once, while debugging on a Node without --allow-inspector', async () => { const lines: string[] = []; + const warnings: string[] = []; const execute = makeExecutor({ nodeFlags: withoutAllowInspector, isInspectorActive: () => true, writeOutput: (_stream, line) => lines.push(line), + log: getMockLogger({ warn: (text: string) => warnings.push(text) }), }); - const result = await execute( - makeRequest(fixtureFunction('childSandbox', 'runSandboxProbes'), [ - projectFile, - fetchUrl, - sharedTmpFile, - ]), - ); - expect(result.data).toEqual({ - sharedTmpWrite: 'ok: written', - projectWrite: 'refused: EROFS', - tempWrite: 'ok: x', - spawn: 'refused: Spawning a subprocess is not allowed in backend functions.', - worker: 'refused: Spawning a worker thread is not allowed in backend functions.', - fetch: 'ok: 200 pong', - }); - expect(fs.existsSync(projectFile)).toBe(false); + // Outside the fixtures folder, which other suites copy while this one runs. + const outsideDir = makeProbeDirOutsideTmp('dd-child-unrestricted-'); + const outsideFile = path.join(outsideDir, 'probe.txt'); + const probes = fixtureFunction('childSandbox', 'runSandboxProbes'); + const request = makeRequest(probes, [outsideFile, fetchUrl, sharedTmpFile]); + try { + const results = [await execute(request), await execute(request)]; + for (const result of results) { + expect(result.data).toEqual({ + sharedTmpWrite: 'ok: written', + projectWrite: 'ok: written', + tempWrite: 'ok: x', + spawn: 'ok: hi\n', + worker: 'ok: started', + fetch: 'ok: 200 pong', + }); + } + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + expect(warnings).toEqual([UNRESTRICTED_LOCAL_EXECUTION_WARNING]); expect(lines.join('\n')).toContain('Debugger listening on ws://127.0.0.1:'); }, CALL_TIMEOUT_MS, ); + test.each([ + { refused: 'a subprocess', name: 'spawnEcho', message: SUBPROCESS_BLOCKED_MESSAGE }, + { + refused: 'a write outside the temp dirs, naming its path', + name: 'writeFile', + message: `${OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE} Refused: `, + }, + { refused: 'a native addon', name: 'loadAddon', message: NATIVE_ADDON_BLOCKED_MESSAGE }, + { + refused: 'an API without a scope, naming the reason', + name: 'loadFsBinding', + message: `${PERMISSION_BLOCKED_MESSAGE} Refused: process.binding`, + }, + ])( + "Should report the permission model's refusal of $refused with the dev:verify hint", + async ({ name, message }) => { + const execute = makeExecutor(); + const outsideDir = makeProbeDirOutsideTmp('dd-child-refusal-'); + try { + const outsideFile = path.join(outsideDir, 'probe.txt'); + const call = execute( + makeRequest(fixtureFunction('childSandbox', name), [outsideFile]), + ); + // The detail keeps the refused call's own frames, as a deployed app's stack would. + await expect(call).rejects.toMatchObject({ + message: expect.stringContaining(message), + detail: expect.stringMatching(/\n {4}at /), + }); + expect(fs.existsSync(outsideFile)).toBe(false); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }, + CALL_TIMEOUT_MS, + ); + test( - "Should guard a module's top-level code too while debugging on a Node without --allow-inspector", + "Should keep the message of a function's own error that reuses the permission model's code", async () => { - const execute = makeExecutor({ - nodeFlags: withoutAllowInspector, - isInspectorActive: () => true, + const execute = makeExecutor(); + const lookalike = fixtureFunction('childSandbox', 'throwAccessDeniedLookalike'); + await expect(execute(makeRequest(lookalike, []))).rejects.toMatchObject({ + message: 'upstream refused the request', }); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should report the permission model's refusal of a module's top-level write with the dev:verify hint", + async () => { + const execute = makeExecutor(); await expect( execute( makeRequest( @@ -813,7 +895,7 @@ describe('Local execution in a child process', () => { [], ), ), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); + ).rejects.toThrow(OUTSIDE_TEMP_WRITE_BLOCKED_MESSAGE); expect(fs.existsSync(path.join(FIXTURE_ROOT, 'top-level-probe.txt'))).toBe(false); }, CALL_TIMEOUT_MS, @@ -1196,11 +1278,10 @@ export class ModuleRunner { const countCalls = fixtureFunction('childState', 'countCalls'); await execute(makeRequest(countCalls, [0])); await execute(makeRequest(countCalls, [0])); - expect(warn.mock.calls).toEqual( - Array.from({ length: warnings }, () => [ - IMPORT_META_RESOLVE_UNAVAILABLE_MESSAGE, - ]), + const importMetaWarnings = warn.mock.calls.filter( + ([message]) => message === IMPORT_META_RESOLVE_UNAVAILABLE_MESSAGE, ); + expect(importMetaWarnings).toHaveLength(warnings); }, CALL_TIMEOUT_MS, ); diff --git a/packages/plugins/apps/src/vite/local-execution-process.ts b/packages/plugins/apps/src/vite/local-execution-process.ts index 408dd4802..16c2ebddb 100644 --- a/packages/plugins/apps/src/vite/local-execution-process.ts +++ b/packages/plugins/apps/src/vite/local-execution-process.ts @@ -22,6 +22,7 @@ import type { BackendFunction, BackendOutputs } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import type { LongPollingOptions } from '../types'; +import { UNRESTRICTED_LOCAL_EXECUTION_WARNING } from './local-execution-limits'; import type { ChildStartConfig, ParentMessage } from './local-execution-protocol'; import { collectReadPaths, @@ -250,7 +251,7 @@ function isActionMessage(message: Record): message is Record; + config: ChildStartConfig; allowedConnectionIds: string[]; executeAction: ExecuteAction; timeoutMs: number; @@ -297,6 +298,7 @@ export function createChildProcessExecutor( const [realModuleRunnerPath = moduleRunnerPath] = withRealPath(moduleRunnerPath).slice(-1); const moduleRunnerUrl = pathToFileURL(realModuleRunnerPath).href; let callCount = 0; + let warnedUnrestricted = false; const ownChildren: ChildTmpDirs = new Map(); let closed = false; @@ -356,6 +358,10 @@ export function createChildProcessExecutor( rmSync(tmpDir); throw error; } + if (!sandboxArgv && !warnedUnrestricted) { + warnedUnrestricted = true; + log.warn(UNRESTRICTED_LOCAL_EXECUTION_WARNING); + } log.debug( `Executing "${func.name}" in a child process (pid ${child.pid}, temp dir ${tmpDir}) with args`, ); @@ -613,10 +619,7 @@ export function createChildProcessExecutor( }); startLoadTimer(); - reply({ - type: 'start', - config: { ...config, installGuards: sandboxArgv === undefined }, - }); + reply({ type: 'start', config }); }); }; diff --git a/packages/plugins/apps/src/vite/local-execution-protocol.ts b/packages/plugins/apps/src/vite/local-execution-protocol.ts index 4689b3283..573317a5a 100644 --- a/packages/plugins/apps/src/vite/local-execution-protocol.ts +++ b/packages/plugins/apps/src/vite/local-execution-protocol.ts @@ -14,7 +14,6 @@ export interface ChildStartConfig { runtimeContext: Record; actionCatalogInstalled: boolean; appsBackendInstalled: boolean; - installGuards: boolean; flushTimeoutMs: number; } diff --git a/packages/plugins/apps/src/vite/local-execution-sandbox.ts b/packages/plugins/apps/src/vite/local-execution-sandbox.ts index e5a59acd0..b7e3301e3 100644 --- a/packages/plugins/apps/src/vite/local-execution-sandbox.ts +++ b/packages/plugins/apps/src/vite/local-execution-sandbox.ts @@ -18,7 +18,7 @@ function permissionFlag(nodeFlags: ReadonlySet): string | undefined { return ['--permission', '--experimental-permission'].find((flag) => nodeFlags.has(flag)); } -/** Node's permission model flags for a call, or undefined when this Node has none, or has no `--allow-inspector` while debugging; the child then installs the in-process guards instead. */ +/** Node's permission model flags for a call, or undefined when this Node has none, or has no `--allow-inspector` while debugging; the call then runs unrestricted. */ export function permissionModelArgv(options: { readPaths: string[]; writePaths: string[]; diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts deleted file mode 100644 index cae808580..000000000 --- a/packages/plugins/apps/src/vite/network-guard.test.ts +++ /dev/null @@ -1,2449 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -/* global globalThis */ - -import { outputFileSync, rmSync } from '@dd/core/helpers/fs'; -import child_process from 'child_process'; -import { buildSync } from 'esbuild'; -import fs from 'fs'; -import net from 'net'; -import { once } from 'node:events'; -import os from 'os'; -import path from 'path'; -import { promisify } from 'util'; -import worker_threads from 'worker_threads'; - -import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; -import { - ALREADY_GUARDED, - FOREIGN_GUARD_MESSAGE, - FS_WRITE_BLOCKED_MESSAGE, - getSharedContext, - guardWorker, - installGuardedProperty, - installGuards, - networkGuardSymbol, - runBlocked, -} from './network-guard'; - -beforeAll(() => { - installGuards(); -}); - -// Per-run directory, so leftover probe files from a regressed guard can't collide across runs or workers. -let probeDir: string; -beforeAll(() => { - probeDir = makeProbeDirOutsideTmp('dd-network-guard-probes-'); -}); -afterAll(() => { - rmSync(probeDir); -}); - -// The OS temp dir is the one place a run may write; a fresh subdirectory per test keeps runs apart. -let tmpWorkDir: string; -beforeEach(() => { - const workPrefix = path.join(os.tmpdir(), 'dd-network-guard-work-'); - tmpWorkDir = fs.mkdtempSync(workPrefix); -}); -afterEach(() => { - rmSync(tmpWorkDir); -}); - -// `(globalThis as { fetch: typeof fetch }).fetch = impl` repeated verbatim at every mock/restore -// call site — this collapses the cast to one place. -function setGlobalFetch(impl: typeof fetch): void { - (globalThis as { fetch: typeof fetch }).fetch = impl; -} - -describe('network-guard', () => { - describe('runBlocked', () => { - // spawn()/fork() synthesize a brand-new ChildProcess and never throw synchronously in real - // Node — failure is only ever reported via the returned object's async 'error' event, so - // the guard returns a stub shaped like the real return value instead of throwing. - test("Should block child_process.spawn() and fork() made inside fn via the async 'error' event on the returned stub, not a synchronous throw", async () => { - await runBlocked(async () => { - let child: ReturnType | undefined; - expect(() => { - child = child_process.spawn('curl', ['https://example.com']); - }).not.toThrow(); - const err = await new Promise((resolve) => child?.once('error', resolve)); - expect(err.message).toMatch(/Spawning a subprocess is not allowed/); - }); - - await runBlocked(async () => { - let child: ReturnType | undefined; - expect(() => { - child = child_process.fork('./some-script.js'); - }).not.toThrow(); - const err = await new Promise((resolve) => child?.once('error', resolve)); - expect(err.message).toMatch(/Spawning a subprocess is not allowed/); - }); - }); - - // Real spawn()/fork() always populate stdout/stderr/stdin and (for fork()) send()/ - // disconnect(), even for a command that never actually runs — a caller commonly touches - // these right after the call, before any 'error' event has had a chance to fire. - test('Should let a blocked spawn()/fork() stub be used like a real ChildProcess without throwing', async () => { - await runBlocked(async () => { - const child = child_process.spawn('curl', ['https://example.com']); - expect(() => child.stdout?.on('data', () => {})).not.toThrow(); - expect(() => child.stderr?.on('data', () => {})).not.toThrow(); - expect(() => child.stdin?.write('data')).not.toThrow(); - expect(child.kill()).toBe(false); - }); - - await runBlocked(async () => { - const child = child_process.fork('./some-script.js'); - expect(() => child.disconnect()).not.toThrow(); - // send() with a callback: the callback receives the error, matching a real - // disconnected channel's contract. - const callbackErr = await new Promise((resolve) => { - expect(() => - child.send({ hello: 'world' }, (err) => resolve(err as Error)), - ).not.toThrow(); - }); - expect(callbackErr.message).toBeTruthy(); - - // send() with no callback: falls back to an 'error' event instead of silently - // dropping the failure. - const eventErr = await new Promise((resolve) => { - child.once('error', resolve); - expect(() => child.send({ hello: 'world' })).not.toThrow(); - }); - expect(eventErr.message).toBeTruthy(); - }); - }); - - // spawnSync never throws in real Node either — it returns a SpawnSyncReturns-shaped object - // with `.error` set, so the guard mirrors that shape instead of throwing. `output` is `null` - // on a real launch failure (not an array), and `stdout`/`stderr` are `undefined` — a caller - // checking `if (result.output) { result.output[1].toString() }` would TypeError against a - // truthy-but-empty array. - test('Should block child_process.spawnSync() made inside fn via a SpawnSyncReturns-shaped `.error` matching real Node exactly, not a synchronous throw', async () => { - await runBlocked(async () => { - let result: ReturnType | undefined; - expect(() => { - result = child_process.spawnSync('curl', ['https://example.com']); - }).not.toThrow(); - expect(result?.error?.message).toMatch(/Spawning a subprocess is not allowed/); - expect(result?.output).toBeNull(); - expect(result?.stdout).toBeUndefined(); - expect(result?.stderr).toBeUndefined(); - expect(result?.status).toBeNull(); - expect(result?.signal).toBeNull(); - }); - }); - - // exec/execFile report failure via an error-first callback in real Node, unlike execSync/ - // execFileSync below, which genuinely do throw synchronously. Real Node sets stdout/stderr - // to empty strings (not undefined) even on a launch failure — a caller doing - // `err.stderr.trim()` in its callback would TypeError against `undefined`. - test('Should block child_process.exec() and execFile() made inside fn via their error-first callback, matching real Node exactly', async () => { - await runBlocked(async () => { - const [err, stdout, stderr] = await new Promise<[Error, unknown, unknown]>( - (resolve) => { - expect(() => - child_process.exec('curl https://example.com', (execErr, out, errOut) => - resolve([execErr as Error, out, errOut]), - ), - ).not.toThrow(); - }, - ); - expect(err.message).toMatch(/Spawning a subprocess is not allowed/); - expect(stdout).toBe(''); - expect(stderr).toBe(''); - }); - - await runBlocked(async () => { - const [err, stdout, stderr] = await new Promise<[Error, unknown, unknown]>( - (resolve) => { - expect(() => - child_process.execFile( - 'curl', - ['https://example.com'], - (execErr, out, errOut) => resolve([execErr as Error, out, errOut]), - ), - ).not.toThrow(); - }, - ); - expect(err.message).toMatch(/Spawning a subprocess is not allowed/); - expect(stdout).toBe(''); - expect(stderr).toBe(''); - }); - }); - - test('Should not crash the process when exec()/execFile() is called with no callback', async () => { - await runBlocked(async () => { - expect(() => child_process.exec('curl https://example.com')).not.toThrow(); - expect(() => child_process.execFile('curl', ['https://example.com'])).not.toThrow(); - }); - // If the guard had emitted an unlistened 'error' on the discarded stub, the resulting - // uncaught exception would already have crashed this Jest worker by now. - await new Promise((resolve) => setImmediate(resolve)); - }); - - test('Should block child_process.execSync() and execFileSync() made inside fn via a synchronous throw, matching their real contract', async () => { - await expect( - runBlocked(async () => { - child_process.execSync('curl https://example.com'); - }), - ).rejects.toThrow(/Spawning a subprocess is not allowed/); - await expect( - runBlocked(async () => { - child_process.execFileSync('curl', ['https://example.com']); - }), - ).rejects.toThrow(/Spawning a subprocess is not allowed/); - }); - - // promisify.custom lives on the specific function object, not inherited by a fresh wrapper — @dd/tools execute() depends on the real shape. - test('Should resolve promisify(execFile) to the real {stdout, stderr} shape, not a bare string, when not blocked', async () => { - const execFileP = promisify(child_process.execFile); - const result = await execFileP('node', ['-e', 'console.log("hi")']); - expect(result).toEqual( - expect.objectContaining({ stdout: expect.stringContaining('hi') }), - ); - }); - - test("Should still block promisify(execFile) inside a runBlocked scope, with stdout/stderr matching real Node's empty-string contract", async () => { - const execFileP = promisify(child_process.execFile); - await expect( - runBlocked(async () => { - await execFileP('node', ['-e', 'console.log("hi")']); - }), - ).rejects.toMatchObject({ - message: expect.stringMatching(/Spawning a subprocess is not allowed/), - stdout: '', - stderr: '', - }); - }); - - // exec/execFile share a guard maker but take different argument shapes — a fix for one could silently miss the other. - test('Should resolve promisify(exec) to the real {stdout, stderr} shape and still block it inside runBlocked', async () => { - const execP = promisify(child_process.exec); - const result = await execP('node -e "console.log(\'hi\')"'); - expect(result).toEqual( - expect.objectContaining({ stdout: expect.stringContaining('hi') }), - ); - - await expect( - runBlocked(async () => { - await execP('node -e "console.log(\'hi\')"'); - }), - ).rejects.toThrow(/Spawning a subprocess is not allowed/); - }); - - // Matches Node's real promisify(execFile) contract: a rejected error carries stdout/stderr too, not just a resolved success. - test('Should attach stdout/stderr onto a rejected promisify(execFile) error, matching real Node behavior', async () => { - const execFileP = promisify(child_process.execFile); - await expect( - execFileP('node', [ - '-e', - 'console.log("out"); console.error("boom"); process.exit(1)', - ]), - ).rejects.toEqual( - expect.objectContaining({ - stdout: expect.stringContaining('out'), - stderr: expect.stringContaining('boom'), - }), - ); - }); - - // Matches Node's real PromiseWithChild contract — a caller outside a blocked scope that - // inspects/signals/terminates `.child` must not lose it to this guard's own implementation. - test("Should expose the spawned ChildProcess as `.child` on promisify(execFile)'s returned promise", async () => { - const execFileP = promisify(child_process.execFile); - const resultPromise = execFileP('node', ['-e', 'console.log("hi")']); - expect(resultPromise.child).toBeInstanceOf(child_process.ChildProcess); - await resultPromise; - }); - - test("Should expose the spawned ChildProcess as `.child` on promisify(exec)'s returned promise too", async () => { - const execP = promisify(child_process.exec); - const resultPromise = execP('node -e "console.log(\'hi\')"'); - expect(resultPromise.child).toBeInstanceOf(child_process.ChildProcess); - await resultPromise; - }); - - // ChildProcess.prototype.spawn isn't in @types/node's public surface, so a locally-scoped - // interface stands in for its real shape instead of an `any` escape hatch. - interface ChildProcessWithSpawn { - spawn(options: { file: string }): number; - once(event: 'error', listener: (err: Error) => void): void; - } - - // A dependency calling `new child_process.ChildProcess().spawn(...)` directly bypasses all - // the higher-level guarded factory functions above. Unlike those, `this` is already the - // real ChildProcess instance — spawn() itself never throws in real Node and returns a - // synchronous integer, not undefined, so the guard emits 'error' on `this` and returns a - // negative placeholder rather than fabricating a stub. - test("Should block a direct new child_process.ChildProcess().spawn(...) call via the async 'error' event, bypassing the factory functions", async () => { - await runBlocked(async () => { - const child = new child_process.ChildProcess() as unknown as ChildProcessWithSpawn; - let returnValue: number | undefined; - expect(() => { - returnValue = child.spawn({ file: 'curl' }); - }).not.toThrow(); - expect(typeof returnValue).toBe('number'); - expect(returnValue).toBeLessThan(0); - const err = await new Promise((resolve) => child.once('error', resolve)); - expect(err.message).toMatch(/Spawning a subprocess is not allowed/); - }); - }); - - // A worker gets a fresh V8 realm with its own module registry, so nothing inside it inherits - // this file's monkeypatches — the only enforceable boundary is blocking construction itself. - test('Should block new Worker(...) construction made inside fn', async () => { - await expect( - runBlocked(async () => { - new worker_threads.Worker('', { eval: true }); - }), - ).rejects.toThrow(/Spawning a worker thread is not allowed/); - }); - - test('Should allow constructing, messaging, and cleanly terminating a Worker outside a blocked scope', async () => { - const worker = new worker_threads.Worker( - "require('worker_threads').parentPort.on('message', () => undefined);", - { eval: true }, - ); - expect(worker).toBeInstanceOf(worker_threads.Worker); - try { - expect(() => worker.postMessage('ping')).not.toThrow(); - } finally { - await expect(worker.terminate()).resolves.toEqual(expect.any(Number)); - } - }); - - // fn returning doesn't mean fn is done — detached async work it scheduled without awaiting keeps running and must still see the guard. - test('Should still block a detached, unawaited setTimeout callback scheduled during fn, even after fn itself has already resolved', async () => { - const probePath = path.join(probeDir, 'detached.txt'); - let detachedWriteResult: Promise | undefined; - let detachedWriteSettled = false; - - await runBlocked(async () => { - // Deliberately not awaited — fn returns immediately while this keeps running in the background. - setTimeout(() => { - const result = fs.promises.writeFile(probePath, 'data'); - detachedWriteResult = result; - // Attached synchronously so the rejection is never briefly unhandled before the `.rejects` assertion below attaches its own handler. - result.then( - () => { - detachedWriteSettled = true; - }, - () => { - detachedWriteSettled = true; - }, - ); - }, 0); - }); - - // fn (and therefore runBlocked) has already resolved here — a per-cycle restore would have put the real write method back before this fires. - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(detachedWriteSettled).toBe(true); - await expect(detachedWriteResult).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - test('Should allow fs writes again after fn resolves', async () => { - const probePath = path.join(probeDir, 'after-resolve.txt'); - await runBlocked(async () => undefined); - fs.writeFileSync(probePath, 'written'); - const content = fs.readFileSync(probePath, 'utf8'); - fs.rmSync(probePath, { force: true }); - expect(content).toBe('written'); - }); - - test('Should allow fs writes again even when fn throws', async () => { - const probePath = path.join(probeDir, 'after-throw.txt'); - await expect( - runBlocked(async () => { - throw new Error('customer function boom'); - }), - ).rejects.toThrow('customer function boom'); - fs.writeFileSync(probePath, 'written'); - const content = fs.readFileSync(probePath, 'utf8'); - fs.rmSync(probePath, { force: true }); - expect(content).toBe('written'); - }); - - test('Should not block a subsequent, separate runBlocked call after an earlier one already restored', async () => { - await expect( - runBlocked(async () => { - throw new Error('first execution boom'); - }), - ).rejects.toThrow('first execution boom'); - - // A naive boolean never reset on throw would leave writes blocked outside any scope. - const result = await runBlocked(async () => 'second execution result'); - const probePath = path.join(probeDir, 'after-second.txt'); - fs.writeFileSync(probePath, 'written'); - const content = fs.readFileSync(probePath, 'utf8'); - expect(result).toBe('second execution result'); - expect(content).toBe('written'); - }); - - // An abandoned execution's late settlement must not restore real fs access out from under a newer, active runBlocked scope. - test("Should not let an abandoned runBlocked call's late restore corrupt a newer, currently-active runBlocked scope", async () => { - const probePath = path.join(probeDir, 'abandoned.txt'); - let resolveAbandoned: (() => void) | undefined; - const abandoned = runBlocked( - () => - new Promise((resolve) => { - resolveAbandoned = resolve; - }), - ); - - // A second, newer execution starts its own scope; the write check runs from inside its fn to verify customer code is still blocked. - let openGate: (() => void) | undefined; - const gate = new Promise((resolve) => { - openGate = resolve; - }); - let currentWriteResult: Promise | undefined; - const current = runBlocked(async () => { - await gate; - currentWriteResult = fs.promises.writeFile(probePath, 'data'); - await currentWriteResult.catch(() => undefined); - }); - - // The abandoned execution's fn() finally settles — its own finally block must not unblock the still-running newer scope. - resolveAbandoned?.(); - await abandoned; - - openGate?.(); - await current; - await expect(currentWriteResult).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - // The "const original = x; x = mock; x = original;" idiom hands the guard itself back on - // restore — confirms this round-trips to the real value instead of recursing into itself. - test('Should not infinite-recurse when a caller restores a previously-read guard back onto a guarded property', async () => { - const nativeStandIn = jest.fn().mockReturnValue('native result'); - const originalWriteFileSync = fs.writeFileSync; - Reflect.set(fs, 'writeFileSync', nativeStandIn); - - try { - const capturedOriginal = fs.writeFileSync; - const mock = jest.fn().mockReturnValue('mock result'); - Reflect.set(fs, 'writeFileSync', mock); - - const writeResult = fs.writeFileSync('probe.txt', 'data'); - expect(writeResult).toBe('mock result'); - - Reflect.set(fs, 'writeFileSync', capturedOriginal); - - const writeResult2 = fs.writeFileSync('probe.txt', 'data'); - expect(writeResult2).toBe('native result'); - } finally { - Reflect.set(fs, 'writeFileSync', originalWriteFileSync); - } - }); - - // The guarded property is a process-wide singleton — code that never entered any runBlocked scope must not be blocked by an unrelated one. - test('Should not block a concurrent fs.writeFileSync made from code that never entered any runBlocked scope', async () => { - const writeFileSyncMock = jest.fn().mockReturnValue('unrelated result'); - const originalWriteFileSync = fs.writeFileSync; - Reflect.set(fs, 'writeFileSync', writeFileSyncMock); - - try { - let resolveBlocked: (() => void) | undefined; - const blocked = runBlocked( - () => - new Promise((resolve) => { - resolveBlocked = resolve; - }), - ); - - // Made from code entirely outside runBlocked, e.g. a concurrent cloud-mode request's own real write call. - const writeResult = fs.writeFileSync('unrelated.txt', 'data'); - expect(writeResult).toBe('unrelated result'); - - resolveBlocked?.(); - await blocked; - } finally { - Reflect.set(fs, 'writeFileSync', originalWriteFileSync); - } - }); - }); - - describe('fs write guard', () => { - let tmpDir: string; - let testFile: string; - let nestedPrefix: string; - - beforeEach(() => { - tmpDir = makeProbeDirOutsideTmp('dd-fs-guard-'); - testFile = path.join(tmpDir, 'test.txt'); - nestedPrefix = path.join(tmpDir, 'nested-'); - }); - - afterEach(() => { - rmSync(tmpDir); - }); - - // A non-UTF-8 byte decodes to U+FFFD, so the decoded path can differ from the one Node writes. - test('Should refuse a Buffer path that does not decode losslessly', async () => { - const tmpPath = path.join(tmpWorkDir, 'lossy-'); - const tmpPathBytes = Buffer.from(tmpPath); - const invalidByte = Buffer.from([0xff]); - const lossyPath = Buffer.concat([tmpPathBytes, invalidByte]); - - await expect( - runBlocked(async () => { - fs.writeFileSync(lossyPath, 'data'); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - test('Should allow a UTF-8 Buffer path under the temp dir', async () => { - const tmpPath = path.join(tmpWorkDir, 'buffer-path.txt'); - const tmpPathBytes = Buffer.from(tmpPath); - - await runBlocked(async () => { - fs.writeFileSync(tmpPathBytes, 'data'); - }); - const written = fs.readFileSync(tmpPath, 'utf8'); - - expect(written).toBe('data'); - }); - - test('Should block fs.writeFileSync made inside fn', async () => { - await expect( - runBlocked(async () => { - fs.writeFileSync(testFile, 'data'); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - const fileExists = fs.existsSync(testFile); - expect(fileExists).toBe(false); - }); - - // unlink reports failure via its mandatory error-first callback, never a synchronous throw. - test('Should block fs.unlink made inside fn via its error-first callback, not a synchronous throw', async () => { - fs.writeFileSync(testFile, 'data'); - await runBlocked(async () => { - const err = await new Promise((resolve) => { - expect(() => - fs.unlink(testFile, (unlinkErr) => resolve(unlinkErr)), - ).not.toThrow(); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const fileExists = fs.existsSync(testFile); - expect(fileExists).toBe(true); - }); - - // Real fs.unlink throws synchronously for a missing callback regardless of block state, - // a caller bug that must surface rather than be silently swallowed. - test('Should throw synchronously from fs.unlink made inside fn with no callback argument, with the same code as real fs', async () => { - await expect( - runBlocked(async () => { - Reflect.apply(fs.unlink, fs, [testFile]); - }), - ).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_TYPE' }); - }); - - test('Should reject rather than throw synchronously from fs.promises.writeFile when blocked', async () => { - await runBlocked(async () => { - const blockedWrite = fs.promises.writeFile(testFile, 'data'); - await expect(blockedWrite).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - const fileExists = fs.existsSync(testFile); - expect(fileExists).toBe(false); - }); - - test('Should allow fs.writeFileSync outside a blocked scope', () => { - expect(() => fs.writeFileSync(testFile, 'data')).not.toThrow(); - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('data'); - }); - - test('Should still allow fs.readFileSync inside a blocked scope, since only writes are guarded', async () => { - fs.writeFileSync(testFile, 'data'); - await runBlocked(async () => { - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('data'); - }); - }); - - // readFile opens the file itself rather than through the guarded fs.open, so its flag can - // truncate or create a file. - const readFileForms: Array<{ - api: string; - read: (target: string, flag: string) => Promise; - }> = [ - { - api: 'fs.readFileSync', - read: async (target, flag) => fs.readFileSync(target, { flag }), - }, - // UTF-8 reads take a separate native fast path. - { - api: 'fs.readFileSync with utf8', - read: async (target, flag) => fs.readFileSync(target, { flag, encoding: 'utf8' }), - }, - { - api: 'fs.readFile', - read: (target, flag) => - new Promise((resolve, reject) => { - fs.readFile(target, { flag }, (err, data) => - err ? reject(err) : resolve(data), - ); - }), - }, - { - api: 'fs.promises.readFile', - read: (target, flag) => fs.promises.readFile(target, { flag }), - }, - ]; - - test.each(readFileForms)( - 'Should refuse $api with a write flag outside the temp dir', - async ({ read }) => { - fs.writeFileSync(testFile, 'kept'); - const createdFile = path.join(tmpDir, 'created.txt'); - await runBlocked(async () => { - await expect(read(testFile, 'w')).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - await expect(read(createdFile, 'a')).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - const content = fs.readFileSync(testFile, 'utf8'); - const createdExists = fs.existsSync(createdFile); - - expect(content).toBe('kept'); - expect(createdExists).toBe(false); - }, - ); - - // Streams open through the guarded fs.open, so their flags are covered there. - test('Should refuse fs.createReadStream with a write flag outside the temp dir', async () => { - fs.writeFileSync(testFile, 'kept'); - const streamError = await runBlocked( - () => - new Promise((resolve) => { - fs.createReadStream(testFile, { flags: 'w' }).on('error', resolve); - }), - ); - const content = fs.readFileSync(testFile, 'utf8'); - - expect(streamError.message).toBe(FS_WRITE_BLOCKED_MESSAGE); - expect(content).toBe('kept'); - }); - - test.each(readFileForms)( - 'Should allow $api with a read flag anywhere and a write flag under the temp dir', - async ({ api, read }) => { - fs.writeFileSync(testFile, 'outside'); - const tmpFile = path.join(tmpWorkDir, `${api.replace(/\W/g, '-')}.txt`); - fs.writeFileSync(tmpFile, 'inside'); - const contents = await runBlocked(async () => [ - String(await read(testFile, 'r')), - String(await read(tmpFile, 'r+')), - ]); - - expect(contents).toEqual(['outside', 'inside']); - }, - ); - - // An fd opened for writing before the blocked scope started is still refused. - test('Should block fs.writeSync made inside fn on an fd from openSync', async () => { - const fd = fs.openSync(testFile, 'w'); - try { - await expect( - runBlocked(async () => { - fs.writeSync(fd, 'data'); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - } finally { - fs.closeSync(fd); - } - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe(''); - }); - - // Same openSync-then-fd-op bypass as writeSync above, but destroying existing content - // instead of appending new content. - test('Should block fs.ftruncateSync made inside fn on an fd from openSync', async () => { - fs.writeFileSync(testFile, 'data'); - const fd = fs.openSync(testFile, 'r+'); - try { - await expect( - runBlocked(async () => { - fs.ftruncateSync(fd, 0); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - } finally { - fs.closeSync(fd); - } - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('data'); - }); - - test('Should block fs.write made inside fn via its error-first callback, not a synchronous throw', async () => { - const fd = fs.openSync(testFile, 'w'); - try { - await runBlocked(async () => { - const err = await new Promise((resolve) => { - expect(() => - fs.write(fd, 'data', (writeErr) => resolve(writeErr)), - ).not.toThrow(); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - } finally { - fs.closeSync(fd); - } - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe(''); - }); - - test('Should block fs.ftruncate made inside fn via its error-first callback, not a synchronous throw', async () => { - fs.writeFileSync(testFile, 'data'); - const fd = fs.openSync(testFile, 'r+'); - try { - await runBlocked(async () => { - const err = await new Promise((resolve) => { - expect(() => - fs.ftruncate(fd, 0, (truncateErr) => resolve(truncateErr)), - ).not.toThrow(); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - } finally { - fs.closeSync(fd); - } - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('data'); - }); - - // mkdtemp creates a real directory, same class of write as mkdir — omitting it would let a - // blocked function still create directories under the OS temp path. - test('Should block fs.mkdtempSync made inside fn', async () => { - await expect( - runBlocked(async () => { - fs.mkdtempSync(nestedPrefix); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - const entries = fs.readdirSync(tmpDir); - expect(entries).toHaveLength(0); - }); - - test('Should block fs.mkdtemp made inside fn via its error-first callback, not a synchronous throw', async () => { - await runBlocked(async () => { - const err = await new Promise((resolve) => { - expect(() => - fs.mkdtemp(nestedPrefix, (mkdtempErr) => resolve(mkdtempErr)), - ).not.toThrow(); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const entries = fs.readdirSync(tmpDir); - expect(entries).toHaveLength(0); - }); - - test('Should reject rather than throw synchronously from fs.promises.mkdtemp when blocked', async () => { - await runBlocked(async () => { - await expect(fs.promises.mkdtemp(nestedPrefix)).rejects.toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - }); - const entries = fs.readdirSync(tmpDir); - expect(entries).toHaveLength(0); - }); - - // fs.writeFile opens with 'w' (truncating) before its first write, so blocking only the write loses data. - test('Should block fs.writeFile made inside fn without truncating the existing file', async () => { - fs.writeFileSync(testFile, 'ORIGINAL'); - await runBlocked(async () => { - const err = await new Promise((resolve) => { - fs.writeFile(testFile, 'replacement', resolve); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - test("Should block fs.createWriteStream made inside fn via the stream's error event, without truncating the existing file", async () => { - fs.writeFileSync(testFile, 'ORIGINAL'); - await runBlocked(async () => { - const stream = fs.createWriteStream(testFile); - const err = await new Promise((resolve) => { - stream.once('error', resolve); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - test('Should block opening a file for writing inside fn via fs.open and fs.openSync', async () => { - fs.writeFileSync(testFile, 'ORIGINAL'); - const numericWriteFlags = fs.constants.O_WRONLY + fs.constants.O_TRUNC; - await runBlocked(async () => { - expect(() => fs.openSync(testFile, 'w')).toThrow(FS_WRITE_BLOCKED_MESSAGE); - expect(() => fs.openSync(testFile, numericWriteFlags)).toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - const err = await new Promise((resolve) => { - fs.open(testFile, 'r+', (openErr) => resolve(openErr)); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - // Bit 31 makes the flag value negative, which the kernel ignores but a naive bit test misses. - test('Should treat a negative numeric open flag as opening for writing', async () => { - fs.writeFileSync(testFile, 'ORIGINAL'); - const signBitWriteFlags = -(2 ** 31) + fs.constants.O_WRONLY + fs.constants.O_TRUNC; - await runBlocked(async () => { - expect(() => fs.openSync(testFile, signBitWriteFlags)).toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - }); - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - test('Should block changing file permissions and ownership inside fn', async () => { - fs.writeFileSync(testFile, 'data'); - const { uid, gid } = fs.statSync(testFile); - await runBlocked(async () => { - expect(() => fs.chmodSync(testFile, 0o600)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - await expect(fs.promises.chown(testFile, uid, gid)).rejects.toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - }); - }); - - // Matches a read-only filesystem, so dependencies that tolerate one degrade the same way. - test("Should report a blocked fs write with code 'EROFS'", async () => { - await expect( - runBlocked(async () => { - fs.mkdirSync(nestedPrefix); - }), - ).rejects.toMatchObject({ code: 'EROFS' }); - }); - - test('Should block changing file timestamps inside fn', async () => { - fs.writeFileSync(testFile, 'data'); - await runBlocked(async () => { - expect(() => fs.utimesSync(testFile, 0, 0)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - await expect(fs.promises.utimes(testFile, 0, 0)).rejects.toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - }); - const { mtimeMs } = fs.statSync(testFile); - expect(mtimeMs).toBeGreaterThan(0); - }); - - test('Should still allow opening a file read-only inside fn', async () => { - fs.writeFileSync(testFile, 'data'); - await runBlocked(async () => { - // No flags argument, so the callback sits where the flags normally go. - const defaultFd = await new Promise((resolve, reject) => { - fs.open(testFile, (openErr, fd) => (openErr ? reject(openErr) : resolve(fd))); - }); - fs.closeSync(defaultFd); - const handle = await fs.promises.open(testFile, 'r'); - await handle.close(); - const content = await new Promise((resolve, reject) => { - fs.readFile(testFile, 'utf8', (readErr, data) => - readErr ? reject(readErr) : resolve(data), - ); - }); - expect(content).toBe('data'); - }); - }); - - // pino/sonic-boom log via fs.write(Sync) on fd 1/2, which isn't a filesystem write. - test('Should let fs.writeSync and fs.write to stdout/stderr through inside fn', async () => { - await runBlocked(async () => { - const bytesWritten = fs.writeSync(1, ''); - expect(bytesWritten).toBe(0); - const err = await new Promise((resolve) => { - fs.write(2, '', (writeErr) => resolve(writeErr)); - }); - expect(err).toBeNull(); - }); - }); - - test.each([undefined, null, false])( - 'Should report a guarded fs method reassigned to %s as that value, so feature detection stays accurate', - (stub) => { - const originalCpSync = fs.cpSync; - try { - Reflect.set(fs, 'cpSync', stub); - const reported: unknown = fs.cpSync; - expect(reported).toBe(stub); - } finally { - Reflect.set(fs, 'cpSync', originalCpSync); - } - }, - ); - - test('Should let fs.writeFileSync and fs.appendFileSync to stdout/stderr through inside fn', async () => { - await runBlocked(async () => { - expect(() => fs.writeFileSync(1, '')).not.toThrow(); - expect(() => fs.appendFileSync(2, '')).not.toThrow(); - }); - }); - - test('Should block fs.copyFile made inside fn via its error-first callback, without creating the destination', async () => { - fs.writeFileSync(testFile, 'data'); - const destination = path.join(tmpDir, 'copy.txt'); - await runBlocked(async () => { - const err = await new Promise((resolve) => { - fs.copyFile(testFile, destination, resolve); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const destinationExists = fs.existsSync(destination); - expect(destinationExists).toBe(false); - }); - - test('Should block fs.writevSync made inside fn on an fd opened before the blocked scope', async () => { - const fd = fs.openSync(testFile, 'w'); - const chunk = Buffer.from('data'); - try { - await expect( - runBlocked(async () => { - fs.writevSync(fd, [chunk]); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - } finally { - fs.closeSync(fd); - } - const content = fs.readFileSync(testFile, 'utf8'); - expect(content).toBe(''); - }); - - test('Should refuse a write-mode fs.promises.open inside fn without truncating the file', async () => { - fs.writeFileSync(testFile, 'ORIGINAL'); - const openError = await runBlocked(async () => { - try { - const handle = await fs.promises.open(testFile, 'w'); - await handle.close(); - return undefined; - } catch (err) { - return err; - } - }); - const content = fs.readFileSync(testFile, 'utf8'); - - expect(openError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE, code: 'EROFS' }); - expect(content).toBe('ORIGINAL'); - }); - - // Other packages' tests jest.spyOn(fs.promises, 'open') in the same Jest worker, which a - // non-configurable accessor would break. - test('Should keep the guarded fs.promises.open spy-able and restore it afterwards', () => { - const guardedOpen = fs.promises.open; - const spy = jest.spyOn(fs.promises, 'open'); - spy.mockRestore(); - const descriptor = Object.getOwnPropertyDescriptor(fs.promises, 'open'); - - expect(descriptor?.get).toBeUndefined(); - expect(fs.promises.open).toBe(guardedOpen); - }); - - // A plain data property can be replaced outright, so each run re-wraps whatever is there. - test('Should re-guard fs.promises.open after a reassignment that never calls the guard', async () => { - fs.writeFileSync(testFile, 'ORIGINAL'); - const guardedOpen = fs.promises.open; - const replacement = jest.fn(async () => 'replacement-handle'); - await runBlocked(async () => undefined); - const stableWhileOurs = fs.promises.open === guardedOpen; - Reflect.set(fs.promises, 'open', replacement); - try { - const openError = await runBlocked(async () => { - const opening = fs.promises.open(testFile, 'w'); - return opening.then( - () => undefined, - (err: unknown) => err, - ); - }); - const outsideRun: unknown = await fs.promises.open(testFile, 'r'); - const content = fs.readFileSync(testFile, 'utf8'); - - expect(stableWhileOurs).toBe(true); - expect(openError).toMatchObject({ - message: FS_WRITE_BLOCKED_MESSAGE, - code: 'EROFS', - }); - expect(outsideRun).toBe('replacement-handle'); - expect(replacement).toHaveBeenCalledTimes(1); - expect(content).toBe('ORIGINAL'); - } finally { - Reflect.set(fs.promises, 'open', guardedOpen); - } - }); - - test('Should keep a jest.spyOn of fs.promises.open guarded in a run and leave one guard once restored', async () => { - const guardedOpen = fs.promises.open; - const spy = jest.spyOn(fs.promises, 'open'); - try { - const { refused, content } = await runBlocked(async () => { - const outsideOpen = fs.promises.open(testFile, 'w'); - const settledOutside = outsideOpen.then( - () => undefined, - (err: unknown) => err, - ); - const tmpFile = path.join(tmpWorkDir, 'spied.txt'); - const handle = await fs.promises.open(tmpFile, 'w'); - await fs.promises.writeFile(handle, 'spied'); - await handle.close(); - return { - refused: await settledOutside, - content: fs.readFileSync(tmpFile, 'utf8'), - }; - }); - - expect(refused).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(content).toBe('spied'); - expect(spy).toHaveBeenCalled(); - } finally { - spy.mockRestore(); - } - expect(fs.promises.open).toBe(guardedOpen); - }); - - test('Should keep util.promisify(fs.write) resolving to its { bytesWritten, buffer } shape', async () => { - const promisifiedWrite = promisify(fs.write); - const fd = fs.openSync(testFile, 'w'); - try { - const result = await promisifiedWrite(fd, 'abc'); - expect(result).toEqual({ bytesWritten: 3, buffer: 'abc' }); - } finally { - fs.closeSync(fd); - } - }); - }); -}); - -// For behavior only a separate process shows, like process exit or a real ESM import. -function buildGuardBundle(fileName: string): string { - const bundlePath = path.join(probeDir, fileName); - const guardSource = require.resolve('./network-guard'); - buildSync({ - entryPoints: [guardSource], - bundle: true, - platform: 'node', - format: 'cjs', - outfile: bundlePath, - logLevel: 'silent', - }); - return bundlePath; -} - -// Opens a temp-dir fd inside a run, closes it from outside the run, then writes to it from the run. -async function writeAfterClosingOutsideRun(run: typeof runBlocked): Promise { - let tmpFd = -1; - let markOpened: (() => void) | undefined; - const opened = new Promise((resolve) => { - markOpened = resolve; - }); - let releaseRun: (() => void) | undefined; - const runGate = new Promise((resolve) => { - releaseRun = resolve; - }); - const pendingRun = run(async () => { - const tmpFile = path.join(tmpWorkDir, 'closed-elsewhere.txt'); - tmpFd = fs.openSync(tmpFile, 'w'); - markOpened?.(); - await runGate; - try { - fs.writeSync(tmpFd, 'data'); - return undefined; - } catch (err) { - return err; - } - }); - await opened; - fs.closeSync(tmpFd); - releaseRun?.(); - return pendingRun; -} - -describe('temp dir writes', () => { - test('Should return the real OS temp dir from os.tmpdir() inside a run', async () => { - const outsideRun = os.tmpdir(); - const insideRun = await runBlocked(async () => os.tmpdir()); - - expect(insideRun).toBe(outsideRun); - }); - - test('Should allow writes under the OS temp dir on every fs surface', async () => { - const outsideSource = path.join(probeDir, 'copy-source.txt'); - fs.writeFileSync(outsideSource, 'source'); - const contents = await runBlocked(async () => { - const dir = tmpWorkDir; - const nested = path.join(dir, 'nested', 'deeper'); - fs.mkdirSync(nested, { recursive: true }); - const syncFile = path.join(nested, 'sync.txt'); - fs.writeFileSync(syncFile, 'sync'); - fs.appendFileSync(syncFile, '+appended'); - const promiseFile = path.join(dir, 'promise.txt'); - await fs.promises.writeFile(promiseFile, 'promise'); - const callbackFile = path.join(dir, 'callback.txt'); - await new Promise((resolve, reject) => { - fs.writeFile(callbackFile, 'callback', (err) => (err ? reject(err) : resolve())); - }); - const streamFile = path.join(dir, 'stream.txt'); - await new Promise((resolve, reject) => { - const stream = fs.createWriteStream(streamFile); - stream.on('error', reject); - stream.end('stream', resolve); - }); - const copiedFile = path.join(dir, 'copied.txt'); - fs.copyFileSync(outsideSource, copiedFile); - const renamedFile = path.join(dir, 'renamed.txt'); - fs.renameSync(copiedFile, renamedFile); - const fileNames = [syncFile, promiseFile, callbackFile, streamFile, renamedFile]; - return fileNames.map((name) => fs.readFileSync(name, 'utf8')); - }); - - expect(contents).toEqual(['sync+appended', 'promise', 'callback', 'stream', 'source']); - }); - - test('Should still block writes outside the OS temp dir, including a ../ escape out of it', async () => { - const outsideFile = path.join(probeDir, 'outside-escape.txt'); - await runBlocked(async () => { - const escapePath = path.join(tmpWorkDir, '..', '..', `dd-escape-${process.pid}.txt`); - expect(() => fs.writeFileSync(outsideFile, 'data')).toThrow(FS_WRITE_BLOCKED_MESSAGE); - expect(() => fs.writeFileSync(escapePath, 'data')).toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - const outsideExists = fs.existsSync(outsideFile); - expect(outsideExists).toBe(false); - }); - - test('Should block renaming or hardlinking files across the temp dir boundary', async () => { - const outsideFile = path.join(probeDir, 'boundary.txt'); - fs.writeFileSync(outsideFile, 'ORIGINAL'); - await runBlocked(async () => { - const inside = path.join(tmpWorkDir, 'inside.txt'); - fs.writeFileSync(inside, 'inside'); - const movedIn = path.join(tmpWorkDir, 'moved-in.txt'); - const linkedIn = path.join(tmpWorkDir, 'linked-in.txt'); - expect(() => fs.renameSync(outsideFile, movedIn)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - expect(() => fs.renameSync(inside, outsideFile)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - expect(() => fs.linkSync(outsideFile, linkedIn)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - const content = fs.readFileSync(outsideFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - // The temp dir can then never hold a link that redirects a write elsewhere. - test('Should refuse creating symlinks, even inside the temp dir', async () => { - const outsideFile = path.join(probeDir, 'symlink-target.txt'); - fs.writeFileSync(outsideFile, 'ORIGINAL'); - await runBlocked(async () => { - const link = path.join(tmpWorkDir, 'link.txt'); - const promiseLink = path.join(tmpWorkDir, 'promise-link.txt'); - expect(() => fs.symlinkSync(outsideFile, link)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - await expect(fs.promises.symlink(outsideFile, promiseLink)).rejects.toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - }); - const content = fs.readFileSync(outsideFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - // cp copies symlinks as-is, which would put a link in the temp dir. - test('Should refuse cp into the temp dir, while copyFile stays allowed', async () => { - const sourceDir = path.join(probeDir, 'cp-source'); - fs.mkdirSync(sourceDir, { recursive: true }); - const sourceFile = path.join(sourceDir, 'file.txt'); - fs.writeFileSync(sourceFile, 'data'); - await runBlocked(async () => { - const destination = path.join(tmpWorkDir, 'copied-dir'); - const copiedFile = path.join(tmpWorkDir, 'copied.txt'); - expect(() => fs.cpSync(sourceDir, destination, { recursive: true })).toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - expect(() => fs.copyFileSync(sourceFile, copiedFile)).not.toThrow(); - }); - }); - - // mkdtemp appends random characters to its prefix, so a prefix equal to the temp root lands beside it. - test('Should refuse mkdtemp with the real OS temp dir itself as the prefix', async () => { - const realTmpDir = fs.realpathSync(os.tmpdir()); - await runBlocked(async () => { - const nestedPrefix = path.join(tmpWorkDir, 'nested-'); - expect(() => fs.mkdtempSync(realTmpDir)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - expect(() => fs.mkdtempSync(nestedPrefix)).not.toThrow(); - }); - }); - - test("Should allow mkdtemp with a temp subdirectory plus a trailing separator, Node's documented idiom", async () => { - await runBlocked(async () => { - const workDirWithSeparator = `${tmpWorkDir}${path.sep}`; - expect(() => fs.mkdtempSync(workDirWithSeparator)).not.toThrow(); - }); - }); - - // Only data writes count as console output; truncating or chmod-ing a redirected log does not. - test('Should refuse non-write fd operations on stdout and stderr', async () => { - await runBlocked(async () => { - expect(() => fs.ftruncateSync(2, 0)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - expect(() => fs.fchmodSync(1, 0o600)).toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - }); - - test('Should reject without running fn when the signal is already aborted', async () => { - const controller = new AbortController(); - controller.abort(); - const fn = jest.fn(async () => 'ran'); - - const run = runBlocked(fn, { signal: controller.signal }); - - await expect(run).rejects.toThrow('aborted'); - expect(fn).not.toHaveBeenCalled(); - }); - - test('Should refuse fd writes on a temp file descriptor once it has been closed', async () => { - await runBlocked(async () => { - const tmpFile = path.join(tmpWorkDir, 'closed-fd.txt'); - const fd = fs.openSync(tmpFile, 'w'); - fs.closeSync(fd); - expect(() => fs.writeSync(fd, 'data')).toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - }); - - // Closing outside the run must still revoke the fd, or the OS's next file with that number - // would inherit write access; an unrevoked fd fails with EBADF instead of the guard's error. - test('Should forget a temp fd closed from outside the run', async () => { - const closedElsewhereError = await writeAfterClosingOutsideRun(runBlocked); - - expect(closedElsewhereError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - }); - - test('Should forget a temp fd closed from outside a run started by another copy of the guard', async () => { - let secondCopyRunBlocked: typeof runBlocked | undefined; - jest.isolateModules(() => { - ({ runBlocked: secondCopyRunBlocked } = require('./network-guard')); - }); - if (!secondCopyRunBlocked) { - throw new Error('The second copy of network-guard did not load.'); - } - - const closedElsewhereError = await writeAfterClosingOutsideRun(secondCopyRunBlocked); - - expect(closedElsewhereError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - }); - - // A timed-out execution is abandoned without cancelling fn, so runBlocked's own finally never runs. - test("Should close the scope when the run is aborted, refusing fn's later path writes even if it never settles", async () => { - const controller = new AbortController(); - const lateFile = path.join(tmpWorkDir, 'after-abort.txt'); - let lateWriteError: unknown; - let lateWriteDone: (() => void) | undefined; - const lateWriteFinished = new Promise((resolve) => { - lateWriteDone = resolve; - }); - const hung = runBlocked( - async () => { - setTimeout(() => { - try { - fs.writeFileSync(lateFile, 'data'); - } catch (err) { - lateWriteError = err; - } - lateWriteDone?.(); - }, 30); - await new Promise(() => {}); - }, - { signal: controller.signal }, - ); - hung.catch(() => undefined); - await new Promise((resolve) => { - setImmediate(resolve); - }); - - controller.abort(); - await lateWriteFinished; - const written = fs.existsSync(lateFile); - - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(written).toBe(false); - }); - - test('Should allow fd writes on a file opened in the temp dir, not on an fd opened outside it', async () => { - const outsideFile = path.join(probeDir, 'outside-fd.txt'); - const outsideFd = fs.openSync(outsideFile, 'w'); - try { - const tmpContent = await runBlocked(async () => { - const tmpFile = path.join(tmpWorkDir, 'fd.txt'); - const tmpFd = fs.openSync(tmpFile, 'w'); - fs.writeSync(tmpFd, 'via fd'); - fs.closeSync(tmpFd); - expect(() => fs.writeSync(outsideFd, 'data')).toThrow(FS_WRITE_BLOCKED_MESSAGE); - return fs.readFileSync(tmpFile, 'utf8'); - }); - expect(tmpContent).toBe('via fd'); - } finally { - fs.closeSync(outsideFd); - } - const outsideContent = fs.readFileSync(outsideFile, 'utf8'); - expect(outsideContent).toBe(''); - }); - - // A detached callback keeps the run's async context, so its path writes stay refused. - test('Should block path writes from a detached callback after the run ended, even under the OS temp dir', async () => { - const lateFile = path.join(tmpWorkDir, 'detached.txt'); - let lateWriteError: unknown; - let lateWriteDone: (() => void) | undefined; - const lateWriteFinished = new Promise((resolve) => { - lateWriteDone = resolve; - }); - await runBlocked(async () => { - setTimeout(() => { - try { - fs.writeFileSync(lateFile, 'data'); - } catch (err) { - lateWriteError = err; - } - lateWriteDone?.(); - }, 20); - }); - await lateWriteFinished; - const written = fs.existsSync(lateFile); - - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(written).toBe(false); - }); - - test('Should let a FileHandle opened in the temp dir be written, but not one opened outside it', async () => { - const outsideFile = path.join(probeDir, 'outside-handle.txt'); - const outsideHandle = await fs.promises.open(outsideFile, 'w'); - try { - const { content, outsideError } = await runBlocked(async () => { - const tmpFile = path.join(tmpWorkDir, 'handle.txt'); - const handle = await fs.promises.open(tmpFile, 'w'); - await fs.promises.writeFile(handle, 'via handle'); - await fs.promises.appendFile(handle, '+appended'); - await handle.close(); - const outsideWrite = fs.promises.writeFile(outsideHandle, 'data'); - const settled = await outsideWrite.then( - () => undefined, - (err: unknown) => err, - ); - return { content: fs.readFileSync(tmpFile, 'utf8'), outsideError: settled }; - }); - - expect(content).toBe('via handle+appended'); - expect(outsideError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - } finally { - await outsideHandle.close(); - } - const outsideContent = fs.readFileSync(outsideFile, 'utf8'); - expect(outsideContent).toBe(''); - }); -}); - -// A run can return before a stream it started has written, as plain Node allows. -describe('cold install', () => { - // FileHandle's prototype is guarded from a handle the install opens asynchronously. - test("Should refuse a FileHandle write in the first run, before the FileHandle guard's async install finished", () => { - const bundlePath = buildGuardBundle('network-guard-cold-filehandle.bundle.cjs'); - const childTmpDir = path.join(probeDir, 'cold-filehandle-tmp'); - fs.mkdirSync(childTmpDir, { recursive: true }); - const outsideTmpFile = path.join(probeDir, 'cold-filehandle-target.txt'); - fs.writeFileSync(outsideTmpFile, 'data'); - const probeScript = [ - "const fs = require('fs');", - 'const guard = require(process.argv[1]);', - "fs.promises.open(process.argv[2], 'r').then(async (handle) => {", - ' const outcome = await guard', - ' .runBlocked(() => handle.chmod(0o600))', - " .then(() => 'allowed', (err) => 'refused: ' + err.message);", - ' console.log(outcome);', - ' await handle.close();', - '});', - ].join('\n'); - - const child = child_process.spawnSync( - process.execPath, - ['-e', probeScript, bundlePath, outsideTmpFile], - { encoding: 'utf8', env: { PATH: process.env.PATH, TMPDIR: childTmpDir } }, - ); - - expect(child.stderr).toBe(''); - expect(child.stdout).toBe(`refused: ${FS_WRITE_BLOCKED_MESSAGE}\n`); - }); -}); - -describe('late writes from a run that ended', () => { - test('Should not crash when a run returns without waiting for a write stream it started', () => { - const bundlePath = buildGuardBundle('network-guard-late-stream.bundle.cjs'); - const childTmpDir = path.join(probeDir, 'late-stream-tmp'); - fs.mkdirSync(childTmpDir, { recursive: true }); - const probeScript = [ - "const fs = require('fs');", - "const os = require('os');", - "const path = require('path');", - 'const guard = require(process.argv[1]);', - 'const startStream = async () => {', - " const tmpFile = path.join(os.tmpdir(), 'late.txt');", - ' const stream = fs.createWriteStream(tmpFile);', - " stream.on('finish', () => console.log('finish'));", - " stream.end('data');", - ' return 1;', - '};', - // The second run starts from a promise continuation, as the dev server's does. - 'guard', - ' .runBlocked(startStream)', - ' .then(() => new Promise((resolve) => setTimeout(resolve, 10)))', - ' .then(() => guard.runBlocked(startStream));', - ].join('\n'); - - const child = child_process.spawnSync(process.execPath, ['-e', probeScript, bundlePath], { - encoding: 'utf8', - env: { PATH: process.env.PATH, TMPDIR: childTmpDir }, - }); - - expect(child.stderr).toBe(''); - expect(child.status).toBe(0); - expect(child.stdout).toBe('finish\nfinish\n'); - }); - - test('Should let a stream opened in the run keep writing after the run ended', async () => { - const lateData = 'late data'; - const stream = await runBlocked(async () => { - const tmpFile = path.join(tmpWorkDir, 'late-write.txt'); - const tmpStream = fs.createWriteStream(tmpFile); - await once(tmpStream, 'open'); - setTimeout(() => tmpStream.end(lateData), 50); - return tmpStream; - }); - const streamError = await new Promise((resolve) => { - stream.on('error', resolve); - stream.on('close', () => resolve(undefined)); - }); - - expect(streamError).toBeUndefined(); - expect(stream.bytesWritten).toBe(lateData.length); - }); - - // The OS hands a closed fd's number to the next open, here an unrelated file outside any run. - test("Should revoke a run's fd closed after the run ended, so a reused fd number isn't writable", async () => { - let releaseLateWrite: (() => void) | undefined; - const lateWriteGate = new Promise((resolve) => { - releaseLateWrite = resolve; - }); - let lateWrite: Promise | undefined; - const tmpFd = await runBlocked(async () => { - const tmpFile = path.join(tmpWorkDir, 'reused.txt'); - const fd = fs.openSync(tmpFile, 'w'); - lateWrite = lateWriteGate.then(() => { - try { - fs.writeSync(fd, 'late'); - return undefined; - } catch (err) { - return err; - } - }); - return fd; - }); - fs.closeSync(tmpFd); - const unrelatedFile = path.join(probeDir, 'reused-fd.txt'); - const unrelatedFd = fs.openSync(unrelatedFile, 'w'); - try { - releaseLateWrite?.(); - const lateWriteError = await lateWrite; - const content = fs.readFileSync(unrelatedFile, 'utf8'); - - expect(unrelatedFd).toBe(tmpFd); - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(content).toBe(''); - } finally { - fs.closeSync(unrelatedFd); - } - }); - - test('Should refuse path writes from a run that ended, even while a stream it opened is still writing', async () => { - let releaseLateWrite: (() => void) | undefined; - const lateWriteGate = new Promise((resolve) => { - releaseLateWrite = resolve; - }); - let lateWrite: Promise | undefined; - const lateFile = path.join(tmpWorkDir, 'late-path.txt'); - const stream = await runBlocked(async () => { - const streamFile = path.join(tmpWorkDir, 'still-open.txt'); - const openStream = fs.createWriteStream(streamFile); - await once(openStream, 'open'); - lateWrite = lateWriteGate.then(() => { - try { - fs.writeFileSync(lateFile, 'data'); - return undefined; - } catch (err) { - return err; - } - }); - return openStream; - }); - releaseLateWrite?.(); - const lateWriteError = await lateWrite; - const written = fs.existsSync(lateFile); - stream.end(); - await once(stream, 'close'); - - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(written).toBe(false); - }); - - // A FIFO's write-mode open blocks until a reader opens it, so it completes after the run ended. - // The reader goes through a link outside the temp dir, so a regression can't hang the writer. - test('Should record an fd whose open completes after the run ended, and revoke it once closed', async () => { - const fifoPath = path.join(tmpWorkDir, 'fifo'); - child_process.execFileSync('mkfifo', [fifoPath]); - const fifoLink = path.join(probeDir, 'fifo-link'); - fs.linkSync(fifoPath, fifoLink); - let releaseLateWrite: (() => void) | undefined; - const lateWriteGate = new Promise((resolve) => { - releaseLateWrite = resolve; - }); - let lateOpen: - | Promise<{ fd: number; writeError: unknown; lateWrite: Promise }> - | undefined; - await runBlocked(async () => { - lateOpen = new Promise((resolve, reject) => { - fs.open(fifoPath, 'w', (openError, fd) => { - if (openError) { - reject(openError); - return; - } - let writeError: unknown; - try { - fs.writeSync(fd, 'x'); - } catch (err) { - writeError = err; - } - const lateWrite = lateWriteGate.then(() => { - try { - fs.writeSync(fd, 'late'); - return undefined; - } catch (err) { - return err; - } - }); - resolve({ fd, writeError, lateWrite }); - }); - }); - }); - const readerFlags = fs.constants.O_RDONLY + fs.constants.O_NONBLOCK; - const readerFd = fs.openSync(fifoLink, readerFlags); - const unrelatedFile = path.join(probeDir, 'fifo-reused-fd.txt'); - try { - const opened = await lateOpen; - if (!opened) { - throw new Error('The open never started.'); - } - fs.closeSync(opened.fd); - const unrelatedFd = fs.openSync(unrelatedFile, 'w'); - releaseLateWrite?.(); - const lateWriteError = await opened.lateWrite; - fs.closeSync(unrelatedFd); - const unrelatedContent = fs.readFileSync(unrelatedFile, 'utf8'); - - expect(opened.writeError).toBeUndefined(); - expect(unrelatedFd).toBe(opened.fd); - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(unrelatedContent).toBe(''); - } finally { - fs.closeSync(readerFd); - } - }); -}); - -// tempy and temp-dir save the temp dir (temp-dir via fs.realpathSync) when they load, before any run. -function loadTempyLikeModule() { - const savedTmpDir = os.tmpdir(); - const savedRealTmpDir = fs.realpathSync(savedTmpDir); - return { - temporaryPath: (name: string) => path.join(savedTmpDir, name), - temporaryRealPath: (name: string) => path.join(savedRealTmpDir, name), - }; -} - -describe('writes under the real OS temp directory', () => { - test('Should let a library that saved the real temp dir at load write under it inside a run', async () => { - const tempyLike = loadTempyLikeModule(); - const suffix = `${process.pid}-${Date.now()}`; - const viaTmpdir = tempyLike.temporaryPath(`dd-tempy-like-${suffix}`); - const viaRealPath = tempyLike.temporaryRealPath(`dd-temp-dir-like-${suffix}`); - try { - await runBlocked(async () => { - const cacheDir = path.join(viaTmpdir, 'nested'); - fs.mkdirSync(cacheDir, { recursive: true }); - const cacheFile = path.join(cacheDir, 'cache.json'); - fs.writeFileSync(cacheFile, 'cached'); - await fs.promises.mkdir(viaRealPath); - const realPathFile = path.join(viaRealPath, 'file.txt'); - await fs.promises.writeFile(realPathFile, 'real'); - }); - const cacheContent = fs.readFileSync( - path.join(viaTmpdir, 'nested', 'cache.json'), - 'utf8', - ); - const realPathContent = fs.readFileSync(path.join(viaRealPath, 'file.txt'), 'utf8'); - - expect(cacheContent).toBe('cached'); - expect(realPathContent).toBe('real'); - } finally { - rmSync(viaTmpdir); - rmSync(viaRealPath); - } - }); - - test('Should allow fd and FileHandle writes on files opened under the real temp dir', async () => { - const tempyLike = loadTempyLikeModule(); - const suffix = `${process.pid}-${Date.now()}`; - const fdFile = tempyLike.temporaryPath(`dd-fd-${suffix}.txt`); - const handleFile = tempyLike.temporaryPath(`dd-handle-${suffix}.txt`); - try { - await runBlocked(async () => { - const fd = fs.openSync(fdFile, 'w'); - fs.writeSync(fd, 'via fd'); - fs.closeSync(fd); - const handle = await fs.promises.open(handleFile, 'w'); - await fs.promises.writeFile(handle, 'via handle'); - await handle.close(); - }); - const fdContent = fs.readFileSync(fdFile, 'utf8'); - const handleContent = fs.readFileSync(handleFile, 'utf8'); - - expect(fdContent).toBe('via fd'); - expect(handleContent).toBe('via handle'); - } finally { - rmSync(fdFile); - rmSync(handleFile); - } - }); - - // On Terrapin's Linux os.tmpdir() is /tmp, so a dependency that hard-codes /tmp works there. - test('Should allow writes under /tmp even when os.tmpdir() is elsewhere', async () => { - if (process.platform === 'win32') { - return; - } - const suffix = `${process.pid}-${Date.now()}`; - const slashTmpFile = path.join('/tmp', `dd-slash-tmp-${suffix}.txt`); - try { - await runBlocked(async () => { - fs.writeFileSync(slashTmpFile, 'data'); - }); - const content = fs.readFileSync(slashTmpFile, 'utf8'); - - expect(content).toBe('data'); - } finally { - rmSync(slashTmpFile); - } - }); - - test('Should refuse a write to a sibling of the real temp dir, including through a ../ escape', async () => { - const realTmpDir = fs.realpathSync(os.tmpdir()); - const suffix = `${process.pid}-${Date.now()}`; - const tmpParent = path.dirname(realTmpDir); - const siblingPath = path.join(tmpParent, `dd-sibling-${suffix}.txt`); - const escapePath = path.join(os.tmpdir(), '..', `dd-escape-${suffix}.txt`); - const adjacentPath = `${realTmpDir}-dd-adjacent-${suffix}.txt`; - const errors = await runBlocked(async () => - [siblingPath, escapePath, adjacentPath].map((target) => { - try { - fs.writeFileSync(target, 'data'); - return undefined; - } catch (err) { - return err; - } - }), - ); - const written = [siblingPath, escapePath, adjacentPath].filter((target) => - fs.existsSync(target), - ); - - expect(errors).toEqual([ - expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }), - expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }), - expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }), - ]); - expect(written).toEqual([]); - }); - - test('Should refuse a write through a symlink in the real temp dir that points outside it', async () => { - const linkPrefix = path.join(tmpWorkDir, 'dd-guard-links-'); - const linkDir = fs.mkdtempSync(linkPrefix); - const outsideFile = path.join(probeDir, 'link-target.txt'); - fs.writeFileSync(outsideFile, 'ORIGINAL'); - const fileLink = path.join(linkDir, 'file-link.txt'); - const dirLink = path.join(linkDir, 'dir-link'); - fs.symlinkSync(outsideFile, fileLink); - fs.symlinkSync(probeDir, dirLink); - const throughDirLink = path.join(dirLink, 'created.txt'); - try { - const errors = await runBlocked(async () => - [fileLink, throughDirLink].map((target) => { - try { - fs.writeFileSync(target, 'redirected'); - return undefined; - } catch (err) { - return err; - } - }), - ); - const content = fs.readFileSync(outsideFile, 'utf8'); - const created = fs.existsSync(path.join(probeDir, 'created.txt')); - - expect(errors).toEqual([ - expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }), - expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }), - ]); - expect(content).toBe('ORIGINAL'); - expect(created).toBe(false); - } finally { - rmSync(linkDir); - } - }); - - // A library first loaded during a run saves os.tmpdir() then, so it must still work in later runs. - test('Should let a temp-file library first loaded inside a run keep writing in a later run', async () => { - let tempyLike: ReturnType | undefined; - const suffix = `${process.pid}-${Date.now()}`; - const firstFile = await runBlocked(async () => { - tempyLike = loadTempyLikeModule(); - const file = tempyLike.temporaryPath(`dd-lazy-first-${suffix}.txt`); - fs.writeFileSync(file, 'first'); - return file; - }); - if (!tempyLike) { - throw new Error('The tempy-like module did not load.'); - } - const loaded = tempyLike; - try { - const secondFile = await runBlocked(async () => { - const file = loaded.temporaryPath(`dd-lazy-second-${suffix}.txt`); - fs.writeFileSync(file, 'second'); - return file; - }); - const contents = [firstFile, secondFile].map((file) => fs.readFileSync(file, 'utf8')); - - expect(contents).toEqual(['first', 'second']); - } finally { - const secondFile = loaded.temporaryPath(`dd-lazy-second-${suffix}.txt`); - rmSync(firstFile); - rmSync(secondFile); - } - }); - - // unlink, rm and rename act on the entry itself, so a link outside the temp dir that points into it - // must be judged by where the link lives, not where it points. - test('Should refuse renaming over or unlinking a symlink outside the temp dir that points into it', async () => { - const target = path.join(tmpWorkDir, 'link-target.txt'); - fs.writeFileSync(target, 'TARGET'); - const projectLink = path.join(probeDir, '.env-link'); - fs.symlinkSync(target, projectLink); - const source = path.join(tmpWorkDir, 'source.txt'); - fs.writeFileSync(source, 'source'); - try { - const errors = await runBlocked(async () => - [ - () => fs.renameSync(source, projectLink), - () => fs.unlinkSync(projectLink), - () => fs.rmSync(projectLink), - ].map((attempt) => { - try { - attempt(); - return undefined; - } catch (err) { - return err; - } - }), - ); - const linkStat = fs.lstatSync(projectLink); - const targetContent = fs.readFileSync(target, 'utf8'); - const refused = expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }); - - expect(errors).toEqual([refused, refused, refused]); - expect(linkStat.isSymbolicLink()).toBe(true); - expect(targetContent).toBe('TARGET'); - } finally { - fs.unlinkSync(projectLink); - } - }); - - // Node's recursive rm unlinks each entry through the guarded fs, including a link pointing outside. - test('Should allow a recursive rm of a temp subdirectory that holds a symlink pointing outside it', async () => { - const linkTarget = path.join(probeDir, 'rm-target'); - fs.mkdirSync(linkTarget, { recursive: true }); - const keptFile = path.join(linkTarget, 'kept.txt'); - fs.writeFileSync(keptFile, 'kept'); - const makeTree = (name: string) => { - const tree = path.join(tmpWorkDir, name); - fs.mkdirSync(tree); - const nestedFile = path.join(tree, 'file.txt'); - fs.writeFileSync(nestedFile, 'data'); - const outwardLink = path.join(tree, 'outward'); - fs.symlinkSync(linkTarget, outwardLink); - return tree; - }; - const trees = ['sync-tree', 'callback-tree', 'promise-tree'].map(makeTree); - await runBlocked(async () => { - fs.rmSync(trees[0], { recursive: true }); - await new Promise((resolve, reject) => { - fs.rm(trees[1], { recursive: true }, (err) => (err ? reject(err) : resolve())); - }); - await fs.promises.rm(trees[2], { recursive: true }); - }); - const remaining = trees.filter((tree) => fs.existsSync(tree)); - const keptContent = fs.readFileSync(keptFile, 'utf8'); - - expect(remaining).toEqual([]); - expect(keptContent).toBe('kept'); - }); - - // fh.close() never reaches fs.close, so the guarded FileHandle close revokes the fd. - test('Should allow fd writes on a write-mode FileHandle from the temp dir, and revoke its fd once closed', async () => { - const handleFile = path.join(tmpWorkDir, 'handle-fd.txt'); - const { afterClose, content } = await runBlocked(async () => { - const handle = await fs.promises.open(handleFile, 'w'); - const { fd } = handle; - fs.writeSync(fd, 'via fd'); - fs.ftruncateSync(fd, 3); - const stream = fs.createWriteStream('', { fd, autoClose: false, start: 3 }); - stream.end('+stream'); - await once(stream, 'finish'); - await handle.close(); - let lateError: unknown; - try { - fs.writeSync(fd, 'late'); - } catch (err) { - lateError = err; - } - return { afterClose: lateError, content: fs.readFileSync(handleFile, 'utf8') }; - }); - - expect(content).toBe('via+stream'); - expect(afterClose).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - }); - - // outputFile, mkdirp and make-dir first make sure the parent exists, which can be the temp root. - test('Should let a run ensure the temp root exists before writing directly under it', async () => { - const suffix = `${process.pid}-${Date.now()}`; - const tmpRoot = os.tmpdir(); - const outputFile = path.join(tmpRoot, `dd-output-${suffix}.json`); - try { - await runBlocked(async () => { - outputFileSync(outputFile, '{}'); - await fs.promises.mkdir(tmpRoot, { recursive: true }); - await new Promise((resolve, reject) => { - fs.mkdir(tmpRoot, { recursive: true }, (err) => - err ? reject(err) : resolve(), - ); - }); - }); - const content = fs.readFileSync(outputFile, 'utf8'); - - expect(content).toBe('{}'); - } finally { - rmSync(outputFile); - } - }); - - // The kernel follows `link` before applying `..`, so `/..` is the link target's parent. - test('Should refuse writes through `/..` when a link in the real temp dir points outside it', async () => { - const linkPrefix = path.join(tmpWorkDir, 'dd-guard-dotdot-'); - const linkDir = fs.mkdtempSync(linkPrefix); - const linkTarget = path.join(probeDir, 'dotdot-src'); - fs.mkdirSync(linkTarget, { recursive: true }); - const link = path.join(linkDir, 'proj'); - fs.symlinkSync(linkTarget, link); - const source = path.join(linkDir, 'source.txt'); - fs.writeFileSync(source, 'data'); - const escape = [link, '..', 'dotdot-victim.txt'].join(path.sep); - const victim = path.join(probeDir, 'dotdot-victim.txt'); - try { - const errors = await runBlocked(async () => - [ - () => fs.writeFileSync(escape, 'redirected'), - () => fs.copyFileSync(source, escape), - () => fs.renameSync(source, escape), - () => fs.linkSync(source, escape), - () => fs.openSync(escape, 'w'), - ].map((attempt) => { - try { - attempt(); - return undefined; - } catch (err) { - return err; - } - }), - ); - const victimCreated = fs.existsSync(victim); - const refused = expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }); - - expect(errors).toEqual([refused, refused, refused, refused, refused]); - expect(victimCreated).toBe(false); - } finally { - rmSync(linkDir); - } - }); - - // Removing or renaming the temp root would break $TMPDIR for every process, so it runs against - // a disposable TMPDIR in a child process. - test('Should refuse removing, renaming or chmod-ing the real temp dir itself', () => { - const bundlePath = buildGuardBundle('network-guard-tmp-root.bundle.cjs'); - const disposableTmp = path.join(probeDir, 'disposable-tmp'); - fs.mkdirSync(disposableTmp, { recursive: true }); - const probeScript = [ - "const fs = require('fs');", - 'const guard = require(process.argv[1]);', - 'const root = process.env.TMPDIR;', - 'const attempts = {', - ' rm: () => fs.rmSync(root, { recursive: true, force: true }),', - ' chmod: () => fs.chmodSync(root, 0o700),', - " rename: () => fs.renameSync(root, root + '-moved'),", - '};', - 'guard.installGuards();', - 'guard.runBlocked(async () => Object.fromEntries(Object.entries(attempts).map(([name, attempt]) => {', - ' try { attempt(); return [name, "done"]; } catch (err) { return [name, err.code]; }', - '}))).then((codes) => console.log(JSON.stringify(codes)));', - ].join('\n'); - - const output = child_process.execFileSync( - process.execPath, - ['-e', probeScript, bundlePath], - { - encoding: 'utf8', - env: { PATH: process.env.PATH, TMPDIR: disposableTmp }, - }, - ); - const codes: unknown = JSON.parse(output); - const rootSurvived = fs.existsSync(disposableTmp); - - expect(codes).toEqual({ rm: 'EROFS', chmod: 'EROFS', rename: 'EROFS' }); - expect(rootSurvived).toBe(true); - }); - - // chmod, chown and utimes work on a read-mode handle, and a handle's own methods skip the fs guards. - test("Should refuse a read-mode FileHandle's modifying methods inside a run, but allow a writable temp-dir handle's", async () => { - const outsideFile = path.join(probeDir, 'read-handle.txt'); - fs.writeFileSync(outsideFile, 'data'); - fs.chmodSync(outsideFile, 0o644); - const outcome = await runBlocked(async () => { - const readHandle = await fs.promises.open(outsideFile, 'r'); - try { - const settle = (attempt: Promise) => - attempt.then( - () => undefined, - (err: unknown) => err, - ); - const chmodError = await settle(readHandle.chmod(0o600)); - const utimesError = await settle(readHandle.utimes(0, 0)); - const tmpFile = path.join(tmpWorkDir, 'mode.txt'); - const writable = await fs.promises.open(tmpFile, 'w'); - await writable.chmod(0o600); - await writable.close(); - const tmpMode = fs.statSync(tmpFile).mode.toString(8).slice(-3); - return { chmodError, utimesError, tmpMode }; - } finally { - await readHandle.close(); - } - }); - const outsideMode = fs.statSync(outsideFile).mode.toString(8).slice(-3); - const refused = expect.objectContaining({ message: FS_WRITE_BLOCKED_MESSAGE }); - - expect(outcome).toEqual({ chmodError: refused, utimesError: refused, tmpMode: '600' }); - expect(outsideMode).toBe('644'); - }); -}); - -// Every bundler that loads the plugin imports this file, so importing it must not patch anything. -describe('installGuards', () => { - test('Should leave fs and child_process untouched on import, patching them only once installGuards runs', () => { - const bundlePath = buildGuardBundle('network-guard.bundle.cjs'); - const probeScript = [ - "const fs = require('fs');", - "const childProcess = require('child_process');", - 'const isGuarded = () => [', - " Object.getOwnPropertyDescriptor(fs, 'writeFileSync').get !== undefined,", - " Object.getOwnPropertyDescriptor(childProcess, 'spawn').get !== undefined,", - '];', - 'const guard = require(process.argv[1]);', - 'const afterImport = isGuarded();', - 'guard.installGuards();', - 'const afterInstall = isGuarded();', - 'console.log(JSON.stringify({ afterImport, afterInstall }));', - ].join('\n'); - - const output = child_process.execFileSync( - process.execPath, - ['-e', probeScript, bundlePath], - { - encoding: 'utf8', - env: { PATH: process.env.PATH }, - }, - ); - const result: unknown = JSON.parse(output); - - expect(result).toEqual({ afterImport: [false, false], afterInstall: [true, true] }); - }); - - // A partial install must not leave later calls believing everything is installed. - test('Should retry a failed install on the next call instead of silently skipping it', () => { - const bundlePath = buildGuardBundle('network-guard-retry.bundle.cjs'); - const probeScript = [ - "const fs = require('fs');", - "Object.defineProperty(fs, 'cpSync', { value: fs.cpSync, writable: false, configurable: false });", - 'const guard = require(process.argv[1]);', - 'const attempt = () => { try { guard.installGuards(); return "installed"; } catch { return "threw"; } };', - 'console.log(JSON.stringify([attempt(), attempt()]));', - ].join('\n'); - - const output = child_process.execFileSync( - process.execPath, - ['-e', probeScript, bundlePath], - { - encoding: 'utf8', - env: { PATH: process.env.PATH }, - }, - ); - const attempts: unknown = JSON.parse(output); - - expect(attempts).toEqual(['threw', 'threw']); - }); - - // The null-device handle that hands over FileHandle's prototype is closed at install, unawaited. - test('Should not crash the process when closing the FileHandle it inspects at install rejects', () => { - const bundlePath = buildGuardBundle('network-guard-close-rejects.bundle.cjs'); - // A handle's close is its own property, so the rejection is planted on each handle opened. - const probeScript = [ - "const fs = require('fs');", - 'const realOpen = fs.promises.open;', - 'fs.promises.open = async (...args) => {', - ' const handle = await realOpen(...args);', - ' const realClose = handle.close;', - " handle.close = () => realClose.call(handle).then(() => { throw new Error('close failed'); });", - ' return handle;', - '};', - 'const guard = require(process.argv[1]);', - 'guard.installGuards();', - "setTimeout(() => console.log('alive'), 100);", - ].join('\n'); - - const child = child_process.spawnSync(process.execPath, ['-e', probeScript, bundlePath], { - encoding: 'utf8', - env: { PATH: process.env.PATH }, - }); - - expect(child.stderr).toBe(''); - expect(child.status).toBe(0); - expect(child.stdout).toBe('alive\n'); - }); - - test("Should give an ESM named import of fs/promises' open the guarded version", () => { - const bundlePath = buildGuardBundle('network-guard-esm.bundle.cjs'); - const target = path.join(probeDir, 'esm-open.txt'); - const probeScript = [ - "import { open } from 'node:fs/promises';", - "import { createRequire } from 'node:module';", - 'const guard = createRequire(import.meta.url)(process.argv[1]);', - 'guard.installGuards();', - 'const opening = guard.runBlocked(() => open(process.argv[2], "w"));', - 'const code = await opening.then(() => "opened", (err) => err.code);', - 'console.log(code);', - ].join('\n'); - - const output = child_process.execFileSync( - process.execPath, - ['--input-type=module', '-e', probeScript, bundlePath, target], - { - encoding: 'utf8', - env: { PATH: process.env.PATH }, - }, - ); - const code = output.trim(); - const created = fs.existsSync(target); - - expect(code).toBe('EROFS'); - expect(created).toBe(false); - }); -}); - -describe('mixed plugin versions', () => { - // Another release's wrappers consult only that release's context, so a run here would be unguarded. - test("Should refuse to run when another release's guard is already installed", async () => { - let freshCopy: typeof import('./network-guard') | undefined; - jest.isolateModules(() => { - freshCopy = require('./network-guard'); - }); - if (!freshCopy) { - throw new Error('The fresh copy of network-guard did not load.'); - } - const foreignGetter = () => () => 'real'; - Object.defineProperty(foreignGetter, ALREADY_GUARDED, { value: true }); - const target = {}; - Object.defineProperty(target, 'value', { get: foreignGetter, configurable: true }); - freshCopy.installGuardedProperty(target, 'value', (getReal: () => unknown) => getReal); - const fn = jest.fn(async () => 'ran'); - - const running = freshCopy.runBlocked(fn); - - await expect(running).rejects.toThrow(FOREIGN_GUARD_MESSAGE); - expect(fn).not.toHaveBeenCalled(); - }); - - test('Should keep running when the installed guards are from this same release', async () => { - let freshRunBlocked: typeof runBlocked | undefined; - jest.isolateModules(() => { - ({ runBlocked: freshRunBlocked } = require('./network-guard')); - }); - if (!freshRunBlocked) { - throw new Error('The fresh copy of network-guard did not load.'); - } - - const running = freshRunBlocked(async () => 'ran'); - - await expect(running).resolves.toBe('ran'); - }); -}); - -describe('installGuardedProperty resilience', () => { - // A wrapper closure over the previous guard (some mocking libraries' pattern, distinct from the - // direct-reassignment case the WeakMap handles) would otherwise recurse into itself forever, - // since its captured getReal() would read the shared `real` variable the new guard just set. - test('Should not recurse when a guard is restored via a wrapper closure instead of direct reassignment', () => { - const originalWriteFileSync = fs.writeFileSync; - try { - const realMock = jest.fn().mockReturnValue('real result'); - Reflect.set(fs, 'writeFileSync', realMock); - const previous = fs.writeFileSync; - - Reflect.set(fs, 'writeFileSync', (...args: Parameters) => - previous(...args), - ); - - const writeResult = fs.writeFileSync('probe.txt', 'data'); - expect(writeResult).toBe('real result'); - } finally { - Reflect.set(fs, 'writeFileSync', originalWriteFileSync); - } - }); -}); - -describe('network access', () => { - // Local dev deliberately allows network, matching Terrapin's production sandbox. - test('Should let a direct fetch() inside fn reach the delegate without any exemption', async () => { - const originalFetch = globalThis.fetch; - const fetchedUrls: string[] = []; - const fakeFetch: typeof fetch = async (input) => { - const url = String(input); - fetchedUrls.push(url); - return new Response('ok'); - }; - setGlobalFetch(fakeFetch); - try { - const response = await runBlocked(() => fetch('https://example.com/direct')); - const body = await response.text(); - - expect(body).toBe('ok'); - expect(fetchedUrls).toEqual(['https://example.com/direct']); - } finally { - setGlobalFetch(originalFetch); - } - }); - - test('Should let a real socket listen and connect inside fn', async () => { - const received = await runBlocked(async () => { - const server = net.createServer((socket) => socket.end('pong')); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const address = server.address(); - const port = typeof address === 'object' && address ? address.port : 0; - try { - return await new Promise((resolve, reject) => { - const client = net.connect(port, '127.0.0.1'); - let data = ''; - client.on('data', (chunk) => { - data += String(chunk); - }); - client.on('end', () => resolve(data)); - client.on('error', reject); - }); - } finally { - server.close(); - } - }); - - expect(received).toBe('pong'); - }); -}); - -describe('graceful-fs compatibility', () => { - // graceful-fs clones fs's own property descriptors (copying the guard's non-configurable - // accessors), then reassigns open/rename/... on the clone; win32 also exercises its rename patch. - test('Should let graceful-fs load after the guard and patch its own clone of fs, and fail a blocked win32 rename fast', async () => { - const realPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - let gracefulFs: typeof fs | undefined; - try { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - jest.isolateModules(() => { - gracefulFs = require('graceful-fs'); - }); - } finally { - if (realPlatform) { - Object.defineProperty(process, 'platform', realPlatform); - } - } - - expect(gracefulFs?.open).toEqual(expect.any(Function)); - expect(gracefulFs?.open).not.toBe(fs.open); - - // Its win32 rename polyfill retries EACCES/EPERM for up to 60s; a blocked rename must not. - const from = path.join(probeDir, 'rename-from.txt'); - const to = path.join(probeDir, 'rename-to.txt'); - fs.writeFileSync(from, 'data'); - const err = await runBlocked( - () => - new Promise((resolve) => { - gracefulFs?.rename(from, to, resolve); - }), - ); - expect(err).toMatchObject({ code: 'EROFS' }); - }); - - test("Should still block a write made through graceful-fs's patched clone inside fn", async () => { - let gracefulFs: typeof fs | undefined; - jest.isolateModules(() => { - gracefulFs = require('graceful-fs'); - }); - const probeFile = path.join(probeDir, 'graceful-fs.txt'); - fs.writeFileSync(probeFile, 'ORIGINAL'); - - await runBlocked(async () => { - const err = await new Promise((resolve) => { - gracefulFs?.writeFile(probeFile, 'replacement', resolve); - }); - expect(err?.message).toMatch(FS_WRITE_BLOCKED_MESSAGE); - }); - const content = fs.readFileSync(probeFile, 'utf8'); - expect(content).toBe('ORIGINAL'); - }); - - test('Should accept a reassignment through a clone that copied the guarded accessor, without repointing the original', () => { - const target: { value: unknown } = { value: () => 'real' }; - installGuardedProperty( - target, - 'value', - (getReal: () => () => unknown) => - (...args: unknown[]) => { - const real = getReal(); - return Reflect.apply(real, undefined, args); - }, - ); - const clone: { value: unknown } = { value: undefined }; - const guardedDescriptor = Object.getOwnPropertyDescriptor(target, 'value'); - if (guardedDescriptor) { - Reflect.deleteProperty(clone, 'value'); - Object.defineProperty(clone, 'value', guardedDescriptor); - } - const replacement = () => 'clone'; - - clone.value = replacement; - const guardedValue = Object(target.value); - const originalResult = Reflect.apply(guardedValue, undefined, []); - - expect(clone.value).toBe(replacement); - expect(target.value).not.toBe(replacement); - expect(originalResult).toBe('real'); - }); -}); - -describe('installGuardedProperty security', () => { - // A dependency could otherwise call `Object.defineProperty(target, 'value', {...})` directly to - // replace the whole descriptor, silently restoring the real function. - test('Should make a guarded property non-configurable, closing the Object.defineProperty bypass, while still allowing plain reassignment', () => { - const target: { value: unknown } = { value: () => 'real' }; - installGuardedProperty( - target, - 'value', - (getReal: () => () => unknown) => - (...args: unknown[]) => - (getReal() as (...a: unknown[]) => unknown)(...args), - ); - - // A dependency replacing the whole descriptor outright must now fail loudly... - expect(() => { - Object.defineProperty(target, 'value', { - configurable: true, - enumerable: true, - value: () => 'hostile replacement', - }); - }).toThrow(/Cannot redefine property/); - - // ...while the legitimate "capture original, mock, restore" idiom still works via plain assignment. - const mock = () => 'mocked'; - (target as { value: unknown }).value = mock; - expect((target.value as () => string)()).toBe('mocked'); - }); - - // A defineProperty collision must fail loudly rather than silently leave the real function in place. - test('Should rethrow rather than silently relax configurability when defineProperty fails', () => { - const target: { value: unknown } = { value: () => 'real' }; - Object.defineProperty(target, 'value', { - value: () => 'real', - configurable: false, - writable: false, - }); - - expect(() => - installGuardedProperty( - target, - 'value', - (getReal: () => () => unknown) => - (...args: unknown[]) => - (getReal() as (...a: unknown[]) => unknown)(...args), - ), - ).toThrow(/Cannot redefine property/); - }); - - // `oneChild.spawn = mock` on a guarded shared prototype must shadow that instance only, not - // repoint the delegate every other instance calls through. - test('Should shadow a guarded property per-instance instead of corrupting the shared delegate when installed on a shared prototype', () => { - const proto: { value: unknown } = { value: () => 'real' }; - installGuardedProperty( - proto, - 'value', - (getReal: () => () => unknown) => - (...args: unknown[]) => - (getReal() as (...a: unknown[]) => unknown)(...args), - ); - - const instanceA = Object.create(proto) as { value: unknown }; - const instanceB = Object.create(proto) as { value: unknown }; - - instanceA.value = () => 'mocked'; - - expect((instanceA.value as () => string)()).toBe('mocked'); - expect((instanceB.value as () => string)()).toBe('real'); - expect((proto.value as () => string)()).toBe('real'); - }); - - // Matches a fs write method absent on an older Node runtime: wrapping a method that doesn't - // exist would make feature-detection lie, then crash the moment a library actually calls it. - test('Should skip installing a guard entirely when the target property does not exist on this runtime', () => { - const target: Record = {}; - installGuardedProperty(target, 'doesNotExist', () => () => 'guard'); - expect(Object.prototype.hasOwnProperty.call(target, 'doesNotExist')).toBe(false); - }); - - // isCurrentlyBlocked() is the shared gate for every guard in this file, so a fake context - // swapped in by any code holding an `fs` reference would silently disable all of them at once. - test('Should protect the shared-context registry entries stashed on `fs` from being overwritten by any code holding an `fs` reference', () => { - const symbol = networkGuardSymbol('blockedContext'); - const descriptor = Object.getOwnPropertyDescriptor(fs, symbol); - expect(descriptor).toMatchObject({ writable: false, configurable: false }); - - expect(() => { - Object.defineProperty(fs, symbol, { - configurable: true, - value: { - current: () => undefined, - run: (_scope: unknown, fn: () => unknown) => fn(), - }, - }); - }).toThrow(/Cannot redefine property/); - }); - - // A raw AsyncLocalStorage on the registry would let any code with `require('fs')` call - // `.disable()` on it and permanently disarm every future runBlocked call. - test('Should not let a `.disable()` call reached via the `fs`-keyed registry entry disarm write blocking for a later runBlocked call', async () => { - const symbol = networkGuardSymbol('blockedContext'); - const entry: unknown = Reflect.get(fs, symbol); - const entryObject = Object(entry); - const disable: unknown = Reflect.get(entryObject, 'disable'); - const getStore: unknown = Reflect.get(entryObject, 'getStore'); - const probePath = path.join(probeDir, 'disable.txt'); - - expect(disable).toBeUndefined(); - expect(getStore).toBeUndefined(); - await expect( - runBlocked(async () => { - await fs.promises.writeFile(probePath, 'data'); - }), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - // A non-writable registry property stops replacement, not reassignment of the facade's own methods. - test('Should freeze the shared facade so its current/run methods cannot be reassigned', () => { - const symbol = networkGuardSymbol('blockedContext'); - const entry: unknown = Reflect.get(fs, symbol); - const entryObject = Object(entry); - const reassigned = Reflect.set(entryObject, 'current', () => undefined); - const frozen = Object.isFrozen(entry); - - expect(entryObject).toBe(entry); - expect(frozen).toBe(true); - expect(reassigned).toBe(false); - }); - - // Uses fs's actual prototype, since a sandboxed test runtime can give core modules a non-Object one. - test("Should not mistake a value inherited from fs's own prototype chain for an already-installed registry entry", () => { - // Unique per run: a key installed by an earlier run in this process would skip the path under test. - const probeKey = `pollutionProbe-${Date.now()}-${Math.random()}`; - const symbol = networkGuardSymbol(probeKey); - const pollutedFacade = { - current: () => undefined, - run: (_scope: unknown, fn: () => unknown) => fn(), - }; - const fsPrototype: object = Object.getPrototypeOf(fs); - - try { - const polluted = Reflect.set(fsPrototype, symbol, pollutedFacade); - expect(polluted).toBe(true); - - const context = getSharedContext(probeKey); - const installedAsOwnProperty = Object.prototype.hasOwnProperty.call(fs, symbol); - // The polluted facade's current() is always undefined, so this distinguishes the real one. - const probeScope = { - tempDir: probeDir, - tempDirRealPath: probeDir, - writableFds: new Set(), - writableHandles: new WeakSet(), - pendingOperations: 0, - closed: false, - }; - const scopeInsideRun = context.run(probeScope, () => context.current()); - - expect(context).not.toBe(pollutedFacade); - expect(installedAsOwnProperty).toBe(true); - expect(scopeInsideRun).toBe(probeScope); - } finally { - // The own-property entry getSharedContext installed is permanent by design. - Reflect.deleteProperty(fsPrototype, symbol); - } - }); -}); - -describe('guardWorker', () => { - // `new Proxy()` needs a constructor, so a non-constructor value is returned unwrapped. - test('Should return a non-constructor Worker value unchanged', () => { - expect(guardWorker(() => undefined)).toBeUndefined(); - }); -}); - -describe('construct-trap newTarget forwarding', () => { - // Discarding newTarget would make `class Foo extends Worker {}` silently produce a base - // instance instead — exercised against a fake constructor to avoid real construction side effects. - test('guardWorker should forward newTarget so a subclass produces an instance of that subclass', () => { - class FakeWorker { - options: unknown; - constructor(options: unknown) { - this.options = options; - } - } - const Guarded = guardWorker( - () => FakeWorker as unknown as typeof worker_threads.Worker, - ) as unknown as new (options: unknown) => object; - class CustomWorker extends Guarded {} - - const instance = new CustomWorker({}); - expect(instance).toBeInstanceOf(CustomWorker); - }); -}); diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts deleted file mode 100644 index 2f2098942..000000000 --- a/packages/plugins/apps/src/vite/network-guard.ts +++ /dev/null @@ -1,1106 +0,0 @@ -// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. -// This product includes software developed at Datadog (https://www.datadoghq.com/). -// Copyright 2019-Present Datadog, Inc. - -/* global Proxy */ - -import child_process from 'child_process'; -import fs from 'fs'; -import { AsyncLocalStorage } from 'node:async_hooks'; -import { EventEmitter } from 'node:events'; -import { syncBuiltinESMExports } from 'node:module'; -import { Readable, Writable } from 'node:stream'; -import { promisify } from 'node:util'; -import os from 'os'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import worker_threads from 'worker_threads'; - -// No OS sandbox here — blocks subprocesses, worker_threads and fs writes at the JS level, scoped -// per-call via AsyncLocalStorage. Mirrors Terrapin's production sandbox: network is allowed, and -// writes only under the OS temp dir, which isn't cleaned up per run. - -// Targets accidental dependency behavior, not hostile code, which can read the dev server's -// credentials in process.env, use the network, reach the `fs` registry. Only runBlocked is guarded. - -// Residual gaps: a Unix socket or named pipe listener creates a file outside the temp dir, and -// writes on an fd or handle opened outside the current run (by module top-level code or an earlier -// run) are refused even under the temp dir, so such a stream's 'error' can crash the server. - -const SUBPROCESS_BLOCKED_MESSAGE = 'Spawning a subprocess is not allowed in backend functions.'; -const WORKER_THREAD_BLOCKED_MESSAGE = - 'Spawning a worker thread is not allowed in backend functions.'; -const FILE_HANDLE_GUARD_UNAVAILABLE_MESSAGE = - 'Local execution could not guard file handles, so it refuses to run.'; -export const FS_WRITE_BLOCKED_MESSAGE = - 'Writing to the filesystem is not allowed in backend functions outside os.tmpdir() or /tmp.'; - -/** One `runBlocked` call's fds and FileHandles opened for writing; `closed` once the run ends. */ -export interface BlockedScope { - readonly writableFds: Set; - readonly writableHandles: WeakSet; - closed: boolean; -} - -interface GuardedAsyncContext { - current(): BlockedScope | undefined; - run(scope: BlockedScope, fn: () => T): T; - exit(fn: () => T): T; -} - -function isGuardedAsyncContext(value: unknown): value is GuardedAsyncContext { - return ( - typeof value === 'object' && - value !== null && - 'current' in value && - 'run' in value && - 'exit' in value && - typeof value.current === 'function' && - typeof value.run === 'function' && - typeof value.exit === 'function' - ); -} - -// Versioned so a copy from another plugin release, which stores a different shape under older -// keys, can't make this copy throw at load. -const NETWORK_GUARD_SYMBOL_PREFIX = '@dd/apps-plugin/network-guard/v3'; - -export function networkGuardSymbol(name: string): symbol { - return Symbol.for(`${NETWORK_GUARD_SYMBOL_PREFIX} ${name}`); -} - -// Stored on `fs` because core modules are the only state shared across this file's multiple -// evaluations (bundled copies, Jest per-file isolation). The frozen facade keeps other code from -// `.disable()`-ing the store; the own-property lookup keeps a polluted prototype from faking an install. -function getSharedEntry( - key: string, - create: () => T, - isValid: (value: unknown) => value is T, -): T { - const symbol = networkGuardSymbol(key); - if (!Object.prototype.hasOwnProperty.call(fs, symbol)) { - const value = create(); - Object.defineProperty(fs, symbol, { - value, - writable: false, - configurable: false, - enumerable: false, - }); - return value; - } - const stored: unknown = Reflect.get(fs, symbol); - if (!isValid(stored)) { - throw new Error(`Internal error: the "${key}" network-guard registry entry is not valid.`); - } - return stored; -} - -function createGuardedAsyncContext(): GuardedAsyncContext { - const context = new AsyncLocalStorage(); - return Object.freeze({ - current: () => context.getStore(), - run: (scope: BlockedScope, fn: () => T) => context.run(scope, fn), - exit: (fn: () => T) => context.exit(fn), - }); -} - -export function getSharedContext(key: string): GuardedAsyncContext { - return getSharedEntry(key, createGuardedAsyncContext, isGuardedAsyncContext); -} - -type ScopeRegistry = Pick, 'add' | 'delete' | 'forEach'>; - -function isScopeRegistry(value: unknown): value is ScopeRegistry { - return ( - typeof value === 'object' && - value !== null && - 'add' in value && - 'delete' in value && - 'forEach' in value && - typeof value.add === 'function' && - typeof value.delete === 'function' && - typeof value.forEach === 'function' - ); -} - -// Scoped to the active `runBlocked` call's async chain, not process-wide, so unrelated concurrent -// callers aren't blocked too. Created on first use, so importing this file has no side effects. -let blockedContext: GuardedAsyncContext | undefined; -function getBlockedContext(): GuardedAsyncContext { - blockedContext ??= getSharedContext('blockedContext'); - return blockedContext; -} - -// Runs still pending, across every copy of this file: only the first copy's fs.close guard is -// installed, and it must see every run's fds. -let openScopes: ScopeRegistry | undefined; -function getOpenScopes(): ScopeRegistry { - openScopes ??= getSharedEntry('openScopes', () => new Set(), isScopeRegistry); - return openScopes; -} - -function isCurrentlyBlocked(): boolean { - return getBlockedContext().current() !== undefined; -} - -// `Symbol.for` so re-evaluations recognize an installed guard; unversioned so another release's -// copy (whose accessors are non-configurable) is recognized too, rather than crashing on redefine. -export const ALREADY_GUARDED = Symbol.for('@dd/apps-plugin/network-guard installed'); - -// Marks accessors installed by this guard generation. An accessor with only ALREADY_GUARDED came -// from another release whose wrappers consult that release's own context, so this copy can't block. -const GUARD_GENERATION = networkGuardSymbol('installed'); -let foreignGuardFound = false; - -export const FOREIGN_GUARD_MESSAGE = - 'Local execution is unavailable: another version of the Datadog apps plugin in this process already guards Node built-ins, so this version cannot block filesystem writes or subprocesses. Install a single version of the Datadog build plugins.'; - -export function assertNoForeignGuard(): void { - if (foreignGuardFound) { - throw new Error(FOREIGN_GUARD_MESSAGE); - } -} - -function isObjectLike(value: unknown): value is object { - return value !== null && (typeof value === 'object' || typeof value === 'function'); -} - -/** - * Permanent getter/setter — a detached callback can still fire after `runBlocked` resolves and - * must stay blocked. The setter rebuilds the guard on every write since some libraries (e.g. MSW) - * mark the last function object they saw as "already patched," and reusing one frozen object - * collides. Non-configurable, so a dependency can't swap the whole descriptor; plain reassignment - * still works. Re-installing on an already-guarded property is a no-op via `ALREADY_GUARDED`. - */ -export function installGuardedProperty( - target: object, - prop: string, - makeGuard: (getReal: () => T) => T, -): void { - const existingGetter = Object.getOwnPropertyDescriptor(target, prop)?.get; - if (existingGetter && Reflect.get(existingGetter, ALREADY_GUARDED) === true) { - if (Reflect.get(existingGetter, GUARD_GENERATION) !== true) { - foreignGuardFound = true; - } - return; - } - - let real = (target as Record)[prop]; - if (real === undefined) { - // Nothing to guard — this runtime doesn't expose this method/global. A wrapper here would - // make feature-detection lie. - return; - } - // Tracks which `real` was active when each guard was built — the "const original = x; x = - // mock; x = original;" idiom hands the guard itself back on restore, and without this map that - // would make the guard call itself forever. - const realAtGuardCreation = new WeakMap(); - - function buildGuard(): T { - // Closes over its own snapshot of `real`, not the shared variable — a wrapper-closure - // restore would otherwise read whatever `real` currently holds and recurse forever. - const capturedReal = real; - // A non-function stub (undefined, null, false...) reads back as assigned, so feature detection stays accurate. - if (typeof capturedReal !== 'function') { - return capturedReal; - } - const guard = makeGuard(() => capturedReal); - if (isObjectLike(guard)) { - realAtGuardCreation.set(guard, real); - } - return guard; - } - - let currentGuard = buildGuard(); - // Values assigned through a receiver that owns a copy of this accessor (graceful-fs clones fs's - // descriptors), which can't be shadowed with a data property since the copy is non-configurable. - const cloneOverrides = new WeakMap(); - function getter(this: unknown): T { - const override = isObjectLike(this) ? cloneOverrides.get(this) : undefined; - return override ? override.value : currentGuard; - } - Object.defineProperty(getter, ALREADY_GUARDED, { value: true }); - Object.defineProperty(getter, GUARD_GENERATION, { value: true }); - // A plain `function`, not an arrow, so `this` is the real receiver — needed to tell - // `ChildProcess.prototype.spawn = mock` (every instance) apart from `oneChild.spawn = mock` - // (one instance) when `target` is a shared prototype. - function setter(this: unknown, value: T): void { - // Some Node/Jest internals call this with a non-object receiver; those take the shared path. - if (this !== target && isObjectLike(this)) { - if (Object.getOwnPropertyDescriptor(this, prop)?.set === setter) { - cloneOverrides.set(this, { value }); - return; - } - // `target` is a shared prototype — shadow the guard on this instance only, like an - // unguarded assignment would, instead of repointing the delegate every other - // instance's guard calls through. - Object.defineProperty(this, prop, { - value, - writable: true, - configurable: true, - enumerable: true, - }); - return; - } - const realForGuard = isObjectLike(value) ? realAtGuardCreation.get(value) : undefined; - real = realForGuard ?? value; - currentGuard = buildGuard(); - } - Object.defineProperty(target, prop, { - configurable: false, - enumerable: true, - get: getter, - set: setter, - }); -} - -function isFunction(value: unknown): value is (...args: unknown[]) => void { - return typeof value === 'function'; -} - -// Node's error-first callback is always the last argument. Returns whether one was found, so a -// caller can fall back to an 'error' event. -function invokeCallbackArg(args: unknown[], err: Error, ...extraArgs: unknown[]): boolean { - const maybeCallback = args[args.length - 1]; - if (isFunction(maybeCallback)) { - process.nextTick(maybeCallback, err, ...extraArgs); - return true; - } - return false; -} - -// Deferred via process.nextTick so a listener attached right after the call still sees it, -// matching a real event-emission timing. -function emitAsyncErrorIfListened(target: EventEmitter, err: Error): void { - process.nextTick(() => { - if (target.listenerCount('error') > 0) { - target.emit('error', err); - } - }); -} - -// 'throw' is for APIs that throw synchronously (spawnSync/execSync); 'reject' for Promise-returning -// ones; 'callback' for fs's callback APIs, which report failure through their error-first callback. -// `errorCode`, if given, is set on the blocked error. -function makeGuardWrapper unknown>( - getReal: () => F, - blockedMessage: string, - onBlocked: 'throw' | 'reject' | 'callback', - errorCode?: string, -): F { - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - if (!isCurrentlyBlocked()) { - const real = getReal(); - return Reflect.apply(real, this, args); - } - const blockedError = Object.assign( - new Error(blockedMessage), - errorCode ? { code: errorCode } : {}, - ); - if (onBlocked === 'reject') { - return Promise.reject(blockedError); - } - if (onBlocked === 'callback') { - if (invokeCallbackArg(args, blockedError)) { - return undefined; - } - // Real fs throws this synchronously for a missing callback regardless of block state. - const message = 'The "cb" argument must be of type function. Received undefined'; - const missingCallbackError = new TypeError(message); - throw Object.assign(missingCallbackError, { code: 'ERR_INVALID_ARG_TYPE' }); - } - throw blockedError; - }; - const real = getReal(); - copyPromisifyMetadata(real, wrapper); - return wrapper as unknown as F; -} - -// Node marks some fs functions (e.g. fs.write) with an internal symbol telling util.promisify to -// resolve with a named-field object; a wrapper without it resolves with only the first value. -// promisify.custom is skipped, since the real one would call past the guard. -function copyPromisifyMetadata(real: unknown, wrapper: object): void { - if (!isObjectLike(real)) { - return; - } - for (const key of Object.getOwnPropertySymbols(real)) { - const descriptor = Object.getOwnPropertyDescriptor(real, key); - if (key !== promisify.custom && descriptor) { - Object.defineProperty(wrapper, key, descriptor); - } - } -} - -// A worker gets a fresh V8 realm with its own module registry, so nothing inside it inherits this -// file's monkeypatches — blocking construction is the only enforceable boundary. The construct -// trap forwards `newTarget`, so `class Foo extends Worker {}` still produces a Foo. -export function guardWorker(getReal: () => unknown): unknown { - const real = getReal(); - // Worker reassigned to a non-constructor stub reads back as assigned. - if (typeof real !== 'function') { - return real; - } - return new Proxy(real, { - construct(target, args, newTarget) { - if (isCurrentlyBlocked()) { - throw new Error(WORKER_THREAD_BLOCKED_MESSAGE); - } - return Reflect.construct(target, args, newTarget); - }, - }); -} - -// execSync/execFileSync genuinely throw synchronously on failure — this guard is for those two -// only. The rest have their own guards below matching each one's real (never-throws) contract. -function guardSubprocess unknown>(getReal: () => F): F { - return makeGuardWrapper(getReal, SUBPROCESS_BLOCKED_MESSAGE, 'throw'); -} - -// spawn()/fork() return a brand-new ChildProcess with no existing `this` to emit 'error' on, so -// the guard fabricates a stub shaped like the real return value instead of `undefined` (which -// would TypeError on `spawn(...).stdout.on(...)`). stdout/stderr/stdin are real inert streams, not -// null, matching a real launch failure. send()/disconnect() are included even for spawn/exec/ -// execFile (which lack IPC by default) — this guard is dev-loop safety, not a hard security -// boundary, and avoiding a crash matters more than exact fidelity. -function createBlockedChildProcessStub(err: Error): EventEmitter & Record { - const stub = new EventEmitter() as EventEmitter & Record; - stub.pid = undefined; - stub.exitCode = null; - stub.signalCode = null; - stub.killed = false; - stub.connected = false; - stub.channel = undefined; - const stdout = new Readable({ read() {} }); - stdout.push(null); - const stderr = new Readable({ read() {} }); - stderr.push(null); - stub.stdout = stdout; - stub.stderr = stderr; - stub.stdin = new Writable({ - write(_chunk, _encoding, callback) { - callback(); - }, - }); - stub.kill = () => false; - stub.ref = () => stub; - stub.unref = () => stub; - stub.disconnect = () => {}; - stub.send = (...sendArgs: unknown[]) => { - const sendErr = new Error('channel closed'); - if (!invokeCallbackArg(sendArgs, sendErr)) { - // No callback given — fall back to the 'error' event a real disconnected channel uses. - emitAsyncErrorIfListened(stub, sendErr); - } - return false; - }; - emitAsyncErrorIfListened(stub, err); - return stub; -} - -function guardSpawnFactory unknown>(getReal: () => F): F { - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - if (!isCurrentlyBlocked()) { - return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); - } - return createBlockedChildProcessStub(new Error(SUBPROCESS_BLOCKED_MESSAGE)); - }; - return wrapper as unknown as F; -} - -// exec/execFile report failure via an error-first callback and always return a ChildProcess -// synchronously. Real Node sets stdout/stderr to empty strings, not undefined, even on a launch -// failure — a caller doing `err.stderr.trim()` would otherwise TypeError. -function guardExecFactory unknown>(getReal: () => F): F { - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - if (!isCurrentlyBlocked()) { - return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); - } - const err = new Error(SUBPROCESS_BLOCKED_MESSAGE); - invokeCallbackArg(args, err, '', ''); - return createBlockedChildProcessStub(err); - }; - return wrapper as unknown as F; -} - -// ChildProcess.prototype.spawn() configures an existing instance, so there's no factory return -// value to fabricate — just this instance's async 'error' event. The real method returns a -// synchronous integer (0 success, negative errno on failure), so the blocked path returns a -// negative placeholder. This method's `this` type isn't part of @types/node's public surface (see -// the childProcessPrototype cast below), so it needs its own guard rather than reusing another. -function guardChildProcessSpawnMethod unknown>( - getReal: () => F, -): F { - const wrapper = function (this: EventEmitter, ...args: unknown[]): unknown { - if (!isCurrentlyBlocked()) { - return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); - } - emitAsyncErrorIfListened(this, new Error(SUBPROCESS_BLOCKED_MESSAGE)); - return -1; - }; - return wrapper as unknown as F; -} - -// spawnSync never throws either — it returns a SpawnSyncReturns-shaped object with `.error` set, -// so the guard mirrors that shape. `output` is null on a real launch failure, not an array — a -// caller doing `result.output[1].toString()` would otherwise TypeError against a naive stub. -function guardSpawnSyncResult unknown>(getReal: () => F): F { - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - if (!isCurrentlyBlocked()) { - return (getReal() as unknown as (...a: unknown[]) => unknown).apply(this, args); - } - return { - pid: 0, - output: null, - stdout: undefined, - stderr: undefined, - status: null, - signal: null, - error: new Error(SUBPROCESS_BLOCKED_MESSAGE), - }; - }; - return wrapper as unknown as F; -} - -/** - * exec/execFile's native `promisify.custom` lives on the specific function object, so a fresh - * wrapper silently drops it, while reusing the original symbol would bypass the guard. Calls the - * already-guarded `wrapper` and attaches `.child` to match Node's real `PromiseWithChild` - * contract. - */ -function guardExecWithPromisifyCustom unknown>( - getReal: () => F, -): F { - const wrapper = guardExecFactory(getReal); - Object.defineProperty(wrapper, promisify.custom, { - configurable: true, - writable: true, - value: (...args: unknown[]) => { - let child: unknown; - const promise = new Promise((resolve, reject) => { - child = (wrapper as unknown as (...a: unknown[]) => unknown)( - ...args, - (error: unknown, stdout: unknown, stderr: unknown) => { - if (error) { - const errorWithOutput = Object.assign(error as object, { - stdout, - stderr, - }); - reject(errorWithOutput); - } else { - resolve({ stdout, stderr }); - } - }, - ); - }); - (promise as unknown as { child: unknown }).child = child; - return promise; - }, - }); - return wrapper; -} - -function installSubprocessGuards(): void { - installGuardedProperty(child_process, 'spawn', guardSpawnFactory); - installGuardedProperty( - child_process, - 'spawnSync', - guardSpawnSyncResult, - ); - // `unknown` is the correct escape hatch: exec/execFile's `__promisify__` property doesn't structurally satisfy a plain function type. - installGuardedProperty<(...args: never[]) => unknown>( - child_process, - 'exec', - guardExecWithPromisifyCustom, - ); - installGuardedProperty( - child_process, - 'execSync', - guardSubprocess, - ); - installGuardedProperty<(...args: never[]) => unknown>( - child_process, - 'execFile', - guardExecWithPromisifyCustom, - ); - installGuardedProperty( - child_process, - 'execFileSync', - guardSubprocess, - ); - installGuardedProperty<(...args: never[]) => unknown>(child_process, 'fork', guardSpawnFactory); - // Also guards `ChildProcess.prototype.spawn` directly, since the functions above are thin wrappers a dependency could bypass them through. - const childProcessPrototype = child_process.ChildProcess.prototype as unknown as Record< - string, - unknown - >; - installGuardedProperty<(...args: never[]) => unknown>( - childProcessPrototype, - 'spawn', - guardChildProcessSpawnMethod, - ); - - installGuardedProperty(worker_threads, 'Worker', guardWorker); -} - -// Only writes are guarded (reads stay open). Blocked errors use EROFS, since graceful-fs's win32 -// rename retries EACCES/EPERM for a minute. -type FsWriteFn = (...args: never[]) => unknown; -type FsWriteGuard = (getReal: () => FsWriteFn) => FsWriteFn; -const guardFsSyncWriteMethod: FsWriteGuard = (getReal) => - makeGuardWrapper(getReal, FS_WRITE_BLOCKED_MESSAGE, 'throw', 'EROFS'); -const guardFsCallbackWriteMethod: FsWriteGuard = (getReal) => - makeGuardWrapper(getReal, FS_WRITE_BLOCKED_MESSAGE, 'callback', 'EROFS'); -const guardFsPromiseWriteMethod: FsWriteGuard = (getReal) => - makeGuardWrapper(getReal, FS_WRITE_BLOCKED_MESSAGE, 'reject', 'EROFS'); - -const OPEN_WRITE_FLAG_BITS = [ - fs.constants.O_WRONLY, - fs.constants.O_RDWR, - fs.constants.O_CREAT, - fs.constants.O_TRUNC, - fs.constants.O_APPEND, -]; -// Each constant is a single power-of-two bit; tested arithmetically since the repo lints out bitwise operators. -function hasFlagBit(flags: number, bit: number): boolean { - return Math.floor(flags / bit) % 2 === 1; -} -// Flags are the second argument; omitted (or a callback in that position) means 'r'. -function opensForWriting(args: unknown[]): boolean { - const flags = args[1]; - if (typeof flags === 'number') { - // A negative or fractional value can't be bit-tested reliably, so it's treated as a write. - if (!Number.isInteger(flags) || flags < 0) { - return true; - } - return OPEN_WRITE_FLAG_BITS.some((bit) => hasFlagBit(flags, bit)); - } - return typeof flags === 'string' && /[wa+]/.test(flags); -} - -// The run's scope while it's still going; undefined outside a run or once it ended. -function openScope(): BlockedScope | undefined { - const scope = getBlockedContext().current(); - return scope && !scope.closed ? scope : undefined; -} - -function toFilePath(value: unknown): string | undefined { - if (typeof value === 'string') { - return value; - } - if (Buffer.isBuffer(value)) { - const decoded = value.toString(); - // A path that doesn't survive a UTF-8 round trip names a different file than the one checked. - const roundTripped = Buffer.from(decoded); - return roundTripped.equals(value) ? decoded : undefined; - } - if (value instanceof URL && value.protocol === 'file:') { - return fileURLToPath(value); - } - return undefined; -} - -function existsWithoutFollowingLinks(candidate: string): boolean { - try { - fs.lstatSync(candidate); - return true; - } catch { - return false; - } -} - -// Where the kernel lands a write: never textually normalized, since path.resolve and fs.realpathSync -// collapse `link/..` while the kernel follows the link first. A dangling link or a `..` past a -// missing segment resolves to nothing and is refused. -function realPathForWrite(filePath: string): string | undefined { - const cwd = process.cwd(); - const target = path.isAbsolute(filePath) ? filePath : `${cwd}${path.sep}${filePath}`; - const missingSegments: string[] = []; - let current = target; - while (!existsWithoutFollowingLinks(current)) { - const parent = path.dirname(current); - if (parent === current) { - return undefined; - } - const missingSegment = path.basename(current); - missingSegments.unshift(missingSegment); - current = parent; - } - if (missingSegments.includes('..')) { - return undefined; - } - try { - const realPath = fs.realpathSync.native(current); - return path.join(realPath, ...missingSegments); - } catch { - return undefined; - } -} - -// Where the kernel resolves the directory entry an operation like unlink or rename acts on: through -// its parent's links but not the final component's. A trailing separator or a `.`/`..` name makes -// the kernel follow the final component, so those take the full resolution. -function realEntryPath(filePath: string): string | undefined { - const name = path.basename(filePath); - if (filePath.endsWith(path.sep) || name === '' || name === '.' || name === '..') { - return realPathForWrite(filePath); - } - const parentPath = path.dirname(filePath); - const realParent = realPathForWrite(parentPath); - return realParent === undefined ? undefined : path.join(realParent, name); -} - -// The real OS temp dir, plus /tmp outside Windows, resolved at install. Libraries like tempy and -// temp-dir save os.tmpdir() when they load, and Terrapin's os.tmpdir() is /tmp, which some -// dependencies hard-code. -let realTmpDirs: string[] | undefined; - -function captureRealTmpDirs(): string[] | undefined { - const tmpDirs = process.platform === 'win32' ? [os.tmpdir()] : [os.tmpdir(), '/tmp']; - const realDirs = tmpDirs.flatMap((tmpDir) => { - try { - return [fs.realpathSync.native(tmpDir)]; - } catch { - return []; - } - }); - return realDirs.length > 0 ? [...new Set(realDirs)] : undefined; -} - -interface PathRule { - readonly operatesOnEntry: boolean; - readonly allowsRoot: boolean; -} -const WRITE_THROUGH_PATH: PathRule = { operatesOnEntry: false, allowsRoot: false }; - -// Strictly under a real temp dir, since removing or renaming a temp root would break $TMPDIR for -// every process; mkdir may name the root itself, as it only makes sure it exists. -function isPermittedPath(value: unknown, rule: PathRule): boolean { - const filePath = toFilePath(value); - if (filePath === undefined || realTmpDirs === undefined) { - return false; - } - const realPath = rule.operatesOnEntry ? realEntryPath(filePath) : realPathForWrite(filePath); - if (realPath === undefined) { - return false; - } - return realTmpDirs.some( - (tmpDir) => - (rule.allowsRoot && realPath === tmpDir) || realPath.startsWith(`${tmpDir}${path.sep}`), - ); -} - -// Which arguments name what a method modifies; every other method modifies its first argument. -const WRITTEN_ARG_INDEXES: Record = { - copyFile: [1], - rename: [0, 1], - link: [0, 1], -}; -// Arguments naming a directory entry the method acts on itself rather than through a final symlink. -const ENTRY_ARG_INDEXES: Record = { - unlink: [0], - rm: [0], - rmdir: [0], - rename: [0, 1], - link: [1], - lchmod: [0], - lchown: [0], - lutimes: [0], -}; -// Refused everywhere, so no writable directory holds a link that could redirect a write (cp copies -// symlinks as-is). -const NEVER_PERMITTED_WRITES = new Set(['symlink', 'cp']); -// Create `XXXXXX`, so that path, not the prefix, must be in a writable directory. -const PREFIX_CREATING_WRITES = new Set(['mkdtemp', 'mkdtempDisposable']); -function pathCreatedFromPrefix(value: unknown): string | undefined { - const prefix = toFilePath(value); - return prefix === undefined ? undefined : `${prefix}XXXXXX`; -} -// Data writes to stdout/stderr are console output (e.g. pino's sonic-boom); truncating or -// chmod-ing them is not. -const STDIO_DATA_WRITES = new Set(['write', 'writev', 'writeFile', 'appendFile']); - -// Allowed when every modified target is console output, a path under a writable directory, or an -// fd or FileHandle opened there. Worked out once per method, since it runs on every guarded fs call. -function makeWritePermission(method: string): (scope: BlockedScope, args: unknown[]) => boolean { - const baseMethod = method.replace(/Sync$/, ''); - if (NEVER_PERMITTED_WRITES.has(baseMethod)) { - return () => false; - } - const writtenIndexes = WRITTEN_ARG_INDEXES[baseMethod] ?? [0]; - const entryIndexes = ENTRY_ARG_INDEXES[baseMethod] ?? []; - const allowsRoot = baseMethod === 'mkdir'; - const allowsStdio = STDIO_DATA_WRITES.has(baseMethod); - const createsFromPrefix = PREFIX_CREATING_WRITES.has(baseMethod); - return (scope, args) => - writtenIndexes.every((index) => { - const target = args[index]; - if (allowsStdio && (target === 1 || target === 2)) { - return true; - } - // Stay writable after the run ends, so a stream it left writing can finish. - if (typeof target === 'number') { - return scope.writableFds.has(target); - } - if (isObjectLike(target) && scope.writableHandles.has(target)) { - return true; - } - if (scope.closed) { - return false; - } - const writtenPath = createsFromPrefix ? pathCreatedFromPrefix(target) : target; - const operatesOnEntry = entryIndexes.includes(index); - return isPermittedPath(writtenPath, { operatesOnEntry, allowsRoot }); - }); -} - -// A run that ended stays registered while it owns fds, so the fs.close guard still revokes them and -// a reused fd number never inherits write access. -function deregisterIfIdle(scope: BlockedScope): void { - if (scope.closed && scope.writableFds.size === 0) { - getOpenScopes().delete(scope); - } -} - -// Re-registers a run that ended while the open was in flight, so the fd can still be revoked. -function recordWritableFd(scope: BlockedScope, fd: number): void { - scope.writableFds.add(fd); - getOpenScopes().add(scope); -} - -function forgetWritableFd(fd: number): void { - getOpenScopes().forEach((scope) => { - scope.writableFds.delete(fd); - deregisterIfIdle(scope); - }); -} - -type FsWriteForm = 'sync' | 'callback' | 'promise'; -const FS_WRITE_REFUSALS: Record = { - sync: guardFsSyncWriteMethod, - callback: guardFsCallbackWriteMethod, - promise: guardFsPromiseWriteMethod, -}; - -// Refuses a write outside the writable directories; outside any run, the real function is called -// directly, skipping argument inspection. -function guardFsWriteMethod(method: string, form: FsWriteForm): FsWriteGuard { - const isPermitted = makeWritePermission(method); - const makeRefusal = FS_WRITE_REFUSALS[form]; - return (getReal) => { - const refuse = makeRefusal(getReal); - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - const scope = getBlockedContext().current(); - const callTarget = - scope !== undefined && !isPermitted(scope, args) ? refuse : getReal(); - return Reflect.apply(callTarget, this, args); - }; - const real = getReal(); - copyPromisifyMetadata(real, wrapper); - return wrapper; - }; -} - -// Records each fd opened for writing in a writable directory, so fd-based writes on it (including -// createWriteStream's) are allowed; any other write-mode open is refused before it can truncate. -function guardFsOpenMethod(mode: 'sync' | 'callback'): FsWriteGuard { - return (getReal) => { - const refuse = FS_WRITE_REFUSALS[mode](getReal); - return function (this: unknown, ...args: unknown[]): unknown { - const real = getReal(); - if (!opensForWriting(args)) { - return Reflect.apply(real, this, args); - } - const scope = openScope(); - if (!scope || !isPermittedPath(args[0], WRITE_THROUGH_PATH)) { - return Reflect.apply(refuse, this, args); - } - if (mode === 'sync') { - const fd: unknown = Reflect.apply(real, this, args); - if (typeof fd === 'number') { - recordWritableFd(scope, fd); - } - return fd; - } - const callback = args[args.length - 1]; - if (typeof callback !== 'function') { - return Reflect.apply(real, this, args); - } - // Recorded even if the run ended meanwhile, since the caller, such as a stream, owns the fd. - const recordFd = (err: unknown, fd: unknown) => { - if (!err && typeof fd === 'number') { - recordWritableFd(scope, fd); - } - Reflect.apply(callback, undefined, [err, fd]); - }; - const argsWithRecorder = [...args.slice(0, -1), recordFd]; - return Reflect.apply(real, this, argsWithRecorder); - }; - }; -} - -// Forgets a closed fd in every run, wherever it's closed from, so a later file the OS gives the -// same number doesn't inherit write access. -const guardFsCloseMethod: FsWriteGuard = (getReal) => - function (this: unknown, ...args: unknown[]): unknown { - const fd = args[0]; - if (typeof fd === 'number') { - forgetWritableFd(fd); - } - const real = getReal(); - return Reflect.apply(real, this, args); - }; - -// readFile opens a path itself rather than through fs.open, so a write flag in its options can -// truncate or create the file. Node ignores the flag for an fd or FileHandle. -function readsPathForWriting(args: unknown[]): boolean { - const [target, options] = args; - const isPath = typeof target === 'string' || Buffer.isBuffer(target) || target instanceof URL; - const flag: unknown = isObjectLike(options) ? Reflect.get(options, 'flag') : undefined; - return isPath && opensForWriting([target, flag]); -} - -function guardFsReadFileMethod(form: FsWriteForm): FsWriteGuard { - return (getReal) => { - const refuse = FS_WRITE_REFUSALS[form](getReal); - const wrapper = function (this: unknown, ...args: unknown[]): unknown { - const scope = getBlockedContext().current(); - const isRefused = - scope !== undefined && - readsPathForWriting(args) && - (scope.closed || !isPermittedPath(args[0], WRITE_THROUGH_PATH)); - return Reflect.apply(isRefused ? refuse : getReal(), this, args); - }; - const real = getReal(); - copyPromisifyMetadata(real, wrapper); - return wrapper; - }; -} - -// Each async name is guarded on fs (callback form) and fs.promises (promise form); the installer -// skips a name a module doesn't expose. fs.promises and require('fs/promises') are the same object. -const FS_SYNC_WRITE_METHODS = [ - 'writeFileSync', - 'appendFileSync', - 'writeSync', - 'writevSync', - 'copyFileSync', - 'unlinkSync', - 'rmSync', - 'rmdirSync', - 'renameSync', - 'mkdirSync', - 'symlinkSync', - 'linkSync', - 'cpSync', - 'truncateSync', - 'ftruncateSync', - 'mkdtempSync', - 'mkdtempDisposableSync', - 'utimesSync', - 'futimesSync', - 'lutimesSync', - 'chmodSync', - 'fchmodSync', - 'lchmodSync', - 'chownSync', - 'fchownSync', - 'lchownSync', -]; -const FS_ASYNC_WRITE_METHODS = FS_SYNC_WRITE_METHODS.map((method) => method.replace(/Sync$/, '')); -const FS_WRITE_GUARD_INSTALLS: Array<[target: object, methods: string[], form: FsWriteForm]> = [ - [fs, FS_SYNC_WRITE_METHODS, 'sync'], - [fs, FS_ASYNC_WRITE_METHODS, 'callback'], - [fs.promises, FS_ASYNC_WRITE_METHODS, 'promise'], -]; -type PromisesOpen = typeof fs.promises.open; - -// A write-mode open is refused outside the temp dir. Inside it both the handle and its fd are -// recorded, and the fd is revoked when the handle closes. -// A FileHandle's close is an own property of each handle and skips fs.close, so it's wrapped here. -function revokeFdOnClose(handle: fs.promises.FileHandle): void { - const { fd } = handle; - const realClose = handle.close; - handle.close = function close(this: unknown, ...args: unknown[]) { - forgetWritableFd(fd); - return Reflect.apply(realClose, this, args); - }; -} - -function guardReturnedHandle(handle: fs.promises.FileHandle): fs.promises.FileHandle { - guardFileHandlePrototype(handle); - return handle; -} - -function guardPromisesOpen(real: PromisesOpen): PromisesOpen { - const refuse = guardFsPromiseWriteMethod(() => real); - function open(this: unknown, ...args: Parameters): ReturnType { - if (!opensForWriting(args)) { - const reading: ReturnType = Reflect.apply(real, this, args); - return fileHandlePrototypeGuarded ? reading : reading.then(guardReturnedHandle); - } - const scope = openScope(); - if (!scope || !isPermittedPath(args[0], WRITE_THROUGH_PATH)) { - return Reflect.apply(refuse, this, args); - } - const opening: ReturnType = Reflect.apply(real, this, args); - return opening.then((handle) => { - scope.writableHandles.add(handle); - recordWritableFd(scope, handle.fd); - revokeFdOnClose(handle); - return guardReturnedHandle(handle); - }); - } - Object.defineProperty(open, GUARD_GENERATION, { value: true }); - return open; -} - -// A handle's own modifying methods skip the fs guards, and chmod/chown/utimes work even on a -// read-mode handle, so inside a run they only work on a handle opened for writing where allowed. -const FILE_HANDLE_WRITE_METHODS = [ - 'appendFile', - 'chmod', - 'chown', - 'truncate', - 'utimes', - 'write', - 'writeFile', - 'writev', -]; - -let fileHandlePrototypeGuarded = false; -function guardFileHandlePrototype(handle: object): void { - const prototype: unknown = Object.getPrototypeOf(handle); - if (fileHandlePrototypeGuarded || !isObjectLike(prototype)) { - return; - } - fileHandlePrototypeGuarded = true; - for (const method of FILE_HANDLE_WRITE_METHODS) { - const real: unknown = Reflect.get(prototype, method); - if (!isFunction(real) || Reflect.get(real, GUARD_GENERATION) === true) { - continue; - } - const refuse = guardFsPromiseWriteMethod(() => real); - const guarded = function (this: unknown, ...args: unknown[]): unknown { - const scope = getBlockedContext().current(); - const writable = isObjectLike(this) && scope?.writableHandles.has(this) === true; - const callTarget = scope === undefined || writable ? real : refuse; - return Reflect.apply(callTarget, this, args); - }; - Object.defineProperty(guarded, GUARD_GENERATION, { value: true }); - Reflect.set(prototype, method, guarded); - } -} - -// FileHandle isn't exported, so its prototype comes from a handle on the null device, opened at -// install; the open guard also applies it to the first handle it returns, if that comes sooner. -let fileHandleGuard: Promise | undefined; -function startGuardingFileHandles(): void { - fileHandleGuard ??= fs.promises - .open(os.devNull, 'r') - .then(async (handle) => { - guardFileHandlePrototype(handle); - await handle.close(); - }) - .catch(() => { - // Retried on the next install, like any other guard that failed to install. - fileHandleGuard = undefined; - }); -} - -// A plain data property, unlike the accessors above, so other packages' tests can still -// jest.spyOn(fs.promises, 'open'). A reassignment can drop it, so every install re-wraps the current -// value unless it's already this guard; a spy around the guard just gets wrapped once more. -function installPromisesOpenGuard(): void { - const current = fs.promises.open; - if (typeof current === 'function' && Reflect.get(current, GUARD_GENERATION) !== true) { - fs.promises.open = guardPromisesOpen(current); - } -} - -function installFsGuards(): void { - installPromisesOpenGuard(); - installGuardedProperty(fs, 'open', guardFsOpenMethod('callback')); - installGuardedProperty(fs, 'openSync', guardFsOpenMethod('sync')); - installGuardedProperty(fs, 'close', guardFsCloseMethod); - installGuardedProperty(fs, 'closeSync', guardFsCloseMethod); - installGuardedProperty(fs, 'readFileSync', guardFsReadFileMethod('sync')); - installGuardedProperty(fs, 'readFile', guardFsReadFileMethod('callback')); - installGuardedProperty(fs.promises, 'readFile', guardFsReadFileMethod('promise')); - for (const [target, methods, form] of FS_WRITE_GUARD_INSTALLS) { - for (const method of methods) { - const guard = guardFsWriteMethod(method, form); - installGuardedProperty(target, method, guard); - } - } - realTmpDirs ??= captureRealTmpDirs(); -} - -let guardsInstalled = false; -// Patches process-wide built-ins, so a call's process calls it explicitly rather than at import: -// every bundler that loads this plugin imports this file. -export function installGuards(): void { - if (guardsInstalled) { - installPromisesOpenGuard(); - startGuardingFileHandles(); - return; - } - // Each property install is a no-op once done, so a call after a failed attempt retries the rest. - installSubprocessGuards(); - installFsGuards(); - // Node keeps ESM named bindings (`import { spawn } from 'node:child_process'`) as separate - // references to the original values until this re-syncs them with the patched CJS exports. - syncBuiltinESMExports(); - startGuardingFileHandles(); - guardsInstalled = true; -} - -// Captured at load, since Jest's fake timers replace process.nextTick and would stall a run's close. -const { nextTick } = process; - -function closeScope(scope: BlockedScope): void { - scope.closed = true; - deregisterIfIdle(scope); -} - -// Runs `fn` with subprocesses, worker_threads and fs writes outside the OS temp dir blocked; wraps -// a call's module load and function in its own process. -// Aborting `signal` closes the scope for a caller that gives up on a `fn` that never settles. -export async function runBlocked( - fn: () => Promise, - options: { signal?: AbortSignal } = {}, -): Promise { - if (options.signal?.aborted) { - throw new Error('The run was aborted before it started.'); - } - installGuards(); - assertNoForeignGuard(); - // A FileHandle opened before install stays unguarded until the async prototype guard lands, so - // only a cold install waits; afterwards fn still starts synchronously. - if (!fileHandlePrototypeGuarded) { - await fileHandleGuard; - if (!fileHandlePrototypeGuarded) { - throw new Error(FILE_HANDLE_GUARD_UNAVAILABLE_MESSAGE); - } - if (options.signal?.aborted) { - throw new Error('The run was aborted before it started.'); - } - } - const scope: BlockedScope = { - writableFds: new Set(), - writableHandles: new WeakSet(), - closed: false, - }; - getOpenScopes().add(scope); - const onAbort = () => closeScope(scope); - options.signal?.addEventListener('abort', onAbort, { once: true }); - try { - return await getBlockedContext().run(scope, fn); - } finally { - options.signal?.removeEventListener('abort', onAbort); - // Work fn queued for the next tick, like a write stream's open, still starts in the run. - await new Promise((resolve) => { - nextTick(resolve); - }); - closeScope(scope); - } -} diff --git a/packages/plugins/apps/src/vite/network-guard.fixtures.ts b/packages/plugins/apps/src/vite/probe-dir.fixtures.ts similarity index 88% rename from packages/plugins/apps/src/vite/network-guard.fixtures.ts rename to packages/plugins/apps/src/vite/probe-dir.fixtures.ts index 5fd3f1a2b..944b8caab 100644 --- a/packages/plugins/apps/src/vite/network-guard.fixtures.ts +++ b/packages/plugins/apps/src/vite/probe-dir.fixtures.ts @@ -6,7 +6,7 @@ import { mkdirSync } from '@dd/core/helpers/fs'; import fs from 'fs'; import path from 'path'; -// A run may write anywhere under the OS temp dir, so a probe that must be refused lives outside it, +// A call may write anywhere under the OS temp dir, so a probe that must be refused lives outside it, // in the gitignored node_modules/.cache. export function makeProbeDirOutsideTmp(prefix: string): string { const jestPackageJson = require.resolve('jest/package.json'); diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts index b3eb34077..3ac666b30 100644 --- a/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts @@ -2,12 +2,43 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; + import { forgeInvoke, probeSandbox } from './childSandboxProbes'; -export async function runSandboxProbes(projectFile: string, fetchUrl: string, sharedTmpFile: string) { +export async function runSandboxProbes( + projectFile: string, + fetchUrl: string, + sharedTmpFile: string, +) { return probeSandbox(projectFile, fetchUrl, sharedTmpFile); } export async function loadOverIpc(url: string, importer?: string) { return forgeInvoke(url, importer); } + +export async function writeFile(filePath: string) { + fs.writeFileSync(filePath, 'x'); + return 'written'; +} + +export async function loadAddon(addonPath: string) { + process.dlopen({ exports: {} }, addonPath); + return 'loaded'; +} + +export async function spawnEcho() { + const output = execFileSync('echo', ['hi']); + return String(output); +} + +export async function loadFsBinding() { + const binding: unknown = Reflect.get(process, 'binding'); + return typeof binding === 'function' ? typeof binding.call(process, 'fs') : 'no binding'; +} + +export async function throwAccessDeniedLookalike() { + throw Object.assign(new Error('upstream refused the request'), { code: 'ERR_ACCESS_DENIED' }); +} diff --git a/yarn.lock b/yarn.lock index c36ff2842..209c53ece 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1961,7 +1961,6 @@ __metadata: esbuild: "npm:0.25.8" eslint-scope: "npm:7.2.2" glob: "npm:11.1.0" - graceful-fs: "npm:4.2.11" jszip: "npm:3.10.1" picomatch: "npm:4.0.7" rollup: "npm:4.60.4" @@ -7402,7 +7401,7 @@ __metadata: languageName: node linkType: hard -"graceful-fs@npm:4.2.11, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.3, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.9": +"graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.2.11, graceful-fs@npm:^4.2.3, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.9": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" checksum: 10/bf152d0ed1dc159239db1ba1f74fdbc40cb02f626770dcd5815c427ce0688c2635a06ed69af364396da4636d0408fcf7d4afdf7881724c3307e46aff30ca49e2