diff --git a/docs/testing.md b/docs/testing.md index 0c59dc56cd..d9d4775d35 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -897,8 +897,13 @@ After import, deleting that source must preserve the captured database identity. Instruction inspection and containing-function lookup also agree across hexadecimal case, leading zeros, and encoded default address-space spellings. The same source-acquisition workflow exercises missing and directory-replaced -inputs, plus real read-permission denial on a non-root host. Root runs report -that permission-denial check as unverified. Filesystem integration separately checks selected-platform routing and exclusive creation. +inputs, plus real read-permission denial on a host that enforces it. Permission +fixtures must probe the relevant read, write or search operation before relying +on POSIX mode bits: a non-root Linux process may bypass DAC through capabilities. +`requirePermissionDenial` reports an explicit skip when the host permits the +probe; it restores the probe's permissions before skipping. Such a skip leaves +real permission coverage unverified. Filesystem integration separately checks +selected-platform routing and exclusive creation. Namespace annotation probes compile a separate host C fixture with C++ ABI symbols, avoiding a C++ runtime prerequisite. Real Ghidra demangling supplies duplicate leaf names in two top-level namespaces and a nested namespace. The diff --git a/src/application/environment/environmentBundleWrite.test.ts b/src/application/environment/environmentBundleWrite.test.ts index 1e1ec5dd5e..5a10e337c9 100644 --- a/src/application/environment/environmentBundleWrite.test.ts +++ b/src/application/environment/environmentBundleWrite.test.ts @@ -1,3 +1,4 @@ +import { requirePermissionDenial } from "../../../tests/fixtures/permissionDenial.js"; import { appendFile, chmod, @@ -167,9 +168,10 @@ it("uses confirmed publication after the output owner's descriptor cleanup compl } }); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "returns a tagged permission refusal for an existing read-only empty directory", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-write"); const root = await createTestTempDirectory("rea-bundle-readonly-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( @@ -200,9 +202,10 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( }, ); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "names the nested path when write permission is lost after earlier files", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-write"); const root = await createTestTempDirectory("rea-bundle-later-denied-"); const files = await writeEnvironmentServeFiles(root); const inputs = await readEnvironmentExportInputs( diff --git a/src/hopper/HopperMachOImage.test.ts b/src/hopper/HopperMachOImage.test.ts index 0e509b5e58..3a90caa6c9 100644 --- a/src/hopper/HopperMachOImage.test.ts +++ b/src/hopper/HopperMachOImage.test.ts @@ -1,3 +1,4 @@ +import { requirePermissionDenial } from "../../tests/fixtures/permissionDenial.js"; import { createHash } from "node:crypto"; import { access, @@ -129,9 +130,10 @@ const resolveHopperMachOImage = ( resourceScope: inspectionResources.owner, }); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "reports a FAT64 source that became unreadable as a host read denial", - async () => { + async (context) => { + await requirePermissionDenial(context, "file-read"); const directory = await createTestTempDirectory("rea-hopper-fat64-"); const path = join(directory, "universal"); await writeFile(path, Buffer.alloc(64)); diff --git a/src/hopper/HopperTargetLease.test.ts b/src/hopper/HopperTargetLease.test.ts index 3170d09d6f..59b68927a0 100644 --- a/src/hopper/HopperTargetLease.test.ts +++ b/src/hopper/HopperTargetLease.test.ts @@ -1,3 +1,4 @@ +import { requirePermissionDenial } from "../../tests/fixtures/permissionDenial.js"; import { execFile } from "node:child_process"; import { once } from "node:events"; import { @@ -75,9 +76,10 @@ describe.skipIf(process.platform === "win32")( expect((await lstat(alias)).isSymbolicLink()).toBe(true); }); - it.skipIf(process.getuid?.() === 0)( + it.skipIf(process.platform === "win32")( "explains unreadable directory recovery without changing unverified permissions", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-read"); const directory = await temporaryDirectory(); await chmod(directory, 0o333); try { diff --git a/src/ida/IdaWorkspaceOwnership.test.ts b/src/ida/IdaWorkspaceOwnership.test.ts index c406dee7b3..d0e014fa3e 100644 --- a/src/ida/IdaWorkspaceOwnership.test.ts +++ b/src/ida/IdaWorkspaceOwnership.test.ts @@ -1,3 +1,4 @@ +import { requirePermissionDenial } from "../../tests/fixtures/permissionDenial.js"; import { chmod, mkdir, readdir, realpath, rm, symlink } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -93,9 +94,10 @@ describe("IDA workspace ownership before input preparation", () => { expect(await readdir(root)).toEqual([]); }); - it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + it.skipIf(process.platform === "win32")( "preserves the input identity failure and retries workspace removal after permission recovery", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-write"); const { root, target } = await createIdaTarget(); roots.push(root); const { client, producer } = clientFor(root, { diff --git a/src/process/capture/FilesystemCoverage.test.ts b/src/process/capture/FilesystemCoverage.test.ts index 6daf6572b2..f60eba8b1b 100644 --- a/src/process/capture/FilesystemCoverage.test.ts +++ b/src/process/capture/FilesystemCoverage.test.ts @@ -1,3 +1,4 @@ +import { requirePermissionDenial } from "../../../tests/fixtures/permissionDenial.js"; import { chmod, mkdir, @@ -104,9 +105,10 @@ it("distinguishes unavailable content hashes from completed path enumeration", a ).toMatchObject({ status: "deleted", before: { sha256: null }, after: null }); }); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "retains readable entries and marks an unreadable directory subtree unknown", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-read"); const root = await createTestTempDirectory("rea-fs-unreadable-directory-"); const visible = join(root, "a-visible.txt"); const blocked = join(root, "z-blocked"); @@ -143,9 +145,10 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( }, ); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "retains unreadable-file and sibling observations in a complete snapshot", - async () => { + async (context) => { + await requirePermissionDenial(context, "file-read"); const root = await createTestTempDirectory("rea-fs-unreadable-file-"); const unreadable = join(root, "unreadable.txt"); const readable = join(root, "readable.txt"); @@ -398,9 +401,10 @@ it("preserves creation when the selected root was observed missing before captur ]); }); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "rejects only selected roots that cannot be admitted before launch", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-search"); const root = await createTestTempDirectory("rea-fs-root-admission-"); await writeFile(join(root, "file"), "regular file"); await mkdir(join(root, "denied")); @@ -466,9 +470,10 @@ it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( }, ); -it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( +it.skipIf(process.platform === "win32")( "records an unsearchable directory's children as omitted rather than failing", - async () => { + async (context) => { + await requirePermissionDenial(context, "directory-search"); const root = await createTestTempDirectory("rea-fs-unsearchable-"); const listed = join(root, "listed"); await mkdir(listed); diff --git a/tests/fixtures/permissionDenial.ts b/tests/fixtures/permissionDenial.ts new file mode 100644 index 0000000000..23e7fd8fa2 --- /dev/null +++ b/tests/fixtures/permissionDenial.ts @@ -0,0 +1,63 @@ +import { + chmod, + mkdir, + readFile, + readdir, + stat, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import type { TestContext } from "vitest"; + +import { createTestTempDirectory } from "./temporaryDirectory.js"; + +type PermissionOperation = + | "file-read" + | "directory-read" + | "directory-write" + | "directory-search"; + +/** Probe actual DAC enforcement; UID alone does not account for Linux capabilities. */ +export const requirePermissionDenial = async ( + context: Pick, + operation: PermissionOperation, +): Promise => { + if (process.platform === "win32") + context.skip("POSIX mode denial is unavailable on Windows"); + const root = await createTestTempDirectory("rea-permission-probe-"); + const directory = join(root, "directory"); + const file = join(directory, "file"); + await mkdir(directory); + await writeFile(file, "probe"); + let denied = false; + try { + if (operation === "file-read") { + await chmod(file, 0); + await readFile(file); + } else if (operation === "directory-read") { + await chmod(directory, 0o333); + await readdir(directory); + } else if (operation === "directory-write") { + await chmod(directory, 0o500); + await writeFile(join(directory, "created"), "probe"); + } else { + await chmod(directory, 0o444); + await stat(file); + } + } catch (cause) { + if ( + !(cause instanceof Error) || + !("code" in cause) || + cause.code !== "EACCES" + ) + throw cause; + denied = true; + } finally { + await chmod(directory, 0o700); + await chmod(file, 0o600); + } + if (!denied) + context.skip( + `Host bypasses POSIX ${operation} denial; real permission coverage is unverified`, + ); +};