Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions fixtures/benchmarks/private/B04/oracle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down
12 changes: 11 additions & 1 deletion src/security/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,17 @@ export async function openConfinedWriter(candidateRoot: string): Promise<Confine
}

await mkdir(path.dirname(absolute), { recursive: true });
const staged = await stageScratch(path.dirname(absolute), `${process.pid}-${Date.now()}`);
// Resolve the destination directory again before staging. Windows refuses
// exclusive creation through junctions. A swapped junction must also never
// receive temporary bytes outside the authorized workspace.
const stagingDirectory = await realpath(path.dirname(absolute));
if (!isInsideRoot(root, stagingDirectory)) {
throw refusal(
relativePath,
'a link or directory on the way resolves outside the workspace',
);
}
const staged = await stageScratch(stagingDirectory, `${process.pid}-${Date.now()}`);
if (staged === null) {
throw refusal(
relativePath,
Expand Down
7 changes: 5 additions & 2 deletions tests/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ function shortSpelling(target: string): string | null {
* it, so the admin directory records the target spelling, not the literal one.
*/
async function linkedWorkspace(repo: string, base: string, runId: string): Promise<string> {
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 });
Expand Down Expand Up @@ -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 <repo>/.git/worktrees/<run-id>.
const fakeOwner = await scratch();
Expand Down
20 changes: 12 additions & 8 deletions tests/pr/github-publisher.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
7 changes: 5 additions & 2 deletions tests/security/live-qualification-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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', () => {
Expand Down
44 changes: 16 additions & 28 deletions tests/security/toctou-window.test.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -52,9 +50,8 @@ vi.mock('node:fs/promises', async (importOriginal) => {
return value;
},
async open(...args: Parameters<typeof real.open>) {
// 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);
},
Expand Down Expand Up @@ -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'),
]);
});
});
Loading