Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Atomic writes for desktop filesystem storage.** Every `services/fs/*Store.ts` writer
(project, active-project marker, settings, API keys, snapshots, Codex, RAG vectors, binder
assets, images) previously wrote directly to its final path — a crash or power loss mid-write
could leave the file truncated/corrupted with no recovery path. New `writeTextFileAtomic`/
`writeFileAtomic` helpers (`services/fs/fsCore.ts`) write to a temp sibling first, then
atomically rename over the final path, so a reader only ever sees the old complete file or the
new complete file, never a partial one. Also fixes a latent capability gap: `fs:allow-read-file`/
`fs:allow-write-file`/`fs:allow-rename` were never declared in
`src-tauri/capabilities/default.json`, even though `assetFsStore.ts` already called the binary
`readFile`/`writeFile` commands they gate. **Review-loop follow-up fixes to the same change:** a
failed temp-file write (not just a failed rename) now also cleans up its orphaned temp file;
same-path writes now serialize in call order via a per-path queue, closing a race where two
overlapping saves could have an older write's rename land after a newer one and silently roll
the file back; `crypto.randomUUID()` now has a `getRandomValues()`-based fallback for WebKit
versions that predate it, matching the existing pattern in
`encryptionMigrationOrchestrator.ts#createMigrationOperationId`. A new startup sweep
(`cleanupOrphanedTempFiles`, run once per session, fire-and-forget) recursively removes
`.tmp-*` siblings left behind by a write interrupted by an actual process kill (crash/power
loss) rather than a caught JS error — neither `atomicRename`'s nor the temp-write failure
handler's in-session cleanup can ever see that case, since execution stops before either runs.
**Second round of review-loop follow-up fixes:** the startup sweep being fire-and-forget meant a
save started immediately after `initialize()` could create a brand-new (legitimate, not
orphaned) temp file that the still-running sweep would then delete out from under it — a
module-level `activeTempPaths` set, populated for the duration of each write, now tells the
sweep to skip any temp file currently being written rather than delaying every session's
startup by awaiting the sweep. The per-path write queue also didn't coalesce: a burst of
same-path writes arriving faster than disk I/O completes (e.g. rapid autosave retries on a slow
or temporarily locked disk) appended every call to a growing promise chain, writing every stale
intermediate version to disk instead of just the latest one. The queue now keeps at most the
currently-running write and a single latest-queued write per path — a write arriving while
another is already queued (not yet started) supersedes it in place instead of appending another
link, bounding both memory and wasted I/O regardless of how many same-path writes arrive in a
burst.

## [1.27.0] — 2026-08-13

### Added
Expand Down
8 changes: 4 additions & 4 deletions services/fs/assetFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { logger } from '../logger';
import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend';
import { retryFs, sanitizePathSegment } from './fsCore';
import { retryFs, sanitizePathSegment, writeFileAtomic, writeTextFileAtomic } from './fsCore';
import { FsSnapshotStore } from './snapshotFsStore';

export class FsAssetStore extends FsSnapshotStore {
Expand All @@ -23,7 +23,7 @@ export class FsAssetStore extends FsSnapshotStore {

const imageFile = await apis.join(imagesPath, `${sanitizePathSegment(id, 'image')}.png`);
const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, '');
await retryFs(() => apis.writeTextFile(imageFile, cleanBase64));
await writeTextFileAtomic(apis, imageFile, cleanBase64);
}

