diff --git a/scripts/proof-cloud-setup-cli.mjs b/scripts/proof-cloud-setup-cli.mjs index 5c793b6a..36ff0c87 100644 --- a/scripts/proof-cloud-setup-cli.mjs +++ b/scripts/proof-cloud-setup-cli.mjs @@ -29,6 +29,7 @@ const requested = { cwd: { base: 'repository', path: 'src' }, } const credential = randomBytes(24).toString('hex') +const credentialB = randomBytes(24).toString('hex') const requests = [] const server = createServer((request, response) => { requests.push({ @@ -77,6 +78,7 @@ async function key(value) { } function capture(label) { assert.ok(!raw.includes(credential), 'credential appeared in terminal output') + assert.ok(!raw.includes(credentialB), 'B credential appeared in terminal output') frames.push({ label, screen: screen.trim() }) } async function snapshot() { @@ -95,7 +97,7 @@ async function snapshot() { }, 'SIGUSR2 state snapshot') return value } -async function openSetup() { +async function openSetup(connectionKeys = ['\r']) { await key('\u000b') await expect(/Setup/u) capture('setup menu') @@ -104,7 +106,7 @@ async function openSetup() { await expect(/choose an AgentProfile/u) await key('\r') await expect(/choose a connection/u) - await key('\r') + for (const choiceKey of connectionKeys) await key(choiceKey) await expect(/workspace · cloud sandbox/u) } async function start(endpoint, phase) { @@ -213,6 +215,19 @@ try { updatedAt: '2026-09-29T00:00:00.000Z', lastHealth: { status: 'unknown' }, }, + { + id: 'connection-tangle-sandbox-b', + kind: 'tangle-sandbox', + name: 'Tangle Sandbox B (loopback refusal)', + endpoint, + providerOptions: { + transport: 'local', + capabilityHints: ['stream', 'placement', 'usage'], + }, + createdAt: '2026-09-29T00:00:00.000Z', + updatedAt: '2026-09-29T00:00:00.000Z', + lastHealth: { status: 'unknown' }, + }, ], databaseKeyFile: keyPath, })}\n`, @@ -269,21 +284,58 @@ try { const afterSave = await snapshot() assert.equal(afterSave.state.runs.length, 0) await key('\u001b') + await expect(/new message/u) await reopenedWorkspace('reopened in same process') assert.deepEqual(await readFile(configPath), savedBytes) assert.equal(requests.length, 0) + await openSetup(['\u001b[B', '\r']) + await key('\u000c') + await expect(/files · lifetime/u) + await key('\r') + await expect(/credential · Tangle Sandbox B/u) + await key(credentialB) + await key('\r') + await expect(/review and/u) + await key('\r') + await expect(/selection applied/u) + const selectedBBytes = await readFile(configPath) + const selectedB = JSON.parse(selectedBBytes.toString('utf8')) + assert.equal(selectedB.connectionId, 'connection-tangle-sandbox-b') + assert.ok(!selectedBBytes.includes(credentialB)) + const afterB = await snapshot() + assert.equal(afterB.state.runConfiguration.connectionId, 'connection-tangle-sandbox-b') + await key('\u001b') + await expect(/new message/u) + await openSetup(['\u001b[A', '\r']) + await key('\u000c') + await expect(/files · lifetime/u) + await key('\r') + await key('\r') + await expect(/review and/u) + await key('\r') + await expect(/selection applied/u) + const selectedABytes = await readFile(configPath) + const selectedA = JSON.parse(selectedABytes.toString('utf8')) + assert.equal(selectedA.connectionId, 'connection-tangle-sandbox') + const afterA = await snapshot() + assert.equal(afterA.state.runConfiguration.connectionId, 'connection-tangle-sandbox') + await key('\u001b') + assert.deepEqual(await readFile(configPath), selectedABytes) + assert.equal(requests.length, 0) // A later user submission must reach the existing authenticated provider preflight. await key('CLOUD_SETUP_LATER_TASK\r') await expect(/SERVER_ERROR/u) assert.ok(requests.length > 0) assert.ok(requests.some((request) => request.credentialReceived)) + const afterTask = await snapshot() + assert.equal(afterTask.state.runConfiguration.connectionId, 'connection-tangle-sandbox') capture('later explicit submission; unchanged provider refusal') await key('\u001b') await stop() const beforeReloadRequests = requests.length await start(endpoint, 'disk-reloaded-cli') await reopenedWorkspace('reopened after encrypted disk reload') - assert.deepEqual(await readFile(configPath), savedBytes) + assert.deepEqual(await readFile(configPath), selectedABytes) assert.equal(requests.length, beforeReloadRequests) await key('CLOUD_SETUP_RELOADED_TASK\r') await expect(/SERVER_ERROR/u) @@ -294,7 +346,7 @@ try { evidence.savedWorkspaceRequest = saved.workspaceRequest evidence.savedLifecycle = connection.providerOptions.lifecycle evidence.result = - 'save, same-process reopen, cancel, disk reload and authenticated preflight verified; no cloud task completed' + 'save, A→B→A, same-process reopen, encrypted reload and authenticated A preflight verified; no cloud task completed' } await stop() evidence.exit = exit diff --git a/src/adapters/storage/sqlite-bound-open.ts b/src/adapters/storage/sqlite-bound-open.ts index 4a50f102..a7d1e8f0 100644 --- a/src/adapters/storage/sqlite-bound-open.ts +++ b/src/adapters/storage/sqlite-bound-open.ts @@ -1,7 +1,11 @@ import { closeSync, constants, fchmodSync, fstatSync, openSync, readdirSync } from 'node:fs' import { basename, dirname, resolve } from 'node:path' import { descriptorPath, openAt, unlinkAt } from '../persistence/posix-at.js' -import type { SqliteDatabase, SqliteDatabaseFactory } from './sqlite-driver.js' +import { + isNativeCipherDatabaseFactory, + type SqliteDatabase, + type SqliteDatabaseFactory, +} from './sqlite-driver.js' import { StorageError } from './sqlite-errors.js' export interface BoundSqliteDatabase { @@ -13,6 +17,10 @@ export interface BoundSqliteDatabase { const REQUIRED_PARENT_FLAGS = constants.O_DIRECTORY | (constants.O_NOFOLLOW ?? 0) const REQUIRED_FILE_FLAGS = constants.O_RDWR | (constants.O_NOFOLLOW ?? 0) +// SQLite's Unix VFS may retain a proven descriptor to preserve POSIX locks and +// reuse it for a later connection without a new open(2) call. +const witnessedNativeDescriptors = new Map>() + function unsupported(message: string): StorageError { return new StorageError('STORAGE_PATH_RACE_UNSUPPORTED', message) } @@ -40,19 +48,69 @@ function liveFileDescriptors(): ReadonlySet { return descriptors } +function witnessedNativeDescriptor(expected: ReturnType): boolean { + let found = false + for (const [descriptor, known] of witnessedNativeDescriptors) { + try { + const current = fstatSync(descriptor) + if (!sameInode(known, current)) { + witnessedNativeDescriptors.delete(descriptor) + continue + } + if (sameInode(expected, current)) found = true + } catch { + witnessedNativeDescriptors.delete(descriptor) + } + } + return found +} + +function nativeDatabaseNamesExpectedFile(database: SqliteDatabase, path: string): boolean { + if (!database.open) return false + try { + const list: unknown = database.pragma('database_list') + return ( + Array.isArray(list) && + list.some( + (row) => + row !== null && + typeof row === 'object' && + (row as { readonly name?: unknown }).name === 'main' && + (row as { readonly file?: unknown }).file === path, + ) + ) + } catch { + return false + } +} + function assertDatabaseDescriptorIdentity( before: ReadonlySet, expected: ReturnType, path: string, + factory: SqliteDatabaseFactory, + database: SqliteDatabase, ): void { + const nativeFactory = isNativeCipherDatabaseFactory(factory) for (const descriptor of liveFileDescriptors()) { if (before.has(descriptor)) continue try { - if (sameInode(expected, fstatSync(descriptor))) return + const current = fstatSync(descriptor) + if (!sameInode(expected, current)) continue + if (nativeFactory) witnessedNativeDescriptors.set(descriptor, current) + return } catch { // A concurrent close cannot establish the required identity. } } + if ( + process.platform === 'linux' && + nativeFactory && + witnessedNativeDescriptor(expected) && + nativeDatabaseNamesExpectedFile(database, path) + ) { + return + } throw new StorageError( 'STORAGE_PATH_RACE', `SQLite did not open the validated database file: ${path}`, @@ -165,7 +223,7 @@ export function openBoundSqliteDatabase( fchmodSync(fileDescriptor, 0o600) const descriptorsBefore = liveFileDescriptors() database = factory(descriptorPath(fileDescriptor), { timeout }) - assertDatabaseDescriptorIdentity(descriptorsBefore, metadata, normalizedPath) + assertDatabaseDescriptorIdentity(descriptorsBefore, metadata, normalizedPath, factory, database) return { database, fileDescriptor, newDatabase: opened.newDatabase } } catch (error) { try { diff --git a/src/adapters/storage/sqlite-driver.ts b/src/adapters/storage/sqlite-driver.ts index c2736804..fe6181a9 100644 --- a/src/adapters/storage/sqlite-driver.ts +++ b/src/adapters/storage/sqlite-driver.ts @@ -6,6 +6,7 @@ import { StorageError } from './sqlite-errors.js' export const SQLITE_DRIVER_PACKAGE = 'better-sqlite3-multiple-ciphers' export const SQLITE_DRIVER_VERSION = '13.0.3' const RAW_KEY_PREFIX = Buffer.from('raw:', 'ascii') +const nativeCipherFactories = new WeakSet() export type SqliteValue = string | number | bigint | Buffer | null @@ -88,7 +89,7 @@ export function loadCipherDatabaseFactory(): SqliteDatabaseFactory { 'The encrypted SQLite module has no constructor', ) } - return (filename, options) => { + const factory: SqliteDatabaseFactory = (filename, options) => { try { return new (Constructor as new (path: string, options: unknown) => SqliteDatabase)( filename, @@ -102,6 +103,12 @@ export function loadCipherDatabaseFactory(): SqliteDatabaseFactory { ) } } + nativeCipherFactories.add(factory) + return factory +} + +export function isNativeCipherDatabaseFactory(factory: SqliteDatabaseFactory): boolean { + return nativeCipherFactories.has(factory) } export function configureCipherDatabase( diff --git a/test/security.test.ts b/test/security.test.ts index e2bdc45a..082c245f 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -63,7 +63,10 @@ import { openBoundSqliteDatabase, } from '../src/adapters/storage/sqlite-bound-open.js' import { assertPersistablePayload } from '../src/adapters/storage/sqlite-crypto.js' -import type { SqliteDatabase } from '../src/adapters/storage/sqlite-driver.js' +import { + loadCipherDatabaseFactory, + type SqliteDatabase, +} from '../src/adapters/storage/sqlite-driver.js' import { StorageError } from '../src/adapters/storage/sqlite-errors.js' import { prepareConversationImport } from '../src/app/conversation-import-document.js' import { providerEventFor } from '../src/app/run-event-mapper.js' @@ -1192,3 +1195,44 @@ test('backup and restore enforce the approved root, descriptor identity, and no- ) await storage.close() }) + +test('native SQLite reuses a verified file descriptor on a third open', async () => { + if (process.platform !== 'linux') return + const root = await mkdtemp(join(tmpdir(), 'braid-sqlite-native-reopen-')) + const path = join(root, 'braid.sqlite') + let first: ReturnType | undefined + let second: ReturnType | undefined + let third: ReturnType | undefined + try { + const factory = loadCipherDatabaseFactory() + first = openBoundSqliteDatabase(path, factory, 5_000) + first.database.pragma('journal_mode = WAL') + first.database.exec('CREATE TABLE probe (value TEXT)') + first.database.exec("INSERT INTO probe (value) VALUES ('bound')") + second = openBoundSqliteDatabase(path, factory, 5_000) + second.database.pragma('journal_mode = WAL') + closeBoundSqliteDatabase(first) + first = undefined + assert.throws( + () => + openBoundSqliteDatabase( + path, + () => + ({ + open: true, + pragma: () => [{ name: 'main', file: path }], + close: () => undefined, + }) as unknown as SqliteDatabase, + 5_000, + ), + (error: unknown) => error instanceof StorageError && error.code === 'STORAGE_PATH_RACE', + ) + third = openBoundSqliteDatabase(path, factory, 5_000) + assert.deepEqual(third.database.prepare('SELECT value FROM probe').all(), [{ value: 'bound' }]) + } finally { + if (third) closeBoundSqliteDatabase(third) + if (second) closeBoundSqliteDatabase(second) + if (first) closeBoundSqliteDatabase(first) + await rm(root, { recursive: true, force: true }) + } +})