diff --git a/fixtures/benchmarks/private/B04/oracle.mjs b/fixtures/benchmarks/private/B04/oracle.mjs index 1c0224a..041911d 100644 --- a/fixtures/benchmarks/private/B04/oracle.mjs +++ b/fixtures/benchmarks/private/B04/oracle.mjs @@ -19,9 +19,19 @@ function runInline(script) { } function timed(script) { - const started = Date.now(); - const result = runInline(script); - return { result, elapsed: Date.now() - started }; + const result = runInline(` +const started = performance.now(); +process.once('beforeExit', () => { + process.stdout.write('\\n__ELAPSED__' + (performance.now() - started)); +}); +${script} +`); + const marker = result.stdout?.lastIndexOf('\n__ELAPSED__') ?? -1; + const elapsed = marker < 0 ? Number.NaN : Number(result.stdout.slice(marker + 12)); + return { + result: { ...result, stdout: marker < 0 ? result.stdout : result.stdout.slice(0, marker) }, + elapsed, + }; } const resolved = timed(` diff --git a/src/security/writer.ts b/src/security/writer.ts index 6b46890..c5fdf8f 100644 --- a/src/security/writer.ts +++ b/src/security/writer.ts @@ -172,7 +172,17 @@ export async function openConfinedWriter(candidateRoot: string): Promise { - const workspace = path.join(base, runId); + // Node's recursive mkdir cannot create through Windows junctions. + // Git records the resolved worktree path instead. + await mkdir(base, { recursive: true }); + const workspace = path.join(await realpath(base), runId); const admin = path.join(repo, '.git', 'worktrees', runId); await mkdir(workspace, { recursive: true }); await mkdir(admin, { recursive: true }); @@ -732,7 +735,7 @@ describe('repository sandbox readiness', () => { const base = path.join(repo, '.mergesutra', 'worktrees'); await mkdir(path.dirname(base), { recursive: true }); await symlink(outsideBase, base, process.platform === 'win32' ? 'junction' : 'dir'); - const workspace = path.join(base, 'run-20260101T000000Z-cccccc'); + const workspace = path.join(await realpath(base), 'run-20260101T000000Z-cccccc'); // A fake owner whose path arithmetic would line up with the workspace, but // whose "admin" directory is not shaped like /.git/worktrees/. const fakeOwner = await scratch(); diff --git a/tests/pr/github-publisher.test.ts b/tests/pr/github-publisher.test.ts index b072d8d..b6495dc 100644 --- a/tests/pr/github-publisher.test.ts +++ b/tests/pr/github-publisher.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from 'node:fs'; -import { rm } from 'node:fs/promises'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { createGitHubPublicationRemote } from '../../src/pr/github-publisher.js'; @@ -972,13 +972,17 @@ describe.skipIf(!AVAILABLE)('the GitHub draft-pull-request transport', () => { it('refuses a patch that would exceed the read-back budget, before any write', async () => { const fixture = await implementedRun(tempDirs); const longDir = `generated/${'a'.repeat(40)}/${'b'.repeat(40)}/${'c'.repeat(30)}`; - for (let index = 0; index < 400; index += 1) { - await put( - fixture.repoDir, - `${longDir}/file-${String(index).padStart(3, '0')}.txt`, - `${String(index)}\n`, - ); - } + const parent = path.join(fixture.repoDir, longDir); + await mkdir(parent, { recursive: true }); + await Promise.all( + Array.from({ length: 400 }, (_, index) => + writeFile( + path.join(parent, `file-${String(index).padStart(3, '0')}.txt`), + `${String(index)}\n`, + 'utf8', + ), + ), + ); const input = await inputFor(fixture.repoDir, fixture.base); const scripted = scriptedGitHub(); const remote = createGitHubPublicationRemote({ run: scripted.run }); diff --git a/tests/security/live-qualification-rules.test.ts b/tests/security/live-qualification-rules.test.ts index 39c15b0..cc820c5 100644 --- a/tests/security/live-qualification-rules.test.ts +++ b/tests/security/live-qualification-rules.test.ts @@ -4,7 +4,10 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +// Windows measured 34.6 seconds for this 26-process fixture matrix. +vi.setConfig({ hookTimeout: 60_000 }); const ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..'); const LIB = path.join(ROOT, 'scripts', 'lib', 'live-qualification.mjs'); @@ -377,7 +380,7 @@ describe('the live fixture-acceptance scanner, run against planted workspaces', it('answers the whole case matrix from one real Node process, inside the hook budget', () => { expect(Object.keys(harness.proofs)).toHaveLength(INSPECT_CASES.length); - expect(harnessMs).toBeLessThan(25_000); + expect(harnessMs).toBeLessThan(45_000); }); it('does not count required inputs that only appear in a line comment', () => { diff --git a/tests/security/toctou-window.test.ts b/tests/security/toctou-window.test.ts index b41031c..92efed0 100644 --- a/tests/security/toctou-window.test.ts +++ b/tests/security/toctou-window.test.ts @@ -1,12 +1,10 @@ import { createHash } from 'node:crypto'; import type * as fsPromises from 'node:fs/promises'; -import { existsSync, realpathSync } from 'node:fs'; -import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { AppError } from '../../src/core/errors.js'; import { CAN_SYMLINK, makeFixtureTree, makeLink, snapshotTree } from '../helpers/fixture.js'; -import { isInsideRoot } from '../../src/security/path-safety.js'; import { openConfinedWriter, type ConfinedWriter } from '../../src/security/writer.js'; /** @@ -52,9 +50,8 @@ vi.mock('node:fs/promises', async (importOriginal) => { return value; }, async open(...args: Parameters) { - // Recorded, not intercepted: the second proof refuses a write whose scratch - // file has already been put where the swapped ancestor points. Saying that - // in a document needs the path, not an inference about ordering. + // Record every attempted scratch open. The swapped-ancestor case must refuse + // before this boundary, without touching the external target. stagedPaths.push(String(args[0])); return real.open(...args); }, @@ -181,35 +178,26 @@ describe('what the last moment re-checks, and what it cannot', () => { // write staged before it noticed. expect(await outsideTree()).toEqual(outsideBefore); - // And the narrower truth about the gap that *was* closed: the scratch file is - // staged before the second proof runs, so it really did go wherever the - // swapped ancestor pointed. The refusal is what stopped it becoming a result; - // the cleanup that removes a failed temp is what stopped it staying there. - // Recorded here because SECURITY_MODEL §4.3 says this, and a document should - // not be the only place a claim like that lives. + // A swapped ancestor must be refused before staging. On Windows, exclusive + // creation through a junction reports EEXIST; accepting that as a collision + // would mask the path refusal and skip containment proof. const staged = stagedPaths.filter((p) => path.basename(p).startsWith('.mergesutra-tmp-')); - expect(staged.length, 'the write staged a scratch file before it was refused').toBe(1); - const scratch = staged[0] as string; - // The *spelling* of the scratch path is inside the workspace; it is the - // resolved directory that is not. Saying which one escaped is the point. - const stagedDir = realpathSync(path.dirname(scratch)); - expect( - isInsideRoot(writer.root, stagedDir), - 'the scratch file was staged inside the workspace', - ).toBe(false); - expect(stagedDir).toBe(realpathSync(OUTSIDE)); - expect(existsSync(scratch), 'the refused write left its scratch file behind').toBe(false); + expect(staged, 'the writer must refuse before opening scratch').toEqual([]); + expect(await readdir(OUTSIDE)).toEqual(['secret.txt']); }, ); - it('proves the way twice: before the bytes are staged, and before they are placed', async () => { + it('proves the way before staging and before placing bytes', async () => { // The link layer is consulted for the deepest existing ancestor. Once is not // enough, because the operation that follows is long: a temp file is written, - // synced and read back before the rename that replaces anything. Twice is the - // most this design can do, and the count is the claim — a window between the - // second proof and the rename syscall remains, and SECURITY_MODEL says so. + // synced and read back before the rename that replaces anything. Canonical + // staging adds one path resolution between the containment proofs. linkCheckSaw.length = 0; await writer.writeText('sub/one.ts', 'one\n', { expectedAbsent: true }); - expect(linkCheckSaw).toEqual([path.join(writer.root, 'sub'), path.join(writer.root, 'sub')]); + expect(linkCheckSaw).toEqual([ + path.join(writer.root, 'sub'), + path.join(writer.root, 'sub'), + path.join(writer.root, 'sub'), + ]); }); });