/** * AclSandbox orchestration failure-path tests: the win32 resolver is mocked * to hand each test a stub binding table, so every checked Win32 call in * init/spawn/dispose has a failing counterpart without opening real token or * ACL handles. Constructor validation, the fail-closed init cleanup, and the * dispose aggregation use the same stubs. Pure stubs — no real Win32 calls, * so these run on every platform; the real-FFI round-trip lives in * acl.spec.ts and runner.spec.ts (win32 only). */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import koffi from 'koffi' import { PROCESS_INFORMATION } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { Win32Error } from '../src/errors.ts' import { AclSandbox } from '../src/index.ts' import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') type MockFn = ReturnType /** The stub binding table plus the mocks the assertions inspect directly. */ interface HappyStubs { api: Win32Bindings setNamedSecurityInfoW: MockFn convertStringSidToSidW: MockFn closeHandle: MockFn localFree: MockFn createRestrictedToken: MockFn createJobObjectW: MockFn getNamedSecurityInfoW: MockFn } const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) vi.mock('../src/ffi.ts', async (importOriginal) => { const actual = await importOriginal() return { ...actual, win32: () => Promise.resolve(state.stubs?.api as Win32Bindings), win32Sync: () => state.stubs?.api as Win32Bindings, } }) const scratchDirs: string[] = [] afterAll(() => { for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) function scratch(): string { const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-index-')) scratchDirs.push(dir) return dir } /** * The stub the whole happy pipeline needs: token opening, capability-SID * parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token, * default-DACL merge, piped/inherited spawns, drains, and exit waits all * succeed. Every test flips one call per branch. */ function happyStubs(): HappyStubs { let next = 0n const fresh = () => ++next const openProcess = vi.fn(() => fresh()) const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { koffi.encode(slot, PVOID, fresh()) return 1 }) const convertStringSidToSidW = vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, fresh()) return 1 }) const getTempPathW = vi.fn((_length: number, buffer: Buffer) => { const temp = tmpdir().replace(/[\\/]$/u, '') buffer.write(temp, 'utf16le') return temp.length }) const createFileW = vi.fn(() => fresh()) const getNamedSecurityInfoW = vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) const setEntriesInAclW = vi.fn((_count: unknown, _entries: unknown, _old: unknown, newAcl: NativePtr) => { koffi.encode(newAcl, PVOID, fresh()) return 0 }) const setNamedSecurityInfoW = vi.fn(() => 0) const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { if (info === null) { koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) return 0 // the size probe is expected to "fail" } if (cls === abi.TokenGroups) { info.writeUInt32LE(1, 0) info.writeBigUInt64LE(77n, abi.TOKEN_GROUPS_OFFSET) info.writeUInt32LE(abi.SE_GROUP_LOGON_ID, abi.TOKEN_GROUPS_OFFSET + 8) } else { info.writeBigUInt64LE(88n, 0) // the token's current default DACL } return 1 }) const getLengthSid = vi.fn(() => 12) const copySid = vi.fn(() => 1) const createWellKnownSid = vi.fn(() => 1) const isValidSid = vi.fn(() => 1) const createRestrictedToken = vi.fn(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, _rc: unknown, _rs: unknown, slot: NativePtr, ) => { koffi.encode(slot, PVOID, fresh()) return 1 }) const setTokenInformation = vi.fn(() => 1) const createPipe = vi.fn((readSlot: NativePtr, writeSlot: NativePtr) => { koffi.encode(readSlot, PVOID, fresh()) koffi.encode(writeSlot, PVOID, fresh()) return 1 }) const setHandleInformation = vi.fn(() => 1) const createProcessAsUserW = vi.fn(( _token: unknown, _app: unknown, _cmd: unknown, _pa: unknown, _ta: unknown, _inherit: unknown, _flags: unknown, _env: unknown, _cwd: unknown, _si: unknown, processInfo: NativePtr, ) => { koffi.encode(processInfo, PROCESS_INFORMATION, { hProcess: fresh(), hThread: fresh(), dwProcessId: 1234, dwThreadId: 5678 }) return 1 }) const peekNamedPipe = vi.fn(() => 0) const readFile = vi.fn(() => 1) const waitForSingleObject = vi.fn(() => 0) const getExitCodeProcess = vi.fn((_process: unknown, slot: NativePtr) => { koffi.encode(slot, 'uint32', 42) return 1 }) const createJobObjectW = vi.fn(() => fresh()) const setInformationJobObject = vi.fn(() => 1) const assignProcessToJobObject = vi.fn(() => 1) const resumeThread = vi.fn(() => 0) const getStdHandle = vi.fn(() => fresh()) const localFree = vi.fn(() => 0n) const closeHandle = vi.fn(() => 1) const getLastError = vi.fn(() => abi.ERROR_BROKEN_PIPE) // the drains' clean EOF const formatMessageW = vi.fn(() => 0) const api = { openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, setInformationJobObject, assignProcessToJobObject, resumeThread, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, } as unknown as Win32Bindings return { api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, } } beforeEach(() => { state.stubs = happyStubs() }) describe('AclSandbox constructor validation', () => { it('rejects a writable directory that does not exist', () => { const missing = join(scratch(), 'missing') expect(() => new AclSandbox({ writableDirs: [missing], tempDir: null, mode: 'read-only' })) .toThrow(/writable dir does not exist/u) }) it('resolves relative writable directories to absolute paths', () => { const dir = scratch() const sandbox = new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'read-only' }) expect(sandbox.writableDirs).toEqual([resolve(dir)]) expect(sandbox.mode).toBe('read-only') expect(sandbox.tempDir).toBeUndefined() }) it('rejects temp authority under read-only', () => { const workspace = scratch() const temp = scratch() expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' })) .toThrow(/read-only does not accept a temp directory/u) expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' })) .toThrow(/read-only does not accept write SIDs/u) expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' })) .toThrow(/read-only does not accept write SIDs/u) }) it('rejects a temp SID when temp writes are disabled', () => { const workspace = scratch() expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-2', tempWriteSid: 'S-1-4-9000-2-1', mode: 'workspace-write', })).toThrow(/temp write SID requires a temp directory/u) }) }) describe('AclSandbox init', () => { it('completes the happy workspace-write pipeline: workspace and temp grants, restricted token, resolved temp dir', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const temp = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', tempWriteSid: 'S-1-4-9000-1-1', mode: 'workspace-write', }) await sandbox.init() expect(sandbox.tempDir).toBe(resolve(temp)) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) }) it('requires an explicit private temp directory or null under workspace-write', () => { const workspace = scratch() expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })) .toThrow(/requires an explicit private temp directory or null/u) }) it('applies no grants when the temp dir option is null', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) await sandbox.init() expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) // workspace only }) it('rejects a temp dir that does not exist', async () => { const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', tempWriteSid: 'S-1-4-9000-4-1', mode: 'workspace-write', }) await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u) }) it('builds a read-only token without parsing a write SID or applying grants', async () => { const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, mode: 'read-only' }) await sandbox.init() expect(convertStringSidToSidW).not.toHaveBeenCalled() expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(() => { sandbox.dispose() }).not.toThrow() // no write SID: nothing to revoke or free }) it('applies no grants when the caller owns the DACLs (manageDacls: false)', async () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-5', mode: 'workspace-write', manageDacls: false }) await sandbox.init() expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(() => { sandbox.dispose() }).not.toThrow() // caller-owned DACLs: nothing to revoke }) it('refuses a second init on the same instance', async () => { const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-6', mode: 'workspace-write' }) await sandbox.init() await expect(sandbox.init()).rejects.toThrow(/already initialized/u) }) it('reports a ConvertStringSidToSidW failure before granting anything', async () => { const { convertStringSidToSidW, setNamedSecurityInfoW } = state.stubs as HappyStubs convertStringSidToSidW.mockReturnValue(0) const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-7', mode: 'workspace-write' }) await expect(sandbox.init()).rejects.toMatchObject({ api: 'ConvertStringSidToSidW' }) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() }) it('rejects a NULL write SID after ConvertStringSidToSidW succeeds', async () => { const { convertStringSidToSidW } = state.stubs as HappyStubs convertStringSidToSidW.mockImplementation(() => 1) // no out slot write const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-8', mode: 'workspace-write' }) await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) }) it('aggregates failed current and restricted token closes after init', async () => { const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) const restrictedToken = 99n createRestrictedToken.mockImplementation(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, _rc: unknown, _rs: unknown, slot: NativePtr, ) => { koffi.encode(slot, PVOID, restrictedToken) return 1 }) // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the // token-layer close of 1n succeeds and init's close of 2n fails. closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n || handle === restrictedToken ? 0 : 1)) // The failure lands after this.token is stored but before this.api is // assigned. Cleanup retries the still-open handle and reports both close // failures plus the restricted-token close after releasing parsed SIDs. await expect(sandbox.init()).rejects.toMatchObject({ errors: [ { api: 'CloseHandle' }, { api: 'CloseHandle' }, { api: 'CloseHandle' }, ], }) }) it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { const { createRestrictedToken, localFree, getNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const temp = scratch() let inCleanup = false createRestrictedToken.mockImplementation(() => { inCleanup = true // the grants already landed: every later call is the cleanup's return 0 }) localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) getNamedSecurityInfoW.mockImplementation(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, ) => { if (inCleanup) return 2 // the cleanup's revocation read fails too koffi.encode(dacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', tempWriteSid: 'S-1-4-9000-10-1', mode: 'workspace-write', }) await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u) expect(sandbox.tempDir).toBeUndefined() }) }) describe('AclSandbox spawn', () => { it('refuses to spawn before init', () => { const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-11', mode: 'workspace-write' }) expect(() => sandbox.spawn({ command: 'probe.exe' })).toThrow(/not initialized/u) }) it('pipe spawn drains empty pipes and settles with the child exit code', async () => { const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-12', mode: 'workspace-write' }) await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe', args: ['--flag'], cwd: workspace }) expect(child.pid).toBe(1234) const expected = { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 } await expect(child.wait()).resolves.toEqual(expected) // The second wait reuses the settled exit-code promise instead of re-waiting. await expect(child.wait()).resolves.toEqual(expected) }) it('inherit spawn settles with empty stdio and closes the kill-on-close job', async () => { const { closeHandle } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-13', mode: 'workspace-write' }) await sandbox.init() const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) await expect(child.wait()).resolves.toEqual({ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), exitCode: 42 }) expect(closeHandle).toHaveBeenCalled() }) it('inherit spawn reports a failed close of the kill-on-close job', async () => { const { closeHandle, createJobObjectW } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-14', mode: 'workspace-write' }) await sandbox.init() let jobHandle = 0n closeHandle.mockImplementation((handle: NativePtr) => (handle === jobHandle ? 0 : 1)) const child = sandbox.spawn({ command: 'probe.exe', stdio: 'inherit' }) jobHandle = createJobObjectW.mock.results.at(-1)?.value as NativePtr await expect(child.wait()).rejects.toMatchObject({ api: 'CloseHandle' }) }) }) describe('AclSandbox dispose', () => { it('is a no-op before init', () => { const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-15', mode: 'workspace-write' }) expect(() => { sandbox.dispose() }).not.toThrow() }) it('aggregates a failing temp revocation into an AggregateError', async () => { const { getNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const temp = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', tempWriteSid: 'S-1-4-9000-16-1', mode: 'workspace-write', }) await sandbox.init() getNamedSecurityInfoW.mockReturnValue(2) expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u) }) it('aggregates SID and token cleanup failures into an AggregateError', async () => { const { localFree } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-17', mode: 'workspace-write' }) await sandbox.init() localFree.mockReturnValue(1n) expect(() => { sandbox.dispose() }).toThrow(AggregateError) }) it('reports a failed close of the restricted token', async () => { const { createRestrictedToken, closeHandle } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-18', mode: 'workspace-write' }) let restrictedToken = 0n createRestrictedToken.mockImplementation(( _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, _rc: unknown, _rs: unknown, slot: NativePtr, ) => { restrictedToken = 99n koffi.encode(slot, PVOID, restrictedToken) return 1 }) closeHandle.mockImplementation((handle: NativePtr) => (handle === restrictedToken ? 0 : 1)) await sandbox.init() expect(() => { sandbox.dispose() }).toThrow(AggregateError) }) })