Merge pull request #357 from deepseek-harness/worktree/fix-sqlite-file-permissions
fix(session-persistence): create SQLite databases owner-only
This commit is contained in:
@@ -656,8 +656,12 @@ Requires: `sessions`
|
|||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* Filesystem path to the SQLite database file. The special value `:memory:`
|
* Filesystem path to the SQLite database file. The special value `:memory:`
|
||||||
* opens an in-process database (tests); a file path is created (with parent
|
* opens an in-process database (tests). On filesystems with POSIX modes,
|
||||||
* dirs) on construction.
|
* missing directories and databases are created owner-only; existing path
|
||||||
|
* modes are preserved. Filesystem setup errors other than an existing database
|
||||||
|
* fail initialization. The backend does not protect confidentiality or
|
||||||
|
* integrity when another principal can replace the database entry in its
|
||||||
|
* parent directory.
|
||||||
*/
|
*/
|
||||||
path: string
|
path: string
|
||||||
/**
|
/**
|
||||||
@@ -680,7 +684,7 @@ export interface Config {
|
|||||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||||
```
|
```
|
||||||
|
|
||||||
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:55`](../packages/session-persistence/session-persistence-sqlite/src/index.ts)
|
||||||
|
|
||||||
## `@deepseek-ai/dsh-session-query`
|
## `@deepseek-ai/dsh-session-query`
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq,
|
|||||||
|
|
||||||
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
|
The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations.
|
||||||
|
|
||||||
|
On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory.
|
||||||
|
|
||||||
## Contract semantics over rows
|
## Contract semantics over rows
|
||||||
|
|
||||||
- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.)
|
- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import z from 'schemastery'
|
import z from 'schemastery'
|
||||||
import { DatabaseSync } from 'node:sqlite'
|
import { DatabaseSync } from 'node:sqlite'
|
||||||
import { mkdir } from 'node:fs/promises'
|
import { mkdir, open } from 'node:fs/promises'
|
||||||
import { dirname, resolve } from 'node:path'
|
import { dirname, resolve } from 'node:path'
|
||||||
import {
|
import {
|
||||||
SessionPersistence, PersistenceCoordinator,
|
SessionPersistence, PersistenceCoordinator,
|
||||||
@@ -35,12 +35,32 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclusively create a missing database file with owner-only permissions.
|
||||||
|
* Existing files retain their modes, and errors other than `EEXIST` propagate.
|
||||||
|
* `DatabaseSync` reopens by path, so this does not protect confidentiality or
|
||||||
|
* integrity when another principal can replace the database entry in its parent
|
||||||
|
* directory.
|
||||||
|
*/
|
||||||
|
async function createDatabaseFile(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const handle = await open(path, 'wx', 0o600)
|
||||||
|
await handle.close()
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Plugin configuration. */
|
/** Plugin configuration. */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
/**
|
/**
|
||||||
* Filesystem path to the SQLite database file. The special value `:memory:`
|
* Filesystem path to the SQLite database file. The special value `:memory:`
|
||||||
* opens an in-process database (tests); a file path is created (with parent
|
* opens an in-process database (tests). On filesystems with POSIX modes,
|
||||||
* dirs) on construction.
|
* missing directories and databases are created owner-only; existing path
|
||||||
|
* modes are preserved. Filesystem setup errors other than an existing database
|
||||||
|
* fail initialization. The backend does not protect confidentiality or
|
||||||
|
* integrity when another principal can replace the database entry in its
|
||||||
|
* parent directory.
|
||||||
*/
|
*/
|
||||||
path: string
|
path: string
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +108,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
|||||||
if (path !== ':memory:') {
|
if (path !== ':memory:') {
|
||||||
const abs = resolve(path)
|
const abs = resolve(path)
|
||||||
await mkdir(dirname(abs), { recursive: true, mode: 0o700 })
|
await mkdir(dirname(abs), { recursive: true, mode: 0o700 })
|
||||||
|
await createDatabaseFile(abs)
|
||||||
this.db = openDatabase(abs, journalMode)
|
this.db = openDatabase(abs, journalMode)
|
||||||
} else {
|
} else {
|
||||||
this.db = openDatabase(path, journalMode)
|
this.db = openDatabase(path, journalMode)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { afterEach, describe, expect, it } from 'vitest'
|
import { afterEach, describe, expect, it } from 'vitest'
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { mkdtemp, rm } from 'node:fs/promises'
|
import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||||
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
|
||||||
@@ -390,6 +390,61 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('SessionPersistenceSqlite: edge cases', () => {
|
describe('SessionPersistenceSqlite: edge cases', () => {
|
||||||
|
it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => {
|
||||||
|
if (process.platform === 'win32') return
|
||||||
|
const path = await freshDbPath()
|
||||||
|
const dir = dirname(path)
|
||||||
|
await chmod(dir, 0o755)
|
||||||
|
|
||||||
|
const b = await backend(path)
|
||||||
|
await b.ctx.sessionPersistence.list()
|
||||||
|
|
||||||
|
expect((await stat(dir)).mode & 0o777).toBe(0o755)
|
||||||
|
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||||
|
expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
|
||||||
|
expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
|
||||||
|
await b.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a persistent rollback journal with owner-only mode', async () => {
|
||||||
|
if (process.platform === 'win32') return
|
||||||
|
const path = await freshDbPath()
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'persist' })
|
||||||
|
const m = meta('persist-permissions')
|
||||||
|
|
||||||
|
await ctx.sessionPersistence.create(m)
|
||||||
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||||
|
|
||||||
|
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||||
|
expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
|
||||||
|
await fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the mode of an existing database file', async () => {
|
||||||
|
if (process.platform === 'win32') return
|
||||||
|
const path = await freshDbPath()
|
||||||
|
await writeFile(path, '', { mode: 0o644 })
|
||||||
|
await chmod(path, 0o644)
|
||||||
|
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' })
|
||||||
|
await ctx.sessionPersistence.list()
|
||||||
|
|
||||||
|
expect((await stat(path)).mode & 0o777).toBe(0o644)
|
||||||
|
await fiber.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces an invalid database path during pre-creation', async () => {
|
||||||
|
const path = await freshDbPath()
|
||||||
|
const b = await backend(`${path}\0`)
|
||||||
|
|
||||||
|
await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
|
||||||
|
await b.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
|
it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
|
||||||
const path = await freshDbPath()
|
const path = await freshDbPath()
|
||||||
const m = meta('rollback-insert')
|
const m = meta('rollback-insert')
|
||||||
|
|||||||
Reference in New Issue
Block a user