From aea8823eaba3d79285fb45bb2c915ac3d2955c96 Mon Sep 17 00:00:00 2001 From: enieuwy Date: Sat, 2 May 2026 23:23:35 +0800 Subject: [PATCH] refactor: introduce client-side VaultFs boundary --- package.json | 2 +- src/main.ts | 3 + src/sync/diskMirror.ts | 210 ++++++++++----- src/sync/vaultFs/inMemoryAdapter.ts | 197 ++++++++++++++ src/sync/vaultFs/index.ts | 12 + src/sync/vaultFs/obsidianAdapter.ts | 141 ++++++++++ src/sync/vaultFs/types.ts | 78 ++++++ src/utils/normalizeVaultPath.ts | 9 + tests/sync/diskMirror-vaultFs.test.ts | 364 ++++++++++++++++++++++++++ 9 files changed, 945 insertions(+), 71 deletions(-) create mode 100644 src/sync/vaultFs/inMemoryAdapter.ts create mode 100644 src/sync/vaultFs/index.ts create mode 100644 src/sync/vaultFs/obsidianAdapter.ts create mode 100644 src/sync/vaultFs/types.ts create mode 100644 src/utils/normalizeVaultPath.ts create mode 100644 tests/sync/diskMirror-vaultFs.test.ts diff --git a/package.json b/package.json index 6a1f1a3..5c0a1d6 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "build:server-release": "node build-server-release.mjs", "test:server-update-local": "npm run build:server-release && node tests/server-update-local.mjs", - "test:regressions": "node --import jiti/register tests/diff-regressions.mjs && node --import jiti/register tests/external-edit-policy-regressions.mjs && node --import jiti/register tests/bound-recovery-regressions.mjs && node --import jiti/register tests/editor-binding-health-regressions.mjs && node --import jiti/register tests/frontmatter-guard-regressions.mjs && node --import jiti/register tests/frontmatter-quarantine-regressions.mjs && node tests/disk-mirror-regressions.mjs && node tests/markdown-ingest-regressions.mjs && node --import jiti/register tests/closed-file-mirror.ts && node --import jiti/register tests/folder-rename.ts && node --import jiti/register tests/chunked-doc-store.ts && node --import jiti/register tests/trace-store.ts && node --import jiti/register tests/server-hardening.ts && node --import jiti/register tests/v2-offline-rename-regressions.mjs", + "test:regressions": "node --import jiti/register tests/diff-regressions.mjs && node --import jiti/register tests/external-edit-policy-regressions.mjs && node --import jiti/register tests/bound-recovery-regressions.mjs && node --import jiti/register tests/editor-binding-health-regressions.mjs && node --import jiti/register tests/frontmatter-guard-regressions.mjs && node --import jiti/register tests/frontmatter-quarantine-regressions.mjs && node tests/disk-mirror-regressions.mjs && node --import jiti/register --test tests/sync/diskMirror-vaultFs.test.ts && node tests/markdown-ingest-regressions.mjs && node --import jiti/register tests/closed-file-mirror.ts && node --import jiti/register tests/folder-rename.ts && node --import jiti/register tests/chunked-doc-store.ts && node --import jiti/register tests/trace-store.ts && node --import jiti/register tests/server-hardening.ts && node --import jiti/register tests/v2-offline-rename-regressions.mjs", "test:integration:worker": "node tests/worker-integration.mjs", "test:e2e:obsidian": "wdio run wdio.conf.mts", "test:ci": "npm run test:regressions && npm run test:integration:worker", diff --git a/src/main.ts b/src/main.ts index 4a6d3c2..851aa2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { VaultSync, type ReconcileMode } from "./sync/vaultSync"; import { SCHEMA_VERSION } from "./sync/vaultSync"; import { EditorBindingManager } from "./sync/editorBinding"; import { DiskMirror } from "./sync/diskMirror"; +import { ObsidianVaultFs } from "./sync/vaultFs"; import { BlobSyncManager, type BlobQueueSnapshot } from "./sync/blobSync"; import { parseExcludePatterns } from "./sync/exclude"; import { @@ -454,8 +455,10 @@ export default class VaultCrdtSyncPlugin extends Plugin { ); // 4. DiskMirror + const vaultFs = new ObsidianVaultFs(this.app); this.diskMirror = new DiskMirror( this.app, + vaultFs, this.vaultSync, this.editorBindings, this.settings.debug, diff --git a/src/sync/diskMirror.ts b/src/sync/diskMirror.ts index 94ce7d6..aa00bbc 100644 --- a/src/sync/diskMirror.ts +++ b/src/sync/diskMirror.ts @@ -1,4 +1,4 @@ -import { type App, arrayBufferToHex, MarkdownView, TFile, normalizePath } from "obsidian"; +import { type App, MarkdownView, TFile } from "obsidian"; import * as Y from "yjs"; import type { VaultSync } from "./vaultSync"; import type { EditorBindingManager } from "./editorBinding"; @@ -11,6 +11,7 @@ import { validateFrontmatterTransition, type FrontmatterValidationResult, } from "./frontmatterGuard"; +import { VaultFsError, type VaultFs } from "./vaultFs"; /** * Handles writeback from Y.Text -> disk with: @@ -64,6 +65,12 @@ function describeOrigin(origin: unknown, provider: unknown): string { return formatUnknown(origin); } +function bufferToHex(buffer: ArrayBuffer): string { + return Array.from(new Uint8Array(buffer)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + interface SuppressionEntry { kind: "write" | "delete"; expiresAt: number; @@ -92,6 +99,7 @@ export class DiskMirror { string, { ytext: import("yjs").Text; handler: (event: import("yjs").YTextEvent, txn: import("yjs").Transaction) => void } >(); + private fallbackTexts = new Map(); private mapObserverCleanups: (() => void)[] = []; @@ -99,6 +107,7 @@ export class DiskMirror { constructor( private app: App, + private vaultFs: VaultFs, private vaultSync: VaultSync, private editorBindings: EditorBindingManager, debug: boolean, @@ -128,8 +137,8 @@ export class DiskMirror { event.changes.keys.forEach((change, fileId) => { const oldMeta = change.oldValue as import("../types").FileMeta | undefined; const newMeta = this.vaultSync.meta.get(fileId); - const oldPath = typeof oldMeta?.path === "string" ? normalizePath(oldMeta.path) : null; - const newPath = typeof newMeta?.path === "string" ? normalizePath(newMeta.path) : null; + const oldPath = typeof oldMeta?.path === "string" ? this.vaultFs.normalize(oldMeta.path) : null; + const newPath = typeof newMeta?.path === "string" ? this.vaultFs.normalize(newMeta.path) : null; const wasDeleted = this.vaultSync.isFileMetaDeleted(oldMeta); const isDeleted = this.vaultSync.isFileMetaDeleted(newMeta); @@ -177,6 +186,7 @@ export class DiskMirror { for (const [changedType] of txn.changed) { if (!(changedType instanceof Y.Text)) continue; + if (this.isObservedYText(changedType)) continue; // Reverse lookup: find the fileId that owns this Y.Text const fileId = this.findFileIdForText(changedType); @@ -186,7 +196,7 @@ export class DiskMirror { const meta = this.vaultSync.meta.get(fileId); if (!meta || this.vaultSync.isFileMetaDeleted(meta)) continue; - const path = meta.path; + const path = this.vaultFs.normalize(meta.path); // Skip if this path is already open (handled by per-file observer policy) if (this.openPaths.has(path)) continue; @@ -224,7 +234,7 @@ export class DiskMirror { // ------------------------------------------------------------------- notifyFileOpened(path: string): void { - path = normalizePath(path); + path = this.vaultFs.normalize(path); this.trace?.("disk", "notifyFileOpened", { path }); this.openPaths.add(path); if (this.writeQueue.delete(path)) { @@ -242,7 +252,7 @@ export class DiskMirror { } notifyFileClosed(path: string): void { - path = normalizePath(path); + path = this.vaultFs.normalize(path); this.trace?.("disk", "notifyFileClosed", { path }); this.openPaths.delete(path); // Flush any pending debounce for this path @@ -267,7 +277,7 @@ export class DiskMirror { private observeText(path: string): void { if (this.textObservers.has(path)) return; - const ytext = this.vaultSync.getTextForPath(path); + const ytext = this.getTextForWrite(path); if (!ytext) return; const handler = (_event: import("yjs").YTextEvent, txn: import("yjs").Transaction) => { @@ -289,6 +299,7 @@ export class DiskMirror { this.textObservers.delete(path); this.log(`unobserveText: stopped watching "${path}"`); } + this.fallbackTexts.delete(path); } /** Set of currently observed paths (for external cleanup). */ @@ -301,7 +312,7 @@ export class DiskMirror { // ------------------------------------------------------------------- scheduleWrite(path: string): void { - path = normalizePath(path); + path = this.vaultFs.normalize(path); if (this.openPaths.has(path)) { this.scheduleOpenWrite(path); return; @@ -340,7 +351,7 @@ export class DiskMirror { this.openWriteTimers.delete(path); if (!this.pendingOpenWrites.has(path)) return; - const ytext = this.vaultSync.getTextForPath(path); + const ytext = this.getTextForWrite(path); const crdtContent = yTextToString(ytext); if ( this.isActivelyViewedPath(path) @@ -416,12 +427,12 @@ export class DiskMirror { // ------------------------------------------------------------------- async flushWrite(path: string, force = false): Promise { - path = normalizePath(path); + path = this.vaultFs.normalize(path); return this.runPathWriteLocked(path, () => this.flushWriteUnlocked(path, force)); } private async flushWriteUnlocked(path: string, force: boolean): Promise { - const ytext = this.vaultSync.getTextForPath(path); + const ytext = this.getTextForWrite(path); if (!ytext) { this.log(`flushWrite: no Y.Text for "${path}", skipping`); return; @@ -444,12 +455,9 @@ export class DiskMirror { } } - const normalized = normalizePath(path); - try { - const existing = this.app.vault.getAbstractFileByPath(normalized); - if (existing instanceof TFile) { - const currentContent = await this.app.vault.read(existing); + const currentContent = await this.vaultFs.readText(path); + if (currentContent !== null) { if (currentContent === content) { this.log(`flushWrite: "${path}" unchanged, skipping`); return; @@ -459,22 +467,14 @@ export class DiskMirror { } await this.suppressWrite(path, content); - await this.app.vault.modify(existing, content); + await this.vaultFs.writeText(path, content); this.log(`flushWrite: updated "${path}" (${content.length} chars)`); } else { if (this.shouldBlockFrontmatterWrite(path, null, content)) { return; } await this.suppressWrite(path, content); - const dir = normalized.substring(0, normalized.lastIndexOf("/")); - if (dir) { - const dirExists = - this.app.vault.getAbstractFileByPath(normalizePath(dir)); - if (!dirExists) { - await this.app.vault.createFolder(dir); - } - } - await this.app.vault.create(normalized, content); + await this.vaultFs.writeText(path, content); this.log( `flushWrite: created "${path}" on disk (${content.length} chars)`, ); @@ -510,7 +510,7 @@ export class DiskMirror { } private async handleRemoteDelete(path: string): Promise { - const normalized = normalizePath(path); + const normalized = this.vaultFs.normalize(path); const wasOpen = this.openPaths.has(normalized); const wasObserved = this.textObservers.has(normalized); const wasSuppressed = this.isSuppressed(normalized); @@ -539,30 +539,32 @@ export class DiskMirror { // Unbind editor before suppressed delete so the vault `delete` event // (which skips unbind due to suppression) doesn't leave a stale binding. this.editorBindings.unbindByPath(normalized); - const file = this.app.vault.getAbstractFileByPath(normalized); - if (file instanceof TFile) { - try { - this.suppressDelete(path); - await this.app.vault.delete(file); + try { + const stats = await this.vaultFs.stat(normalized); + if (stats?.isFile) { + this.suppressDelete(normalized); + await this.vaultFs.delete(normalized); this.log(`handleRemoteDelete: deleted "${path}" from disk`); - } catch (err) { - console.error( - `[yaos] handleRemoteDelete failed for "${path}":`, - err, - ); } + } catch (err) { + console.error( + `[yaos] handleRemoteDelete failed for "${path}":`, + err, + ); } } private async handleRemoteRename(oldPath: string, newPath: string): Promise { - const oldNormalized = normalizePath(oldPath); - const newNormalized = normalizePath(newPath); + const oldNormalized = this.vaultFs.normalize(oldPath); + const newNormalized = this.vaultFs.normalize(newPath); if (oldNormalized === newNormalized) return; const wasOpen = this.openPaths.delete(oldNormalized); - if (wasOpen) { - this.openPaths.add(newNormalized); - } + const hadPendingOldWrite = + this.pendingOpenWrites.has(oldNormalized) + || this.writeQueue.has(oldNormalized) + || this.debounceTimers.has(oldNormalized) + || this.openWriteTimers.has(oldNormalized); this.pendingOpenWrites.delete(oldNormalized); const oldDebounce = this.debounceTimers.get(oldNormalized); @@ -578,34 +580,54 @@ export class DiskMirror { this.writeQueue.delete(oldNormalized); this.forcedWritePaths.delete(oldNormalized); + const oldObserver = this.textObservers.get(oldNormalized) ?? null; this.unobserveText(oldNormalized); - this.editorBindings.updatePathsAfterRename(new Map([[oldNormalized, newNormalized]])); - const oldFile = this.app.vault.getAbstractFileByPath(oldNormalized); - if (oldFile instanceof TFile) { - try { - const target = this.app.vault.getAbstractFileByPath(newNormalized); - if (target instanceof TFile) { - this.suppressDelete(oldNormalized); - await this.app.vault.delete(oldFile); - } else { - const dir = newNormalized.substring(0, newNormalized.lastIndexOf("/")); - if (dir) { - const dirNode = this.app.vault.getAbstractFileByPath(normalizePath(dir)); - if (!dirNode) { - await this.app.vault.createFolder(dir); + + let shouldWriteTarget = true; + try { + const oldStats = await this.vaultFs.stat(oldNormalized); + if (oldStats?.isFile) { + try { + await this.vaultFs.rename(oldNormalized, newNormalized); + } catch (err) { + if (err instanceof VaultFsError && err.code === "target_exists") { + const targetStats = await this.vaultFs.stat(newNormalized); + if (!targetStats?.isFile) { + throw err; } + // Preserve pre-VaultFs behavior for destination-file collisions: + // remove the old source, then let the final target write converge + // the existing destination file to the remote CRDT content. + this.suppressDelete(oldNormalized); + await this.vaultFs.delete(oldNormalized); + } else { + throw err; } - await this.app.fileManager.renameFile(oldFile, newNormalized); } this.log(`handleRemoteRename: "${oldNormalized}" -> "${newNormalized}"`); - } catch (err) { - console.error(`[yaos] handleRemoteRename failed for "${oldNormalized}" -> "${newNormalized}":`, err); } + } catch (err) { + console.error(`[yaos] handleRemoteRename failed for "${oldNormalized}" -> "${newNormalized}":`, err); + shouldWriteTarget = false; } + if (!shouldWriteTarget || !(await this.canMaterializeTargetPath(newNormalized))) { + if (wasOpen) { + this.openPaths.add(oldNormalized); + this.restoreTextObserver(oldNormalized, oldObserver); + if (hadPendingOldWrite) { + this.scheduleOpenWrite(oldNormalized); + } + } + return; + } + + this.editorBindings.updatePathsAfterRename(new Map([[oldNormalized, newNormalized]])); + if (wasOpen) { + this.openPaths.add(newNormalized); this.observeText(newNormalized); this.scheduleOpenWrite(newNormalized); } else { @@ -630,7 +652,7 @@ export class DiskMirror { } consumeDeleteSuppression(path: string): boolean { - path = normalizePath(path); + path = this.vaultFs.normalize(path); const entry = this.getActiveSuppression(path); if (!entry) return false; @@ -662,7 +684,7 @@ export class DiskMirror { } async flushOpenPath(path: string, reason: string): Promise { - path = normalizePath(path); + path = this.vaultFs.normalize(path); const timer = this.openWriteTimers.get(path); const hadTimer = !!timer; if (timer) { @@ -739,6 +761,7 @@ export class DiskMirror { obs.ytext.unobserve(obs.handler); } this.textObservers.clear(); + this.fallbackTexts.clear(); for (const timer of this.debounceTimers.values()) { clearTimeout(timer); @@ -774,7 +797,7 @@ export class DiskMirror { private hasFocusedEditorUnflushedChanges(path: string, expectedCrdtContent: string | null): boolean { if (expectedCrdtContent == null) return false; const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (activeView?.file?.path !== path) return false; + if (!activeView?.file || this.vaultFs.normalize(activeView.file.path) !== path) return false; try { return activeView.editor.getValue() !== expectedCrdtContent; } catch { @@ -788,11 +811,53 @@ export class DiskMirror { return false; } const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); - return activeView?.file?.path === path; + return !!activeView?.file && this.vaultFs.normalize(activeView.file.path) === path; + } + + private async canMaterializeTargetPath(path: string): Promise { + const normalized = this.vaultFs.normalize(path); + const targetStats = await this.vaultFs.stat(normalized); + if (targetStats?.isDirectory) return false; + + const parts = normalized.split("/"); + let current = ""; + for (const part of parts.slice(0, -1)) { + current = current ? `${current}/${part}` : part; + const parentStats = await this.vaultFs.stat(current); + if (parentStats?.isFile) return false; + } + return true; + } + + private getTextForWrite(path: string): Y.Text | null { + const normalized = this.vaultFs.normalize(path); + return this.fallbackTexts.get(normalized) ?? this.vaultSync.getTextForPath(normalized) ?? null; + } + + private restoreTextObserver( + path: string, + observer: { ytext: Y.Text; handler: (event: Y.YTextEvent, txn: Y.Transaction) => void } | null, + ): void { + if (this.textObservers.has(path)) return; + if (!observer) { + this.observeText(path); + return; + } + observer.ytext.observe(observer.handler); + this.textObservers.set(path, observer); + this.fallbackTexts.set(path, observer.ytext); + this.log(`observeText: restored "${path}" (remote-only)`); + } + + private isObservedYText(ytext: Y.Text): boolean { + for (const observer of this.textObservers.values()) { + if (observer.ytext === ytext) return true; + } + return false; } private queueImmediateWrite(path: string, reason: string, force = false): void { - path = normalizePath(path); + path = this.vaultFs.normalize(path); if (force) { this.forcedWritePaths.add(path); } @@ -802,7 +867,7 @@ export class DiskMirror { } private getActiveSuppression(path: string): SuppressionEntry | null { - path = normalizePath(path); + path = this.vaultFs.normalize(path); const entry = this.suppressedPaths.get(path); if (!entry) return null; if (Date.now() < entry.expiresAt) { @@ -816,7 +881,7 @@ export class DiskMirror { // Record the exact content we wrote so vault modify/create events can // acknowledge our own write by observed state, not just timing. const fingerprint = await this.fingerprintContent(content); - this.suppressedPaths.set(normalizePath(path), { + this.suppressedPaths.set(this.vaultFs.normalize(path), { kind: "write", expiresAt: Date.now() + SUPPRESS_MS, expectedBytes: fingerprint.bytes, @@ -825,7 +890,7 @@ export class DiskMirror { } private suppressDelete(path: string): void { - this.suppressedPaths.set(normalizePath(path), { + this.suppressedPaths.set(this.vaultFs.normalize(path), { kind: "delete", expiresAt: Date.now() + SUPPRESS_MS, }); @@ -835,7 +900,7 @@ export class DiskMirror { file: TFile, event: "modify" | "create", ): Promise { - const path = normalizePath(file.path); + const path = this.vaultFs.normalize(file.path); const entry = this.getActiveSuppression(path); if (!entry) return false; @@ -861,7 +926,12 @@ export class DiskMirror { try { // Read back the file only when a suppression candidate exists. This // keeps the hot path cheap while making self-event detection causal. - const content = await this.app.vault.read(file); + const content = await this.vaultFs.readText(path); + if (content === null) { + this.suppressedPaths.delete(path); + this.log(`suppression: "${path}" ${event} file missing`); + return false; + } const fingerprint = await this.fingerprintContent(content); if ( fingerprint.bytes === entry.expectedBytes @@ -885,7 +955,7 @@ export class DiskMirror { const digest = await crypto.subtle.digest("SHA-256", bytes); return { bytes: bytes.length, - hash: arrayBufferToHex(digest), + hash: bufferToHex(digest), }; } diff --git a/src/sync/vaultFs/inMemoryAdapter.ts b/src/sync/vaultFs/inMemoryAdapter.ts new file mode 100644 index 0000000..0c061ac --- /dev/null +++ b/src/sync/vaultFs/inMemoryAdapter.ts @@ -0,0 +1,197 @@ +import { isMarkdownSyncable } from "../../types"; +import { normalizeVaultPath } from "../../utils/normalizeVaultPath"; +import { + VaultFsError, + type VaultFs, + type VaultFsListOptions, + type VaultFsListing, + type VaultFsReadOptions, + type VaultFsRenameOptions, + type VaultFsStats, +} from "./types"; + +interface FakeFileEntry { + content: string; + mtime: number; +} + +export class FakeVaultFs implements VaultFs { + readonly writes: Array<{ path: string; content: string }> = []; + readonly deletes: string[] = []; + readonly renames: Array<{ oldPath: string; newPath: string; overwrite: boolean }> = []; + + private readonly files = new Map(); + private now = 1; + + constructor(initialFiles: Record = {}) { + for (const [path, content] of Object.entries(initialFiles)) { + this.setFile(path, content); + } + } + + normalize(path: string): string { + return normalizeVaultPath(path); + } + + async readText(path: string, options?: VaultFsReadOptions): Promise { + const normalized = this.assertSafePath(path); + const entry = this.files.get(normalized); + if (!entry) return null; + this.assertMaxBytes(normalized, this.byteSize(entry.content), options?.maxBytes); + return entry.content; + } + + async writeText(path: string, content: string): Promise { + const normalized = this.assertSafePath(path); + this.assertWritableFilePath(normalized); + this.files.set(normalized, { + content, + mtime: this.nextMtime(), + }); + this.writes.push({ path: normalized, content }); + } + + async delete(path: string): Promise { + const normalized = this.assertSafePath(path); + this.files.delete(normalized); + this.deletes.push(normalized); + } + + async rename(oldPath: string, newPath: string, options?: VaultFsRenameOptions): Promise { + const oldNormalized = this.assertSafePath(oldPath); + const newNormalized = this.assertSafePath(newPath); + if (oldNormalized === newNormalized) return; + const source = this.files.get(oldNormalized); + if (!source) { + throw new VaultFsError(`Cannot rename missing file: ${oldNormalized}`, "missing"); + } + this.assertWritableFilePath(newNormalized, options?.overwrite === true); + this.files.delete(oldNormalized); + this.files.set(newNormalized, { + content: source.content, + mtime: this.nextMtime(), + }); + this.renames.push({ + oldPath: oldNormalized, + newPath: newNormalized, + overwrite: options?.overwrite === true, + }); + } + + async stat(path: string): Promise { + const normalized = this.assertSafePath(path); + const entry = this.files.get(normalized); + if (entry) { + return this.fileStats(entry); + } + const prefix = normalized ? `${normalized}/` : ""; + if ([...this.files.keys()].some((filePath) => filePath.startsWith(prefix))) { + return { + size: 0, + mtime: 0, + isFile: false, + isDirectory: true, + }; + } + return null; + } + + async *listMarkdown(options: VaultFsListOptions): AsyncIterable { + const paths = [...this.files.keys()].sort(); + for (const path of paths) { + if (path !== path.normalize("NFC")) { + throw new VaultFsError(`Filesystem entry is not NFC: ${path}`, "non_nfc"); + } + if (!isMarkdownSyncable(path, options.excludePatterns, options.configDir)) continue; + const entry = this.files.get(path); + if (!entry) continue; + yield { + path, + stats: this.fileStats(entry), + }; + } + } + + setFile(path: string, content: string): void { + const normalized = this.assertSafePath(path); + this.assertWritableFilePath(normalized); + this.files.set(normalized, { + content, + mtime: this.nextMtime(), + }); + } + + hasFile(path: string): boolean { + return this.files.has(this.assertSafePath(path)); + } + + getFile(path: string): string | null { + return this.files.get(this.assertSafePath(path))?.content ?? null; + } + + snapshot(): Record { + return Object.fromEntries( + [...this.files.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([path, entry]) => [path, entry.content]), + ); + } + + private assertSafePath(path: string): string { + const normalized = this.normalize(path); + const parts = normalized.split("/"); + if (parts.some((part) => part === "." || part === "..")) { + throw new VaultFsError(`Unsafe vault path: ${path}`, "traversal"); + } + return normalized; + } + + private assertWritableFilePath(path: string, overwrite = true): void { + this.assertParentFoldersAvailable(path); + if (this.hasDescendant(path)) { + throw new VaultFsError(`Target path is a folder: ${path}`, "target_exists"); + } + if (!overwrite && this.files.has(path)) { + throw new VaultFsError(`Target file already exists: ${path}`, "target_exists"); + } + } + + private assertParentFoldersAvailable(path: string): void { + const parts = path.split("/"); + let current = ""; + for (const part of parts.slice(0, -1)) { + current = current ? `${current}/${part}` : part; + if (this.files.has(current)) { + throw new VaultFsError(`Parent path is not a folder: ${current}`, "target_exists"); + } + } + } + + private hasDescendant(path: string): boolean { + const prefix = path ? `${path}/` : ""; + return [...this.files.keys()].some((filePath) => filePath.startsWith(prefix)); + } + + private fileStats(entry: FakeFileEntry): VaultFsStats { + return { + size: this.byteSize(entry.content), + mtime: entry.mtime, + isFile: true, + isDirectory: false, + }; + } + + private byteSize(content: string): number { + return new TextEncoder().encode(content).byteLength; + } + + private nextMtime(): number { + return this.now++; + } + + private assertMaxBytes(path: string, observedBytes: number, maxBytes: number | undefined): void { + if (typeof maxBytes === "number" && observedBytes > maxBytes) { + throw new VaultFsError(`Read rejected for oversized file: ${path}`, "too_large"); + } + } +} diff --git a/src/sync/vaultFs/index.ts b/src/sync/vaultFs/index.ts new file mode 100644 index 0000000..680ff9e --- /dev/null +++ b/src/sync/vaultFs/index.ts @@ -0,0 +1,12 @@ +export type { + VaultFs, + VaultFsListOptions, + VaultFsListing, + VaultFsReadOptions, + VaultFsRenameOptions, + VaultFsStats, +} from "./types"; +export { VaultFsError } from "./types"; +export { ObsidianVaultFs } from "./obsidianAdapter"; +export { FakeVaultFs } from "./inMemoryAdapter"; +export type { VaultFsErrorCode } from "./types"; diff --git a/src/sync/vaultFs/obsidianAdapter.ts b/src/sync/vaultFs/obsidianAdapter.ts new file mode 100644 index 0000000..947fca5 --- /dev/null +++ b/src/sync/vaultFs/obsidianAdapter.ts @@ -0,0 +1,141 @@ +import { App, TAbstractFile, TFile, TFolder } from "obsidian"; +import { isMarkdownSyncable } from "../../types"; +import { normalizeVaultPath } from "../../utils/normalizeVaultPath"; +import { + VaultFsError, + type VaultFs, + type VaultFsListOptions, + type VaultFsListing, + type VaultFsReadOptions, + type VaultFsRenameOptions, + type VaultFsStats, +} from "./types"; + +export class ObsidianVaultFs implements VaultFs { + constructor(private readonly app: App) {} + + normalize(path: string): string { + return normalizeVaultPath(path); + } + + async readText(path: string, options?: VaultFsReadOptions): Promise { + const normalized = this.assertSafePath(path); + const file = this.app.vault.getAbstractFileByPath(normalized); + if (!(file instanceof TFile)) return null; + this.assertMaxBytes(normalized, file.stat.size, options?.maxBytes); + const content = await this.app.vault.read(file); + this.assertMaxBytes(normalized, new TextEncoder().encode(content).byteLength, options?.maxBytes); + return content; + } + + async writeText(path: string, content: string): Promise { + const normalized = this.assertSafePath(path); + await this.ensureParentFolders(normalized); + const existing = this.app.vault.getAbstractFileByPath(normalized); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + return; + } + if (existing instanceof TFolder) { + throw new VaultFsError(`Cannot write text over folder: ${normalized}`, "target_exists"); + } + await this.app.vault.create(normalized, content); + } + + async delete(path: string): Promise { + const normalized = this.assertSafePath(path); + const file = this.app.vault.getAbstractFileByPath(normalized); + if (file instanceof TFile) { + await this.app.vault.delete(file); + } + } + + async rename(oldPath: string, newPath: string, options?: VaultFsRenameOptions): Promise { + const oldNormalized = this.assertSafePath(oldPath); + const newNormalized = this.assertSafePath(newPath); + if (oldNormalized === newNormalized) return; + + const source = this.app.vault.getAbstractFileByPath(oldNormalized); + if (!(source instanceof TFile)) { + throw new VaultFsError(`Cannot rename missing file: ${oldNormalized}`, "missing"); + } + + const target = this.app.vault.getAbstractFileByPath(newNormalized); + if (target) { + if (!options?.overwrite || !(target instanceof TFile)) { + throw new VaultFsError(`Rename target already exists: ${newNormalized}`, "target_exists"); + } + await this.app.vault.delete(target); + } + + await this.ensureParentFolders(newNormalized); + await this.app.fileManager.renameFile(source, newNormalized); + } + + async stat(path: string): Promise { + const normalized = this.assertSafePath(path); + const node = this.app.vault.getAbstractFileByPath(normalized); + if (!node) return null; + return this.toStats(node); + } + + async *listMarkdown(options: VaultFsListOptions): AsyncIterable { + for (const file of this.app.vault.getMarkdownFiles()) { + if (file.path !== file.path.normalize("NFC")) { + throw new VaultFsError(`Filesystem entry is not NFC: ${file.path}`, "non_nfc"); + } + const path = this.normalize(file.path); + if (!isMarkdownSyncable(path, options.excludePatterns, options.configDir)) continue; + yield { + path, + stats: this.toStats(file), + }; + } + } + + private assertSafePath(path: string): string { + const normalized = this.normalize(path); + const parts = normalized.split("/"); + if (parts.some((part) => part === "." || part === "..")) { + throw new VaultFsError(`Unsafe vault path: ${path}`, "traversal"); + } + return normalized; + } + + private async ensureParentFolders(path: string): Promise { + const parentParts = path.split("/").slice(0, -1); + let current = ""; + for (const part of parentParts) { + current = current ? `${current}/${part}` : part; + const existing = this.app.vault.getAbstractFileByPath(current); + if (existing instanceof TFolder) continue; + if (existing) { + throw new VaultFsError(`Parent path is not a folder: ${current}`, "target_exists"); + } + await this.app.vault.createFolder(current); + } + } + + private toStats(node: TAbstractFile): VaultFsStats { + if (node instanceof TFile) { + return { + size: node.stat.size, + mtime: node.stat.mtime, + isFile: true, + isDirectory: false, + }; + } + return { + size: 0, + mtime: 0, + isFile: false, + isDirectory: node instanceof TFolder, + }; + } + + private assertMaxBytes(path: string, observedBytes: number, maxBytes: number | undefined): void { + if (typeof maxBytes === "number" && observedBytes > maxBytes) { + throw new VaultFsError(`Read rejected for oversized file: ${path}`, "too_large"); + } + } +} diff --git a/src/sync/vaultFs/types.ts b/src/sync/vaultFs/types.ts new file mode 100644 index 0000000..dc11908 --- /dev/null +++ b/src/sync/vaultFs/types.ts @@ -0,0 +1,78 @@ +export interface VaultFsStats { + size: number; + /** Milliseconds since epoch. */ + mtime: number; + isFile: boolean; + isDirectory: boolean; +} + +export interface VaultFsListOptions { + excludePatterns: string[]; + configDir: string; +} + +export interface VaultFsListing { + path: string; + stats: VaultFsStats; +} + +export interface VaultFsRenameOptions { + /** When true, overwrite an existing target. Default false. */ + overwrite?: boolean; +} + +export interface VaultFsReadOptions { + /** Reject reads larger than `maxBytes` with a `VaultFsError` carrying code `too_large`. */ + maxBytes?: number; +} + +export type VaultFsErrorCode = + | "missing" + | "target_exists" + | "too_large" + | "traversal" + | "symlink_escape" + | "non_nfc" + | "io"; + +export class VaultFsError extends Error { + constructor( + message: string, + readonly code: VaultFsErrorCode, + options?: { cause?: unknown }, + ) { + super(message); + this.name = "VaultFsError"; + if (options && "cause" in options) { + (this as Error & { cause?: unknown }).cause = options.cause; + } + } +} + +export interface VaultFs { + /** Canonical vault-relative form. NFC, slash-normalized, no leading/trailing slash. */ + normalize(path: string): string; + + /** Read UTF-8 text. Returns null when the file is missing. Throws `too_large` if `maxBytes` exceeded. */ + readText(path: string, options?: VaultFsReadOptions): Promise; + + /** Write UTF-8 text. Atomic where the runtime supports it. Creates parent directories. */ + writeText(path: string, content: string): Promise; + + /** Delete a file. No-op when the file is missing. */ + delete(path: string): Promise; + + /** + * Rename `oldPath` to `newPath`. Creates parent directories of the target. + * Plugin adapter MUST preserve link rewrites (Obsidian `app.fileManager.renameFile`). + * Throws `missing` when source is missing. + * Throws `target_exists` when the target exists and `options.overwrite` is not true. + */ + rename(oldPath: string, newPath: string, options?: VaultFsRenameOptions): Promise; + + /** Stat a path. Returns null when missing. */ + stat(path: string): Promise; + + /** Yield every vault-relative markdown path that survives `excludePatterns` and is a file. */ + listMarkdown(options: VaultFsListOptions): AsyncIterable; +} diff --git a/src/utils/normalizeVaultPath.ts b/src/utils/normalizeVaultPath.ts new file mode 100644 index 0000000..015c942 --- /dev/null +++ b/src/utils/normalizeVaultPath.ts @@ -0,0 +1,9 @@ +export function normalizeVaultPath(path: string): string { + return path + .normalize("NFC") + .replace(/\\/g, "/") + .replace(/\/{2,}/g, "/") + .replace(/^(\.\/)+/, "") + .replace(/^\/+/, "") + .replace(/\/+$/, ""); +} diff --git a/tests/sync/diskMirror-vaultFs.test.ts b/tests/sync/diskMirror-vaultFs.test.ts new file mode 100644 index 0000000..e2bfe8c --- /dev/null +++ b/tests/sync/diskMirror-vaultFs.test.ts @@ -0,0 +1,364 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import type { App, TFile } from "obsidian"; +import * as Y from "yjs"; +import { DiskMirror } from "../../src/sync/diskMirror"; +import type { EditorBindingManager } from "../../src/sync/editorBinding"; +import type { VaultSync } from "../../src/sync/vaultSync"; +import { FakeVaultFs, ObsidianVaultFs, VaultFsError } from "../../src/sync/vaultFs"; +import type { FileMeta } from "../../src/types"; + +const encoder = new TextEncoder(); + +class FakeVaultSync { + readonly ydoc = new Y.Doc(); + readonly pathToId = this.ydoc.getMap("pathToId"); + readonly idToText = this.ydoc.getMap("idToText"); + readonly meta = this.ydoc.getMap("meta"); + readonly provider = { name: "fake-provider" }; + + private readonly textToFileId = new WeakMap(); + private nextId = 1; + + setText(path: string, content: string): Y.Text { + let fileId = this.pathToId.get(path); + if (!fileId) { + fileId = `file-${this.nextId++}`; + this.pathToId.set(path, fileId); + } + const ytext = new Y.Text(); + ytext.insert(0, content); + this.idToText.set(fileId, ytext); + this.meta.set(fileId, { path, mtime: Date.now() }); + this.textToFileId.set(ytext, fileId); + return ytext; + } + + getTextForPath(path: string): Y.Text | null { + const fileId = this.pathToId.get(path); + if (!fileId) return null; + const text = this.idToText.get(fileId) ?? null; + if (text) this.textToFileId.set(text, fileId); + return text; + } + + getFileIdForText(ytext: Y.Text): string | undefined { + return this.textToFileId.get(ytext); + } + + isFileMetaDeleted(meta: FileMeta | undefined): boolean { + return !!meta && (meta.deleted === true || typeof meta.deletedAt === "number"); + } +} + +interface Harness { + vaultFs: FakeVaultFs; + vaultSync: FakeVaultSync; + mirror: DiskMirror; + editorRenames: Array>; +} + +function createHarness( + diskFiles: Record = {}, + crdtFiles: Record = {}, + frontmatterGuardEnabled = true, +): Harness { + const vaultFs = new FakeVaultFs(diskFiles); + const vaultSync = new FakeVaultSync(); + for (const [path, content] of Object.entries(crdtFiles)) { + vaultSync.setText(vaultFs.normalize(path), content); + } + const app = { + workspace: { + getActiveViewOfType: () => null, + }, + } as unknown as App; + const editorRenames: Array> = []; + const editorBindings = { + getLastEditorActivityForPath: () => null, + unbindByPath: () => undefined, + updatePathsAfterRename: (renames: Map) => { + editorRenames.push(renames); + }, + } as unknown as EditorBindingManager; + const mirror = new DiskMirror( + app, + vaultFs, + vaultSync as unknown as VaultSync, + editorBindings, + false, + undefined, + () => frontmatterGuardEnabled, + ); + return { vaultFs, vaultSync, mirror, editorRenames }; +} + +function tFile(path: string, content: string): TFile { + const size = encoder.encode(content).byteLength; + return { + path, + stat: { size, mtime: Date.now(), ctime: Date.now() }, + } as TFile; +} + +async function handleRemoteDelete(mirror: DiskMirror, path: string): Promise { + await (mirror as unknown as { handleRemoteDelete(path: string): Promise }) + .handleRemoteDelete(path); +} + +async function handleRemoteRename(mirror: DiskMirror, oldPath: string, newPath: string): Promise { + await (mirror as unknown as { handleRemoteRename(oldPath: string, newPath: string): Promise }) + .handleRemoteRename(oldPath, newPath); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function assertVaultFsError(err: unknown, code: string): boolean { + return err instanceof VaultFsError && err.code === code; +} + +test("VaultFs adapters share path normalization and reject dot segments", async () => { + const adapters = [ + new FakeVaultFs(), + new ObsidianVaultFs({} as App), + ]; + const cases: Array<[string, string]> = [ + ["Cafe\u0301.md", "Café.md"], + ["./folder//note.md", "folder/note.md"], + ["/folder/note.md/", "folder/note.md"], + ["folder\\sub\\note.md", "folder/sub/note.md"], + ["", ""], + ["folder/.hidden.md", "folder/.hidden.md"], + ]; + + for (const adapter of adapters) { + for (const [input, expected] of cases) { + assert.equal(adapter.normalize(input), expected); + } + } + + for (const adapter of adapters) { + await assert.rejects( + () => adapter.readText("folder/./note.md"), + (err) => assertVaultFsError(err, "traversal"), + ); + await assert.rejects( + () => adapter.writeText("folder/../note.md", "unsafe"), + (err) => assertVaultFsError(err, "traversal"), + ); + } + + const fakeVaultFs = new FakeVaultFs({ dir: "not a folder" }); + await assert.rejects( + () => fakeVaultFs.writeText("dir/note.md", "content"), + (err) => assertVaultFsError(err, "target_exists"), + ); +}); + +test("DiskMirror creates a missing markdown file through VaultFs", async (t) => { + const h = createHarness({}, { "note.md": "new content" }); + t.after(() => h.mirror.destroy()); + + await h.mirror.flushWrite("note.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { "note.md": "new content" }); + assert.deepEqual(h.vaultFs.writes, [{ path: "note.md", content: "new content" }]); +}); + +test("DiskMirror updates an existing markdown file through VaultFs", async (t) => { + const h = createHarness({ "note.md": "old content" }, { "note.md": "new content" }); + t.after(() => h.mirror.destroy()); + + await h.mirror.flushWrite("note.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { "note.md": "new content" }); + assert.deepEqual(h.vaultFs.writes, [{ path: "note.md", content: "new content" }]); +}); + +test("DiskMirror deletes remote tombstones through VaultFs", async (t) => { + const h = createHarness({ "note.md": "old content" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteDelete(h.mirror, "note.md"); + + assert.deepEqual(h.vaultFs.snapshot(), {}); + assert.deepEqual(h.vaultFs.deletes, ["note.md"]); +}); + +test("DiskMirror renames remote moves through VaultFs", async (t) => { + const h = createHarness({ "old.md": "old content" }, { "new.md": "old content" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "old.md", "new.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { "new.md": "old content" }); + assert.deepEqual(h.vaultFs.renames, [{ oldPath: "old.md", newPath: "new.md", overwrite: false }]); + assert.equal(h.editorRenames.length, 1); + assert.equal(h.editorRenames[0]?.get("old.md"), "new.md"); +}); + +test("DiskMirror suppression validates the VaultFs readback fingerprint", async (t) => { + const h = createHarness({ "note.md": "old content" }, { "note.md": "new content" }); + t.after(() => h.mirror.destroy()); + + await h.mirror.flushWrite("note.md"); + const suppressed = await h.mirror.shouldSuppressModify(tFile("note.md", "new content")); + + assert.equal(suppressed, true); + assert.equal(h.mirror.isSuppressed("note.md"), false); +}); + +test("DiskMirror frontmatter guard blocks unsafe VaultFs writes", async (t) => { + const h = createHarness({}, { "note.md": "---\ntitle: Broken\n# Missing closing fence" }); + t.after(() => h.mirror.destroy()); + + await h.mirror.flushWrite("note.md"); + + assert.deepEqual(h.vaultFs.snapshot(), {}); + assert.deepEqual(h.vaultFs.writes, []); +}); + +test("DiskMirror target-file rename collision rewrites target from CRDT", async (t) => { + const h = createHarness( + { "old.md": "old content", "new.md": "target content" }, + { "new.md": "remote renamed content" }, + ); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "old.md", "new.md"); + assert.equal(h.mirror.pendingWriteCount, 1); + + await sleep(350); + + assert.deepEqual(h.vaultFs.snapshot(), { "new.md": "remote renamed content" }); + assert.deepEqual(h.vaultFs.renames, []); + assert.deepEqual(h.vaultFs.deletes, ["old.md"]); +}); + +test("DiskMirror target directory rename collision leaves the source intact", async (t) => { + const h = createHarness({ "old.md": "old content", "new.md/child.md": "local child" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "old.md", "new.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { + "new.md/child.md": "local child", + "old.md": "old content", + }); + assert.deepEqual(h.vaultFs.renames, []); + assert.deepEqual(h.vaultFs.deletes, []); + assert.equal(h.mirror.pendingWriteCount, 0); +}); + +test("DiskMirror open-file target directory collision restores old open observer", async (t) => { + const h = createHarness( + { "old.md": "old content", "new.md/child.md": "local child" }, + { "old.md": "old content" }, + ); + t.after(() => h.mirror.destroy()); + h.mirror.notifyFileOpened("old.md"); + const fileId = h.vaultSync.pathToId.get("old.md"); + assert.ok(fileId); + h.vaultSync.pathToId.delete("old.md"); + h.vaultSync.pathToId.set("new.md", fileId); + h.vaultSync.meta.set(fileId, { path: "new.md", mtime: Date.now() }); + + await handleRemoteRename(h.mirror, "old.md", "new.md"); + + const snapshot = h.mirror.getDebugSnapshot(); + assert.deepEqual(snapshot.openPaths, ["old.md"]); + assert.deepEqual(snapshot.observedPaths, ["old.md"]); + assert.equal(h.mirror.pendingWriteCount, 0); + const ytext = h.vaultSync.idToText.get(fileId); + assert.ok(ytext); + h.mirror.startMapObservers(); + const replacement = new Y.Text(); + replacement.insert(0, "replacement content"); + h.vaultSync.pathToId.set("old.md", "replacement-file"); + h.vaultSync.idToText.set("replacement-file", replacement); + h.vaultSync.meta.set("replacement-file", { path: "old.md", mtime: Date.now() }); + h.vaultSync.ydoc.transact(() => { + ytext.insert(ytext.length, " updated"); + }, h.vaultSync.provider); + const internals = h.mirror as unknown as { debounceTimers: Map }; + assert.equal(internals.debounceTimers.has("new.md"), false); + await h.mirror.flushOpenPath("old.md", "test-collision-restore"); + assert.equal(h.vaultFs.getFile("old.md"), "old content updated"); +}); + +test("DiskMirror blocked rename flushes an already pending open write", async (t) => { + const h = createHarness( + { "old.md": "old content", "new.md/child.md": "local child" }, + { "old.md": "old content" }, + ); + t.after(() => h.mirror.destroy()); + h.mirror.notifyFileOpened("old.md"); + const fileId = h.vaultSync.pathToId.get("old.md"); + assert.ok(fileId); + const ytext = h.vaultSync.idToText.get(fileId); + assert.ok(ytext); + h.vaultSync.ydoc.transact(() => { + ytext.insert(ytext.length, " pending"); + }, h.vaultSync.provider); + assert.equal(h.mirror.pendingWriteCount, 1); + + h.vaultSync.pathToId.delete("old.md"); + h.vaultSync.pathToId.set("new.md", fileId); + h.vaultSync.meta.set(fileId, { path: "new.md", mtime: Date.now() }); + + await handleRemoteRename(h.mirror, "old.md", "new.md"); + + assert.equal(h.mirror.pendingWriteCount, 1); + await h.mirror.flushOpenPath("old.md", "test-pending-rollback"); + assert.equal(h.vaultFs.getFile("old.md"), "old content pending"); + assert.equal(h.mirror.pendingWriteCount, 0); +}); + +test("DiskMirror parent-file rename collision leaves the source intact", async (t) => { + const h = createHarness({ "old.md": "old content", dir: "not a folder" }, { "dir/new.md": "remote content" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "old.md", "dir/new.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { + dir: "not a folder", + "old.md": "old content", + }); + assert.deepEqual(h.vaultFs.renames, []); + assert.deepEqual(h.vaultFs.deletes, []); + assert.equal(h.mirror.pendingWriteCount, 0); +}); + +test("DiskMirror missing-source target directory collision does not schedule a doomed write", async (t) => { + const h = createHarness({ "new.md/child.md": "local child" }, { "new.md": "remote content" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "missing.md", "new.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { "new.md/child.md": "local child" }); + assert.equal(h.mirror.pendingWriteCount, 0); +}); + +test("DiskMirror missing-source parent-file collision does not schedule a doomed write", async (t) => { + const h = createHarness({ dir: "not a folder" }, { "dir/new.md": "remote content" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "missing.md", "dir/new.md"); + + assert.deepEqual(h.vaultFs.snapshot(), { dir: "not a folder" }); + assert.equal(h.mirror.pendingWriteCount, 0); +}); + +test("DiskMirror missing-source rename schedules a write for the remote target", async (t) => { + const h = createHarness({}, { "new.md": "remote content" }); + t.after(() => h.mirror.destroy()); + + await handleRemoteRename(h.mirror, "missing.md", "new.md"); + assert.equal(h.mirror.pendingWriteCount, 1); + + await sleep(350); + + assert.deepEqual(h.vaultFs.snapshot(), { "new.md": "remote content" }); +});