diff --git a/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts b/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts new file mode 100644 index 0000000000..45a93248ad --- /dev/null +++ b/packages/storage/src/__tests__/quiescent-session-snapshot.test.ts @@ -0,0 +1,749 @@ +import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { afterEach, test } from 'node:test'; +import { + createFileQuiescentSessionSnapshotCoordinator, + type SessionSnapshotCancellation, + SessionSnapshotError, + type SessionSnapshotQuiescenceAuthority, + type SessionSnapshotStatePreparer, + type SessionSnapshotWorkspacePreparation, + type SessionSnapshotWorkspacePreparer, + SESSION_SNAPSHOT_WORKSPACE_POLICY_V1, +} from '../quiescent-session-snapshot.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +test('prepares state and workspace under one quiescence boundary, then releases live writers', async () => { + const fixture = await createFixture(); + let liveState = 'state-at-boundary'; + let liveWorkspace = 'workspace-at-boundary'; + const events: string[] = []; + let quiescent = false; + + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + assert.equal(quiescent, false); + quiescent = true; + events.push('enter'); + try { + return await operation(); + } finally { + quiescent = false; + events.push('exit'); + } + }, + }, + state: { + async prepareState(input) { + assert.equal(quiescent, true); + events.push('state'); + await mkdir(input.destinationRoot); + await writeFile(join(input.destinationRoot, 'runtime.sqlite'), liveState, 'utf8'); + return { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from('{"makaSessionId":"session-1"}', 'utf8'), + }; + }, + }, + workspace: { + async prepareWorkspace(input) { + assert.equal(quiescent, true); + assert.equal(input.policy, SESSION_SNAPSHOT_WORKSPACE_POLICY_V1); + events.push('workspace'); + await mkdir(input.destinationRoot); + await writeFile(join(input.destinationRoot, 'main.ts'), liveWorkspace, 'utf8'); + return workspaceResult({ includedEntries: 1 }); + }, + }, + }).prepare({ makaSessionId: 'session-1' }); + + assert.deepEqual(events, ['enter', 'state', 'workspace', 'exit']); + assert.equal(quiescent, false); + assert.notEqual(handle.snapshot.stateRoot, fixture.liveStateRoot); + assert.notEqual(handle.snapshot.workspaceRoot, fixture.liveWorkspaceRoot); + assert.equal( + await readFile(join(handle.snapshot.stateRoot, 'runtime.sqlite'), 'utf8'), + liveState, + ); + assert.equal( + await readFile(join(handle.snapshot.workspaceRoot, 'main.ts'), 'utf8'), + liveWorkspace, + ); + + liveState = 'later-state'; + liveWorkspace = 'later-workspace'; + assert.equal( + await readFile(join(handle.snapshot.stateRoot, 'runtime.sqlite'), 'utf8'), + 'state-at-boundary', + ); + assert.equal( + await readFile(join(handle.snapshot.workspaceRoot, 'main.ts'), 'utf8'), + 'workspace-at-boundary', + ); + assert.deepEqual(handle.workspace, workspaceResult({ includedEntries: 1 })); + + const publishedRoot = dirname(handle.snapshot.stateRoot); + const cleanupRename = interceptSnapshotCleanupRename(publishedRoot, async () => { + await mkdir(publishedRoot, { mode: 0o700 }); + await writeFile(join(publishedRoot, 'replacement.txt'), 'keep', 'utf8'); + }); + await Promise.all([handle.release(), handle.release()]); + await cleanupRename.completed; + assert.equal(await readFile(join(publishedRoot, 'replacement.txt'), 'utf8'), 'keep'); + await rm(publishedRoot, { recursive: true }); + await handle.release(); +}); + +test('serializes concurrent preparations for the same Session through the authority contract', async () => { + const fixture = await createFixture(); + const authority = new SerialQuiescenceAuthority(); + const firstWorkspaceEntered = deferred(); + const allowFirstWorkspace = deferred(); + let statePreparations = 0; + + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: authority, + state: { + async prepareState(input) { + statePreparations += 1; + await mkdir(input.destinationRoot); + return stateIdentity(input.makaSessionId); + }, + }, + workspace: { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + if (statePreparations === 1) { + firstWorkspaceEntered.resolve(); + await allowFirstWorkspace.promise; + } + return workspaceResult(); + }, + }, + }); + + const first = coordinator.prepare({ makaSessionId: 'same-session' }); + await firstWorkspaceEntered.promise; + const second = coordinator.prepare({ makaSessionId: 'same-session' }); + await Promise.resolve(); + assert.equal(statePreparations, 1); + assert.deepEqual(authority.activeSessions, ['same-session']); + + allowFirstWorkspace.resolve(); + const firstHandle = await first; + const secondHandle = await second; + assert.equal(statePreparations, 2); + assert.equal(authority.maximumConcurrentBySession.get('same-session'), 1); + await Promise.all([firstHandle.release(), secondHandle.release()]); +}); + +test('does not globally serialize preparations for different Sessions', async () => { + const fixture = await createFixture(); + const authority = new SerialQuiescenceAuthority(); + const firstWorkspaceEntered = deferred(); + const allowFirstWorkspace = deferred(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: authority, + state: directoryStatePreparer, + workspace: { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + if (input.makaSessionId === 'session-a') { + firstWorkspaceEntered.resolve(); + await allowFirstWorkspace.promise; + } + return workspaceResult(); + }, + }, + }); + + const first = coordinator.prepare({ makaSessionId: 'session-a' }); + await firstWorkspaceEntered.promise; + const secondHandle = await coordinator.prepare({ makaSessionId: 'session-b' }); + assert.deepEqual(authority.activeSessions, ['session-a']); + assert.equal(authority.maximumConcurrentBySession.get('session-a'), 1); + assert.equal(authority.maximumConcurrentBySession.get('session-b'), 1); + + allowFirstWorkspace.resolve(); + const firstHandle = await first; + await Promise.all([firstHandle.release(), secondHandle.release()]); +}); + +test('removes partial staging and preserves a stable policy rejection', async () => { + const fixture = await createFixture(); + const rejection = new SessionSnapshotError( + 'policy_rejected', + 'Workspace snapshot policy rejected an entry', + { details: { phase: 'workspace', policyCategory: 'known_secret_file' } }, + ); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: { + async prepareState(input) { + await mkdir(input.destinationRoot); + await writeFile(join(input.destinationRoot, 'runtime.sqlite'), 'partial', 'utf8'); + return stateIdentity(input.makaSessionId); + }, + }, + workspace: { + async prepareWorkspace() { + throw rejection; + }, + }, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'session-secret' }), + (error: unknown) => error === rejection, + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); + assert.equal(rejection.message.includes('.env'), false); +}); + +test('failure cleanup refuses a staging root replaced by an unrelated directory', async () => { + const fixture = await createFixture(); + let unrelatedFile = ''; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: { + async prepareWorkspace(input) { + const preparingRoot = dirname(input.destinationRoot); + await rename(preparingRoot, `${preparingRoot}.displaced`); + await mkdir(preparingRoot, { mode: 0o700 }); + unrelatedFile = join(preparingRoot, 'unrelated.txt'); + await writeFile(unrelatedFile, 'keep', 'utf8'); + throw new Error('workspace preparation failed'); + }, + }, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'failure-cleanup-owner' }), + isSnapshotError('cleanup_failed'), + ); + assert.equal(await readFile(unrelatedFile, 'utf8'), 'keep'); +}); + +test('cleans a published snapshot when the authority fails while releasing quiescence', async () => { + const fixture = await createFixture(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + await operation(); + throw new Error('authority release failed'); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'session-release-failure' }), + (error: unknown) => + error instanceof SessionSnapshotError && + error.code === 'io_failure' && + error.message === 'Session snapshot preparation failed', + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('cancellation and an expired deadline stop before staging begins', async () => { + const fixture = await createFixture(); + let authorityCalls = 0; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + now: () => 10_000, + quiescence: { + async runQuiescent(_input, operation) { + authorityCalls += 1; + return operation(); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + const controller = new AbortController(); + controller.abort(); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'cancelled', signal: controller.signal }), + isSnapshotError('snapshot_cancelled'), + ); + await assert.rejects( + coordinator.prepare({ makaSessionId: 'expired', deadlineAt: 10_000 }), + isSnapshotError('snapshot_cancelled'), + ); + assert.equal(authorityCalls, 0); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('cancellation while waiting for quiescence is propagated as snapshot_cancelled', async () => { + const fixture = await createFixture(); + const authorityEntered = deferred(); + const controller = new AbortController(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(input) { + authorityEntered.resolve(); + await waitForAbort(input.cancellation); + throw Object.assign(new Error('aborted'), { name: 'AbortError' }); + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + const preparation = coordinator.prepare({ makaSessionId: 'waiting', signal: controller.signal }); + await authorityEntered.promise; + controller.abort(); + await assert.rejects(preparation, isSnapshotError('snapshot_cancelled')); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('cancellation while leaving quiescence cleans the published snapshot', async () => { + const fixture = await createFixture(); + const controller = new AbortController(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: { + async runQuiescent(_input, operation) { + const result = await operation(); + controller.abort(); + return result; + }, + }, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'cancel-after-publish', signal: controller.signal }), + isSnapshotError('snapshot_cancelled'), + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('release refuses a replacement directory and remains retryable for its owned root', async () => { + const fixture = await createFixture(); + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }).prepare({ makaSessionId: 'release-owner' }); + const publishedRoot = dirname(handle.snapshot.stateRoot); + const displacedRoot = `${publishedRoot}.displaced`; + await rename(publishedRoot, displacedRoot); + await mkdir(publishedRoot, { mode: 0o700 }); + const unrelated = join(publishedRoot, 'unrelated.txt'); + await writeFile(unrelated, 'keep', 'utf8'); + + await assert.rejects(handle.release(), isSnapshotError('cleanup_failed')); + assert.equal(await readFile(unrelated, 'utf8'), 'keep'); + + await rm(publishedRoot, { recursive: true }); + await rename(displacedRoot, publishedRoot); + await handle.release(); + await assert.rejects(readdir(publishedRoot), isCode('ENOENT')); +}); + +test('release resumes an interrupted partial cleanup using the external ownership record', async () => { + const fixture = await createFixture(); + const handle = await createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }).prepare({ makaSessionId: 'partial-release-retry' }); + const publishedRoot = dirname(handle.snapshot.stateRoot); + const snapshotId = basename(publishedRoot).slice('snapshot-'.length); + const ownerFile = join(fixture.stagingParent, `.snapshot-${snapshotId}.owner.json`); + const owner = JSON.parse(await readFile(ownerFile, 'utf8')) as { ownerToken: string }; + const cleanupRoot = join( + fixture.stagingParent, + `.snapshot-${snapshotId}.${owner.ownerToken}.cleanup`, + ); + + await rename(publishedRoot, cleanupRoot); + await rm(join(cleanupRoot, 'state'), { recursive: true }); + await handle.release(); + + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('creation failure cleans only the inode it created and preserves a colliding owner file', async () => { + const fixture = await createFixture(); + const snapshotId = '00000000-0000-4000-8000-000000000001'; + const ownerFile = join(fixture.stagingParent, `.snapshot-${snapshotId}.owner.json`); + await writeFile(ownerFile, 'unrelated', 'utf8'); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + newSnapshotId: () => snapshotId, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'create-failure-cleanup' }), + isSnapshotError('io_failure'), + ); + assert.equal(await readFile(ownerFile, 'utf8'), 'unrelated'); + assert.deepEqual(await readdir(fixture.stagingParent), [basename(ownerFile)]); +}); + +test('rejects a caller-supplied workspace policy override instead of downgrading V1 safety', async () => { + const fixture = await createFixture(); + assert.throws( + () => + createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + policy: { + version: 1, + classify: () => ({ kind: 'include' }), + }, + } as Parameters[0]), + /cannot be overridden/u, + ); +}); + +test('binds private-root verification to the exact canonical staging path', async () => { + const fixture = await createFixture(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority: { + async verifyPrivateStagingRoot() { + return { canonicalPath: fixture.root }; + }, + }, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'wrong-private-root-attestation' }), + isSnapshotError('unsafe_source'), + ); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('verifies and cleans each newly created snapshot directory when platform privacy fails', async () => { + const fixture = await createFixture(); + let verificationCalls = 0; + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + privateStagingRootAuthority: { + async verifyPrivateStagingRoot(input) { + verificationCalls += 1; + return { + canonicalPath: verificationCalls === 1 ? input.canonicalPath : fixture.stagingParent, + }; + }, + }, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'unsafe-created-staging-root' }), + isSnapshotError('unsafe_source'), + ); + assert.equal(verificationCalls, 2); + assert.deepEqual(await readdir(fixture.stagingParent), []); +}); + +test('requires a caller-provided Windows ACL verifier', { + skip: process.platform !== 'win32', +}, async () => { + const fixture = await createFixture(); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent: fixture.stagingParent, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'missing-windows-acl-verifier' }), + isSnapshotError('unsafe_source'), + ); +}); + +test('requires a private staging parent on POSIX', { + skip: process.platform === 'win32', +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-session-snapshot-public-')); + roots.push(root); + const stagingParent = join(root, 'staging'); + await mkdir(stagingParent, { mode: 0o755 }); + const coordinator = createFileQuiescentSessionSnapshotCoordinator({ + stagingParent, + quiescence: immediateAuthority, + state: directoryStatePreparer, + workspace: directoryWorkspacePreparer, + }); + + await assert.rejects( + coordinator.prepare({ makaSessionId: 'unsafe-parent' }), + isSnapshotError('unsafe_source'), + ); + assert.deepEqual(await readdir(stagingParent), []); +}); + +test('V1 workspace policy includes portable inputs, excludes rebuildable data, and rejects secrets', () => { + const cases = [ + ['package.json', 'file', { kind: 'include' }], + ['pnpm-lock.yaml', 'file', { kind: 'include' }], + ['.maka-workspace.json', 'file', { kind: 'include' }], + ['.git/config', 'file', { kind: 'exclude', category: 'source_control' }], + [ + 'packages/app/node_modules/pkg/index.js', + 'file', + { kind: 'exclude', category: 'dependency_tree' }, + ], + ['.turbo/cache.bin', 'file', { kind: 'exclude', category: 'cache' }], + ['logs/agent.txt', 'file', { kind: 'exclude', category: 'log' }], + ['debug.log', 'file', { kind: 'exclude', category: 'log' }], + ['secrets.log', 'file', { kind: 'exclude', category: 'log' }], + ['.maka-runtime/input.json', 'file', { kind: 'exclude', category: 'runtime_scratch' }], + ['.env.local', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/id_ed25519', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['credentials.yaml', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['secrets.json', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.terraformrc', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.git-credentials.lock', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['keys/client-private-key.pem', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['certs/client.p12', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['certs/client.crt', 'file', { kind: 'include' }], + ['certs/client.cer', 'file', { kind: 'include' }], + ['certs/client.csr', 'file', { kind: 'include' }], + ['certs/client.pem', 'file', { kind: 'include' }], + ['certs/client.der', 'file', { kind: 'include' }], + ['keys/service-account.json', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.ssh/config', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.aws/credentials', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.cargo/credentials', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.docker/config.json', 'file', { kind: 'reject', category: 'known_secret_file' }], + ['.kube/config', 'file', { kind: 'reject', category: 'known_secret_file' }], + [ + '.config/gcloud/application_default_credentials.json', + 'file', + { kind: 'reject', category: 'known_secret_file' }, + ], + ['../escape', 'file', { kind: 'reject', category: 'unsafe_path' }], + ['a\\b', 'file', { kind: 'reject', category: 'unsafe_path' }], + ] as const; + + for (const [relativePath, kind, expected] of cases) { + assert.deepEqual( + SESSION_SNAPSHOT_WORKSPACE_POLICY_V1.classify({ relativePath, kind }), + expected, + ); + } +}); + +const immediateAuthority: SessionSnapshotQuiescenceAuthority = { + async runQuiescent(_input, operation) { + return operation(); + }, +}; + +const privateStagingRootAuthority = { + async verifyPrivateStagingRoot(input: { canonicalPath: string }) { + return { canonicalPath: await realpath(input.canonicalPath) }; + }, +}; + +const directoryStatePreparer: SessionSnapshotStatePreparer = { + async prepareState(input) { + await mkdir(input.destinationRoot); + return stateIdentity(input.makaSessionId); + }, +}; + +const directoryWorkspacePreparer: SessionSnapshotWorkspacePreparer = { + async prepareWorkspace(input) { + await mkdir(input.destinationRoot); + return workspaceResult(); + }, +}; + +function stateIdentity(makaSessionId: string) { + return { + mediaType: 'application/vnd.maka.session-state-identity+json;version=1', + bytes: Buffer.from(JSON.stringify({ makaSessionId }), 'utf8'), + }; +} + +function workspaceResult( + overrides: Partial = {}, +): SessionSnapshotWorkspacePreparation { + return { + includedEntries: 0, + excludedEntries: 0, + excludedEntriesByCategory: { + dependency_tree: 0, + source_control: 0, + cache: 0, + log: 0, + runtime_scratch: 0, + }, + payloadBytes: 0, + ...overrides, + }; +} + +async function createFixture(): Promise<{ + root: string; + stagingParent: string; + liveStateRoot: string; + liveWorkspaceRoot: string; +}> { + const root = await mkdtemp(join(tmpdir(), 'maka-session-snapshot-')); + roots.push(root); + const stagingParent = join(root, 'staging'); + const liveStateRoot = join(root, 'live-state'); + const liveWorkspaceRoot = join(root, 'live-workspace'); + await Promise.all([ + mkdir(stagingParent, { mode: 0o700 }), + mkdir(liveStateRoot), + mkdir(liveWorkspaceRoot), + ]); + return { root, stagingParent, liveStateRoot, liveWorkspaceRoot }; +} + +function deferred(): { + promise: Promise; + resolve(value: T): void; +} { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +class SerialQuiescenceAuthority implements SessionSnapshotQuiescenceAuthority { + readonly activeSessions: string[] = []; + readonly maximumConcurrentBySession = new Map(); + readonly #tails = new Map>(); + readonly #activeBySession = new Map(); + + async runQuiescent( + input: { makaSessionId: string; cancellation: SessionSnapshotCancellation }, + operation: () => Promise, + ): Promise { + const predecessor = this.#tails.get(input.makaSessionId) ?? Promise.resolve(); + const release = deferred(); + const tail = predecessor.catch(() => {}).then(() => release.promise); + this.#tails.set(input.makaSessionId, tail); + await predecessor; + if (input.cancellation.signal.aborted) throw Object.assign(new Error(), { name: 'AbortError' }); + + const active = (this.#activeBySession.get(input.makaSessionId) ?? 0) + 1; + this.#activeBySession.set(input.makaSessionId, active); + this.maximumConcurrentBySession.set( + input.makaSessionId, + Math.max(this.maximumConcurrentBySession.get(input.makaSessionId) ?? 0, active), + ); + this.activeSessions.push(input.makaSessionId); + try { + return await operation(); + } finally { + this.activeSessions.splice(this.activeSessions.indexOf(input.makaSessionId), 1); + this.#activeBySession.set(input.makaSessionId, active - 1); + release.resolve(); + if (this.#tails.get(input.makaSessionId) === tail) this.#tails.delete(input.makaSessionId); + } + } +} + +async function waitForAbort(cancellation: SessionSnapshotCancellation): Promise { + if (cancellation.signal.aborted) return; + await new Promise((resolve) => { + cancellation.signal.addEventListener('abort', () => resolve(), { once: true }); + }); +} + +function isSnapshotError(code: SessionSnapshotError['code']): (error: unknown) => boolean { + return (error) => error instanceof SessionSnapshotError && error.code === code; +} + +function isCode(code: string): (error: unknown) => boolean { + return (error) => + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code; +} + +function interceptSnapshotCleanupRename( + publishedRoot: string, + afterRename: () => Promise, +): { completed: Promise } { + const stagingParent = dirname(publishedRoot); + const expectedName = basename(publishedRoot); + let resolveCompleted!: () => void; + let rejectCompleted!: (error: unknown) => void; + const completed = new Promise((resolve, reject) => { + resolveCompleted = resolve; + rejectCompleted = reject; + }); + const observer = async () => { + try { + while (true) { + const names = await readdir(stagingParent); + if (!names.includes(expectedName)) { + await afterRename(); + resolveCompleted(); + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + } catch (error) { + rejectCompleted(error); + } + }; + void observer(); + return { completed }; +} diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts index 0f7b305ba0..2bc2b66d1e 100644 --- a/packages/storage/src/index.ts +++ b/packages/storage/src/index.ts @@ -137,6 +137,7 @@ export * from './session-bundle-manifest.js'; export * from './session-bundle-canonical-tree.js'; export * from './session-bundle-ustar.js'; export * from './session-bundle-file-service.js'; +export * from './quiescent-session-snapshot.js'; export * from './managed-secret-store.js'; export * from './activation-secret-injector.js'; export * from './encrypted-file-managed-secret-store.js'; diff --git a/packages/storage/src/quiescent-session-snapshot.ts b/packages/storage/src/quiescent-session-snapshot.ts new file mode 100644 index 0000000000..3aace71e19 --- /dev/null +++ b/packages/storage/src/quiescent-session-snapshot.ts @@ -0,0 +1,1227 @@ +import { randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { copyOpaqueStateIdentityDescriptor } from './session-bundle-contract.js'; +import type { + OpaqueStateIdentityDescriptor, + PreparedSessionBundleSnapshot, +} from './session-bundle-contract.js'; +import { isSafeSessionId } from './session-store.js'; + +export const SESSION_SNAPSHOT_POLICY_VERSION = 1 as const; +export const SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION = 1 as const; + +const SNAPSHOT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const MAX_OWNER_RECORD_BYTES = 1_024; +const NO_FOLLOW_OPEN_FLAG = process.platform === 'win32' ? 0 : fsConstants.O_NOFOLLOW; + +export type SessionSnapshotWorkspaceEntryKind = 'file' | 'directory'; + +export type SessionSnapshotWorkspaceExclusionCategory = + | 'dependency_tree' + | 'source_control' + | 'cache' + | 'log' + | 'runtime_scratch'; + +export type SessionSnapshotWorkspaceRejectionCategory = + | 'known_secret_file' + | 'unsafe_path' + | 'unsupported_entry'; + +export type SessionSnapshotWorkspacePolicyDecision = + | { readonly kind: 'include' } + | { + readonly kind: 'exclude'; + readonly category: SessionSnapshotWorkspaceExclusionCategory; + } + | { + readonly kind: 'reject'; + readonly category: SessionSnapshotWorkspaceRejectionCategory; + }; + +export interface SessionSnapshotWorkspaceEntry { + /** Slash-separated relative path supplied without normalization aliases. */ + readonly relativePath: string; + readonly kind: SessionSnapshotWorkspaceEntryKind; +} + +export interface SessionSnapshotWorkspacePolicy { + readonly version: typeof SESSION_SNAPSHOT_POLICY_VERSION; + /** + * Receives normalized relative paths. A preparer must stop descending as + * soon as a directory is excluded; descendant entries are not counted. + */ + classify(entry: SessionSnapshotWorkspaceEntry): SessionSnapshotWorkspacePolicyDecision; +} + +const INCLUDE = Object.freeze({ kind: 'include' } as const); + +// This fail-closed rejection set is deliberately narrower than the workspace +// measurement rules introduced by #1353. Snapshot rejection is reserved for +// names that identify known secret material; public certificate encodings and +// other ambiguous formats are not rejected by extension alone. +const KNOWN_SECRET_WORKSPACE_FILE_PATTERNS = [ + /^\.env(?:\..*)?$/i, + /^\.(?:npmrc|netrc|pypirc|terraformrc)$/i, + /^\.git-credentials(?:\.lock)?$/i, + /^(?:credentials?|secrets?)(?:\..*)?$/i, + /(?:^|[-_.])(?:id_(?:rsa|dsa|ecdsa|ed25519)|private[-_.]?key)(?:$|[-_.])/i, + /\.(?:key|p12|pfx)$/i, +] as const; + +/** + * V1 portable-workspace policy. The coordinator pins this exact policy, while + * the trusted filesystem preparer is its enforcement point: the coordinator + * does not re-traverse or attest the prepared destination. The preparer remains + * responsible for applying every decision and rejecting symlinks, hard links, + * special files, path races, case conflicts, and quota violations. + */ +export const SESSION_SNAPSHOT_WORKSPACE_POLICY_V1: SessionSnapshotWorkspacePolicy = Object.freeze({ + version: SESSION_SNAPSHOT_POLICY_VERSION, + classify(entry: SessionSnapshotWorkspaceEntry): SessionSnapshotWorkspacePolicyDecision { + const decoded = decodeWorkspaceEntry(entry); + if (decoded.kind === 'reject') return decoded; + const { segments, basename: name } = decoded; + const lowerSegments = segments.map((segment) => segment.toLowerCase()); + const lowerName = name.toLowerCase(); + + if (lowerSegments.includes('.git')) { + return { kind: 'exclude', category: 'source_control' }; + } + if (lowerSegments.includes('node_modules')) { + return { kind: 'exclude', category: 'dependency_tree' }; + } + if ( + lowerSegments.includes('.cache') || + lowerSegments.includes('.maka-cache') || + lowerSegments.includes('.turbo') + ) { + return { kind: 'exclude', category: 'cache' }; + } + if ( + lowerSegments.includes('logs') || + lowerSegments.includes('.logs') || + (entry.kind === 'file' && lowerName.endsWith('.log')) + ) { + return { kind: 'exclude', category: 'log' }; + } + if (lowerSegments.includes('.maka-runtime') || lowerSegments.includes('.maka-activation')) { + return { kind: 'exclude', category: 'runtime_scratch' }; + } + if (entry.kind === 'file' && isKnownSecretPath(lowerSegments, lowerName)) { + return { kind: 'reject', category: 'known_secret_file' }; + } + return INCLUDE; + }, +}); + +export interface SessionSnapshotCancellation { + readonly signal: AbortSignal; + /** Absolute Unix time in milliseconds. */ + readonly deadlineAt?: number; +} + +/** + * Trusted host/owner authority for one complete Session mutation boundary. + * + * Before invoking `operation`, an implementation must stop admitting new + * mutations for this Maka Session, drain already-admitted state, Artifact and + * workspace mutations, and reject non-terminal Activations, background + * processes, pending approvals, and externally resumable actions. It must keep + * that boundary until `operation` settles, serialize preparations for the same + * Session, and honor cancellation/deadline while waiting. This interface does + * not make a process-local mutex authoritative by itself: every real writer + * must already be governed by the supplied Host/Owner implementation. + */ +export interface SessionSnapshotQuiescenceAuthority { + runQuiescent( + input: { + readonly makaSessionId: string; + readonly cancellation: SessionSnapshotCancellation; + }, + operation: () => Promise, + ): Promise; +} + +export interface SessionSnapshotStatePreparer { + /** Creates the exact, previously absent root and closes every source/destination handle. */ + prepareState(input: { + readonly makaSessionId: string; + readonly destinationRoot: string; + readonly cancellation: SessionSnapshotCancellation; + }): Promise; +} + +export interface SessionSnapshotWorkspacePreparation { + /** Number of included files and directories, including empty directories. */ + readonly includedEntries: number; + /** Number of topmost excluded entries; descendants of an excluded directory are not counted. */ + readonly excludedEntries: number; + /** Bounded audit diagnostics; paths and file contents are deliberately absent. */ + readonly excludedEntriesByCategory: Readonly< + Record + >; + readonly payloadBytes: number; +} + +export interface SessionSnapshotWorkspacePreparer { + /** + * Trusted enforcement point for the supplied workspace policy. Creates the + * exact, previously absent root, applies every policy decision without + * downgrading it, and closes every source/destination handle. The coordinator + * validates the returned root and bounded counters, but does not independently + * traverse the result to prove that the policy was applied. + */ + prepareWorkspace(input: { + readonly makaSessionId: string; + readonly destinationRoot: string; + readonly policy: SessionSnapshotWorkspacePolicy; + readonly cancellation: SessionSnapshotCancellation; + }): Promise; +} + +/** + * Trusted platform adapter that verifies a staging root is private to the + * current principal. On Windows this must inspect the effective ACL of both + * the parent and each newly created snapshot directory; POSIX mode bits are + * neither available nor an adequate substitute there. + */ +export interface SessionSnapshotPrivateStagingRootAuthority { + verifyPrivateStagingRoot(input: { + readonly canonicalPath: string; + }): Promise<{ readonly canonicalPath: string }>; +} + +export interface PrepareQuiescentSessionSnapshotInput { + readonly makaSessionId: string; + readonly signal?: AbortSignal; + /** Absolute Unix time in milliseconds. */ + readonly deadlineAt?: number; +} + +export interface PreparedSessionBundleHandle { + readonly snapshot: PreparedSessionBundleSnapshot; + readonly policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION; + readonly workspace: SessionSnapshotWorkspacePreparation; + /** + * Idempotent after successful cleanup; failures remain retryable. Path and + * identity checks fail closed on replacements observable before deletion, but + * do not defend against an adversarial same-principal replacement in the final + * path-based filesystem-operation window. + */ + release(): Promise; +} + +export interface QuiescentSessionSnapshotCoordinator { + prepare(input: PrepareQuiescentSessionSnapshotInput): Promise; +} + +export type SessionSnapshotErrorCode = + | 'invalid_input' + | 'snapshot_busy' + | 'snapshot_cancelled' + | 'session_not_quiescent' + | 'source_changed' + | 'unsafe_source' + | 'policy_rejected' + | 'quota_exceeded' + | 'cleanup_failed' + | 'io_failure'; + +export type SessionSnapshotPhase = + | 'admission' + | 'staging' + | 'state' + | 'workspace' + | 'publication' + | 'cleanup'; + +export interface SessionSnapshotErrorDetails { + readonly phase?: SessionSnapshotPhase; + readonly policyCategory?: SessionSnapshotWorkspaceRejectionCategory; + readonly limit?: number; + readonly observed?: number; +} + +export interface SessionSnapshotErrorOptions extends ErrorOptions { + readonly details?: SessionSnapshotErrorDetails; +} + +export class SessionSnapshotError extends Error { + readonly details?: Readonly; + + constructor( + readonly code: SessionSnapshotErrorCode, + message: string, + options: SessionSnapshotErrorOptions = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'SessionSnapshotError'; + if (options.details !== undefined) this.details = Object.freeze({ ...options.details }); + } +} + +export interface FileQuiescentSessionSnapshotCoordinatorOptions { + /** + * Private control-plane directory outside live state and workspace roots. + * Code running as the same OS principal and able to mutate this parent is in + * the trusted computing boundary; Node's path-based recursive removal cannot + * make an adversarial final-check-to-delete race impossible. + */ + readonly stagingParent: string; + readonly quiescence: SessionSnapshotQuiescenceAuthority; + readonly state: SessionSnapshotStatePreparer; + readonly workspace: SessionSnapshotWorkspacePreparer; + /** Required on Windows; optional additional verification on POSIX platforms. */ + readonly privateStagingRootAuthority?: SessionSnapshotPrivateStagingRootAuthority; + readonly now?: () => number; + readonly newSnapshotId?: () => string; +} + +export function createFileQuiescentSessionSnapshotCoordinator( + options: FileQuiescentSessionSnapshotCoordinatorOptions, +): QuiescentSessionSnapshotCoordinator { + return new FileQuiescentSessionSnapshotCoordinator(options); +} + +class FileQuiescentSessionSnapshotCoordinator implements QuiescentSessionSnapshotCoordinator { + readonly #stagingParent: string; + readonly #quiescence: SessionSnapshotQuiescenceAuthority; + readonly #state: SessionSnapshotStatePreparer; + readonly #workspace: SessionSnapshotWorkspacePreparer; + readonly #privateStagingRootAuthority: SessionSnapshotPrivateStagingRootAuthority | undefined; + readonly #now: () => number; + readonly #newSnapshotId: () => string; + + constructor(options: FileQuiescentSessionSnapshotCoordinatorOptions) { + if (!isAbsolute(options.stagingParent)) { + throw new TypeError('Session snapshot stagingParent must be absolute'); + } + if ('policy' in options) { + throw new TypeError('Session snapshot V1 safety policy cannot be overridden'); + } + this.#stagingParent = resolve(options.stagingParent); + this.#quiescence = options.quiescence; + this.#state = options.state; + this.#workspace = options.workspace; + this.#privateStagingRootAuthority = options.privateStagingRootAuthority; + this.#now = options.now ?? Date.now; + this.#newSnapshotId = options.newSnapshotId ?? randomUUID; + } + + async prepare(input: PrepareQuiescentSessionSnapshotInput): Promise { + const makaSessionId = requireMakaSessionId(input.makaSessionId); + const cancellation = createCancellation(input, this.#now); + let prepared: OwnedPreparedSessionBundleHandle | undefined; + let operationStarted = false; + try { + cancellation.assertActive(); + const result = await this.#quiescence.runQuiescent( + { makaSessionId, cancellation: cancellation.value }, + async () => { + if (operationStarted) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot quiescence operation was invoked more than once', + { details: { phase: 'admission' } }, + ); + } + operationStarted = true; + cancellation.assertActive(); + const staging = await OwnedSnapshotStaging.create( + this.#stagingParent, + requireSnapshotId(this.#newSnapshotId()), + this.#privateStagingRootAuthority, + ); + try { + cancellation.assertActive(); + const stateIdentity = copyOpaqueStateIdentityDescriptor( + await this.#state.prepareState({ + makaSessionId, + destinationRoot: staging.stateRoot, + cancellation: cancellation.value, + }), + ); + cancellation.assertActive(); + await assertPreparedRoot(staging.stateRoot, 'state'); + + const workspace = normalizeWorkspacePreparation( + await this.#workspace.prepareWorkspace({ + makaSessionId, + destinationRoot: staging.workspaceRoot, + policy: SESSION_SNAPSHOT_WORKSPACE_POLICY_V1, + cancellation: cancellation.value, + }), + ); + cancellation.assertActive(); + await assertPreparedRoot(staging.workspaceRoot, 'workspace'); + cancellation.assertActive(); + + const published = await staging.publish(); + cancellation.assertActive(); + const handle = new OwnedPreparedSessionBundleHandle( + published, + stateIdentity, + workspace, + SESSION_SNAPSHOT_POLICY_VERSION, + ); + prepared = handle; + return handle; + } catch (error) { + await cleanupAfterPreparationFailure(staging, error); + } + }, + ); + cancellation.assertActive(); + if (!prepared || result !== prepared) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot quiescence operation did not return its prepared handle', + { details: { phase: 'admission' } }, + ); + } + return result; + } catch (error) { + if (prepared) { + try { + await prepared.release(); + } catch (cleanupError) { + throw cleanupFailure(new AggregateError([error, cleanupError])); + } + } + throw normalizePreparationError(error, cancellation.value.signal); + } finally { + cancellation.close(); + } + } +} + +interface SnapshotOwnerRecord { + readonly schemaVersion: typeof SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION; + readonly snapshotId: string; + readonly ownerToken: string; + readonly rootDev: string; + readonly rootIno: string; +} + +interface FilesystemIdentity { + readonly dev: bigint; + readonly ino: bigint; +} + +interface SnapshotOwnerBinding { + readonly path: string; + readonly cleanupPath: string; + readonly record: SnapshotOwnerRecord; + readonly identity: FilesystemIdentity; +} + +interface PublishedSnapshotStaging { + readonly parent: string; + readonly root: string; + readonly cleanupRoot: string; + readonly stateRoot: string; + readonly workspaceRoot: string; + readonly owner: SnapshotOwnerBinding; + readonly identity: FilesystemIdentity; +} + +class OwnedSnapshotStaging { + readonly stateRoot: string; + readonly workspaceRoot: string; + readonly #preparingRoot: string; + readonly #publishedRoot: string; + readonly #cleanupRoot: string; + readonly #ownerFile: string; + readonly #ownerCleanupFile: string; + readonly #snapshotId: string; + readonly #ownerToken: string; + #owner: SnapshotOwnerBinding | undefined; + #identity: FilesystemIdentity | undefined; + #published = false; + + private constructor(parent: string, snapshotId: string, ownerToken: string) { + this.#preparingRoot = join(parent, `.snapshot-${snapshotId}.preparing`); + this.#publishedRoot = join(parent, `snapshot-${snapshotId}`); + this.#cleanupRoot = join(parent, `.snapshot-${snapshotId}.${ownerToken}.cleanup`); + this.#ownerFile = join(parent, `.snapshot-${snapshotId}.owner.json`); + this.#ownerCleanupFile = join(parent, `.snapshot-${snapshotId}.${ownerToken}.owner-cleanup`); + this.#snapshotId = snapshotId; + this.#ownerToken = ownerToken; + this.stateRoot = join(this.#preparingRoot, 'state'); + this.workspaceRoot = join(this.#preparingRoot, 'workspace'); + } + + static async create( + parent: string, + snapshotId: string, + privateRootAuthority: SessionSnapshotPrivateStagingRootAuthority | undefined, + ): Promise { + const canonicalParent = await preparePrivateStagingParent(parent, privateRootAuthority); + const staging = new OwnedSnapshotStaging(canonicalParent, snapshotId, randomUUID()); + let rootCreated = false; + try { + await assertMissing(staging.#preparingRoot); + await assertMissing(staging.#publishedRoot); + await assertMissing(staging.#cleanupRoot); + await assertMissing(staging.#ownerCleanupFile); + await mkdir(staging.#preparingRoot, { mode: 0o700 }); + rootCreated = true; + staging.#identity = await readDirectoryIdentity(staging.#preparingRoot); + try { + await verifyPrivateStagingDirectory(staging.#preparingRoot, privateRootAuthority); + } catch (verificationError) { + throw new SessionSnapshotError('unsafe_source', 'Session snapshot staging root is unsafe', { + cause: verificationError, + details: { phase: 'staging' }, + }); + } + const record: SnapshotOwnerRecord = { + schemaVersion: SESSION_SNAPSHOT_STAGING_SCHEMA_VERSION, + snapshotId: staging.#snapshotId, + ownerToken: staging.#ownerToken, + rootDev: staging.#identity.dev.toString(), + rootIno: staging.#identity.ino.toString(), + }; + staging.#owner = await writeOwnerRecord( + staging.#ownerFile, + staging.#ownerCleanupFile, + record, + ); + return staging; + } catch (error) { + if (rootCreated && staging.#identity) { + try { + await removeDirectoryBoundToIdentity({ + parent: canonicalParent, + root: staging.#preparingRoot, + cleanupRoot: staging.#cleanupRoot, + identity: staging.#identity, + }); + } catch (cleanupError) { + throw cleanupFailure(new AggregateError([error, cleanupError])); + } + } else if (rootCreated) { + throw cleanupFailure( + new AggregateError([error, new Error('Snapshot root identity is unavailable')]), + ); + } + if (error instanceof SessionSnapshotError) throw error; + throw new SessionSnapshotError('io_failure', 'Unable to create Session snapshot staging', { + cause: error, + details: { phase: 'staging' }, + }); + } + } + + async publish(): Promise { + if (this.#published) { + throw new SessionSnapshotError( + 'io_failure', + 'Session snapshot staging is already published', + { + details: { phase: 'publication' }, + }, + ); + } + try { + if (!this.#identity || !this.#owner) { + throw new Error('Snapshot ownership is unavailable'); + } + await assertOwnedRoot(this.#preparingRoot, this.#owner, this.#identity); + await rename(this.#preparingRoot, this.#publishedRoot); + this.#published = true; + const identity = await readDirectoryIdentity(this.#publishedRoot); + if (!sameFilesystemIdentity(identity, this.#identity)) { + throw new Error('Published root identity changed'); + } + await assertOwnerRecord(this.#owner); + return { + parent: dirname(this.#publishedRoot), + root: this.#publishedRoot, + cleanupRoot: this.#cleanupRoot, + stateRoot: join(this.#publishedRoot, 'state'), + workspaceRoot: join(this.#publishedRoot, 'workspace'), + owner: this.#owner, + identity, + }; + } catch (error) { + throw new SessionSnapshotError('io_failure', 'Unable to publish Session snapshot staging', { + cause: error, + details: { phase: 'publication' }, + }); + } + } + + async discard(): Promise { + const root = this.#published ? this.#publishedRoot : this.#preparingRoot; + try { + if (!this.#identity || !this.#owner) { + throw new Error('Snapshot ownership is unavailable'); + } + await removeOwnedSnapshotDirectory({ + parent: dirname(root), + root, + cleanupRoot: this.#cleanupRoot, + owner: this.#owner, + identity: this.#identity, + }); + } catch (error) { + throw cleanupFailure(error); + } + } +} + +class OwnedPreparedSessionBundleHandle implements PreparedSessionBundleHandle { + readonly snapshot: PreparedSessionBundleSnapshot; + readonly workspace: SessionSnapshotWorkspacePreparation; + readonly policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION; + readonly #staging: PublishedSnapshotStaging; + #releaseTask: Promise | undefined; + #released = false; + + constructor( + staging: PublishedSnapshotStaging, + stateIdentity: OpaqueStateIdentityDescriptor, + workspace: SessionSnapshotWorkspacePreparation, + policyVersion: typeof SESSION_SNAPSHOT_POLICY_VERSION, + ) { + this.#staging = staging; + this.snapshot = Object.freeze({ + stateRoot: staging.stateRoot, + workspaceRoot: staging.workspaceRoot, + stateIdentity: Object.freeze(copyOpaqueStateIdentityDescriptor(stateIdentity)), + }); + this.workspace = Object.freeze({ ...workspace }); + this.policyVersion = policyVersion; + } + + async release(): Promise { + if (this.#released) return; + if (this.#releaseTask) return this.#releaseTask; + const task = this.#releaseOnce(); + this.#releaseTask = task; + try { + await task; + this.#released = true; + } finally { + if (!this.#released) this.#releaseTask = undefined; + } + } + + async #releaseOnce(): Promise { + try { + await removeOwnedSnapshotDirectory({ + parent: this.#staging.parent, + root: this.#staging.root, + cleanupRoot: this.#staging.cleanupRoot, + owner: this.#staging.owner, + identity: this.#staging.identity, + }); + } catch (error) { + throw cleanupFailure(error); + } + } +} + +function decodeWorkspaceEntry( + entry: SessionSnapshotWorkspaceEntry, +): + | { readonly kind: 'valid'; readonly segments: readonly string[]; readonly basename: string } + | { readonly kind: 'reject'; readonly category: 'unsafe_path' | 'unsupported_entry' } { + if (entry.kind !== 'file' && entry.kind !== 'directory') { + return { kind: 'reject', category: 'unsupported_entry' }; + } + if ( + typeof entry.relativePath !== 'string' || + entry.relativePath.length === 0 || + entry.relativePath.includes('\\') || + entry.relativePath.includes('\0') || + entry.relativePath.startsWith('/') || + entry.relativePath.endsWith('/') + ) { + return { kind: 'reject', category: 'unsafe_path' }; + } + const segments = entry.relativePath.split('/'); + if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) { + return { kind: 'reject', category: 'unsafe_path' }; + } + return { kind: 'valid', segments, basename: segments.at(-1)! }; +} + +function isKnownSecretPath(lowerSegments: readonly string[], lowerName: string): boolean { + if (KNOWN_SECRET_WORKSPACE_FILE_PATTERNS.some((pattern) => pattern.test(lowerName))) return true; + if ( + lowerSegments.includes('.ssh') || + lowerName === 'service-account.json' || + lowerName === 'service-account-key.json' + ) { + return true; + } + return ( + (lowerSegments.at(-2) === '.docker' && lowerName === 'config.json') || + (lowerSegments.at(-2) === '.aws' && lowerName === 'credentials') || + (lowerSegments.at(-2) === '.cargo' && lowerName === 'credentials') || + (lowerSegments.at(-2) === '.kube' && lowerName === 'config') || + (lowerSegments.at(-2) === 'gcloud' && + lowerSegments.at(-3) === '.config' && + lowerName === 'application_default_credentials.json') + ); +} + +function requireMakaSessionId(value: unknown): string { + if (typeof value !== 'string' || !isSafeSessionId(value)) { + throw new SessionSnapshotError('invalid_input', 'Maka Session identity is invalid', { + details: { phase: 'admission' }, + }); + } + return value; +} + +function requireSnapshotId(value: unknown): string { + if (typeof value !== 'string' || !SNAPSHOT_ID_PATTERN.test(value)) { + throw new SessionSnapshotError('io_failure', 'Session snapshot identity allocation failed', { + details: { phase: 'staging' }, + }); + } + return value; +} + +function normalizeWorkspacePreparation( + value: SessionSnapshotWorkspacePreparation, +): SessionSnapshotWorkspacePreparation { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + for (const count of [value.includedEntries, value.excludedEntries, value.payloadBytes]) { + if (!Number.isSafeInteger(count) || count < 0) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + } + const excludedEntriesByCategory = normalizeExclusionCounts(value.excludedEntriesByCategory); + const categorizedExclusions = Object.values(excludedEntriesByCategory).reduce( + (total, count) => total + count, + 0, + ); + if (categorizedExclusions !== value.excludedEntries) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + return { + includedEntries: value.includedEntries, + excludedEntries: value.excludedEntries, + excludedEntriesByCategory, + payloadBytes: value.payloadBytes, + }; +} + +const WORKSPACE_EXCLUSION_CATEGORIES = [ + 'dependency_tree', + 'source_control', + 'cache', + 'log', + 'runtime_scratch', +] as const satisfies readonly SessionSnapshotWorkspaceExclusionCategory[]; + +function normalizeExclusionCounts( + value: Readonly>, +): Readonly> { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + const record = value as Record; + const keys = Object.keys(record); + if ( + keys.length !== WORKSPACE_EXCLUSION_CATEGORIES.length || + keys.some( + (key) => + !WORKSPACE_EXCLUSION_CATEGORIES.includes(key as SessionSnapshotWorkspaceExclusionCategory), + ) + ) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + const normalized = Object.fromEntries( + WORKSPACE_EXCLUSION_CATEGORIES.map((category) => { + const count = record[category]; + if (!Number.isSafeInteger(count) || (count as number) < 0) { + throw new SessionSnapshotError('io_failure', 'Workspace snapshot result is invalid', { + details: { phase: 'workspace' }, + }); + } + return [category, count]; + }), + ) as Record; + return Object.freeze(normalized); +} + +function createCancellation( + input: PrepareQuiescentSessionSnapshotInput, + now: () => number, +): { + readonly value: SessionSnapshotCancellation; + assertActive(): void; + close(): void; +} { + if ( + input.deadlineAt !== undefined && + (!Number.isSafeInteger(input.deadlineAt) || input.deadlineAt < 0) + ) { + throw new SessionSnapshotError('invalid_input', 'Session snapshot deadline is invalid', { + details: { phase: 'admission' }, + }); + } + const controller = new AbortController(); + const abort = () => controller.abort(); + input.signal?.addEventListener('abort', abort, { once: true }); + const remaining = input.deadlineAt === undefined ? undefined : input.deadlineAt - now(); + const timeout = + remaining === undefined || remaining <= 0 + ? undefined + : setTimeout(abort, Math.min(remaining, 2_147_483_647)); + timeout?.unref(); + if (input.signal?.aborted || (remaining !== undefined && remaining <= 0)) abort(); + const value = Object.freeze({ + signal: controller.signal, + ...(input.deadlineAt === undefined ? {} : { deadlineAt: input.deadlineAt }), + }); + return { + value, + assertActive: () => { + if (controller.signal.aborted) { + throw new SessionSnapshotError( + 'snapshot_cancelled', + 'Session snapshot preparation was cancelled', + { details: { phase: 'admission' } }, + ); + } + }, + close: () => { + if (timeout) clearTimeout(timeout); + input.signal?.removeEventListener('abort', abort); + }, + }; +} + +async function preparePrivateStagingParent( + path: string, + authority: SessionSnapshotPrivateStagingRootAuthority | undefined, +): Promise { + try { + await mkdir(path, { recursive: true, mode: 0o700 }); + const canonical = await realpath(path); + await verifyPrivateStagingDirectory(canonical, authority); + return canonical; + } catch (error) { + throw new SessionSnapshotError('unsafe_source', 'Session snapshot staging root is unsafe', { + cause: error, + details: { phase: 'staging' }, + }); + } +} + +async function verifyPrivateStagingDirectory( + path: string, + authority: SessionSnapshotPrivateStagingRootAuthority | undefined, +): Promise { + const canonical = await realpath(path); + if (canonical !== path) throw new Error('Staging directory is not canonical'); + const info = await lstat(canonical, { bigint: true }); + if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Staging parent is invalid'); + if (process.platform === 'win32' && !authority) { + throw new Error('A Windows ACL verifier is required for the staging parent'); + } + if (process.platform !== 'win32') { + const permissions = Number(info.mode & 0o777n); + if ((permissions & 0o077) !== 0) { + throw new Error('Staging parent is accessible outside its owner'); + } + const currentUserId = process.getuid?.(); + if (currentUserId !== undefined && info.uid !== BigInt(currentUserId)) { + throw new Error('Staging parent has a different filesystem owner'); + } + } + if (authority) { + const verification = await authority.verifyPrivateStagingRoot({ canonicalPath: canonical }); + if ( + !verification || + typeof verification.canonicalPath !== 'string' || + verification.canonicalPath !== canonical + ) { + throw new Error('Staging parent privacy verification was bound to a different path'); + } + } +} + +async function assertMissing(path: string): Promise { + try { + await lstat(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return; + throw error; + } + throw new Error('Snapshot staging identity already exists'); +} + +async function writeOwnerRecord( + path: string, + cleanupPath: string, + record: SnapshotOwnerRecord, +): Promise { + let handle: Awaited> | undefined; + let identity: FilesystemIdentity | undefined; + let failure: unknown; + try { + handle = await open( + path, + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | NO_FOLLOW_OPEN_FLAG, + 0o600, + ); + const info = await handle.stat({ bigint: true }); + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n) { + throw new Error('Snapshot ownership record is not a private file'); + } + identity = { dev: info.dev, ino: info.ino }; + await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8'); + if (process.platform !== 'win32') await handle.chmod(0o600); + await handle.sync(); + } catch (error) { + failure = error; + } + if (handle) { + try { + await handle.close(); + } catch (error) { + failure = failure === undefined ? error : new AggregateError([failure, error]); + } + } + if (!identity) { + if (failure !== undefined) throw failure; + throw new Error('Snapshot ownership record identity is unavailable'); + } + const binding = { path, cleanupPath, record, identity }; + if (failure === undefined) { + try { + await assertOwnerRecord(binding); + return binding; + } catch (error) { + failure = error; + } + } + try { + await removeFileBoundToIdentity({ + parent: dirname(path), + root: path, + cleanupRoot: cleanupPath, + identity, + }); + } catch (cleanupError) { + throw cleanupFailure(new AggregateError([failure, cleanupError])); + } + throw failure; +} + +async function assertPreparedRoot(path: string, label: 'state' | 'workspace'): Promise { + let info; + try { + info = await lstat(path, { bigint: true }); + } catch (error) { + throw new SessionSnapshotError('io_failure', `Prepared ${label} snapshot is unavailable`, { + cause: error, + details: { phase: label }, + }); + } + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new SessionSnapshotError('unsafe_source', `Prepared ${label} snapshot is unsafe`, { + details: { phase: label }, + }); + } +} + +async function assertOwnerRecord( + binding: SnapshotOwnerBinding, + path = binding.path, +): Promise { + const handle = await open(path, fsConstants.O_RDONLY | NO_FOLLOW_OPEN_FLAG); + try { + const info = await handle.stat({ bigint: true }); + if ( + !info.isFile() || + info.isSymbolicLink() || + info.nlink !== 1n || + info.size > BigInt(MAX_OWNER_RECORD_BYTES) || + !sameFilesystemIdentity({ dev: info.dev, ino: info.ino }, binding.identity) + ) { + throw new Error('Snapshot ownership record is invalid'); + } + const value = JSON.parse(await handle.readFile('utf8')) as unknown; + if (!sameOwnerRecord(value, binding.record)) { + throw new Error('Snapshot ownership record changed'); + } + } finally { + await handle.close(); + } + const pathIdentity = await readFileIdentity(path); + if (!sameFilesystemIdentity(pathIdentity, binding.identity)) { + throw new Error('Snapshot ownership record path changed'); + } +} + +async function assertOwnedRoot( + root: string, + owner: SnapshotOwnerBinding, + identity: FilesystemIdentity, +): Promise { + const actual = await readDirectoryIdentity(root); + if (!sameFilesystemIdentity(actual, identity)) { + throw new Error('Published Session snapshot root identity changed'); + } + if ( + owner.record.rootDev !== identity.dev.toString() || + owner.record.rootIno !== identity.ino.toString() + ) { + throw new Error('Snapshot ownership record is bound to another root'); + } + await assertOwnerRecord(owner); +} + +async function removeOwnedSnapshotDirectory(input: { + parent: string; + root: string; + cleanupRoot: string; + owner: SnapshotOwnerBinding; + identity: FilesystemIdentity; +}): Promise { + if ( + dirname(input.root) !== input.parent || + dirname(input.cleanupRoot) !== input.parent || + dirname(input.owner.path) !== input.parent || + dirname(input.owner.cleanupPath) !== input.parent || + input.root === input.cleanupRoot + ) { + throw new Error('Session snapshot cleanup path escaped its owner'); + } + const cleanupIdentity = await readOptionalDirectoryIdentity(input.cleanupRoot); + const rootIdentity = await readOptionalDirectoryIdentity(input.root); + if (!cleanupIdentity && !rootIdentity) { + await removeOwnerRecord(input.owner); + return; + } + await assertOwnerRecord(input.owner); + if (cleanupIdentity) { + if (rootIdentity) { + throw new Error('Session snapshot cleanup paths conflict'); + } + if (!sameFilesystemIdentity(cleanupIdentity, input.identity)) { + throw new Error('Session snapshot cleanup root identity changed'); + } + await assertOwnedRoot(input.cleanupRoot, input.owner, input.identity); + await rm(input.cleanupRoot, { recursive: true, force: false }); + await removeOwnerRecord(input.owner); + return; + } + await assertOwnedRoot(input.root, input.owner, input.identity); + await rename(input.root, input.cleanupRoot); + await assertOwnedRoot(input.cleanupRoot, input.owner, input.identity); + await rm(input.cleanupRoot, { recursive: true, force: false }); + await removeOwnerRecord(input.owner); +} + +async function removeOwnerRecord(owner: SnapshotOwnerBinding): Promise { + const cleanupIdentity = await readOptionalFileIdentity(owner.cleanupPath); + const ownerIdentity = await readOptionalFileIdentity(owner.path); + if (cleanupIdentity) { + if (ownerIdentity) throw new Error('Snapshot ownership cleanup paths conflict'); + if (!sameFilesystemIdentity(cleanupIdentity, owner.identity)) { + throw new Error('Snapshot ownership cleanup file changed'); + } + await assertOwnerRecord(owner, owner.cleanupPath); + await rm(owner.cleanupPath, { force: false }); + return; + } + if (!ownerIdentity) return; + if (!sameFilesystemIdentity(ownerIdentity, owner.identity)) { + throw new Error('Snapshot ownership record path changed'); + } + await assertOwnerRecord(owner); + await rename(owner.path, owner.cleanupPath); + await assertOwnerRecord(owner, owner.cleanupPath); + await rm(owner.cleanupPath, { force: false }); +} + +async function removeDirectoryBoundToIdentity(input: { + parent: string; + root: string; + cleanupRoot: string; + identity: FilesystemIdentity; +}): Promise { + if ( + dirname(input.root) !== input.parent || + dirname(input.cleanupRoot) !== input.parent || + input.root === input.cleanupRoot + ) { + throw new Error('Session snapshot cleanup path escaped its owner'); + } + const cleanupIdentity = await readOptionalDirectoryIdentity(input.cleanupRoot); + const rootIdentity = await readOptionalDirectoryIdentity(input.root); + if (cleanupIdentity) { + if (rootIdentity) throw new Error('Session snapshot cleanup paths conflict'); + if (!sameFilesystemIdentity(cleanupIdentity, input.identity)) { + throw new Error('Session snapshot cleanup root identity changed'); + } + await rm(input.cleanupRoot, { recursive: true, force: false }); + return; + } + if (!rootIdentity) return; + if (!sameFilesystemIdentity(rootIdentity, input.identity)) { + throw new Error('Session snapshot root identity changed'); + } + await rename(input.root, input.cleanupRoot); + const renamedIdentity = await readDirectoryIdentity(input.cleanupRoot); + if (!sameFilesystemIdentity(renamedIdentity, input.identity)) { + throw new Error('Session snapshot cleanup root identity changed'); + } + await rm(input.cleanupRoot, { recursive: true, force: false }); +} + +async function removeFileBoundToIdentity(input: { + parent: string; + root: string; + cleanupRoot: string; + identity: FilesystemIdentity; +}): Promise { + if ( + dirname(input.root) !== input.parent || + dirname(input.cleanupRoot) !== input.parent || + input.root === input.cleanupRoot + ) { + throw new Error('Session snapshot file cleanup path escaped its owner'); + } + const cleanupIdentity = await readOptionalFileIdentity(input.cleanupRoot); + const rootIdentity = await readOptionalFileIdentity(input.root); + if (cleanupIdentity) { + if (rootIdentity) throw new Error('Session snapshot file cleanup paths conflict'); + if (!sameFilesystemIdentity(cleanupIdentity, input.identity)) { + throw new Error('Session snapshot file cleanup identity changed'); + } + await rm(input.cleanupRoot, { force: false }); + return; + } + if (!rootIdentity) return; + if (!sameFilesystemIdentity(rootIdentity, input.identity)) { + throw new Error('Session snapshot file identity changed'); + } + await rename(input.root, input.cleanupRoot); + const renamedIdentity = await readFileIdentity(input.cleanupRoot); + if (!sameFilesystemIdentity(renamedIdentity, input.identity)) { + throw new Error('Session snapshot file cleanup identity changed'); + } + await rm(input.cleanupRoot, { force: false }); +} + +async function readOptionalDirectoryIdentity( + path: string, +): Promise { + try { + return await readDirectoryIdentity(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +async function readOptionalFileIdentity(path: string): Promise { + try { + return await readFileIdentity(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw error; + } +} + +async function readDirectoryIdentity(path: string): Promise { + const info = await lstat(path, { bigint: true }); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new Error('Session snapshot root is not a directory'); + } + return { dev: info.dev, ino: info.ino }; +} + +async function readFileIdentity(path: string): Promise { + const info = await lstat(path, { bigint: true }); + if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1n) { + throw new Error('Session snapshot ownership record is not a private file'); + } + return { dev: info.dev, ino: info.ino }; +} + +function sameFilesystemIdentity(left: FilesystemIdentity, right: FilesystemIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino; +} + +function sameOwnerRecord(value: unknown, expected: SnapshotOwnerRecord): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + Object.keys(record).length === 5 && + record.schemaVersion === expected.schemaVersion && + record.snapshotId === expected.snapshotId && + record.ownerToken === expected.ownerToken && + record.rootDev === expected.rootDev && + record.rootIno === expected.rootIno + ); +} + +async function cleanupAfterPreparationFailure( + staging: OwnedSnapshotStaging, + primaryError: unknown, +): Promise { + try { + await staging.discard(); + } catch (cleanupError) { + throw cleanupFailure(new AggregateError([primaryError, cleanupError])); + } + throw primaryError; +} + +function normalizePreparationError(error: unknown, signal: AbortSignal): SessionSnapshotError { + if (error instanceof SessionSnapshotError) return error; + if (signal.aborted || isAbortError(error)) { + return new SessionSnapshotError( + 'snapshot_cancelled', + 'Session snapshot preparation was cancelled', + { details: { phase: 'admission' } }, + ); + } + return new SessionSnapshotError('io_failure', 'Session snapshot preparation failed', { + cause: error, + }); +} + +function cleanupFailure(cause: unknown): SessionSnapshotError { + return new SessionSnapshotError('cleanup_failed', 'Session snapshot cleanup failed', { + cause, + details: { phase: 'cleanup' }, + }); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError'; +} + +function isNodeError(error: unknown, code: string): boolean { + return ( + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === code + ); +}