diff --git a/README.md b/README.md index bc193c884..9eff55e76 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ IndexedDB v8 PWA v3.0 i18n 19 locales — 2925 keys - 7266+ tests / 591 files + 7304+ tests / 591 files Codecov Coverage License MIT CI Status @@ -511,7 +511,7 @@ The Settings → AI panel shows a live GPU status badge with adapter details and | **Document Export** | docx + jszip | Word-compatible `.docx` generation (lazy-loaded) | | **PWA** | Service Worker + Web App Manifest v3 | Offline support, installability, Workbox chunking | | **i18n** | Custom React Context (`I18nContext.tsx`) | 2925 keys × 19 locales (de/en/es/fr/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta); EN fallback; `localStorage` persistence | -| **Testing** | Vitest 4.x (7266+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | +| **Testing** | Vitest 4.x (7304+ tests / 591 files) + Playwright E2E | Unit/integration + cross-browser E2E; Stryker mutation (manual workflow) | | **Code Quality** | Biome (lint + format) + TypeScript 7 (tsgo) strict | `--error-on-warnings` in CI; zero `any` policy | | **Visualization** | Force-directed graph | Interactive character relationship network | | **Desktop** | Tauri v2 | Cross-platform installer; auto-updater via `latest.json` | @@ -549,7 +549,7 @@ WorldScript-Studio/ │ ├── sw.js # PWA Service Worker │ └── manifest.json # PWA Web App Manifest v3 ├── tests/ -│ ├── unit/ # Vitest unit tests (7266+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder +│ ├── unit/ # Vitest unit tests (7304+ tests, 591 files) — count spans tests/, components/, packages/*/tests/, not just this folder │ │ ├── ai/ # aiSmallModules, aiCoreFallbackPaths │ │ └── settings/ # WebLlmPanel, AiSections │ └── e2e/ # Playwright specs + helpers.ts @@ -710,8 +710,8 @@ The main pipeline is [`.github/workflows/ci.yml`](.github/workflows/ci.yml). Opt | `deploy` | `main` only | GitHub Pages after **`ci-success`** succeeds | | `scorecard` | weekly + `main` push | OpenSSF Scorecard — SARIF uploaded to GitHub Code Scanning | -**Current test metrics (2026-08-27, source-synchronized; CI remains authoritative for pass/fail):** -- **7266+ unit tests** across **591 test files** — CI is authoritative for pass/fail +**Current test metrics (2026-08-30, source-synchronized; CI remains authoritative for pass/fail):** +- **7304+ unit tests** across **591 test files** — CI is authoritative for pass/fail - Coverage thresholds: lines ≥ 80 · branches ≥ 66 · functions ≥ 72 · statements ≥ 78 — enforced in CI (see Codecov badge for live metrics) - i18n: **2925 keys × 19 locales** (en/de/fr/es/it + ar/he/fa RTL Beta + ja/zh/pt/el/fi/sv/hu/is/eu/ru/ko Beta) diff --git a/features/project/thunks/projectManagementThunks.ts b/features/project/thunks/projectManagementThunks.ts index 7c36d6042..a27c5c458 100644 --- a/features/project/thunks/projectManagementThunks.ts +++ b/features/project/thunks/projectManagementThunks.ts @@ -1,10 +1,24 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; +import type { RootState } from '../../../app/store'; import { parseImportedProjectJson } from '../../../services/projectImportSchema'; import { storageService } from '../../../services/storageService'; import type { Character, World } from '../../../types'; import { charactersAdapter, worldsAdapter } from '../adapters'; import type { ProjectData } from '../projectSlice'; +const LEGACY_PROJECT_DIRECTORY_METADATA_KEY = '__worldscriptLegacyProjectDirectory'; + +// QNBS-v3: compare only storage-owned target identity so mutable snapshot content cannot hide a project switch. +function restoreTargetIdentity(project: unknown): string | null { + if (typeof project !== 'object' || project === null) return null; + const record = project as Record; + if (typeof record['id'] === 'string' && record['id']) return `id:${record['id']}`; + const legacyDirectory = record[LEGACY_PROJECT_DIRECTORY_METADATA_KEY]; + return typeof legacyDirectory === 'string' && legacyDirectory + ? `legacy:${legacyDirectory}` + : null; +} + export const importProjectThunk = createAsyncThunk('project/importProject', async (file: File) => { const text = await file.text(); const projectDataJson = parseImportedProjectJson(text); @@ -88,8 +102,18 @@ export const importProjectThunk = createAsyncThunk('project/importProject', asyn export const restoreSnapshotThunk = createAsyncThunk( 'project/restoreSnapshot', - async (snapshotId: number) => { - const data = await storageService.getSnapshotData(snapshotId); - return data; + async (snapshotId: number, thunkApi) => { + // QNBS-v3: capture ownership before snapshot I/O so payload contents cannot change the restore target. + const currentProject = (thunkApi.getState() as RootState).project?.present?.data; + if (!currentProject) { + throw new Error('Cannot restore a snapshot without an active project.'); + } + const capturedTargetIdentity = restoreTargetIdentity(currentProject); + const restored = await storageService.restoreSnapshot(snapshotId, currentProject); + const liveProject = (thunkApi.getState() as RootState).project?.present?.data; + if (restoreTargetIdentity(liveProject) !== capturedTargetIdentity) { + throw new Error('Cannot restore a snapshot after the active project changed.'); + } + return restored; }, ); diff --git a/services/fs/assetFsStore.ts b/services/fs/assetFsStore.ts index 51344cca4..fb80b4def 100644 --- a/services/fs/assetFsStore.ts +++ b/services/fs/assetFsStore.ts @@ -70,8 +70,11 @@ export class FsAssetStore extends FsSnapshotStore { private async binderAssetPaths(projectId: string, assetId: string) { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(projectId, 'project'); const safeAsset = sanitizePathSegment(assetId, 'asset'); + const safeId = sanitizePathSegment( + this.resolveAuxiliaryProjectId(projectId, 'binder', safeAsset), + 'project', + ); const dir = await apis.join(appDataPath, 'projects', safeId, 'binder'); const binFile = await apis.join(dir, `${safeAsset}.bin`); const metaFile = await apis.join(dir, `${safeAsset}.meta.json`); @@ -84,36 +87,40 @@ export class FsAssetStore extends FsSnapshotStore { data: ArrayBuffer, meta: BinderAssetMeta, ): Promise { - const apis = await this.getApis(); - const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); - if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true }); - const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength }; - await writeFileAtomic(apis, binFile, new Uint8Array(data)); - await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut)); + await this.withLegacyRoutingOperation(async () => { + const apis = await this.getApis(); + const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); + if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true }); + const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength }; + await writeFileAtomic(apis, binFile, new Uint8Array(data)); + await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut)); + }); } async getBinderAsset(projectId: string, assetId: string): Promise { try { - const apis = await this.getApis(); - const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); - if (!(await apis.exists(binFile)) || !(await apis.exists(metaFile))) return null; - const [bytes, metaRaw] = await Promise.all([ - retryFs(() => apis.readFile(binFile)), - retryFs(() => apis.readTextFile(metaFile)), - ]); - const meta = JSON.parse(metaRaw) as BinderAssetMeta; - // QNBS-v3: binary + metadata are two independent atomic writes, not one transaction — a byteSize mismatch is the cheapest reliable signal that a partial failure paired a new generation with a stale one. - if (meta.byteSize !== bytes.byteLength) { - logger.warn('getBinderAsset: byteSize/binary mismatch — treating pair as corrupt', { - projectId, - assetId, - expected: meta.byteSize, - actual: bytes.byteLength, - }); - return null; - } - const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); - return { data: copy, meta }; + return await this.withLegacyRoutingOperation(async () => { + const apis = await this.getApis(); + const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); + if (!(await apis.exists(binFile)) || !(await apis.exists(metaFile))) return null; + const [bytes, metaRaw] = await Promise.all([ + retryFs(() => apis.readFile(binFile)), + retryFs(() => apis.readTextFile(metaFile)), + ]); + const meta = JSON.parse(metaRaw) as BinderAssetMeta; + // QNBS-v3: binary + metadata are two independent atomic writes, not one transaction — a byteSize mismatch is the cheapest reliable signal that a partial failure paired a new generation with a stale one. + if (meta.byteSize !== bytes.byteLength) { + logger.warn('getBinderAsset: byteSize/binary mismatch — treating pair as corrupt', { + projectId, + assetId, + expected: meta.byteSize, + actual: bytes.byteLength, + }); + return null; + } + const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + return { data: copy, meta }; + }); } catch (error) { logger.warn('getBinderAsset failed:', error); return null; @@ -122,28 +129,64 @@ export class FsAssetStore extends FsSnapshotStore { async deleteBinderAsset(projectId: string, assetId: string): Promise { try { - const apis = await this.getApis(); - const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); - if (await apis.exists(binFile)) await retryFs(() => apis.remove(binFile)); - if (await apis.exists(metaFile)) await retryFs(() => apis.remove(metaFile)); + await this.withLegacyRoutingOperation(() => this.deleteBinderAssetStrict(projectId, assetId)); } catch (error) { logger.warn('deleteBinderAsset failed:', error); } } + protected async deleteBinderAssetStrict(projectId: string, assetId: string): Promise { + const { apis, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId); + if (await apis.exists(binFile)) await retryFs(() => apis.remove(binFile)); + if (await apis.exists(metaFile)) await retryFs(() => apis.remove(metaFile)); + } + async listBinderAssetIds(projectId: string): Promise { + try { + return await this.withLegacyRoutingOperation(() => + this.listBinderAssetIdsUnlocked(projectId), + ); + } catch (error) { + logger.warn('listBinderAssetIds failed:', error); + return []; + } + } + + private async listBinderAssetIdsUnlocked(projectId: string): Promise { try { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(projectId, 'project'); - const dir = await apis.join(appDataPath, 'projects', safeId, 'binder'); - if (!(await apis.exists(dir))) return []; - const entries = await retryFs(() => apis.readDir(dir)); const ids = new Set(); - for (const e of entries) { - const name = e.name ?? ''; - if (name.endsWith('.meta.json')) { - ids.add(name.replace(/\.meta\.json$/, '')); + const legacyProjectId = this.legacyBinderProjectId(projectId); + const safeIds = new Set( + [projectId, legacyProjectId] + .filter((id): id is string => Boolean(id)) + .map((id) => sanitizePathSegment(id, 'project')), + ); + for (const safeId of safeIds) { + try { + const dir = await apis.join(appDataPath, 'projects', safeId, 'binder'); + if (!(await apis.exists(dir))) continue; + const legacyOnly = + legacyProjectId !== null && safeId !== sanitizePathSegment(projectId, 'project'); + const allowed = legacyOnly + ? new Set(this.legacyBinderAssetIdsForProject(projectId)) + : null; + const entries = await retryFs(() => apis.readDir(dir)); + for (const e of entries) { + const name = e.name ?? ''; + if (name.endsWith('.meta.json')) { + const id = name.replace(/\.meta\.json$/, ''); + if (!allowed || allowed.has(id)) ids.add(id); + } + } + } catch (error) { + // QNBS-v3: one unreadable legacy directory must not erase IDs already collected from a healthy project directory. + logger.warn('listBinderAssetIds: skipped unreadable project directory', { + projectId, + safeId, + error: error instanceof Error ? error.message : String(error), + }); } } return [...ids]; @@ -154,7 +197,17 @@ export class FsAssetStore extends FsSnapshotStore { } async deleteAllBinderAssetsForProject(projectId: string): Promise { - const ids = await this.listBinderAssetIds(projectId); - await Promise.all(ids.map((id) => this.deleteBinderAsset(projectId, id))); + await this.withLegacyRoutingOperation(async () => { + const ids = await this.listBinderAssetIdsUnlocked(projectId); + await Promise.all( + ids.map(async (id) => { + try { + await this.deleteBinderAssetStrict(projectId, id); + } catch (error) { + logger.warn('deleteBinderAsset failed:', error); + } + }), + ); + }); } } diff --git a/services/fs/codexFsStore.ts b/services/fs/codexFsStore.ts index b1e6d3736..9190c6b20 100644 --- a/services/fs/codexFsStore.ts +++ b/services/fs/codexFsStore.ts @@ -19,24 +19,34 @@ export class FsCodexStore extends FsSettingsStore { // Story Codex — projects/{projectId}/codex/codex.snap async saveStoryCodex(codex: StoryCodex): Promise { - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(codex.projectId, 'project'); - const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex'); - if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true }); - const codexFile = await apis.join(codexDir, 'codex.snap'); - await writeTextFileAtomic(apis, codexFile, compressData(codex)); + await this.withLegacyRoutingOperation(async () => { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeId = sanitizePathSegment( + this.resolveAuxiliaryProjectId(codex.projectId, 'codex'), + 'project', + ); + const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex'); + if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true }); + const codexFile = await apis.join(codexDir, 'codex.snap'); + await writeTextFileAtomic(apis, codexFile, compressData(codex)); + }); } async getStoryCodex(projectId: string): Promise { try { - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(projectId, 'project'); - const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap'); - if (!(await apis.exists(codexFile))) return null; - const content = await retryFs(() => apis.readTextFile(codexFile)); - return decompressData(content); + return await this.withLegacyRoutingOperation(async () => { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeId = sanitizePathSegment( + this.resolveAuxiliaryProjectId(projectId, 'codex'), + 'project', + ); + const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap'); + if (!(await apis.exists(codexFile))) return null; + const content = await retryFs(() => apis.readTextFile(codexFile)); + return decompressData(content); + }); } catch (error) { logger.error('Failed to load story codex:', error); return null; @@ -45,37 +55,55 @@ export class FsCodexStore extends FsSettingsStore { async deleteStoryCodex(projectId: string): Promise { try { - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(projectId, 'project'); - const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap'); - if (await apis.exists(codexFile)) await retryFs(() => apis.remove(codexFile)); + await this.withLegacyRoutingOperation(() => this.deleteStoryCodexStrict(projectId)); } catch (error) { logger.error('Failed to delete story codex:', error); } } - // RAG Vectors — projects/{projectId}/codex/vectors.snap - - async saveRagVectors(projectId: string, vectors: unknown[]): Promise { + protected async deleteStoryCodexStrict(projectId: string): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(projectId, 'project'); - const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex'); - if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true }); - const vectorsFile = await apis.join(codexDir, 'vectors.snap'); - await writeTextFileAtomic(apis, vectorsFile, compressData(vectors)); + const safeId = sanitizePathSegment( + this.resolveAuxiliaryProjectId(projectId, 'codex'), + 'project', + ); + const codexFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'codex.snap'); + if (await apis.exists(codexFile)) await retryFs(() => apis.remove(codexFile)); } - async getRagVectors(projectId: string): Promise { - try { + // RAG Vectors — projects/{projectId}/codex/vectors.snap + + async saveRagVectors(projectId: string, vectors: unknown[]): Promise { + await this.withLegacyRoutingOperation(async () => { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); + // QNBS-v3: vectors.snap has no embedded project provenance, so Codex ownership cannot grant it a legacy fallback route. const safeId = sanitizePathSegment(projectId, 'project'); - const vectorsFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'vectors.snap'); - if (!(await apis.exists(vectorsFile))) return []; - const content = await retryFs(() => apis.readTextFile(vectorsFile)); - return decompressData(content); + const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex'); + if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true }); + const vectorsFile = await apis.join(codexDir, 'vectors.snap'); + await writeTextFileAtomic(apis, vectorsFile, compressData(vectors)); + }); + } + + async getRagVectors(projectId: string): Promise { + try { + return await this.withLegacyRoutingOperation(async () => { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeId = sanitizePathSegment(projectId, 'project'); + const vectorsFile = await apis.join( + appDataPath, + 'projects', + safeId, + 'codex', + 'vectors.snap', + ); + if (!(await apis.exists(vectorsFile))) return []; + const content = await retryFs(() => apis.readTextFile(vectorsFile)); + return decompressData(content); + }); } catch (error) { logger.error('Failed to load RAG vectors:', error); return []; @@ -84,11 +112,19 @@ export class FsCodexStore extends FsSettingsStore { async deleteRagVectors(projectId: string): Promise { try { - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const safeId = sanitizePathSegment(projectId, 'project'); - const vectorsFile = await apis.join(appDataPath, 'projects', safeId, 'codex', 'vectors.snap'); - if (await apis.exists(vectorsFile)) await retryFs(() => apis.remove(vectorsFile)); + await this.withLegacyRoutingOperation(async () => { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeId = sanitizePathSegment(projectId, 'project'); + const vectorsFile = await apis.join( + appDataPath, + 'projects', + safeId, + 'codex', + 'vectors.snap', + ); + if (await apis.exists(vectorsFile)) await retryFs(() => apis.remove(vectorsFile)); + }); } catch (error) { logger.error('Failed to delete RAG vectors:', error); } diff --git a/services/fs/fsCore.ts b/services/fs/fsCore.ts index ef72c7319..7186cf86d 100644 --- a/services/fs/fsCore.ts +++ b/services/fs/fsCore.ts @@ -26,6 +26,12 @@ export type TauriApis = { let tauriApis: TauriApis | null = null; +type LegacyAuxiliaryPolicy = { + legacyProjectId: string; + codex: boolean; + binderAssetIds: ReadonlySet; +}; + export async function loadTauriApis(): Promise { if (tauriApis) return tauriApis; if (!desktopPlatform.runtime.isDesktop) { @@ -158,7 +164,9 @@ export function decompressData(raw: string): T { try { return JSON.parse(decompressed) as T; } catch { - throw new DecompressionError('Failed to parse decompressed data as JSON — the payload is corrupt.'); + throw new DecompressionError( + 'Failed to parse decompressed data as JSON — the payload is corrupt.', + ); } } try { @@ -301,6 +309,8 @@ export function countProjectWords(projectData: unknown): number { export class FsCore { protected appDataPath: string | null = null; + private readonly legacyAuxiliaryPolicies = new Map(); + private legacyRoutingOperationTail: Promise | null = null; protected lastAutoSnapshotTime = Date.now(); protected readonly AUTO_SNAPSHOT_INTERVAL = 5 * 60 * 1000; // 5 minutes protected readonly MAX_AUTO_SNAPSHOTS = 20; @@ -325,4 +335,99 @@ export class FsCore { protected async getApis(): Promise { return loadTauriApis(); } + + // QNBS-v3: serialize complete filesystem operations so legacy route ownership cannot change between awaited mutations. + protected async withLegacyRoutingOperation(operation: () => Promise): Promise { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const previous = this.legacyRoutingOperationTail; + const current = (previous?.catch(() => undefined) ?? Promise.resolve()).then(() => gate); + this.legacyRoutingOperationTail = current; + await previous; + try { + return await operation(); + } finally { + if (this.legacyRoutingOperationTail === current) { + this.legacyRoutingOperationTail = null; + } + release(); + } + } + + protected registerLegacyAuxiliaryPolicy( + projectId: string, + legacyProjectId: string, + policy: Omit, + ): void { + if (policy.codex || policy.binderAssetIds.size > 0) { + // QNBS-v3: one filesystem-owned policy keeps verified legacy auxiliary data addressable without cross-project fallback. + this.legacyAuxiliaryPolicies.set(projectId, { legacyProjectId, ...policy }); + } + } + + protected clearLegacyAuxiliaryPolicy(projectId: string): void { + this.legacyAuxiliaryPolicies.delete(projectId); + } + + // QNBS-v3: quarantine can persist only the verified route, never ambiguous fallback contents, for later recovery. + protected legacyAuxiliaryPolicyForProject(projectId: string): { + legacyProjectId: string; + codex: boolean; + binderAssetIds: readonly string[]; + } | null { + const policy = this.policyFor(projectId); + if (!policy) return null; + return { + legacyProjectId: policy.legacyProjectId, + codex: policy.codex, + binderAssetIds: [...policy.binderAssetIds], + }; + } + + // QNBS-v3: claiming a real project directory invalidates legacy routes targeting that directory before they can redirect another project into it. + protected clearLegacyPoliciesTargetingProject(projectId: string): void { + const safeProjectId = sanitizePathSegment(projectId, ''); + if (!safeProjectId || safeProjectId === '.' || safeProjectId === '..') return; + for (const [policyProjectId, policy] of this.legacyAuxiliaryPolicies) { + if (policy.legacyProjectId === safeProjectId) { + this.legacyAuxiliaryPolicies.delete(policyProjectId); + } + } + } + + private policyFor(projectId: string): LegacyAuxiliaryPolicy | undefined { + const safeProjectId = sanitizePathSegment(projectId, ''); + if (!safeProjectId || safeProjectId === '.' || safeProjectId === '..') return undefined; + return this.legacyAuxiliaryPolicies.get(safeProjectId); + } + + protected resolveAuxiliaryProjectId( + projectId: string, + kind: 'binder' | 'codex', + assetId?: string, + ): string { + const policy = this.policyFor(projectId); + if (!policy) return projectId; + if (kind === 'codex' && policy.codex) return policy.legacyProjectId; + if (kind === 'binder' && assetId && policy.binderAssetIds.has(assetId)) { + return policy.legacyProjectId; + } + return projectId; + } + + protected legacyBinderProjectId(projectId: string): string | null { + const policy = this.policyFor(projectId); + return policy && policy.binderAssetIds.size > 0 ? policy.legacyProjectId : null; + } + + protected legacyCodexProjectId(projectId: string): string | null { + const policy = this.policyFor(projectId); + return policy?.codex ? policy.legacyProjectId : null; + } + + protected legacyBinderAssetIdsForProject(projectId: string): readonly string[] { + return [...(this.policyFor(projectId)?.binderAssetIds ?? [])]; + } } diff --git a/services/fs/legacyProjectIdentity.ts b/services/fs/legacyProjectIdentity.ts new file mode 100644 index 000000000..6a1c49466 --- /dev/null +++ b/services/fs/legacyProjectIdentity.ts @@ -0,0 +1,172 @@ +import type { StoryProject } from '../../types'; +import type { SnapshotRestoreTarget } from '../storageBackend'; +import { sanitizePathSegment } from './fsCore'; + +// QNBS-v3: pure identity and metadata codecs stay separate so recovery orchestration remains auditable. +// QNBS-v3: one sanitizer and empty-ID policy keeps every filesystem project operation on the same path identity. +export function projectPathSegment(projectId: string): string | null { + const safeProjectId = sanitizePathSegment(projectId, ''); + return safeProjectId && safeProjectId !== '.' && safeProjectId !== '..' ? safeProjectId : null; +} + +export function persistedProjectId(project: StoryProject): unknown { + return (project as unknown as Record)['id']; +} + +export const LEGACY_PROJECT_DIRECTORY_METADATA_KEY = '__worldscriptLegacyProjectDirectory'; +export const LEGACY_AUXILIARY_METADATA_KEY = '__worldscriptLegacyAuxiliary'; + +// QNBS-v3: legacy snapshot restoration requires content evidence so an invalid ID cannot claim another project's directory. +export function legacyProjectContent(project: StoryProject): string { + const value = { ...(project as unknown as Record) }; + delete value['id']; + delete value[LEGACY_AUXILIARY_METADATA_KEY]; + delete value[LEGACY_PROJECT_DIRECTORY_METADATA_KEY]; + return JSON.stringify(value) ?? ''; +} + +export function legacyProjectDirectory(project: StoryProject): string | null { + const value = (project as unknown as Record)[ + LEGACY_PROJECT_DIRECTORY_METADATA_KEY + ]; + return typeof value === 'string' && projectPathSegment(value) === value ? value : null; +} + +export function snapshotRestoreTargetDirectory(project: SnapshotRestoreTarget): string | null { + const rawProjectId = (project as unknown as Record)['id']; + if (rawProjectId !== undefined) { + return typeof rawProjectId === 'string' ? projectPathSegment(rawProjectId) : null; + } + return legacyProjectDirectory(project as StoryProject); +} + +export function hasLegacyMissingProjectId(project: StoryProject, safeProjectId: string): boolean { + return ( + typeof persistedProjectId(project) !== 'string' && + safeProjectId === (projectPathSegment(project.title) ?? 'project') + ); +} + +export function isLegacyInvalidProjectId( + project: StoryProject, +): project is StoryProject & { id: string } { + const rawProjectId = persistedProjectId(project); + return typeof rawProjectId === 'string' && !projectPathSegment(rawProjectId); +} + +export function legacyBinderAssetIds(project: StoryProject): string[] { + return (project.binderNodes ?? []) + .map((node) => node.binderAssetId) + .filter((assetId): assetId is string => typeof assetId === 'string') + .map((assetId) => sanitizePathSegment(assetId, 'asset')); +} + +// QNBS-v3: Binder IDs become suffixed filenames, so dot segments remain safe here while project directory dots stay rejected. +export function persistedBinderAssetId(assetId: unknown): assetId is string { + return ( + typeof assetId === 'string' && + assetId.length > 0 && + sanitizePathSegment(assetId, 'asset') === assetId + ); +} + +export type LegacyAuxiliaryEvidence = { + codex: boolean; + binderAssetIds: Set; + inspectionComplete: boolean; +}; + +export type PersistedLegacyAuxiliaryMetadata = { + legacyProjectId: 'project'; + legacyRawProjectId: string; + codex: boolean; + binderAssetIds: string[]; +}; + +export type QuarantineLegacyAuxiliaryManifest = { + projectId: string; + legacyProjectId: string; + codex: boolean; + binderAssetIds: string[]; +}; + +export function persistedLegacyAuxiliaryMetadata( + project: StoryProject, +): PersistedLegacyAuxiliaryMetadata | null { + const value = (project as unknown as Record)[LEGACY_AUXILIARY_METADATA_KEY]; + if (typeof value !== 'object' || value === null) return null; + const candidate = value as Record; + const rawProjectId = candidate['legacyRawProjectId']; + const binderAssetIds = candidate['binderAssetIds']; + if ( + candidate['legacyProjectId'] !== 'project' || + typeof rawProjectId !== 'string' || + !rawProjectId || + projectPathSegment(rawProjectId) || + typeof candidate['codex'] !== 'boolean' || + !Array.isArray(binderAssetIds) || + binderAssetIds.some((assetId) => !persistedBinderAssetId(assetId)) || + (!candidate['codex'] && binderAssetIds.length === 0) + ) { + return null; + } + return { + legacyProjectId: 'project', + legacyRawProjectId: rawProjectId, + codex: candidate['codex'], + binderAssetIds: [...binderAssetIds] as string[], + }; +} + +export function persistedMetadataFromEvidence( + rawProjectId: string, + evidence: LegacyAuxiliaryEvidence, +): PersistedLegacyAuxiliaryMetadata | null { + if (!evidence.inspectionComplete || (!evidence.codex && evidence.binderAssetIds.size === 0)) { + return null; + } + return { + legacyProjectId: 'project', + legacyRawProjectId: rawProjectId, + codex: evidence.codex, + binderAssetIds: [...evidence.binderAssetIds], + }; +} + +export function evidenceFromPersistedMetadata( + metadata: PersistedLegacyAuxiliaryMetadata, +): LegacyAuxiliaryEvidence { + return { + codex: metadata.codex, + binderAssetIds: new Set(metadata.binderAssetIds), + inspectionComplete: true, + }; +} + +export function migratedProjectIdentity( + project: StoryProject, + safeProjectId: string, + rawProjectId?: string, + evidence?: LegacyAuxiliaryEvidence, +): StoryProject { + const metadata = + rawProjectId && evidence ? persistedMetadataFromEvidence(rawProjectId, evidence) : null; + // QNBS-v3: legacy fallback directories carry verified auxiliary provenance across restart, while new invalid IDs remain rejected. + return { + ...project, + id: safeProjectId, + ...(metadata ? { [LEGACY_AUXILIARY_METADATA_KEY]: metadata } : {}), + } as StoryProject; +} + +// QNBS-v3: missing-ID legacy projects retain their loaded directory while callers keep historical auxiliary fallbacks. +export function legacyProjectWithDirectory( + project: StoryProject, + safeProjectId: string, +): StoryProject { + if (legacyProjectDirectory(project) === safeProjectId) return project; + return { + ...project, + [LEGACY_PROJECT_DIRECTORY_METADATA_KEY]: safeProjectId, + } as StoryProject; +} diff --git a/services/fs/projectFsStore.ts b/services/fs/projectFsStore.ts index 10fe992cd..27264af05 100644 --- a/services/fs/projectFsStore.ts +++ b/services/fs/projectFsStore.ts @@ -10,27 +10,115 @@ import type { Character, StoryProject, World } from '../../types'; import { getStaticTranslation } from '../i18n/staticTranslate'; import { logger } from '../logger'; import { parseImportedProjectJson } from '../projectImportSchema'; -import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend'; +import { + normalizeSaveProjectInputToStoryProject, + type ProjectQuarantineResult, + type SaveProjectInput, + type SnapshotRestoreTarget, +} from '../storageBackend'; import { FsAssetStore } from './assetFsStore'; import { compressData, decompressData, retryFs, sanitizePathSegment, + type TauriApis, writeTextFileAtomic, } from './fsCore'; +import { + evidenceFromPersistedMetadata, + hasLegacyMissingProjectId, + isLegacyInvalidProjectId, + LEGACY_AUXILIARY_METADATA_KEY, + LEGACY_PROJECT_DIRECTORY_METADATA_KEY, + type LegacyAuxiliaryEvidence, + legacyBinderAssetIds, + legacyProjectContent, + legacyProjectDirectory, + legacyProjectWithDirectory, + migratedProjectIdentity, + type PersistedLegacyAuxiliaryMetadata, + persistedLegacyAuxiliaryMetadata, + persistedMetadataFromEvidence, + persistedProjectId, + projectPathSegment, + type QuarantineLegacyAuxiliaryManifest, + snapshotRestoreTargetDirectory, +} from './legacyProjectIdentity'; // QNBS-v3 (DA-01): distinguishes corrupt/unreadable saved data from genuine absence — callers must never treat this the same as "no project exists yet". export class ProjectLoadError extends Error { constructor( public readonly reason: 'corrupt' | 'io-error', message: string, + public readonly projectId: string, ) { super(message); this.name = 'ProjectLoadError'; } } +// QNBS-v3: a stable deletion outcome keeps incomplete legacy cleanup retryable without exposing filesystem details. +export class ProjectDeleteError extends Error { + constructor( + public readonly reason: + | 'cleanup-incomplete' + | 'identity-inspection-failed' = 'cleanup-incomplete', + ) { + super( + reason === 'identity-inspection-failed' + ? 'Project deletion was not completed because its stored identity could not be safely verified.' + : 'Project deletion was not completed because legacy auxiliary cleanup is incomplete; project data remains available for retry.', + ); + this.name = 'ProjectDeleteError'; + } +} + +export class ProjectQuarantineError extends Error { + constructor( + public readonly reason: + | 'not-found' + | 'io-error' + | 'name-exhausted' + | 'already-preserved' + | 'source-missing', + ) { + super( + reason === 'source-missing' + ? 'The project source is no longer present, but its preservation location could not be confirmed.' + : 'Project preservation failed. The original project was not deleted.', + ); + this.name = 'ProjectQuarantineError'; + } +} + +export class ProjectSnapshotRestoreError extends Error { + constructor( + public readonly reason: + | 'target-unavailable' + | 'target-mismatch' + | 'snapshot-unavailable' + | 'snapshot-invalid' + | 'snapshot-owner-mismatch' + | 'snapshot-owner-unverifiable', + ) { + super( + reason === 'target-mismatch' + ? 'Snapshot restoration was not completed because the active project changed.' + : reason === 'snapshot-invalid' + ? 'Snapshot restoration was not completed because its contents are invalid.' + : reason === 'snapshot-unavailable' + ? 'Snapshot restoration was not completed because the snapshot could not be read.' + : reason === 'snapshot-owner-mismatch' + ? 'Snapshot restoration was not completed because it belongs to a different project.' + : reason === 'snapshot-owner-unverifiable' + ? 'Snapshot restoration was not completed because its project ownership could not be verified.' + : 'Snapshot restoration was not completed because the current project target could not be safely verified.', + ); + this.name = 'ProjectSnapshotRestoreError'; + } +} + // QNBS-v3 (CodeAnt/CodeRabbit): array-or-EntityState — characters/worlds may be either shape in a real saved project. function isArrayOrEntityState(value: unknown): boolean { if (Array.isArray(value)) return true; @@ -55,23 +143,375 @@ function looksLikeStoryProject(value: unknown): value is StoryProject { ); } +// QNBS-v3: one sanitizer and empty-ID policy keeps every filesystem project operation on the same path identity. export class FsProjectStore extends FsAssetStore { + private readonly verifiedLegacyProjectDirectories = new Set(); + + private async inspectLegacyAuxiliaryEvidence( + project: StoryProject, + safeProjectId: string, + apis: TauriApis, + appDataPath: string, + ): Promise { + const evidence: LegacyAuxiliaryEvidence = { + codex: false, + binderAssetIds: new Set(), + inspectionComplete: true, + }; + const rawProjectId = persistedProjectId(project); + if ( + !isLegacyInvalidProjectId(project) || + safeProjectId === 'project' || + typeof rawProjectId !== 'string' + ) { + return evidence; + } + + try { + const legacyProjectPath = await apis.join(appDataPath, 'projects', 'project'); + const legacyProjectFile = await apis.join(legacyProjectPath, 'project.json'); + if (await apis.exists(legacyProjectFile)) return evidence; + + const codexFile = await apis.join(legacyProjectPath, 'codex', 'codex.snap'); + if (await apis.exists(codexFile)) { + try { + const legacyCodex = decompressData(await apis.readTextFile(codexFile)); + if ( + typeof legacyCodex === 'object' && + legacyCodex !== null && + ((legacyCodex as Record)['projectId'] === rawProjectId || + (legacyCodex as Record)['projectId'] === safeProjectId) + ) { + evidence.codex = true; + } + } catch (error) { + evidence.inspectionComplete = false; + logger.warn('Could not verify legacy codex ownership during project load', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const binderPath = await apis.join(legacyProjectPath, 'binder'); + for (const assetId of new Set(legacyBinderAssetIds(project))) { + const binFile = await apis.join(binderPath, `${assetId}.bin`); + const metaFile = await apis.join(binderPath, `${assetId}.meta.json`); + if ((await apis.exists(binFile)) && (await apis.exists(metaFile))) { + evidence.binderAssetIds.add(assetId); + } + } + } catch (error) { + evidence.inspectionComplete = false; + logger.warn('Could not inspect legacy auxiliary project data during load', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + } + + return evidence; + } + + private async legacyFallbackProjectState( + safeProjectId: string, + apis: TauriApis, + appDataPath: string, + ): Promise<'confirmed' | 'absent' | 'indeterminate'> { + if (safeProjectId === 'project') return 'confirmed'; + try { + const legacyProjectFile = await apis.join(appDataPath, 'projects', 'project', 'project.json'); + return (await apis.exists(legacyProjectFile)) ? 'confirmed' : 'absent'; + } catch (error) { + logger.warn('Could not validate persisted legacy auxiliary provenance', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + return 'indeterminate'; + } + } + + private async migrateLegacyProjectIdentity( + project: StoryProject, + safeProjectId: string, + apis: TauriApis, + appDataPath: string, + ): Promise { + const persistedMetadata = persistedLegacyAuxiliaryMetadata(project); + const currentProjectId = persistedProjectId(project); + const hasLegacyInvalidId = + typeof currentProjectId === 'string' && !projectPathSegment(currentProjectId); + if ( + persistedMetadata && + currentProjectId !== safeProjectId && + currentProjectId !== persistedMetadata.legacyRawProjectId + ) { + this.clearLegacyAuxiliaryPolicy(safeProjectId); + } else if (persistedMetadata) { + const legacyProjectState = await this.legacyFallbackProjectState( + safeProjectId, + apis, + appDataPath, + ); + if (legacyProjectState === 'confirmed') { + this.clearLegacyAuxiliaryPolicy(safeProjectId); + } else if (legacyProjectState === 'absent') { + this.registerLegacyAuxiliaryPolicy( + safeProjectId, + persistedMetadata.legacyProjectId, + evidenceFromPersistedMetadata(persistedMetadata), + ); + } else { + throw new ProjectLoadError( + 'io-error', + 'Could not validate legacy auxiliary project ownership while loading this project.', + safeProjectId, + ); + } + } else if (!hasLegacyInvalidId) { + this.clearLegacyAuxiliaryPolicy(safeProjectId); + } + const rawProjectId = persistedProjectId(project); + if (typeof rawProjectId === 'string' && projectPathSegment(rawProjectId)) { + if (!persistedMetadata) this.clearLegacyPoliciesTargetingProject(safeProjectId); + return project; + } + + if (typeof rawProjectId !== 'string') { + this.clearLegacyAuxiliaryPolicy(safeProjectId); + if (legacyProjectDirectory(project) === safeProjectId) { + this.verifiedLegacyProjectDirectories.add(safeProjectId); + return project; + } + if (!hasLegacyMissingProjectId(project, safeProjectId)) return project; + this.verifiedLegacyProjectDirectories.add(safeProjectId); + return legacyProjectWithDirectory(project, safeProjectId); + } + + if (!projectPathSegment(rawProjectId)) { + const evidence = await this.inspectLegacyAuxiliaryEvidence( + project, + safeProjectId, + apis, + appDataPath, + ); + if (!evidence.inspectionComplete) { + throw new ProjectLoadError( + 'io-error', + 'Could not verify legacy auxiliary project data while loading this project.', + safeProjectId, + ); + } + this.clearLegacyAuxiliaryPolicy(safeProjectId); + this.registerLegacyAuxiliaryPolicy(safeProjectId, 'project', evidence); + return migratedProjectIdentity(project, safeProjectId, rawProjectId, evidence); + } + if (hasLegacyInvalidId) { + return project; + } + this.clearLegacyAuxiliaryPolicy(safeProjectId); + return migratedProjectIdentity(project, safeProjectId); + } + + private async resolveLegacySaveIdentity( + project: StoryProject, + rawProjectId: string, + apis: TauriApis, + appDataPath: string, + ): Promise<{ + projectId: string; + metadata: PersistedLegacyAuxiliaryMetadata | null; + inspectionComplete: boolean; + } | null> { + if (!rawProjectId.trim()) return null; + const legacyProjectId = sanitizePathSegment(rawProjectId, 'item'); + if (!legacyProjectId || legacyProjectId === '.' || legacyProjectId === '..') return null; + const projectFile = await apis.join(appDataPath, 'projects', legacyProjectId, 'project.json'); + if (!(await apis.exists(projectFile))) return null; + + let existingProject: StoryProject; + try { + const parsed = decompressData(await retryFs(() => apis.readTextFile(projectFile))); + if (!looksLikeStoryProject(parsed)) return null; + existingProject = parsed; + } catch { + return null; + } + const existingMetadata = persistedLegacyAuxiliaryMetadata(existingProject); + const existingRawProjectId = persistedProjectId(existingProject); + if ( + existingRawProjectId !== rawProjectId && + existingMetadata?.legacyRawProjectId !== rawProjectId + ) { + return null; + } + if (legacyProjectContent(existingProject) !== legacyProjectContent(project)) return null; + + const evidence = existingMetadata + ? evidenceFromPersistedMetadata(existingMetadata) + : await this.inspectLegacyAuxiliaryEvidence( + { + ...existingProject, + binderNodes: [...(existingProject.binderNodes ?? []), ...(project.binderNodes ?? [])], + }, + legacyProjectId, + apis, + appDataPath, + ); + return { + projectId: legacyProjectId, + metadata: existingMetadata ?? persistedMetadataFromEvidence(rawProjectId, evidence), + inspectionComplete: existingMetadata ? true : evidence.inspectionComplete, + }; + } + + async restoreSnapshot( + snapshotId: number, + currentProject: SnapshotRestoreTarget, + ): Promise { + // QNBS-v3: serialize target validation and snapshot ownership checks so routing cannot change mid-restore. + return this.withLegacyRoutingOperation(() => + this.restoreSnapshotUnlocked(snapshotId, currentProject), + ); + } + + private async restoreSnapshotUnlocked( + snapshotId: number, + currentProject: SnapshotRestoreTarget, + ): Promise { + const targetDirectory = snapshotRestoreTargetDirectory(currentProject); + if (!targetDirectory) { + throw new ProjectSnapshotRestoreError('target-unavailable'); + } + + let validatedTarget: StoryProject | null; + try { + validatedTarget = await this.loadProjectUnlocked(targetDirectory); + } catch (error) { + logger.error('Failed to validate the snapshot restore target', { + projectId: targetDirectory, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectSnapshotRestoreError('target-unavailable'); + } + if (!validatedTarget) { + throw new ProjectSnapshotRestoreError('target-unavailable'); + } + + const snapshot = await super.getSnapshotData(snapshotId); + if (!looksLikeStoryProject(snapshot)) { + throw new ProjectSnapshotRestoreError( + snapshot === null ? 'snapshot-unavailable' : 'snapshot-invalid', + ); + } + + const snapshotProjectId = persistedProjectId(snapshot); + if (typeof snapshotProjectId !== 'string') { + throw new ProjectSnapshotRestoreError('snapshot-owner-unverifiable'); + } + const safeSnapshotProjectId = projectPathSegment(snapshotProjectId); + if (!safeSnapshotProjectId) { + throw new ProjectSnapshotRestoreError('snapshot-owner-unverifiable'); + } + if (safeSnapshotProjectId !== targetDirectory) { + throw new ProjectSnapshotRestoreError('snapshot-owner-mismatch'); + } + + const restored = { ...(snapshot as unknown as Record) }; + delete restored['id']; + delete restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY]; + delete restored[LEGACY_AUXILIARY_METADATA_KEY]; + + const validatedTargetId = persistedProjectId(validatedTarget); + if (typeof validatedTargetId === 'string') { + const safeTargetId = projectPathSegment(validatedTargetId); + if (!safeTargetId || safeTargetId !== targetDirectory) { + throw new ProjectSnapshotRestoreError('target-unavailable'); + } + restored['id'] = safeTargetId; + } + + const validatedTargetDirectory = legacyProjectDirectory(validatedTarget); + if (validatedTargetDirectory) { + restored[LEGACY_PROJECT_DIRECTORY_METADATA_KEY] = validatedTargetDirectory; + } + const validatedTargetMetadata = persistedLegacyAuxiliaryMetadata(validatedTarget); + if (validatedTargetMetadata) { + restored[LEGACY_AUXILIARY_METADATA_KEY] = validatedTargetMetadata; + } + + return restored as unknown as StoryProject; + } + async saveProject(project: SaveProjectInput): Promise { + return this.withLegacyRoutingOperation(() => this.saveProjectUnlocked(project)); + } + + private async saveProjectUnlocked(project: SaveProjectInput): Promise { const flat = normalizeSaveProjectInputToStoryProject(project); + const rawProjectId = (flat as unknown as Record)['id']; + const suppliedProjectId = typeof rawProjectId === 'string'; + let projectId: string; + let projectToPersist = flat; + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + if (suppliedProjectId) { + const safeProjectId = projectPathSegment(rawProjectId); + if (!safeProjectId) { + const legacyIdentity = await this.resolveLegacySaveIdentity( + flat, + rawProjectId, + apis, + appDataPath, + ); + if (!legacyIdentity) { + throw new Error('Cannot save a project with an unusable project ID.'); + } + if (!legacyIdentity.inspectionComplete) { + throw new Error( + 'Cannot safely save this legacy project until its auxiliary data can be verified.', + ); + } + projectId = legacyIdentity.projectId; + projectToPersist = { + ...flat, + id: projectId, + ...(legacyIdentity.metadata + ? { [LEGACY_AUXILIARY_METADATA_KEY]: legacyIdentity.metadata } + : {}), + } as StoryProject; + if (legacyIdentity.metadata) { + this.registerLegacyAuxiliaryPolicy( + projectId, + legacyIdentity.metadata.legacyProjectId, + evidenceFromPersistedMetadata(legacyIdentity.metadata), + ); + } + } else { + projectId = safeProjectId; + this.verifiedLegacyProjectDirectories.delete(projectId); + } + } else { + const legacyDirectory = legacyProjectDirectory(flat); + projectId = + legacyDirectory && this.verifiedLegacyProjectDirectories.has(legacyDirectory) + ? legacyDirectory + : (projectPathSegment(flat.title || '') ?? 'project'); + } // Auto-snapshot: fire-and-forget, mirrors dbService behaviour if (Date.now() - this.lastAutoSnapshotTime > this.AUTO_SNAPSHOT_INTERVAL) { this.lastAutoSnapshotTime = Date.now(); - this.saveSnapshot('auto', flat) + this.saveSnapshot('auto', projectToPersist) .then(() => this.pruneAutoSnapshots()) - .catch(() => {}); + .catch((error) => { + // QNBS-v3: auto-snapshot failure stays non-fatal while remaining visible for recovery diagnostics. + logger.warn('Auto-snapshot failed (project save itself is unaffected)', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + }); } - const apis = await this.getApis(); - const appDataPath = await this.ensureAppDataPath(); - const projectId = sanitizePathSegment( - ((flat as unknown as Record)['id'] as string) || flat.title || 'project', - ); const projectPath = await apis.join(appDataPath, 'projects', projectId); if (!(await apis.exists(projectPath))) { @@ -79,7 +519,8 @@ export class FsProjectStore extends FsAssetStore { } const projectFile = await apis.join(projectPath, 'project.json'); - await writeTextFileAtomic(apis, projectFile, compressData(flat)); + await writeTextFileAtomic(apis, projectFile, compressData(projectToPersist)); + this.clearLegacyPoliciesTargetingProject(projectId); // QNBS-v3 (#332): documented best-effort abort — the project data above already saved; a failed marker write only degrades the next cold-boot's project selection, not worth failing this save over. await this.setActiveProjectId(projectId).catch((error) => { logger.warn('Failed to persist active-project marker (project save itself succeeded)', { @@ -125,15 +566,21 @@ export class FsProjectStore extends FsAssetStore { * collapse into the same `null` a caller would read as "no project exists yet". */ async loadProject(projectId: string): Promise { + return this.withLegacyRoutingOperation(() => this.loadProjectUnlocked(projectId)); + } + + private async loadProjectUnlocked(projectId: string): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); - const safeProjectId = sanitizePathSegment(projectId); + const safeProjectId = projectPathSegment(projectId); + if (!safeProjectId) return null; const projectFile = await apis.join(appDataPath, 'projects', safeProjectId, 'project.json'); // QNBS-v3 (CodeRabbit/codex): exists() rejecting is an I/O failure too, not absence — classify it the same as a readTextFile failure rather than letting it escape raw. let content: string; try { if (!(await apis.exists(projectFile))) { + this.clearLegacyAuxiliaryPolicy(safeProjectId); return null; } content = await retryFs(() => apis.readTextFile(projectFile)); @@ -142,6 +589,7 @@ export class FsProjectStore extends FsAssetStore { throw new ProjectLoadError( 'io-error', `Could not read the project file for "${projectId}" — it may be locked, permission-denied, or otherwise inaccessible.`, + projectId, ); } @@ -157,12 +605,19 @@ export class FsProjectStore extends FsAssetStore { throw new ProjectLoadError( 'corrupt', `The saved project file for "${projectId}" appears to be corrupted and could not be read. The file has not been deleted.`, + projectId, ); } // QNBS-v3: schedule observation after this async load resolves so validation cannot delay or alter the load result. - scheduleCoreProjectValidation(project); - return project; + const migratedProject = await this.migrateLegacyProjectIdentity( + project, + safeProjectId, + apis, + appDataPath, + ); + scheduleCoreProjectValidation(migratedProject); + return migratedProject; } async listProjects(): Promise { @@ -183,14 +638,233 @@ export class FsProjectStore extends FsAssetStore { } } + // QNBS-v3: move the whole folder before reload so corrupt project artifacts remain recoverable. + /** Move the whole corrupt project directory aside so its manuscript and assets remain recoverable. */ + private async prepareLegacyQuarantinePolicy( + safeProjectId: string, + apis: TauriApis, + appDataPath: string, + ): Promise<{ + legacyProjectId: string; + codex: boolean; + binderAssetIds: readonly string[]; + } | null> { + const policy = this.legacyAuxiliaryPolicyForProject(safeProjectId); + if (policy?.legacyProjectId !== 'project') return policy; + const legacyProjectState = await this.legacyFallbackProjectState( + safeProjectId, + apis, + appDataPath, + ); + if (legacyProjectState === 'confirmed') { + this.clearLegacyAuxiliaryPolicy(safeProjectId); + return null; + } + if (legacyProjectState === 'indeterminate') throw new ProjectQuarantineError('io-error'); + return policy; + } + + async quarantineProject(projectId: string): Promise { + return this.withLegacyRoutingOperation(() => this.quarantineProjectUnlocked(projectId)); + } + + private async quarantineProjectUnlocked(projectId: string): Promise { + try { + const apis = await this.getApis(); + const appDataPath = await this.ensureAppDataPath(); + const safeProjectId = projectPathSegment(projectId); + if (!safeProjectId) throw new ProjectQuarantineError('not-found'); + const projectPath = await apis.join(appDataPath, 'projects', safeProjectId); + if (!(await apis.exists(projectPath))) { + throw new ProjectQuarantineError('not-found'); + } + + const quarantineRoot = await apis.join(appDataPath, 'quarantined-projects'); + await apis.mkdir(quarantineRoot, { recursive: true }); + const timestamp = Date.now(); + const legacyPolicy = await this.prepareLegacyQuarantinePolicy( + safeProjectId, + apis, + appDataPath, + ); + for (let attempt = 0; attempt < 100; attempt++) { + const suffix = attempt === 0 ? String(timestamp) : `${timestamp}-${attempt}`; + const quarantinePath = await apis.join( + quarantineRoot, + `${safeProjectId}-corrupt-${suffix}`, + ); + // QNBS-v3: reserve a unique directory atomically so rename cannot replace a concurrent quarantine target. + try { + await apis.mkdir(quarantinePath); + } catch (error) { + let targetExists: boolean; + try { + targetExists = await apis.exists(quarantinePath); + } catch (probeError) { + logger.error('Failed to inspect a concurrent quarantine result', { + projectId, + error: probeError instanceof Error ? probeError.message : String(probeError), + }); + throw new ProjectQuarantineError('io-error'); + } + if (targetExists) continue; + logger.error('Failed to reserve quarantine directory', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectQuarantineError('io-error'); + } + + const preservedPath = await apis.join(quarantinePath, safeProjectId); + const releaseReservation = async (): Promise => { + try { + await apis.remove(quarantinePath, { recursive: true }); + } catch (cleanupError) { + logger.warn('Failed to remove reserved quarantine directory after a failed move', { + projectId, + error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), + }); + } + }; + try { + if (legacyPolicy?.legacyProjectId === 'project') { + const manifestPath = await apis.join(quarantinePath, 'legacy-auxiliary.json'); + const manifest: QuarantineLegacyAuxiliaryManifest = { + projectId: safeProjectId, + legacyProjectId: legacyPolicy.legacyProjectId, + codex: legacyPolicy.codex, + binderAssetIds: [...legacyPolicy.binderAssetIds], + }; + // QNBS-v3: durable quarantine metadata preserves verified auxiliary provenance without claiming ownership of ambiguous fallback files. + await writeTextFileAtomic(apis, manifestPath, JSON.stringify(manifest)); + } + await retryFs(() => apis.rename(projectPath, preservedPath)); + this.clearLegacyAuxiliaryPolicy(safeProjectId); + return { projectId: safeProjectId, path: preservedPath }; + } catch (error) { + let sourceExists: boolean; + let preservedExists: boolean; + try { + sourceExists = await apis.exists(projectPath); + preservedExists = await apis.exists(preservedPath); + } catch (probeError) { + logger.error('Failed to inspect a concurrent quarantine result', { + projectId, + error: probeError instanceof Error ? probeError.message : String(probeError), + }); + throw new ProjectQuarantineError('io-error'); + } + if (!sourceExists && preservedExists) { + this.clearLegacyAuxiliaryPolicy(safeProjectId); + return { projectId: safeProjectId, path: preservedPath }; + } + if (!sourceExists) { + await releaseReservation(); + this.clearLegacyAuxiliaryPolicy(safeProjectId); + throw new ProjectQuarantineError('source-missing'); + } + await releaseReservation(); + logger.error('Failed to quarantine project directory', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectQuarantineError('io-error'); + } + } + + throw new ProjectQuarantineError('name-exhausted'); + } catch (error) { + if (error instanceof ProjectQuarantineError) throw error; + logger.error('Failed to prepare project quarantine', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectQuarantineError('io-error'); + } + } + async deleteProject(projectId: string): Promise { + return this.withLegacyRoutingOperation(() => this.deleteProjectUnlocked(projectId)); + } + + private async deleteProjectUnlocked(projectId: string): Promise { const apis = await this.getApis(); const appDataPath = await this.ensureAppDataPath(); - const safeProjectId = sanitizePathSegment(projectId); + const safeProjectId = projectPathSegment(projectId); + if (!safeProjectId) return; const projectPath = await apis.join(appDataPath, 'projects', safeProjectId); - if (await apis.exists(projectPath)) { - await retryFs(() => apis.remove(projectPath, { recursive: true })); + // QNBS-v3: uncertain existence is a typed retryable deletion failure, never permission to clean up. + let projectExists: boolean; + try { + projectExists = await apis.exists(projectPath); + } catch (error) { + logger.error('Failed to inspect project existence before deletion', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectDeleteError('identity-inspection-failed'); + } + if (projectExists) { + await this.hydrateLegacyPolicyForDeletion(safeProjectId, projectPath, apis, appDataPath); + } + + try { + const legacyBinderIds = this.legacyBinderAssetIdsForProject(safeProjectId); + if (legacyBinderIds.length > 0) { + for (const assetId of legacyBinderIds) { + await this.deleteBinderAssetStrict(safeProjectId, assetId); + } + } + if (this.legacyCodexProjectId(safeProjectId)) { + await this.deleteStoryCodexStrict(safeProjectId); + } + if (projectExists) await retryFs(() => apis.remove(projectPath, { recursive: true })); + } catch (error) { + logger.error('Failed to clean up legacy project data during deletion', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectDeleteError(); + } + this.verifiedLegacyProjectDirectories.delete(safeProjectId); + this.clearLegacyAuxiliaryPolicy(safeProjectId); + } + + private async hydrateLegacyPolicyForDeletion( + safeProjectId: string, + projectPath: string, + apis: TauriApis, + appDataPath: string, + ): Promise { + if ( + this.legacyBinderAssetIdsForProject(safeProjectId).length > 0 || + this.legacyCodexProjectId(safeProjectId) + ) { + return; + } + let project: StoryProject; + try { + const projectFile = await apis.join(projectPath, 'project.json'); + if (!(await apis.exists(projectFile))) return; + const parsed = decompressData(await retryFs(() => apis.readTextFile(projectFile))); + if (!looksLikeStoryProject(parsed)) throw new Error('Stored project is not project-shaped.'); + project = parsed; + } catch (error) { + logger.error('Could not inspect project identity before deletion', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectDeleteError('identity-inspection-failed'); + } + try { + await this.migrateLegacyProjectIdentity(project, safeProjectId, apis, appDataPath); + } catch (error) { + logger.error('Could not validate legacy auxiliary data before deletion', { + projectId: safeProjectId, + error: error instanceof Error ? error.message : String(error), + }); + throw new ProjectDeleteError('identity-inspection-failed'); } } diff --git a/services/storageBackend.ts b/services/storageBackend.ts index 83eef5dfe..fa6754023 100644 --- a/services/storageBackend.ts +++ b/services/storageBackend.ts @@ -13,6 +13,13 @@ export interface BinderAssetPayload { meta: BinderAssetMeta; } +// QNBS-v3: shared result keeps the affected identity and verified quarantine path explicit across backends. +/** Result of moving a corrupt desktop project out of the active project namespace. */ +export interface ProjectQuarantineResult { + projectId: string; + path: string; +} + /** IndexedDB / filesystem key for binder blobs — stable delimiter avoids UUID clashes. */ export function makeBinderAssetStorageKey(projectId: string, assetId: string): string { const safeProject = projectId.replace(/[\s:]/g, '_').slice(0, 200); @@ -39,6 +46,12 @@ export interface SaveProjectEnvelope { */ export type SaveProjectInput = StoryProject | SaveProjectEnvelope; +// QNBS-v3: restore carries pre-read project identity so filesystem ownership never comes from snapshot content. +/** + * Current project state captured before snapshot I/O; filesystem backends inspect only identity markers. + */ +export type SnapshotRestoreTarget = ProjectData | StoryProject; + /** Auto-save from the current `ProjectData` (listener middleware) — returns a properly typed envelope. */ export function saveEnvelopeFromProjectData(data: ProjectData): SaveProjectEnvelope { return { data }; @@ -66,6 +79,8 @@ export interface StorageBackend { deleteProject(projectId: string): Promise; /** QNBS-v3 (#332): optional — only the multi-project Tauri filesystem backend implements this; IndexedDB's single-project contract has no "which one" ambiguity to resolve. */ getActiveProjectId?(): Promise; + // QNBS-v3: optional recovery keeps the filesystem-only quarantine contract out of IndexedDB. + quarantineProject?(projectId: string): Promise; saveImage(id: string, base64Data: string): Promise; getImage(id: string): Promise; @@ -84,6 +99,8 @@ export interface StorageBackend { /** Snapshot IDs: numeric (Date.now / IDB auto-increment). */ saveSnapshot(snapshotLabel: string, data: unknown): Promise; getSnapshotData(snapshotId: number): Promise; + // QNBS-v3: filesystem restore validates this pre-read target; other backends retain their existing snapshot semantics. + restoreSnapshot?(snapshotId: number, currentProject: SnapshotRestoreTarget): Promise; listSnapshots(): Promise; deleteSnapshot(snapshotId: number): Promise; diff --git a/services/storageService.ts b/services/storageService.ts index a1e4a7432..37e52eff9 100644 --- a/services/storageService.ts +++ b/services/storageService.ts @@ -2,15 +2,19 @@ import type { ProjectSnapshot, Settings, StoryCodex, StoryProject } from '../typ import type { BinderAssetMeta, BinderAssetPayload, + ProjectQuarantineResult, SaveProjectInput, + SnapshotRestoreTarget, StorageBackend, } from './storageBackend'; export type { BinderAssetMeta, BinderAssetPayload, + ProjectQuarantineResult, SaveProjectEnvelope, SaveProjectInput, + SnapshotRestoreTarget, StorageBackend, } from './storageBackend'; export { @@ -26,6 +30,8 @@ import { fileSystemService } from './fileSystemService'; import { logger } from './logger'; import { isTauriRuntime } from './tauriRuntime'; +// QNBS-v3: re-exporting the narrow storage contracts keeps callers on one backend-independent type boundary. + declare global { interface Window { __TAURI__?: unknown; @@ -89,6 +95,12 @@ class StorageManager { return (await backend.getActiveProjectId?.()) ?? null; } + // QNBS-v3: delegate supported desktop quarantine and normalize unsupported backends to null. + async quarantineProject(projectId: string): Promise { + const backend = await this.getBackend(); + return (await backend.quarantineProject?.(projectId)) ?? null; + } + async deleteProject(projectId: string): Promise { const backend = await this.getBackend(); return backend.deleteProject(projectId); @@ -149,6 +161,15 @@ class StorageManager { return backend.getSnapshotData(id); } + // QNBS-v3: filesystem backends receive the pre-read target while IndexedDB keeps its existing snapshot fallback. + async restoreSnapshot(id: number, currentProject: SnapshotRestoreTarget): Promise { + const backend = await this.getBackend(); + if (backend.restoreSnapshot) { + return backend.restoreSnapshot(id, currentProject); + } + return backend.getSnapshotData(id); + } + async listSnapshots(): Promise { const backend = await this.getBackend(); return backend.listSnapshots(); diff --git a/tests/unit/libraryBackupService.test.ts b/tests/unit/libraryBackupService.test.ts index 9cafa479e..cdbfb3c88 100644 --- a/tests/unit/libraryBackupService.test.ts +++ b/tests/unit/libraryBackupService.test.ts @@ -97,7 +97,11 @@ describe('libraryBackupService — partial corruption (DA-01)', () => { vi.mocked(storageService.listProjects).mockResolvedValue(['good', 'corrupt']); vi.mocked(storageService.loadProject).mockImplementation(async (projectId: string) => { if (projectId === 'corrupt') { - throw new ProjectLoadError('corrupt', 'The saved project file for "corrupt" is corrupted.'); + throw new ProjectLoadError( + 'corrupt', + 'The saved project file for "corrupt" is corrupted.', + 'corrupt', + ); } return minimalProject() as unknown as StoryProject; }); diff --git a/tests/unit/services/fs/fsStores.test.ts b/tests/unit/services/fs/fsStores.test.ts index a7bd98e4b..431ab524f 100644 --- a/tests/unit/services/fs/fsStores.test.ts +++ b/tests/unit/services/fs/fsStores.test.ts @@ -53,10 +53,12 @@ vi.mock('../../../../services/logger', async (importOriginal) => { }); // QNBS-v3: getStaticTranslation hits the network (fetch) — never call real network in tests. vi.mock('../../../../services/i18n/staticTranslate', () => ({ - getStaticTranslation: (key: string) => Promise.resolve(key === 'export.loglineLabel' ? 'Logline' : 'Manuscript'), + getStaticTranslation: (key: string) => + Promise.resolve(key === 'export.loglineLabel' ? 'Logline' : 'Manuscript'), })); import { appStoreRef } from '../../../../app/storeRef'; +import { compressData, decompressData } from '../../../../services/fs/fsCore'; import { FsProjectStore } from '../../../../services/fs/projectFsStore'; import { logger } from '../../../../services/logger'; @@ -82,7 +84,10 @@ function makeFakeFs(): FakeFs { join: (...parts: string[]) => Promise.resolve(parts.join('/')), exists: (p: string) => Promise.resolve(text.has(p) || bin.has(p) || dirs.has(p) || under(p).length > 0), - mkdir: (p: string) => { + mkdir: (p: string, options?: { recursive?: boolean }) => { + if (dirs.has(p) && !options?.recursive) { + return Promise.reject(new Error(`EEXIST ${p}`)); + } dirs.add(p); return Promise.resolve(); }, @@ -110,16 +115,33 @@ function makeFakeFs(): FakeFs { for (const k of [...bin.keys()]) if (k.startsWith(`${p}/`)) bin.delete(k); return Promise.resolve(); }, + // QNBS-v3: recursive fake moves preserve every project asset so quarantine tests prove full-directory recovery. rename: (from: string, to: string) => { - if (!text.has(from) && !bin.has(from)) return Promise.reject(new Error(`ENOENT ${from}`)); + const fromEntries = [...text.keys(), ...bin.keys(), ...dirs].filter( + (path, index, paths) => + paths.indexOf(path) === index && (path === from || path.startsWith(`${from}/`)), + ); + if (fromEntries.length === 0) return Promise.reject(new Error(`ENOENT ${from}`)); + const targetDirectoryExists = + dirs.has(to) || + [...text.keys(), ...bin.keys(), ...dirs].some((path) => path.startsWith(`${to}/`)); + if (targetDirectoryExists) return Promise.reject(new Error(`EEXIST ${to}`)); text.delete(to); bin.delete(to); - const textValue = text.get(from); - const binaryValue = bin.get(from); - if (textValue !== undefined) text.set(to, textValue); - if (binaryValue !== undefined) bin.set(to, binaryValue); - text.delete(from); - bin.delete(from); + for (const path of fromEntries) { + const target = `${to}${path.slice(from.length)}`; + const textValue = text.get(path); + const binaryValue = bin.get(path); + if (textValue !== undefined) { + text.delete(path); + text.set(target, textValue); + } + if (binaryValue !== undefined) { + bin.delete(path); + bin.set(target, binaryValue); + } + if (dirs.delete(path)) dirs.add(target); + } return Promise.resolve(); }, readDir: (p: string) => Promise.resolve(under(p).map((name) => ({ name, isDirectory: false }))), @@ -169,6 +191,1057 @@ describe('FsProjectStore — projects', () => { expect(await store.listProjects()).toEqual([]); }); + // QNBS-v3: the regression protects manuscripts and assets from partial or destructive quarantine. + it('quarantines a complete project directory without deleting or relisting it', async () => { + await store.saveProject(project as never); + const original = fake.text.get('/app/projects/p1/project.json'); + + const result = await store.quarantineProject('p1'); + + expect(result.projectId).toBe('p1'); + expect(result.path).toMatch(/^\/app\/quarantined-projects\/p1-corrupt-/); + expect(fake.text.get(`${result.path}/project.json`)).toBe(original); + expect(fake.text.has('/app/projects/p1/project.json')).toBe(false); + expect(await store.listProjects()).not.toContain('p1'); + await expect(store.loadProject('p1')).resolves.toBeNull(); + }); + + // QNBS-v3: prove a claimed quarantine target cannot turn preserve-first recovery into data loss. + it('tries the next quarantine name when a concurrent rename claims the checked target', async () => { + await store.saveProject(project as never); + const originalMkdir = fake.apis.mkdir; + let firstTarget: string | undefined; + let racePending = true; + fake.apis.mkdir = (path: string, options?: { recursive?: boolean }) => { + if (racePending && path.startsWith('/app/quarantined-projects/p1-corrupt-')) { + racePending = false; + firstTarget = path; + return originalMkdir(path, options).then(() => Promise.reject(new Error(`EEXIST ${path}`))); + } + return originalMkdir(path, options); + }; + + const result = await store.quarantineProject('p1'); + + expect(firstTarget).toBeDefined(); + expect(result.path).toBe(`${firstTarget}-1/p1`); + expect(fake.text.get(`${result.path}/project.json`)).toBeDefined(); + expect(fake.text.has('/app/projects/p1/project.json')).toBe(false); + }); + + // QNBS-v3: report an unidentifiable concurrent move without claiming a path that was not observed. + it('reports source-missing when another recovery moved the source elsewhere', async () => { + await store.saveProject(project as never); + const original = fake.text.get('/app/projects/p1/project.json'); + const originalRename = fake.apis.rename; + fake.apis.rename = async (from: string) => { + const concurrentPath = '/app/quarantined-projects/p1-corrupt-concurrent'; + await originalRename(from, concurrentPath); + throw new Error(`ENOENT ${from}`); + }; + + await expect(store.quarantineProject('p1')).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'source-missing', + }); + expect(await store.listProjects()).not.toContain('p1'); + expect(fake.text.get('/app/quarantined-projects/p1-corrupt-concurrent/project.json')).toBe( + original, + ); + }); + + // QNBS-v3: a vanished source without a verified destination is not evidence of preservation. + it('reports source-missing when the source disappears without a quarantine copy', async () => { + await store.saveProject(project as never); + fake.apis.rename = async (from: string) => { + await fake.apis.remove(from); + throw new Error(`ENOENT ${from}`); + }; + + await expect(store.quarantineProject('p1')).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'source-missing', + message: + 'The project source is no longer present, but its preservation location could not be confirmed.', + }); + }); + + // QNBS-v3: quarantine retains verified legacy routing without moving ambiguous fallback data into the recovery copy. + it('persists verified legacy routing beside a quarantined project', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + await store.loadProject('item'); + const result = await store.quarantineProject('item'); + const quarantineContainer = result.path.slice(0, result.path.lastIndexOf('/')); + + expect(fake.text.get(`${quarantineContainer}/legacy-auxiliary.json`)).toBe( + JSON.stringify({ + projectId: 'item', + legacyProjectId: 'project', + codex: true, + binderAssetIds: [], + }), + ); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(true); + expect(fake.text.has(`${result.path}/project.json`)).toBe(true); + expect(await store.getStoryCodex('item')).toBeNull(); + }); + + it('does not map an unusable project ID to an arbitrary quarantine directory', async () => { + await expect(store.saveProject({ ...project, id: '***' } as never)).rejects.toThrow( + 'Cannot save a project with an unusable project ID.', + ); + + await expect(store.loadProject('***')).resolves.toBeNull(); + await expect(store.deleteProject('***')).resolves.toBeUndefined(); + await expect(store.quarantineProject('***')).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'not-found', + }); + expect([...fake.text.keys()].some((path) => path.startsWith('/app/projects/'))).toBe(false); + }); + + it('migrates a legacy invalid ID to its existing directory identity before save', async () => { + const legacyProject = { ...project, id: '***' }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + + const loaded = await store.loadProject('item'); + + expect((loaded as unknown as Record)['id']).toBe('item'); + await expect(store.saveProject(loaded as never)).resolves.toBeUndefined(); + expect(fake.text.has('/app/projects/item/project.json')).toBe(true); + expect(await store.loadProject('item')).toEqual(expect.objectContaining({ title: 'My Novel' })); + }); + + // QNBS-v3: verified Codex and Binder evidence remains addressable while provenance-free vectors stay unassigned. + it('keeps verified legacy Binder and Codex data addressable without assigning ambiguous RAG data', async () => { + const legacyProject = { + ...project, + id: '***', + binderNodes: [{ binderAssetId: 'legacy-asset' }], + }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + const legacyVectors = [{ id: 'legacy-vector' }]; + + await store.saveStoryCodex(legacyCodex as never); + await store.saveRagVectors('***', legacyVectors); + await store.saveBinderAsset('***', 'legacy-asset', new Uint8Array([1, 2]).buffer, { + mimeType: 'application/octet-stream', + originalFileName: 'legacy.bin', + byteSize: 2, + }); + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + + const loaded = await store.loadProject('item'); + + expect((loaded as unknown as Record)['id']).toBe('item'); + expect(await store.getStoryCodex('item')).toEqual(legacyCodex); + expect(await store.getRagVectors('item')).toEqual([]); + expect(await store.getRagVectors('project')).toEqual(legacyVectors); + expect(await store.listBinderAssetIds('item')).toContain('legacy-asset'); + expect(await store.getBinderAsset('item', 'legacy-asset')).toEqual( + expect.objectContaining({ + meta: expect.objectContaining({ originalFileName: 'legacy.bin' }), + }), + ); + + await fake.apis.writeFile('/app/projects/project/binder/unregistered.bin', new Uint8Array([9])); + await fake.apis.writeTextFile( + '/app/projects/project/binder/unregistered.meta.json', + JSON.stringify({ + mimeType: 'application/octet-stream', + originalFileName: 'unregistered.bin', + byteSize: 1, + }), + ); + expect(await store.listBinderAssetIds('item')).not.toContain('unregistered'); + await expect(store.getBinderAsset('item', 'unregistered')).resolves.toBeNull(); + await store.deleteAllBinderAssetsForProject('item'); + expect(fake.bin.has('/app/projects/project/binder/unregistered.bin')).toBe(true); + expect(fake.text.has('/app/projects/project/binder/unregistered.meta.json')).toBe(true); + + await store.saveProject(loaded as never); + expect(fake.text.has('/app/projects/item/project.json')).toBe(true); + expect(fake.text.has('/app/projects/project/project.json')).toBe(false); + + await store.deleteProject('item'); + expect(fake.text.has('/app/projects/item/project.json')).toBe(false); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false); + expect(fake.text.has('/app/projects/project/codex/vectors.snap')).toBe(true); + expect(fake.bin.has('/app/projects/project/binder/legacy-asset.bin')).toBe(false); + }); + + // QNBS-v3: complete route-using mutations serialize so a legitimate fallback claimant cannot change ownership mid-operation. + it('keeps a legacy Codex write on one route while a fallback claimant waits', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + await store.loadProject('item'); + + const originalWriteTextFile = fake.apis.writeTextFile; + let releaseWrite!: () => void; + let writeStarted!: () => void; + const writeStartedPromise = new Promise((resolve) => { + writeStarted = resolve; + }); + fake.apis.writeTextFile = (path: string, content: string) => { + if (path.startsWith('/app/projects/project/codex/codex.snap.tmp-')) { + writeStarted(); + return new Promise((resolve, reject) => { + releaseWrite = () => { + originalWriteTextFile(path, content).then(resolve, reject); + }; + }); + } + return originalWriteTextFile(path, content); + }; + + const legacyWrite = store.saveStoryCodex({ + projectId: 'item', + entries: [{ name: 'updated' }], + } as never); + await writeStartedPromise; + + let claimantFinished = false; + const claimant = store + .saveProject({ ...project, id: 'project', title: 'Legitimate Project' } as never) + .then(() => { + claimantFinished = true; + }); + await Promise.resolve(); + expect(claimantFinished).toBe(false); + + releaseWrite(); + await legacyWrite; + await claimant; + + expect( + decompressData>( + fake.text.get('/app/projects/project/codex/codex.snap') as string, + ), + ).toEqual({ projectId: 'item', entries: [{ name: 'updated' }] }); + expect(fake.text.has('/app/projects/project/project.json')).toBe(true); + }); + + // QNBS-v3: deletion cannot report success after route ownership changes during auxiliary cleanup. + it('serializes legacy deletion before a legitimate fallback claimant can save', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + await store.loadProject('item'); + + const originalRemove = fake.apis.remove; + let releaseRemove!: () => void; + let removeStarted!: () => void; + const removeStartedPromise = new Promise((resolve) => { + removeStarted = resolve; + }); + fake.apis.remove = (path: string, options?: { recursive?: boolean }) => { + if (path === '/app/projects/project/codex/codex.snap') { + removeStarted(); + return new Promise((resolve, reject) => { + releaseRemove = () => { + originalRemove(path, options).then(resolve, reject); + }; + }); + } + return originalRemove(path, options); + }; + + const deletion = store.deleteProject('item'); + await removeStartedPromise; + + let claimantFinished = false; + const claimant = store + .saveProject({ ...project, id: 'project', title: 'Legitimate Project' } as never) + .then(() => { + claimantFinished = true; + }); + await Promise.resolve(); + expect(claimantFinished).toBe(false); + + releaseRemove(); + await deletion; + await claimant; + + expect(fake.text.has('/app/projects/item/project.json')).toBe(false); + expect(fake.text.has('/app/projects/project/project.json')).toBe(true); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false); + }); + + // QNBS-v3: a normalized directory identity remains valid evidence while a legacy main file still carries the raw ID. + it('accepts the normalized project identity in a legacy Codex snapshot', async () => { + const legacyProject = { ...project, id: '***' }; + const migratedCodex = { projectId: 'item', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(migratedCodex), + ); + + await expect(store.loadProject('item')).resolves.toEqual( + expect.objectContaining({ id: 'item' }), + ); + await expect(store.getStoryCodex('item')).resolves.toEqual(migratedCodex); + }); + + // QNBS-v3: partial Binder enumeration keeps healthy current assets visible when legacy inspection is temporarily unavailable. + it('retains current Binder IDs when the legacy directory cannot be listed', async () => { + const legacyProject = { + ...project, + id: '***', + binderNodes: [{ binderAssetId: 'legacy-asset' }], + }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/binder', { recursive: true }); + await fake.apis.writeFile('/app/projects/project/binder/legacy-asset.bin', new Uint8Array([1])); + await fake.apis.writeTextFile( + '/app/projects/project/binder/legacy-asset.meta.json', + JSON.stringify({ + mimeType: 'application/octet-stream', + originalFileName: 'legacy.bin', + byteSize: 1, + }), + ); + await store.loadProject('item'); + await store.saveBinderAsset('item', 'current-asset', new Uint8Array([2]).buffer, { + mimeType: 'application/octet-stream', + originalFileName: 'current.bin', + byteSize: 1, + }); + + const originalReadDir = fake.apis.readDir; + fake.apis.readDir = async (path: string) => { + if (path === '/app/projects/project/binder') { + throw new Error('EAGAIN: legacy Binder directory temporarily unavailable'); + } + return originalReadDir(path); + }; + + await expect(store.listBinderAssetIds('item')).resolves.toEqual(['current-asset']); + }); + + // QNBS-v3: persisted legacy provenance keeps coupled filesystem data visible after a desktop restart. + it('persists verified legacy auxiliary routing across normalized saves and reloads', async () => { + const legacyProject = { + ...project, + id: '***', + binderNodes: [{ binderAssetId: 'legacy-asset' }], + }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/binder', { recursive: true }); + await fake.apis.writeFile( + '/app/projects/project/binder/legacy-asset.bin', + new Uint8Array([1, 2]), + ); + await fake.apis.writeTextFile( + '/app/projects/project/binder/legacy-asset.meta.json', + JSON.stringify({ + mimeType: 'application/octet-stream', + originalFileName: 'legacy.bin', + byteSize: 2, + }), + ); + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + const loaded = await store.loadProject('item'); + await store.saveProject(loaded as never); + + const restarted = new FsProjectStore(); + await expect(restarted.loadProject('item')).resolves.toEqual( + expect.objectContaining({ id: 'item' }), + ); + await expect(restarted.getStoryCodex('item')).resolves.toEqual(legacyCodex); + await expect(restarted.getStoryCodex('item/')).resolves.toEqual(legacyCodex); + await expect(restarted.getBinderAsset('item', 'legacy-asset')).resolves.toEqual( + expect.objectContaining({ + meta: expect.objectContaining({ originalFileName: 'legacy.bin' }), + }), + ); + }); + + // QNBS-v3: persisted legacy routing must be revalidated so a later legitimate project cannot be captured by stale fallback metadata. + it('does not restore persisted legacy routing after a legitimate project identity appears', async () => { + const legacyProject = { + ...project, + id: '***', + binderNodes: [{ binderAssetId: 'legacy-asset' }], + }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await store.saveStoryCodex(legacyCodex as never); + await store.saveBinderAsset('***', 'legacy-asset', new Uint8Array([1]).buffer, { + mimeType: 'application/octet-stream', + originalFileName: 'legacy.bin', + byteSize: 1, + }); + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + + const loaded = await store.loadProject('item'); + await store.saveProject(loaded as never); + + const legitimateProject = { ...project, id: 'project', title: 'Legitimate Project' }; + const legitimateCodex = { projectId: 'project', entries: [{ name: 'legitimate' }] }; + const legitimateVectors = [{ id: 'legitimate-vector' }]; + await store.saveProject(legitimateProject as never); + await expect(store.getStoryCodex('item')).resolves.toBeNull(); + await store.saveStoryCodex(legitimateCodex as never); + await store.saveRagVectors('project', legitimateVectors); + await store.saveBinderAsset('project', 'legitimate-asset', new Uint8Array([2]).buffer, { + mimeType: 'application/octet-stream', + originalFileName: 'legitimate.bin', + byteSize: 1, + }); + + const restarted = new FsProjectStore(); + await expect(restarted.loadProject('item')).resolves.toEqual( + expect.objectContaining({ id: 'item' }), + ); + await expect(restarted.getStoryCodex('item')).resolves.toBeNull(); + await expect(restarted.listBinderAssetIds('item')).resolves.toEqual([]); + await expect(restarted.getStoryCodex('project')).resolves.toEqual(legitimateCodex); + await expect(restarted.getRagVectors('project')).resolves.toEqual(legitimateVectors); + await expect(restarted.getBinderAsset('project', 'legitimate-asset')).resolves.not.toBeNull(); + }); + + // QNBS-v3: a failed reload must retain the verified legacy route so transient reads do not hide auxiliary data. + it('retains legacy routing when a later project reload fails', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await store.saveStoryCodex(legacyCodex as never); + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + + await store.loadProject('item'); + const originalReadTextFile = fake.apis.readTextFile; + fake.apis.readTextFile = async (path: string) => { + if (path === '/app/projects/item/project.json') { + throw new Error('EAGAIN: project temporarily unavailable'); + } + return originalReadTextFile(path); + }; + + await expect(store.loadProject('item')).rejects.toMatchObject({ + name: 'ProjectLoadError', + reason: 'io-error', + projectId: 'item', + }); + await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex); + }); + + // QNBS-v3: incomplete legacy evidence fails closed instead of making an unverified normalization durable. + it('aborts legacy migration when auxiliary ownership evidence cannot be read', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + const originalReadTextFile = fake.apis.readTextFile; + fake.apis.readTextFile = async (path: string) => { + if (path === '/app/projects/project/codex/codex.snap') { + throw new Error('EAGAIN: codex temporarily unavailable'); + } + return originalReadTextFile(path); + }; + + await expect(store.loadProject('item')).rejects.toMatchObject({ + name: 'ProjectLoadError', + reason: 'io-error', + projectId: 'item', + }); + expect( + decompressData>( + fake.text.get('/app/projects/item/project.json') as string, + )['id'], + ).toBe('***'); + + fake.apis.readTextFile = originalReadTextFile; + await expect(store.loadProject('item')).resolves.toEqual( + expect.objectContaining({ id: 'item' }), + ); + await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex); + }); + + // QNBS-v3: incomplete save-time evidence must not make an unverified legacy identity durable. + it('rejects a legacy save when auxiliary ownership evidence is incomplete', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + const originalReadTextFile = fake.apis.readTextFile; + fake.apis.readTextFile = async (path: string) => { + if (path === '/app/projects/project/codex/codex.snap') { + throw new Error('EAGAIN: codex temporarily unavailable'); + } + return originalReadTextFile(path); + }; + + await expect(store.saveProject(legacyProject as never)).rejects.toThrow( + 'Cannot safely save this legacy project until its auxiliary data can be verified.', + ); + expect( + decompressData>( + fake.text.get('/app/projects/item/project.json') as string, + )['id'], + ).toBe('***'); + }); + + // QNBS-v3: indeterminate fallback ownership cannot clear a route that remains the only safe retry path. + it('defers persisted legacy routing when the fallback collision probe is indeterminate', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + const loaded = await store.loadProject('item'); + await store.saveProject(loaded as never); + + const originalExists = fake.apis.exists; + fake.apis.exists = async (path: string) => { + if (path === '/app/projects/project/project.json') { + throw new Error('EIO: fallback collision probe unavailable'); + } + return originalExists(path); + }; + + await expect(store.loadProject('item')).rejects.toMatchObject({ + name: 'ProjectLoadError', + reason: 'io-error', + projectId: 'item', + }); + await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex); + }); + + // QNBS-v3: Binder filename suffixes make dot-shaped legacy asset IDs safe without weakening project path rules. + it('retains dot-shaped legacy Binder asset IDs across persisted routing', async () => { + const legacyProject = { + ...project, + id: '***', + binderNodes: [{ binderAssetId: '.' }, { binderAssetId: '..' }], + }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/binder', { recursive: true }); + await fake.apis.writeFile('/app/projects/project/binder/..bin', new Uint8Array([1])); + await fake.apis.writeTextFile( + '/app/projects/project/binder/..meta.json', + JSON.stringify({ + mimeType: 'application/octet-stream', + originalFileName: 'dot.bin', + byteSize: 1, + }), + ); + await fake.apis.writeFile('/app/projects/project/binder/...bin', new Uint8Array([2])); + await fake.apis.writeTextFile( + '/app/projects/project/binder/...meta.json', + JSON.stringify({ + mimeType: 'application/octet-stream', + originalFileName: 'dot-dot.bin', + byteSize: 1, + }), + ); + + const loaded = await store.loadProject('item'); + await store.saveProject(loaded as never); + expect( + decompressData>( + fake.text.get('/app/projects/item/project.json') as string, + )['__worldscriptLegacyAuxiliary'], + ).toEqual( + expect.objectContaining({ + binderAssetIds: expect.arrayContaining(['.', '..']), + }), + ); + const restarted = new FsProjectStore(); + + await expect(restarted.loadProject('item')).resolves.toEqual( + expect.objectContaining({ id: 'item' }), + ); + await expect(restarted.getBinderAsset('item', '.')).resolves.toEqual( + expect.objectContaining({ meta: expect.objectContaining({ originalFileName: 'dot.bin' }) }), + ); + await expect(restarted.getBinderAsset('item', '..')).resolves.toEqual( + expect.objectContaining({ + meta: expect.objectContaining({ originalFileName: 'dot-dot.bin' }), + }), + ); + await expect(restarted.listBinderAssetIds('item')).resolves.toEqual( + expect.arrayContaining(['.', '..']), + ); + }); + + // QNBS-v3: legacy cleanup failures stay retryable so deletion cannot report success while routed data remains. + it('retains legacy routing when auxiliary cleanup fails during deletion', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + await store.loadProject('item'); + + const originalRemove = fake.apis.remove; + fake.apis.remove = async (path: string, options?: { recursive?: boolean }) => { + if (path === '/app/projects/project/codex/codex.snap') { + throw new Error('EIO: legacy codex cleanup unavailable'); + } + return originalRemove(path, options); + }; + + await expect(store.deleteProject('item')).rejects.toMatchObject({ + name: 'ProjectDeleteError', + }); + expect(fake.text.has('/app/projects/item/project.json')).toBe(true); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(true); + await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex); + + fake.apis.remove = originalRemove; + const restarted = new FsProjectStore(); + await expect(restarted.deleteProject('item')).resolves.toBeUndefined(); + expect(fake.text.has('/app/projects/item/project.json')).toBe(false); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false); + }); + + // QNBS-v3: deletion revalidates persisted legacy routing so restart-time cleanup cannot orphan verified auxiliary data. + it('hydrates persisted legacy routing before deleting an unloaded project', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + const loaded = await store.loadProject('item'); + await store.saveProject(loaded as never); + const restarted = new FsProjectStore(); + + await expect(restarted.deleteProject('item')).resolves.toBeUndefined(); + expect(fake.text.has('/app/projects/item/project.json')).toBe(false); + expect(fake.text.has('/app/projects/project/codex/codex.snap')).toBe(false); + }); + + // QNBS-v3: deletion fails closed when project identity cannot be inspected, preserving data for a later retry. + it('does not delete a project when its identity cannot be inspected', async () => { + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', '{corrupt'); + + await expect(store.deleteProject('item')).rejects.toMatchObject({ + name: 'ProjectDeleteError', + reason: 'identity-inspection-failed', + }); + expect(fake.text.has('/app/projects/item/project.json')).toBe(true); + }); + + // QNBS-v3: an uncertain existence probe must preserve the project and expose a retryable typed deletion result. + it('classifies a project existence probe failure before attempting cleanup', async () => { + await store.saveProject(project as never); + const originalExists = fake.apis.exists; + const originalRemove = fake.apis.remove; + const removeSpy = vi.fn(originalRemove); + fake.apis.remove = removeSpy; + fake.apis.exists = (path: string) => + path === '/app/projects/p1' + ? Promise.reject(new Error('EIO: project existence unavailable')) + : originalExists(path); + + await expect(store.deleteProject('p1')).rejects.toMatchObject({ + name: 'ProjectDeleteError', + reason: 'identity-inspection-failed', + }); + expect(removeSpy).not.toHaveBeenCalled(); + expect(fake.text.has('/app/projects/p1/project.json')).toBe(true); + }); + + // QNBS-v3: snapshot recovery accepts an invalid legacy identity only when its existing fallback directory proves ownership. + it('normalizes a legacy invalid project ID restored from a filesystem snapshot', async () => { + const legacyProject = { ...project, id: '***' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + + const snapshotId = await store.saveSnapshot('legacy', legacyProject); + const restored = await store.getSnapshotData(snapshotId); + await expect(store.saveProject(restored as never)).resolves.toBeUndefined(); + await expect(store.getStoryCodex('item')).resolves.toEqual(legacyCodex); + + const persisted = decompressData>( + fake.text.get('/app/projects/item/project.json') as string, + ); + expect(persisted['id']).toBe('item'); + await expect( + store.saveProject({ ...project, id: '***', title: 'Unrelated New Project' } as never), + ).rejects.toThrow('Cannot save a project with an unusable project ID.'); + }); + + // QNBS-v3: matching snapshot identity permits older content while keeping the filesystem target authoritative. + it('rejects a valid snapshot from another project before changing the target', async () => { + await store.saveProject(project as never); + const current = await store.loadProject('p1'); + const snapshotId = await store.saveSnapshot('older', { + ...project, + id: 'p2', + title: 'Older snapshot content', + manuscript: [{ id: 's-old', title: 'Older', content: 'previous draft' }], + }); + + await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'snapshot-owner-mismatch', + }); + expect( + decompressData>( + fake.text.get('/app/projects/p1/project.json') as string, + )['title'], + ).toBe('My Novel'); + expect(fake.text.has('/app/projects/p2/project.json')).toBe(false); + }); + + // QNBS-v3: the best-effort cold-boot marker cannot veto a validated current filesystem target. + it('restores a matching snapshot when the active-project marker is stale', async () => { + const secondProject = { ...project, id: 'p2', title: 'Second Novel' }; + await store.saveProject(secondProject as never); + const current = await store.loadProject('p2'); + const snapshotId = await store.saveSnapshot('p2-snapshot', { + ...secondProject, + title: 'Older second project content', + }); + fake.text.set('/app/config/active-project-id.txt', 'p1'); + + const restored = await store.restoreSnapshot(snapshotId, current as never); + + expect((restored as unknown as Record)['id']).toBe('p2'); + expect(restored.title).toBe('Older second project content'); + }); + + it('restores older content when the snapshot owner matches the validated target', async () => { + await store.saveProject(project as never); + const current = await store.loadProject('p1'); + const snapshotId = await store.saveSnapshot('older', { + ...project, + id: 'p1', + title: 'Older snapshot content', + manuscript: [{ id: 's-old', title: 'Older', content: 'previous draft' }], + }); + + const restored = await store.restoreSnapshot(snapshotId, current as never); + const restoredRecord = restored as unknown as Record; + + expect(restoredRecord['id']).toBe('p1'); + expect(restored.title).toBe('Older snapshot content'); + await store.saveProject(restored as never); + expect( + decompressData>( + fake.text.get('/app/projects/p1/project.json') as string, + )['title'], + ).toBe('Older snapshot content'); + expect(fake.text.has('/app/projects/p2/project.json')).toBe(false); + }); + + // QNBS-v3: target-owned metadata survives a matching restore without trusting snapshot metadata. + it('restores older normalized content and preserves target auxiliary metadata', async () => { + const legacyProject = { ...project, id: '***', title: 'Current legacy content' }; + const legacyCodex = { projectId: '***', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + await fake.apis.mkdir('/app/projects/project/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/project/codex/codex.snap', + compressData(legacyCodex), + ); + const current = await store.loadProject('item'); + const snapshotId = await store.saveSnapshot('older-legacy', { + ...legacyProject, + id: 'item', + title: 'Older legacy content', + manuscript: [{ id: 's-old', title: 'Older', content: 'previous draft' }], + __worldscriptLegacyProjectDirectory: 'project', + __worldscriptLegacyAuxiliary: { + legacyProjectId: 'project', + legacyRawProjectId: '***', + codex: false, + binderAssetIds: ['fabricated'], + }, + }); + + const restored = await store.restoreSnapshot(snapshotId, current as never); + const restoredRecord = restored as unknown as Record; + const metadata = restoredRecord['__worldscriptLegacyAuxiliary'] as Record; + + expect(restoredRecord['id']).toBe('item'); + expect(restored.title).toBe('Older legacy content'); + expect(restoredRecord['__worldscriptLegacyProjectDirectory']).toBeUndefined(); + expect(metadata).toEqual( + expect.objectContaining({ + legacyProjectId: 'project', + legacyRawProjectId: '***', + codex: true, + }), + ); + expect(metadata['binderAssetIds']).toEqual([]); + }); + + it('rejects a historical invalid-ID snapshot even when the target has matching legacy lineage', async () => { + const legacyProject = { ...project, id: '***', title: 'Current legacy content' }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + const current = await store.loadProject('item'); + const snapshotId = await store.saveSnapshot('legacy-invalid-id', { + ...legacyProject, + title: 'Older legacy content', + }); + + await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'snapshot-owner-unverifiable', + }); + expect( + decompressData>( + fake.text.get('/app/projects/item/project.json') as string, + )['title'], + ).toBe('Current legacy content'); + }); + + // QNBS-v3: restore fails closed when no safe current filesystem target is available. + it.each(['.', '..', '***'])( + 'rejects unsafe restore target %s without touching projects', + async (id) => { + const snapshotId = await store.saveSnapshot('unsafe-target', project); + + await expect( + store.restoreSnapshot(snapshotId, { ...project, id } as never), + ).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'target-unavailable', + }); + expect([...fake.text.keys()].some((path) => path.startsWith('/app/projects/'))).toBe(false); + }, + ); + + // QNBS-v3: ownerless historical snapshots fail closed instead of guessing a missing-ID target. + it('rejects a historical missing-ID snapshot for a verified missing-ID target', async () => { + const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' }; + await fake.apis.mkdir('/app/projects/Legacy-Novel', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/Legacy-Novel/project.json', + compressData(legacyProject), + ); + const current = await store.loadProject('Legacy-Novel'); + const snapshotId = await store.saveSnapshot('older-missing-id', { + ...legacyProject, + title: 'Renamed in older snapshot', + }); + + await expect(store.restoreSnapshot(snapshotId, current as never)).rejects.toMatchObject({ + name: 'ProjectSnapshotRestoreError', + reason: 'snapshot-owner-unverifiable', + }); + expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true); + expect(fake.text.has('/app/projects/Renamed-in-older-snapshot/project.json')).toBe(false); + }); + + it('keeps a missing-ID legacy project bound to its existing title-derived directory', async () => { + const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' }; + await fake.apis.mkdir('/app/projects/Legacy-Novel', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/Legacy-Novel/project.json', + compressData(legacyProject), + ); + + const loaded = await store.loadProject('Legacy-Novel'); + + expect((loaded as unknown as Record)['id']).toBeUndefined(); + await store.saveProject({ ...loaded, title: 'Renamed Novel' } as never); + expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true); + expect(fake.text.has('/app/projects/Renamed-Novel/project.json')).toBe(false); + + const restarted = new FsProjectStore(); + const reloaded = await restarted.loadProject('Legacy-Novel'); + await restarted.saveProject({ ...reloaded, title: 'Renamed Again' } as never); + expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true); + expect(fake.text.has('/app/projects/Renamed-Again/project.json')).toBe(false); + }); + + // QNBS-v3: legacy missing-ID saves preserve historical Binder/Codex fallbacks without inventing cross-project ownership. + it('keeps missing-ID legacy auxiliary data on its historical fallback paths', async () => { + const legacyProject = { ...project, id: undefined, title: 'Legacy Novel' }; + const legacyCodex = { projectId: 'default', entries: [{ name: 'legacy' }] }; + await fake.apis.mkdir('/app/projects/Legacy-Novel', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/Legacy-Novel/project.json', + compressData(legacyProject), + ); + await fake.apis.mkdir('/app/projects/browser-project/binder', { recursive: true }); + await fake.apis.writeFile( + '/app/projects/browser-project/binder/legacy-asset.bin', + new Uint8Array([1]), + ); + await fake.apis.writeTextFile( + '/app/projects/browser-project/binder/legacy-asset.meta.json', + JSON.stringify({ + mimeType: 'application/octet-stream', + originalFileName: 'legacy.bin', + byteSize: 1, + }), + ); + await fake.apis.mkdir('/app/projects/default/codex', { recursive: true }); + await fake.apis.writeTextFile( + '/app/projects/default/codex/codex.snap', + compressData(legacyCodex), + ); + + const loaded = await store.loadProject('Legacy-Novel'); + + expect((loaded as unknown as Record)['id']).toBeUndefined(); + await expect(store.getBinderAsset('browser-project', 'legacy-asset')).resolves.not.toBeNull(); + await expect(store.getStoryCodex('default')).resolves.toEqual(legacyCodex); + await store.saveProject({ ...loaded, title: 'Renamed Novel' } as never); + expect(fake.text.has('/app/projects/Legacy-Novel/project.json')).toBe(true); + expect(fake.text.has('/app/projects/Renamed-Novel/project.json')).toBe(false); + expect(fake.bin.has('/app/projects/browser-project/binder/legacy-asset.bin')).toBe(true); + expect(fake.text.has('/app/projects/default/codex/codex.snap')).toBe(true); + }); + + it('does not redirect a legacy project to a legitimate project-identity directory', async () => { + const legitimateCodex = { projectId: 'project', entries: [{ name: 'legitimate' }] }; + const legitimateVectors = [{ id: 'legitimate-vector' }]; + await store.saveProject({ ...project, id: 'project', title: 'Legitimate Project' } as never); + await store.saveStoryCodex(legitimateCodex as never); + await store.saveRagVectors('project', legitimateVectors); + await store.saveBinderAsset('project', 'legitimate-asset', new Uint8Array([3]).buffer, { + mimeType: 'application/octet-stream', + originalFileName: 'legitimate.bin', + byteSize: 1, + }); + + const legacyProject = { + ...project, + id: '***', + binderNodes: [{ binderAssetId: 'legitimate-asset' }], + }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + + await expect(store.loadProject('item')).resolves.toEqual( + expect.objectContaining({ title: 'My Novel' }), + ); + expect(await store.getStoryCodex('item')).toBeNull(); + expect(await store.getRagVectors('item')).toEqual([]); + expect(await store.listBinderAssetIds('item')).toEqual([]); + expect(await store.getStoryCodex('project')).toEqual(legitimateCodex); + expect(await store.getRagVectors('project')).toEqual(legitimateVectors); + expect(await store.getBinderAsset('project', 'legitimate-asset')).not.toBeNull(); + }); + + // QNBS-v3: ambiguous auxiliary data stays in place and unassigned rather than being exposed through a guessed legacy identity. + it('does not assign auxiliary data without ownership evidence to a legacy project', async () => { + const ambiguousVectors = [{ id: 'ambiguous-vector' }]; + await store.saveRagVectors('project', ambiguousVectors); + const legacyProject = { ...project, id: '***' }; + await fake.apis.mkdir('/app/projects/item', { recursive: true }); + await fake.apis.writeTextFile('/app/projects/item/project.json', compressData(legacyProject)); + + await store.loadProject('item'); + + expect(await store.getRagVectors('item')).toEqual([]); + expect(await store.getRagVectors('project')).toEqual(ambiguousVectors); + expect(fake.text.has('/app/projects/project/codex/vectors.snap')).toBe(true); + }); + + it('rejects dot path segments without touching the project namespace', async () => { + await store.saveProject(project as never); + await store.saveProject({ ...project, id: 'p2', title: 'Other Novel' } as never); + + for (const invalidId of ['.', '..']) { + await expect(store.saveProject({ ...project, id: invalidId } as never)).rejects.toThrow( + 'Cannot save a project with an unusable project ID.', + ); + await expect(store.loadProject(invalidId)).resolves.toBeNull(); + await expect(store.deleteProject(invalidId)).resolves.toBeUndefined(); + await expect(store.quarantineProject(invalidId)).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'not-found', + }); + } + + expect(await store.listProjects()).toEqual(expect.arrayContaining(['p1', 'p2'])); + expect(fake.text.has('/app/projects/p1/project.json')).toBe(true); + expect(fake.text.has('/app/projects/p2/project.json')).toBe(true); + }); + + // QNBS-v3: failed preservation must leave the affected source available for safe recovery. + it('leaves the original project intact when quarantine cannot rename it', async () => { + await store.saveProject(project as never); + const original = fake.text.get('/app/projects/p1/project.json'); + fake.apis.rename = () => Promise.reject(new Error('EACCES: permission denied')); + + await expect(store.quarantineProject('p1')).rejects.toMatchObject({ + name: 'ProjectQuarantineError', + reason: 'io-error', + message: 'Project preservation failed. The original project was not deleted.', + }); + + expect(fake.text.get('/app/projects/p1/project.json')).toBe(original); + expect(await store.listProjects()).toContain('p1'); + }); + // QNBS-v3: the store test protects the non-blocking scheduling seam without asserting a verdict authority. it('returns the decoded project without waiting for or adopting the shadow verdict', async () => { await store.saveProject(project as never); diff --git a/tests/unit/services/fs/projectFsStore.test.ts b/tests/unit/services/fs/projectFsStore.test.ts index c90e671af..2c47459d8 100644 --- a/tests/unit/services/fs/projectFsStore.test.ts +++ b/tests/unit/services/fs/projectFsStore.test.ts @@ -52,6 +52,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { await expect(store.loadProject('missing-id')).resolves.toBeNull(); }); + // QNBS-v3: preserve the affected project identity when filesystem reads fail during recovery. it('throws ProjectLoadError("io-error") on a read failure instead of returning null', async () => { const { FsProjectStore, ProjectLoadError } = await import( '../../../../services/fs/projectFsStore' @@ -63,9 +64,10 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { const store = new FsProjectStore(); const promise = store.loadProject('locked-id'); await expect(promise).rejects.toThrow(ProjectLoadError); - await expect(promise).rejects.toMatchObject({ reason: 'io-error' }); + await expect(promise).rejects.toMatchObject({ reason: 'io-error', projectId: 'locked-id' }); }); + // QNBS-v3: keep corruption classification attached to the project so quarantine targets only it. it('throws ProjectLoadError("corrupt") on a corrupt/truncated compressed payload', async () => { const { FsProjectStore, ProjectLoadError } = await import( '../../../../services/fs/projectFsStore' @@ -75,7 +77,7 @@ describe('FsProjectStore.loadProject — DA-01 fail-closed behavior', () => { const store = new FsProjectStore(); const promise = store.loadProject('corrupt-id'); await expect(promise).rejects.toThrow(ProjectLoadError); - await expect(promise).rejects.toMatchObject({ reason: 'corrupt' }); + await expect(promise).rejects.toMatchObject({ reason: 'corrupt', projectId: 'corrupt-id' }); }); it('throws ProjectLoadError("corrupt") on valid JSON that is not project-shaped at all', async () => { diff --git a/tests/unit/storageService.test.ts b/tests/unit/storageService.test.ts index f0b4b4f80..79a5bb42b 100644 --- a/tests/unit/storageService.test.ts +++ b/tests/unit/storageService.test.ts @@ -90,6 +90,11 @@ describe('storageService (IndexedDB backend in browser)', () => { expect(await storageService.getActiveProjectId()).toBeNull(); }); + // QNBS-v3: unsupported IndexedDB quarantine must remain a safe no-op rather than inventing a filesystem path. + it('returns null when the IndexedDB backend does not support filesystem quarantine', async () => { + await expect(storageService.quarantineProject('project-1')).resolves.toBeNull(); + }); + it('delegates saveSettings / loadSettings to dbService', async () => { await storageService.saveSettings({} as never); expect(mockDb.saveSettings).toHaveBeenCalled(); @@ -144,6 +149,10 @@ describe('storageService (IndexedDB backend in browser)', () => { await storageService.getSnapshotData(42); expect(mockDb.getSnapshotData).toHaveBeenCalledWith(42); + const currentProject = { id: 'p1', title: 'Current target' }; + await storageService.restoreSnapshot(42, currentProject as never); + expect(mockDb.getSnapshotData).toHaveBeenCalledWith(42); + await storageService.listSnapshots(); expect(mockDb.listSnapshots).toHaveBeenCalled(); diff --git a/tests/unit/thunks/binderAndManagementThunks.test.ts b/tests/unit/thunks/binderAndManagementThunks.test.ts index 1c58d8e76..e1c695e62 100644 --- a/tests/unit/thunks/binderAndManagementThunks.test.ts +++ b/tests/unit/thunks/binderAndManagementThunks.test.ts @@ -2,12 +2,14 @@ import { configureStore } from '@reduxjs/toolkit'; import undoable from 'redux-undo'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +// QNBS-v3: the thunk mock models the target-aware storage boundary used before snapshot I/O. vi.mock('../../../services/storageService', () => ({ storageService: { saveImage: vi.fn(), deleteBinderAsset: vi.fn(), saveBinderAsset: vi.fn(), getSnapshotData: vi.fn(), + restoreSnapshot: vi.fn(), }, })); @@ -52,6 +54,7 @@ beforeEach(() => { vi.mocked(storageService.saveBinderAsset).mockResolvedValue(undefined); vi.mocked(storageService.saveImage).mockResolvedValue(undefined); vi.mocked(storageService.getSnapshotData).mockResolvedValue(null); + vi.mocked(storageService.restoreSnapshot).mockResolvedValue(null); }); // --------------------------------------------------------------------------- @@ -359,26 +362,57 @@ describe('importProjectThunk', () => { describe('restoreSnapshotThunk', () => { it('dispatches fulfilled with snapshot data from storageService', async () => { const snapshotData = { title: 'Snapshot Title', manuscript: [] }; - vi.mocked(storageService.getSnapshotData).mockResolvedValue(snapshotData as never); + vi.mocked(storageService.restoreSnapshot).mockResolvedValue(snapshotData as never); const store = makeStore(); const action = await store.dispatch(restoreSnapshotThunk(42)); expect(action.type).toBe('project/restoreSnapshot/fulfilled'); expect((action as { payload: typeof snapshotData }).payload).toEqual(snapshotData); + expect(storageService.restoreSnapshot).toHaveBeenCalledWith( + 42, + expect.objectContaining({ id: 'default' }), + ); }); - it('calls storageService.getSnapshotData with the correct id', async () => { - vi.mocked(storageService.getSnapshotData).mockResolvedValue(null); + it('captures the current project before requesting snapshot data', async () => { + vi.mocked(storageService.restoreSnapshot).mockResolvedValue(null); const store = makeStore(); await store.dispatch(restoreSnapshotThunk(99)); - expect(storageService.getSnapshotData).toHaveBeenCalledWith(99); + expect(storageService.restoreSnapshot).toHaveBeenCalledWith( + 99, + expect.objectContaining({ id: 'default' }), + ); + }); + + // QNBS-v3: an async restore must not fulfill into a different Redux project than the captured target. + it('rejects when the active project changes while snapshot I/O is pending', async () => { + let releaseRestore!: (value: unknown) => void; + vi.mocked(storageService.restoreSnapshot).mockReturnValue( + new Promise((resolve) => { + releaseRestore = resolve; + }), + ); + + const store = makeStore(); + const pending = store.dispatch(restoreSnapshotThunk(100)); + const currentData = store.getState().project.present.data; + store.dispatch({ + type: 'project/restoreSnapshot/fulfilled', + payload: { ...currentData, id: 'p2' }, + }); + releaseRestore({ ...currentData, id: 'default' }); + + const action = await pending; + + expect(action.type).toBe('project/restoreSnapshot/rejected'); + expect(store.getState().project.present.data.id).toBe('p2'); }); it('dispatches rejected when storageService throws', async () => { - vi.mocked(storageService.getSnapshotData).mockRejectedValue(new Error('IDB error')); + vi.mocked(storageService.restoreSnapshot).mockRejectedValue(new Error('IDB error')); const store = makeStore(); const action = await store.dispatch(restoreSnapshotThunk(1));