fix(session-query): protect derived index permissions
This commit is contained in:
@@ -36,7 +36,7 @@ One serialized operation reads the provider-neutral `SessionPersistence` snapsho
|
||||
|
||||
Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources.
|
||||
|
||||
The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned.
|
||||
The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned.
|
||||
|
||||
Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption.
|
||||
|
||||
|
||||
@@ -1018,7 +1018,11 @@ Requires: `sessions`
|
||||
```ts config-catalog
|
||||
/** SQLite session-search configuration. */
|
||||
export interface Config {
|
||||
/** Dedicated derived-index path; `:memory:` is supported for tests. */
|
||||
/**
|
||||
* Dedicated derived-index path; `:memory:` is supported for tests. Missing
|
||||
* directories and database files are created owner-only on POSIX filesystems;
|
||||
* existing modes are preserved.
|
||||
*/
|
||||
path: string
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
|
||||
@@ -16,13 +16,13 @@ The service requires `ctx.sessions` and observes optional `ctx.sessionPersistenc
|
||||
|
||||
Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows.
|
||||
|
||||
The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned.
|
||||
The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. |
|
||||
| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. Missing filesystem paths are created owner-only on POSIX filesystems. |
|
||||
| `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. |
|
||||
| `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. |
|
||||
| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. |
|
||||
|
||||
@@ -71,7 +71,11 @@ const STABLE_OBSERVATION_ATTEMPTS = 2
|
||||
|
||||
/** SQLite session-search configuration. */
|
||||
export interface Config {
|
||||
/** Dedicated derived-index path; `:memory:` is supported for tests. */
|
||||
/**
|
||||
* Dedicated derived-index path; `:memory:` is supported for tests. Missing
|
||||
* directories and database files are created owner-only on POSIX filesystems;
|
||||
* existing modes are preserved.
|
||||
*/
|
||||
path: string
|
||||
/** SQLite journal mode. Defaults to `wal`. */
|
||||
journalMode?: JournalMode
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** SQLite schema for the disposable session full-text read model. */
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
/** Current derived-index schema version. Incompatible versions reset in place. */
|
||||
@@ -13,15 +13,31 @@ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
|
||||
/** Supported SQLite journal modes. */
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
/**
|
||||
* Exclusively create a missing database file with owner-only permissions.
|
||||
* Existing files retain their modes, and errors other than `EEXIST` propagate.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open, validate, and initialize persistent and connection-local schemas.
|
||||
* @param path - dedicated derived-index path or `:memory:`.
|
||||
* @param path - dedicated derived-index path or `:memory:`; missing filesystem paths are created owner-only.
|
||||
* @param journalMode - validated SQLite journal mode.
|
||||
* @returns initialized database handle owned by the search service.
|
||||
*/
|
||||
export async function openSearchDatabase(path: string, journalMode: JournalMode): Promise<DatabaseSync> {
|
||||
const actual = path === ':memory:' ? path : resolve(path)
|
||||
if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
|
||||
if (actual !== ':memory:') {
|
||||
await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
|
||||
await createDatabaseFile(actual)
|
||||
}
|
||||
const db = new DatabaseSync(actual)
|
||||
try {
|
||||
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -924,6 +924,57 @@ describe('SQLite reconciliation and source lifecycle', () => {
|
||||
})
|
||||
|
||||
describe('SQLite schema, cancellation, and real persistence integration', () => {
|
||||
it('creates a new database and WAL sidecars owner-only without changing its parent mode', async () => {
|
||||
if (process.platform === 'win32') return
|
||||
const path = await temporaryPath()
|
||||
const directory = dirname(path)
|
||||
await chmod(directory, 0o755)
|
||||
|
||||
const ctx = await liveContext({ path })
|
||||
await ctx.sessionSearch.searchSessions({ query: 'needle' })
|
||||
|
||||
expect((await stat(directory)).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 (ctx.sessionSearch as SessionSearchSqlite).close()
|
||||
})
|
||||
|
||||
it('creates a persistent rollback journal owner-only', async () => {
|
||||
if (process.platform === 'win32') return
|
||||
const path = await temporaryPath()
|
||||
const ctx = await liveContext({ path, journalMode: 'persist' })
|
||||
await ctx.sessionSearch.searchSessions({ query: 'needle' })
|
||||
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o600)
|
||||
expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
|
||||
await (ctx.sessionSearch as SessionSearchSqlite).close()
|
||||
})
|
||||
|
||||
it('preserves the mode of an existing database file', async () => {
|
||||
if (process.platform === 'win32') return
|
||||
const path = await temporaryPath()
|
||||
await writeFile(path, '', { mode: 0o644 })
|
||||
await chmod(path, 0o644)
|
||||
|
||||
const ctx = await liveContext({ path, journalMode: 'delete' })
|
||||
await ctx.sessionSearch.searchSessions({ query: 'needle' })
|
||||
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o644)
|
||||
await (ctx.sessionSearch as SessionSearchSqlite).close()
|
||||
})
|
||||
|
||||
it('surfaces filesystem failures while pre-creating the database', async () => {
|
||||
const path = `${await temporaryPath()}\0`
|
||||
const ctx = await liveContext({ path })
|
||||
|
||||
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toMatchObject({
|
||||
code: 'SESSION_QUERY_INDEX_FAILED',
|
||||
cause: { code: 'ERR_INVALID_ARG_VALUE' },
|
||||
})
|
||||
await (ctx.sessionSearch as SessionSearchSqlite).close()
|
||||
})
|
||||
|
||||
it('resets a recognized incompatible derived schema but refuses a foreign database', async () => {
|
||||
const stalePath = await temporaryPath('stale.db')
|
||||
const stale = new DatabaseSync(stalePath)
|
||||
|
||||
Reference in New Issue
Block a user