async getImage(id: string): Promise<string | null> {
Expand Down Expand Up @@ -88,8 +88,8 @@ export class FsAssetStore extends FsSnapshotStore {
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 retryFs(() => apis.writeFile(binFile, new Uint8Array(data)));
await retryFs(() => apis.writeTextFile(metaFile, JSON.stringify(metaOut)));
await writeFileAtomic(apis, binFile, new Uint8Array(data));
await writeTextFileAtomic(apis, metaFile, JSON.stringify(metaOut));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: These two independent atomic replacements do not provide an atomic commit for a binder asset. Concurrent saves, or a reader running between the two operations, can combine the new binary with old metadata or old binary with new metadata, causing byteSize and the returned payload to describe different versions. Publish a versioned asset directory or commit metadata only after both files are ready, and have readers resolve a single committed version. [race condition]

Severity Level: Major ⚠️
- ⚠️ Binder reads can combine different asset versions.
- ⚠️ Asset metadata can disagree with binary contents.
- ⚠️ Newly imported assets may briefly appear unavailable.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/fs/assetFsStore.ts
**Line:** 91:92
**Comment:**
	*Race Condition: These two independent atomic replacements do not provide an atomic commit for a binder asset. Concurrent saves, or a reader running between the two operations, can combine the new binary with old metadata or old binary with new metadata, causing `byteSize` and the returned payload to describe different versions. Publish a versioned asset directory or commit metadata only after both files are ready, and have readers resolve a single committed version.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

async getBinderAsset(projectId: string, assetId: string): Promise<BinderAssetPayload | null> {
Expand Down
14 changes: 11 additions & 3 deletions services/fs/codexFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

import type { StoryCodex } from '../../types';
import { logger } from '../logger';
import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore';
import {
compressData,
decompressData,
retryFs,
sanitizePathSegment,
writeTextFileAtomic,
} from './fsCore';
import { FsSettingsStore } from './settingsFsStore';

export class FsCodexStore extends FsSettingsStore {
Expand All @@ -19,7 +25,8 @@ export class FsCodexStore extends FsSettingsStore {
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 retryFs(() => apis.writeTextFile(codexFile, compressData(codex)));
// QNBS-v3: atomic write — a crash/power-loss mid-write must never leave codex.snap truncated.
await writeTextFileAtomic(apis, codexFile, compressData(codex));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

async getStoryCodex(projectId: string): Promise<StoryCodex | null> {
Expand Down Expand Up @@ -58,7 +65,8 @@ export class FsCodexStore extends FsSettingsStore {
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 retryFs(() => apis.writeTextFile(vectorsFile, compressData(vectors)));
// QNBS-v3: atomic write — same crash-safety rationale as saveStoryCodex above.
await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
}

async getRagVectors(projectId: string): Promise<unknown[]> {
Expand Down
170 changes: 155 additions & 15 deletions services/fs/fsCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type TauriApis = {
exists: (path: string) => Promise<boolean>;
readDir: (path: string) => Promise<{ name?: string; isDirectory?: boolean }[]>;
remove: (path: string, opts?: { recursive?: boolean }) => Promise<void>;
rename: (oldPath: string, newPath: string) => Promise<void>;
open: (opts?: Record<string, unknown>) => Promise<string | null>;
save: (opts?: Record<string, unknown>) => Promise<string | null>;
appDataDir: () => Promise<string>;
Expand Down Expand Up @@ -44,6 +45,7 @@ export async function loadTauriApis(): Promise<TauriApis> {
exists: fsModule.exists,
readDir: fsModule.readDir as TauriApis['readDir'],
remove: fsModule.remove,
rename: fsModule.rename,
open: dialogModule.open as TauriApis['open'],
save: dialogModule.save as TauriApis['save'],
appDataDir: pathModule.appDataDir,
Expand Down Expand Up @@ -78,6 +80,120 @@ export async function retryFs<T>(fn: () => Promise<T>, retries = 2, delayMs = 50
throw lastError;
}

// --- Atomic writes (write-temp-then-rename) ---
// QNBS-v3: write-temp-then-rename replaces prior direct writeTextFile/writeFile calls to the final path (crash/power-loss mid-write could truncate it); rename() replaces an existing destination per @tauri-apps/plugin-fs 2.5.1's own dist-js/index.d.ts docs, so readers only ever see the old or new complete file, never a partial one.

// QNBS-v3: crypto.randomUUID() is unsupported on some older WebKit still within this app's declared minimumSystemVersion — same feature-detected fallback as createMigrationOperationId() in encryptionMigrationOrchestrator.ts.
function createTempSuffix(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
}

// QNBS-v3: tracks temp files currently mid-write so cleanupOrphanedTempFiles' startup sweep never
// deletes one out from under an active save — a sweep that races a save started right after
// initialize() would otherwise see a brand-new (not orphaned) .tmp-* file and remove it.
Comment thread
qnbs marked this conversation as resolved.
Outdated
const activeTempPaths = new Set<string>();

interface QueuedWrite {
fn: () => Promise<void>;
waiters: Array<{ resolve: () => void; reject: (reason: unknown) => void }>;
}

// QNBS-v3: per-path write coordination — at most one write in flight per path (serializes rename
// order so an older/slower save can never overwrite a newer one) plus at most one queued "next"
// write; a write arriving while one is already queued (not yet started) supersedes it in place
// instead of appending another link, so a burst of same-path saves (e.g. rapid autosave retries
// on a slow/locked disk) can't grow an unbounded promise chain or write every stale intermediate
// version — only the currently-running write and the single latest queued one ever execute.
const inFlightPaths = new Set<string>();
const nextWrite = new Map<string, QueuedWrite>();

function enqueueWrite(path: string, fn: () => Promise<void>): Promise<void> {
return new Promise<void>((resolve, reject) => {
const waiter = { resolve, reject };
const queued = nextWrite.get(path);
if (queued) {
queued.fn = fn;
queued.waiters.push(waiter);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: When multiple writes to the same path arrive while one is in flight, replacing queued.fn causes every earlier waiter to resolve after only the newest content is written. Consequently, an earlier saveProject or saveSettings caller can receive a successful Promise even though its own data was never persisted, violating the storage backend's completion contract. Either reject superseded waiters or preserve each write's operation and resolve each caller only after its own content is committed. [api mismatch]

Severity Level: Major ⚠️
- ⚠️ Concurrent project saves lose intermediate caller payloads.
- ⚠️ Flush callers receive misleading persistence success.
- ⚠️ Save completion ordering becomes difficult to reason about.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/fs/fsCore.ts
**Line:** 109:113
**Comment:**
	*Api Mismatch: When multiple writes to the same path arrive while one is in flight, replacing `queued.fn` causes every earlier waiter to resolve after only the newest content is written. Consequently, an earlier `saveProject` or `saveSettings` caller can receive a successful `Promise` even though its own data was never persisted, violating the storage backend's completion contract. Either reject superseded waiters or preserve each write's operation and resolve each caller only after its own content is committed.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}
if (inFlightPaths.has(path)) {
nextWrite.set(path, { fn, waiters: [waiter] });
return;
}
void runQueuedWrite(path, fn, [waiter]);
});
}

async function runQueuedWrite(
path: string,
fn: () => Promise<void>,
waiters: Array<{ resolve: () => void; reject: (reason: unknown) => void }>,
): Promise<void> {
inFlightPaths.add(path);
try {
await fn();
for (const waiter of waiters) waiter.resolve();
} catch (err) {
for (const waiter of waiters) waiter.reject(err);
} finally {
inFlightPaths.delete(path);
}
const queued = nextWrite.get(path);
if (queued) {
nextWrite.delete(path);
void runQueuedWrite(path, queued.fn, queued.waiters);
}
}

async function atomicRename(apis: TauriApis, tmpPath: string, finalPath: string): Promise<void> {
try {
await retryFs(() => apis.rename(tmpPath, finalPath));
Comment thread
qnbs marked this conversation as resolved.
} catch (err) {
// Best-effort cleanup of the orphaned temp file; the original write error is what matters.
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
await apis.remove(tmpPath).catch(() => {});
throw err;
}
}

// QNBS-v3: cleans up the orphaned temp file on a WRITE failure too, not just on rename failure — otherwise a failed temp write (e.g. disk full mid-write) leaves a uniquely-named orphan behind.
async function writeThenRename(
apis: TauriApis,
tmpPath: string,
finalPath: string,
write: () => Promise<void>,
): Promise<void> {
activeTempPaths.add(tmpPath);
try {
try {
await write();
} catch (err) {
await apis.remove(tmpPath).catch(() => {});
throw err;
}
await atomicRename(apis, tmpPath, finalPath);
} finally {
activeTempPaths.delete(tmpPath);
}
}

export function writeTextFileAtomic(apis: TauriApis, path: string, content: string): Promise<void> {
return enqueueWrite(path, () => {
const tmpPath = `${path}.tmp-${createTempSuffix()}`;
Comment thread
qnbs marked this conversation as resolved.
return writeThenRename(apis, tmpPath, path, () =>
retryFs(() => apis.writeTextFile(tmpPath, content)),
);
});
}

export function writeFileAtomic(apis: TauriApis, path: string, data: Uint8Array): Promise<void> {
return enqueueWrite(path, () => {
const tmpPath = `${path}.tmp-${createTempSuffix()}`;
return writeThenRename(apis, tmpPath, path, () => retryFs(() => apis.writeFile(tmpPath, data)));
});
}

// --- LZ-String compression (mirrors dbService threshold and prefix) ---

const COMPRESS_THRESHOLD = 10_240;
Expand All @@ -98,15 +214,7 @@ export function decompressData<T>(raw: string): T {
}

// --- Crypto helpers ---
// QNBS-v3: PBKDF2-SHA-256 (600k iter, OWASP 2024 minimum) + random 32-byte salt per encryption,
// mirroring services/storage/storageEncryptionService.ts#deriveKey. The prior scheme derived the
// key from a single unsalted SHA-256 digest of publicly-derivable material
// (`${appDataPath}|${provider}|WorldScriptStudio|v1` — anyone who can read the encrypted file
// already knows its own parent path and the provider from the filename), making it obfuscation,
// not encryption (F-05/F-06). No migration path for pre-existing `*_key.enc.json` files: a legacy
// payload (no `salt` field) is treated as unreadable — see decryptText below and
// FsSettingsStore#getApiKey, which already returns null on any decrypt failure so the caller
// naturally re-prompts for the key.
// QNBS-v3: PBKDF2-SHA-256 (600k iter, OWASP 2024 minimum) + random 32-byte salt, mirroring storageEncryptionService.ts#deriveKey, replacing a prior unsalted-SHA-256-of-public-material scheme (F-05/F-06); legacy (no `salt` field) payloads are treated as unreadable, not migrated — see decryptText below.

const PBKDF2_ITERATIONS = 600_000; // OWASP 2024 minimum for PBKDF2-HMAC-SHA-256
const SALT_BYTE_LENGTH = 32;
Expand All @@ -119,9 +227,7 @@ function bytesToBase64(bytes: Uint8Array): string {
return btoa(bin);
}

// QNBS-v3: explicit Uint8Array<ArrayBuffer> return type — a bare `Uint8Array` annotation widens to
// `Uint8Array<ArrayBufferLike>` (includes SharedArrayBuffer), which crypto.subtle rejects as a
// BufferSource. Same pattern as services/libraryBackupService.ts#copyToFixedBuffer.
// QNBS-v3: explicit Uint8Array<ArrayBuffer> return type — a bare `Uint8Array` annotation widens to `Uint8Array<ArrayBufferLike>` (includes SharedArrayBuffer), rejected by crypto.subtle as a BufferSource; same pattern as libraryBackupService.ts#copyToFixedBuffer.
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
Expand Down Expand Up @@ -180,8 +286,7 @@ export async function decryptText(
secretMaterial: string,
): Promise<string> {
if (!payload.salt) {
// QNBS-v3: pre-2026-07-29 payloads have no salt field (unsalted single-SHA-256 scheme, F-05).
// Not migrated by design (locked decision) — the caller treats this as "no key available".
// QNBS-v3: pre-2026-07-29 payloads have no salt field (unsalted single-SHA-256 scheme, F-05) — not migrated by design (locked decision); the caller treats this as "no key available".
throw new Error('Legacy unsalted key payload is no longer supported; re-enter the API key.');
}
const salt = base64ToBytes(payload.salt);
Expand Down Expand Up @@ -228,20 +333,55 @@ export function countProjectWords(projectData: unknown): number {

// --- Base class: Tauri path resolution ---

// QNBS-v3: both atomicRename's and writeThenRename's cleanup only run when a JS promise actually rejects — a process kill (crash/power-loss) mid-write stops execution before either handler runs, leaving a uniquely-named `.tmp-*` orphan with no in-session path to reclaim it. Swept once at startup below.
const TEMP_FILE_PATTERN = /\.tmp-[0-9a-f-]+$/i;

export async function cleanupOrphanedTempFiles(
apis: TauriApis,
dir: string,
depth = 0,
): Promise<void> {
if (depth > 6) return; // guard against an unexpectedly deep tree
let entries: { name?: string; isDirectory?: boolean }[];
try {
entries = await apis.readDir(dir);
} catch {
return; // directory may not exist yet on a fresh install — nothing to clean up
}
await Promise.all(
entries.map(async (entry) => {
if (!entry.name) return;
const entryPath = await apis.join(dir, entry.name);
if (entry.isDirectory) {
await cleanupOrphanedTempFiles(apis, entryPath, depth + 1);
return;
}
if (TEMP_FILE_PATTERN.test(entry.name) && !activeTempPaths.has(entryPath)) {
await apis.remove(entryPath).catch(() => {});
}
}),
);
}

export class FsCore {
protected appDataPath: string | null = null;
protected lastAutoSnapshotTime = Date.now();
protected readonly AUTO_SNAPSHOT_INTERVAL = 5 * 60 * 1000; // 5 minutes
protected readonly MAX_AUTO_SNAPSHOTS = 20;

async initialize(): Promise<void> {
let apis: TauriApis;
try {
const apis = await loadTauriApis();
apis = await loadTauriApis();
this.appDataPath = await apis.appDataDir();
} catch (error) {
logger.error('Failed to get app data directory:', error);
throw error;
}
// Fire-and-forget: never delays the caller waiting on this initialize() call.
cleanupOrphanedTempFiles(apis, this.appDataPath).catch((error) => {
Comment thread
qnbs marked this conversation as resolved.
Outdated
logger.warn('Failed to clean up orphaned temp files:', error);
});
}

protected async ensureAppDataPath(): Promise<string> {
Expand Down
12 changes: 9 additions & 3 deletions services/fs/projectFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import { logger } from '../logger';
import { parseImportedProjectJson } from '../projectImportSchema';
import { normalizeSaveProjectInputToStoryProject, type SaveProjectInput } from '../storageBackend';
import { FsAssetStore } from './assetFsStore';
import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore';
import {
compressData,
decompressData,
retryFs,
sanitizePathSegment,
writeTextFileAtomic,
} from './fsCore';

export class FsProjectStore extends FsAssetStore {
async saveProject(project: SaveProjectInput): Promise<void> {
Expand All @@ -36,7 +42,7 @@ export class FsProjectStore extends FsAssetStore {
}

const projectFile = await apis.join(projectPath, 'project.json');
await retryFs(() => apis.writeTextFile(projectFile, compressData(flat)));
await writeTextFileAtomic(apis, projectFile, compressData(flat));
Comment thread
qnbs marked this conversation as resolved.
// 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)', {
Expand All @@ -54,7 +60,7 @@ export class FsProjectStore extends FsAssetStore {
await apis.mkdir(configPath, { recursive: true });
}
const markerFile = await apis.join(configPath, 'active-project-id.txt');
await retryFs(() => apis.writeTextFile(markerFile, projectId));
await writeTextFileAtomic(apis, markerFile, projectId);
}

/**
Expand Down
Loading
Loading