From d557298cebbc4a5a2210aea9bd37442df70850da Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Tue, 3 Nov 2026 04:27:01 +0000 Subject: [PATCH] Adopt Commonlib storage path contracts Use the shared Node adapter and retain one comparison policy throughout each CLI scan. Propagate permission and I/O failures instead of reporting absence or a successful partial scan. Use shared namespace and comparison helpers in Obsidian while preserving its Vault APIs and alias policy. Cover native spelling and host filename compatibility, and document the dependency and integration boundaries. --- devs.md | 7 +- docs/design_docs/unicode_path_identity.md | 10 + .../cli/adapters/NodeFileSystemAdapter.ts | 116 ++++++++-- .../NodeFileSystemAdapter.unit.spec.ts | 94 +++++++- src/apps/cli/adapters/NodeVaultAdapter.ts | 29 +-- .../adapters/NodeVaultAdapter.unit.spec.ts | 8 +- .../UnicodeResolvingNodeStorageAdapter.ts | 207 +----------------- .../conflictResolution/operations.ts | 7 +- .../documentHistoryRestoration.ts | 6 +- .../FileSystemAdapters/pathAliasGuard.ts | 16 +- .../pathAliasGuard.unit.spec.ts | 26 ++- test/e2e-cli/test-mirror.ts | 41 +++- updates.md | 6 + 13 files changed, 301 insertions(+), 272 deletions(-) diff --git a/devs.md b/devs.md index d936734c..e1703810 100644 --- a/devs.md +++ b/devs.md @@ -264,9 +264,10 @@ Commonlib owns the typed English fallback for messages requested by its services ### File Path Handling -- Use tagged types from `types.ts`: `FilePath`, `FilePathWithPrefix`, `DocumentID` -- Prefix constants: `CHeader` (chunks), `ICHeader`/`ICHeaderEnd` (internal data) -- Path utilities are supplied by the focused Commonlib compatibility path `@vrtmrz/livesync-commonlib/compat/string_and_binary/path` +- New path boundaries use `FilePath`, `DocumentPath`, `StoragePath`, and domain-specific `PathComparisonKey` from `@vrtmrz/livesync-commonlib/storage`. Original paths retain spelling; comparison keys are never converted back into filenames. +- Parse original document input and route its namespace before storage access. `splitDocumentPath` and `joinDocumentPath` retain one recognised namespace; `DocumentPath` excludes legacy comparison keys. Existing `FilePathWithPrefix`, `FilePathWithPrefixLC`, and `DocumentID` remain at compatibility boundaries. +- The CLI uses Commonlib's `UnicodeResolvingNodeStorageAdapter` and one strict resolver per NFC directory scan. Only absence becomes a missing file; permission and enumeration failures propagate. Obsidian keeps its own Vault APIs and alias guard, whose fixed case-insensitive NFC policy is independent of the selected database identity mode. +- The [Commonlib storage-path design](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/design_docs/storage_path_resolution.md) and [ADR](https://github.com/vrtmrz/livesync-commonlib/blob/main/docs/adr/2026_11_shared_storage_path_resolution.md) define this unreleased boundary. WebApp and Bridge NFC enablement remains separate work. ### Logging & Debugging diff --git a/docs/design_docs/unicode_path_identity.md b/docs/design_docs/unicode_path_identity.md index 98bf406c..ee9c2e73 100644 --- a/docs/design_docs/unicode_path_identity.md +++ b/docs/design_docs/unicode_path_identity.md @@ -27,6 +27,16 @@ Obsidian hosts, moves the setting to General Settings, and defaults new synchronisation environments to NFC. The original dates and reviewed versions above remain the historical baseline. +## Unreleased shared storage boundary + +The CLI adopts Commonlib's shared Unicode resolver and typed physical-I/O +ports. Each NFC scan retains one comparison-policy snapshot; directory and +permission errors propagate instead of producing a successful partial scan. +Obsidian uses the shared namespace and comparison helpers while retaining its +Vault APIs and existing alias-guard policy. The [developer guide](../../devs.md#file-path-handling) +links the Commonlib design and ADR. This extraction does not change Metadata +IDs, defaults, database migration, or the host support matrix below. + ## Scope | Area | Contract | diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.ts index 9ba123e4..78dc750c 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.ts @@ -9,6 +9,12 @@ import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes"; import { path } from "@vrtmrz/livesync-commonlib/node"; import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput"; import { findCaseInsensitiveFile } from "@/apps/caseInsensitiveFileLookup"; +import { + createStoragePathResolver, + parseFilePath, + type StoragePath, + type StoragePathResolver, +} from "@vrtmrz/livesync-commonlib/storage"; /** * Complete file system adapter implementation for Node.js @@ -21,6 +27,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter(); + private readonly isCaseInsensitiveSetting: () => boolean; constructor( private basePath: string, @@ -31,6 +38,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { @@ -122,15 +130,28 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { const pathStr = this.normalisePath(p); + if (this.storage.isUnicodePathNormalizationEnabled) { + try { + const resolvedPath = await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false); + if (resolvedPath === null) { + this.fileCache.delete(pathStr); + return null; + } + return await this.refreshResolvedFile(resolvedPath); + } catch (error) { + this.fileCache.delete(pathStr); + throw error; + } + } try { - const resolvedPath = this.storage.isUnicodePathNormalizationEnabled - ? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false) - : pathStr; - if (resolvedPath === null) { + const stat = await this.storage.stat(pathStr, caseInsensitive); + if (stat?.type !== "file") { this.fileCache.delete(pathStr); return null; } - return await this.refreshResolvedFile(resolvedPath); + const file: NodeFile = { path: pathStr, stat }; + this.fileCache.set(pathStr, file); + return file; } catch (error) { if (error instanceof AmbiguousNormalisedPathError) throw error; this.fileCache.delete(pathStr); @@ -138,16 +159,16 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { + private async refreshResolvedFile(resolvedPath: StoragePath): Promise { try { - const stat = await this.storage.statExact(resolvedPath); + const stat = await this.storage.statResolved(resolvedPath); if (stat?.type !== "file") { this.fileCache.delete(resolvedPath); return null; } const file: NodeFile = { - path: resolvedPath as FilePath, + path: resolvedPath, stat: { size: stat.size, mtime: stat.mtime, @@ -158,10 +179,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { - const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false); - if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache); + const files = new Map(); + await this.scanDirectoryInto(relativePath, files); + for (const [path, file] of files) this.fileCache.set(path, file); } private async scanDirectoryInto(relativePath: string, files: Map): Promise { + const resolver = this.createScanResolver(); + const filePath = parseFilePath(relativePath); + if (resolver) { + const resolvedPath = + filePath === "" ? await resolver.resolveForWrite(filePath) : await resolver.resolveExisting(filePath); + if (resolvedPath !== null) await this.scanResolvedDirectoryInto(resolver, resolvedPath, files); + return; + } + await this.scanLegacyDirectoryInto(filePath, files); + } + + private createScanResolver(): StoragePathResolver | null { + if (!this.storage.isUnicodePathNormalizationEnabled) return null; + const caseInsensitive = this.isCaseInsensitiveSetting(); + return createStoragePathResolver({ + readDirectory: async (physicalDirectory) => await this.storage.readDirectory(physicalDirectory), + policy: { + pathNormalizationVersion: 1, + handleFilenameCaseSensitive: !caseInsensitive, + }, + }); + } + + private async scanResolvedDirectoryInto( + resolver: StoragePathResolver, + relativePath: StoragePath, + files: Map + ): Promise { + const directoryStat = await this.storage.statResolved(relativePath); + if (directoryStat === null) return; + if (directoryStat.type !== "folder") { + throw new Error(`Path is not a directory: ${this.resolvePath(relativePath)}`); + } + + const entries = await resolver.listResolvedDirectory(relativePath); + if (entries === null) return; + for (const entry of entries) { + if (entry.kind === "directory") { + await this.scanResolvedDirectoryInto(resolver, entry.path, files); + continue; + } + + const stat = await this.storage.statResolved(entry.path); + if (stat?.type !== "file") continue; + const file: NodeFile = { path: entry.path, stat }; + files.set(entry.path, file); + } + } + + private async scanLegacyDirectoryInto(relativePath: string, files: Map): Promise { const fullPath = this.resolvePath(relativePath); try { - const directoryStat = this.storage.isUnicodePathNormalizationEnabled - ? await this.storage.statExact(relativePath) - : await this.storage.stat(relativePath, false); + const directoryStat = await this.storage.stat(relativePath, false); if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`); - const entries = await this.storage.listExact(relativePath); + const entries = await this.storage.list(relativePath, false); for (const entryPath of entries.files) { - const stat = await this.storage.statExact(entryPath); + const filePath = parseFilePath(entryPath); + const stat = await this.storage.stat(filePath, false); if (stat?.type !== "file") continue; const file: NodeFile = { - path: entryPath as FilePath, + path: filePath, stat, }; - files.set(entryPath, file); + files.set(filePath, file); } for (const entryPath of entries.folders) { - await this.scanDirectoryInto(entryPath, files); + await this.scanLegacyDirectoryInto(entryPath, files); } } catch (error) { if (error instanceof AmbiguousNormalisedPathError) throw error; - // Directory doesn't exist or is not readable this.reportDiagnostic(`Error scanning directory ${fullPath}:`, error); } } diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts index 1791f6d5..10120a7a 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.unit.spec.ts @@ -1,12 +1,14 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { fsPromises as fs, NodeStorageAdapter, os, path } from "@vrtmrz/livesync-commonlib/node"; -import { DEFAULT_SETTINGS, type FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { DEFAULT_SETTINGS } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { parseFilePath, type StoragePath } from "@vrtmrz/livesync-commonlib/storage"; import { ServiceRebuilder, type ServiceRebuilderDependencies, } from "@vrtmrz/livesync-commonlib/compat/serviceModules/Rebuilder"; import { PathServiceCompat } from "@vrtmrz/livesync-commonlib/compat/services/implements/injectable/InjectablePathService"; import { ServiceContext } from "@vrtmrz/livesync-commonlib/compat/services/base/ServiceBase"; +import { AmbiguousNormalisedPathError } from "./UnicodeResolvingNodeStorageAdapter"; import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter"; describe("NodeFileSystemAdapter file enumeration", () => { @@ -219,6 +221,77 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => { ); } + it("propagates path-resolution failures when refreshing a file", async () => { + const directory = await createVault(); + const adapter = createNormalisingAdapter(directory); + const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" }); + vi.spyOn(adapter.storage, "resolveLogicalPath").mockRejectedValue(error); + + await expect(adapter.refreshFile("a.md")).rejects.toBe(error); + }); + + it("propagates strict metadata failures when refreshing a resolved file", async () => { + const directory = await createVault(); + await fs.writeFile(path.join(directory, "a.md"), "content"); + const adapter = createNormalisingAdapter(directory); + const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" }); + vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error); + + await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error); + }); + + it("keeps resolved metadata reads strict when the identity mode changes during lookup", async () => { + const directory = await createVault(); + await fs.writeFile(path.join(directory, "a.md"), "content"); + let version = 1; + const adapter = new NodeFileSystemAdapter(directory, undefined, () => version); + const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" }); + const resolve = adapter.storage.resolveLogicalPath.bind(adapter.storage); + vi.spyOn(adapter.storage, "resolveLogicalPath").mockImplementation(async (...args) => { + const result = await resolve(...args); + version = 0; + return result; + }); + vi.spyOn(adapter.storage, "statResolved").mockRejectedValue(error); + + await expect(adapter.getAbstractFileByPath("a.md")).rejects.toBe(error); + }); + + it("propagates strict directory-read failures without reporting an incomplete scan", async () => { + const directory = await createVault(); + const report = vi.fn(); + const adapter = new NodeFileSystemAdapter(directory, report, () => 1); + const error = Object.assign(new Error("Storage permission denied"), { code: "EACCES" }); + vi.spyOn(adapter.storage, "readDirectory").mockRejectedValue(error); + + await expect(adapter.getFiles()).rejects.toBe(error); + expect(report).not.toHaveBeenCalled(); + }); + + it("uses one comparison policy snapshot for the whole NFC scan", async () => { + const directory = await createVault(); + await fs.mkdir(path.join(directory, "Folder")); + await fs.writeFile(path.join(directory, "Folder/Note.md"), "upper"); + await fs.writeFile(path.join(directory, "Folder/note.md"), "lower"); + let policyReads = 0; + let normalizationReads = 0; + const adapter = new NodeFileSystemAdapter( + directory, + undefined, + () => { + normalizationReads++; + return 1; + }, + () => ++policyReads > 1 + ); + + const files = await adapter.getFiles(); + + expect(files.map((file) => file.path).sort()).toEqual(["Folder/Note.md", "Folder/note.md"]); + expect(normalizationReads).toBe(1); + expect(policyReads).toBe(1); + }); + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); }); @@ -253,13 +326,13 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => { ); for (const name of names) await fs.writeFile(path.join(directory, child, name), "content"); const adapter = createNormalisingAdapter(directory, caseInsensitive); - const list = vi.spyOn(NodeStorageAdapter.prototype, "list"); + const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory"); try { const files = await adapter.getFiles(); expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort()); - expect(list).toHaveBeenCalledTimes(3); + expect(readDirectory).toHaveBeenCalledTimes(3); } finally { - list.mockRestore(); + readDirectory.mockRestore(); } } ); @@ -306,12 +379,12 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => { await fs.writeFile(path.join(directory, `${index}.md`), "content"); } const adapter = createNormalisingAdapter(directory, caseInsensitive); - const list = vi.spyOn(NodeStorageAdapter.prototype, "list"); + const readDirectory = vi.spyOn(NodeStorageAdapter.prototype, "readDirectory"); try { expect(await adapter.getFiles()).toHaveLength(12); - expect(list).toHaveBeenCalledTimes(1); + expect(readDirectory).toHaveBeenCalledTimes(1); } finally { - list.mockRestore(); + readDirectory.mockRestore(); } } ); @@ -379,11 +452,11 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => { await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8"); const adapter = createNormalisingAdapter(directory); const exactFile = { - path: nfcName as FilePath, + path: parseFilePath(nfcName), stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const }, }; - await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i); + await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toBeInstanceOf(AmbiguousNormalisedPathError); await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i); await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i); @@ -545,7 +618,8 @@ describe("NodeFileSystemAdapter Unicode path resolution", () => { const adapter = createNormalisingAdapter(directory); await expect(adapter.getFiles()).resolves.toEqual([]); - await expect(adapter.storage.statExact("linked/note.md")).rejects.toThrow(/symbolic links/i); + await expect(adapter.storage.resolveLogicalPath(parseFilePath("linked/note.md"))).rejects.toThrow(/symbolic links/i); + await expect(adapter.storage.statExact("linked/note.md" as StoragePath)).rejects.toThrow(/symbolic links/i); await expect(adapter.storage.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i); await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside"); }); diff --git a/src/apps/cli/adapters/NodeVaultAdapter.ts b/src/apps/cli/adapters/NodeVaultAdapter.ts index 00ff734d..f36fee33 100644 --- a/src/apps/cli/adapters/NodeVaultAdapter.ts +++ b/src/apps/cli/adapters/NodeVaultAdapter.ts @@ -1,8 +1,8 @@ -import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { NodeFile, NodeFolder } from "./NodeTypes"; -import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node"; -import { UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter"; +import { NodeStorageAdapter, UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node"; +import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage"; /** * Vault adapter implementation for Node.js @@ -56,7 +56,7 @@ export class NodeVaultAdapter implements IVaultAdapter { async rename(file: NodeFile, newPath: string): Promise { await this.storage.rename(file.path, newPath); - file.path = newPath as FilePath; + file.path = parseFilePath(newPath); } async delete(file: NodeFile | NodeFolder, force = false): Promise { @@ -74,16 +74,17 @@ export class NodeVaultAdapter implements IVaultAdapter { } private async toNodeFile(path: string): Promise { - const resolvedPath = - this.storage instanceof UnicodeResolvingNodeStorageAdapter - ? await this.storage.resolveLogicalPath(path, undefined, false) - : path; - if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`); - const stat = - this.storage instanceof UnicodeResolvingNodeStorageAdapter - ? await this.storage.statExact(resolvedPath) - : await this.storage.stat(resolvedPath); + const filePath = parseFilePath(path); + if (this.storage instanceof UnicodeResolvingNodeStorageAdapter) { + const resolvedPath = await this.storage.resolveLogicalPath(filePath, undefined, false); + if (resolvedPath === null) throw new Error(`Could not read created file metadata: ${path}`); + const stat = await this.storage.statExact(resolvedPath); + if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`); + return { path: resolvedPath, stat }; + } + + const stat = await this.storage.stat(filePath); if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`); - return { path: resolvedPath as FilePath, stat }; + return { path: filePath, stat }; } } diff --git a/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts index 9d648957..7d7ed0b1 100644 --- a/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts @@ -1,6 +1,6 @@ import { fsPromises, os, path } from "@vrtmrz/livesync-commonlib/node"; import { describe, expect, it, vi } from "vitest"; -import type { FilePath } from "@vrtmrz/livesync-commonlib/compat/common/types"; +import { parseFilePath } from "@vrtmrz/livesync-commonlib/storage"; import { NodeFileSystemAdapter } from "./NodeFileSystemAdapter"; import { NodeVaultAdapter } from "./NodeVaultAdapter"; @@ -11,7 +11,7 @@ describe("NodeVaultAdapter.rename", () => { await fsPromises.writeFile(path.join(directory, "Calculus.md"), "content", "utf8"); const adapter = new NodeVaultAdapter(directory); const file = { - path: "Calculus.md" as FilePath, + path: parseFilePath("Calculus.md"), stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const }, }; @@ -37,7 +37,7 @@ describe("NodeVaultAdapter.rename", () => { ); const adapter = new NodeVaultAdapter(directory); const file = { - path: "source.md" as FilePath, + path: parseFilePath("source.md"), stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const }, }; @@ -65,7 +65,7 @@ describe("NodeVaultAdapter.rename", () => { ); const adapter = new NodeVaultAdapter(directory); const file = { - path: "linked/victim.md" as FilePath, + path: parseFilePath("linked/victim.md"), stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const }, }; diff --git a/src/apps/cli/adapters/UnicodeResolvingNodeStorageAdapter.ts b/src/apps/cli/adapters/UnicodeResolvingNodeStorageAdapter.ts index 7041e132..3c00d6e9 100644 --- a/src/apps/cli/adapters/UnicodeResolvingNodeStorageAdapter.ts +++ b/src/apps/cli/adapters/UnicodeResolvingNodeStorageAdapter.ts @@ -1,205 +1,2 @@ -import { NodeStorageAdapter, validateStoragePath } from "@vrtmrz/livesync-commonlib/node"; - -export class AmbiguousNormalisedPathError extends Error { - constructor(path: string) { - super(`Ambiguous path after Unicode normalisation: ${path}`); - this.name = "AmbiguousNormalisedPathError"; - } -} - -/** Resolves NFC-equivalent logical paths against current Node storage listings. */ -export class UnicodeResolvingNodeStorageAdapter extends NodeStorageAdapter { - constructor( - rootPath: string, - private readonly getPathNormalizationVersion: () => number = () => 0, - private readonly isCaseInsensitive: () => boolean = () => false - ) { - super(rootPath); - } - - get isUnicodePathNormalizationEnabled(): boolean { - return this.getPathNormalizationVersion() === 1; - } - - /** Read metadata for an entry already resolved or obtained from a storage listing. */ - async statExact(path: string) { - return await super.trystat(path); - } - - /** Inspect a physical directory once before descending into its enumerated entries. */ - async listExact(path: string) { - const entries = await super.list(path); - if (this.isUnicodePathNormalizationEnabled) { - const caseInsensitive = this.isCaseInsensitive(); - const names = new Map(); - for (const entry of [...entries.files, ...entries.folders]) { - const key = this.comparisonKey(entry, caseInsensitive); - if (names.has(key)) throw new AmbiguousNormalisedPathError(entry); - names.set(key, entry); - } - } - return entries; - } - - async resolveLogicalPath( - path: string, - caseInsensitive = this.isCaseInsensitive(), - allowMissing = true - ): Promise { - if (!this.isUnicodePathNormalizationEnabled) return path; - - validateStoragePath(path, true); - if (path === "") return ""; - - const segments = path.split("/"); - let resolvedParent = ""; - - for (let index = 0; index < segments.length; index++) { - const segment = segments[index]; - const entries = await super.list(resolvedParent); - const candidates = [...entries.files, ...entries.folders]; - const requestedKey = this.comparisonKey(segment, caseInsensitive); - const matches = candidates.filter((entryPath) => { - const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1); - return this.comparisonKey(entryName, caseInsensitive) === requestedKey; - }); - - if (matches.length > 1) throw new AmbiguousNormalisedPathError(path); - if (matches.length === 1) { - resolvedParent = matches[0]; - continue; - } - - if (!allowMissing) return null; - const remainingPath = segments.slice(index).join("/"); - return resolvedParent === "" ? remainingPath : `${resolvedParent}/${remainingPath}`; - } - - return resolvedParent; - } - - override async exists(path: string, caseInsensitive = this.isCaseInsensitive()): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false); - if (resolvedPath === null) return false; - return await super.exists(resolvedPath); - } - - override async trystat(path: string, caseInsensitive = this.isCaseInsensitive()) { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false); - if (resolvedPath === null) return null; - return await super.trystat(resolvedPath); - } - - override async stat(path: string, caseInsensitive = this.isCaseInsensitive()) { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false); - if (resolvedPath === null) return null; - return await super.trystat(resolvedPath); - } - - override async mkdir(path: string, caseInsensitive = this.isCaseInsensitive()): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true); - if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`); - await super.mkdir(resolvedPath); - } - - override async remove(path: string, caseInsensitive = this.isCaseInsensitive()): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false); - await super.remove(resolvedPath ?? path); - } - - override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false); - return await super.read(resolvedPath ?? path); - } - - override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false); - return await super.readBinary(resolvedPath ?? path); - } - - override async write( - path: string, - data: string, - options?: Parameters[2], - caseInsensitive = this.isCaseInsensitive() - ): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true); - if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`); - await super.write(resolvedPath, data, options); - } - - override async writeBinary( - path: string, - data: ArrayBuffer, - options?: Parameters[2], - caseInsensitive = this.isCaseInsensitive() - ): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true); - if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`); - await super.writeBinary(resolvedPath, data, options); - } - - override async append( - path: string, - data: string, - options?: Parameters[2], - caseInsensitive = this.isCaseInsensitive() - ): Promise { - const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, true); - if (resolvedPath === null) throw new Error(`Could not resolve path: ${path}`); - await super.append(resolvedPath, data, options); - } - - override async rename( - sourcePath: string, - targetPath: string, - caseInsensitive = this.isCaseInsensitive() - ): Promise { - const resolvedSource = await this.resolveLogicalPath(sourcePath, caseInsensitive, false); - if (resolvedSource === null) return await super.rename(sourcePath, targetPath); - - const resolvedTarget = await this.resolveLogicalPath(targetPath, caseInsensitive, true); - if (resolvedTarget === null) throw new Error(`Could not resolve path: ${targetPath}`); - - // Preserve explicit case-only or Unicode-spelling renames when the target resolves to the source. - const sourceKey = this.comparisonKey(resolvedSource, caseInsensitive); - const targetKey = this.comparisonKey(resolvedTarget, caseInsensitive); - const target = - sourceKey === targetKey && resolvedTarget === resolvedSource && targetPath !== resolvedSource - ? await this.resolveRenameTargetSpelling(targetPath, caseInsensitive) - : resolvedTarget; - - await super.rename(resolvedSource, target); - } - - override async list(basePath: string, caseInsensitive = this.isCaseInsensitive()) { - const resolvedPath = await this.resolveLogicalPath(basePath, caseInsensitive, false); - if (resolvedPath === null) return { files: [], folders: [] }; - return await super.list(resolvedPath); - } - - private async resolveRenameTargetSpelling(path: string, caseInsensitive: boolean): Promise { - validateStoragePath(path, false); - const segments = path.split("/"); - const leaf = segments.pop(); - if (leaf === undefined) throw new Error(`Could not resolve path: ${path}`); - const parentPath = segments.join("/"); - const resolvedParent = await this.resolveLogicalPath(parentPath, caseInsensitive, true); - if (resolvedParent === null) throw new Error(`Could not resolve path: ${path}`); - - const entries = await super.list(resolvedParent); - const requestedKey = this.comparisonKey(leaf, caseInsensitive); - const matches = [...entries.files, ...entries.folders].filter((entryPath) => { - const entryName = entryPath.slice(resolvedParent === "" ? 0 : resolvedParent.length + 1); - return this.comparisonKey(entryName, caseInsensitive) === requestedKey; - }); - if (matches.length > 1) throw new AmbiguousNormalisedPathError(path); - - return resolvedParent === "" ? leaf : `${resolvedParent}/${leaf}`; - } - - private comparisonKey(segment: string, caseInsensitive: boolean): string { - const normalisedSegment = segment.normalize("NFC"); - return caseInsensitive ? normalisedSegment.toLowerCase() : normalisedSegment; - } -} +export { UnicodeResolvingNodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node"; +export { AmbiguousNormalisedPathError } from "@vrtmrz/livesync-commonlib/storage"; diff --git a/src/serviceFeatures/conflictResolution/operations.ts b/src/serviceFeatures/conflictResolution/operations.ts index afb974f8..98e13276 100644 --- a/src/serviceFeatures/conflictResolution/operations.ts +++ b/src/serviceFeatures/conflictResolution/operations.ts @@ -27,9 +27,10 @@ import type { LiveSyncEventHub } from "@vrtmrz/livesync-commonlib/context"; import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess"; import { EVENT_CONFLICT_CANCELLED } from "@vrtmrz/livesync-commonlib/compat/events/coreEvents"; import { NO_INTERACTION } from "@vrtmrz/livesync-commonlib/replication"; +import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage"; import { isLockAcquired, withLock } from "octagonal-wheels/concurrency/lock"; import diff_match_patch from "diff-match-patch"; -import { stripAllPrefixes, isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; export type ConflictResolutionSettings = Pick< ObsidianLiveSyncSettings, @@ -108,7 +109,9 @@ export function createConflictResolutionOperations( return AUTO_MERGED; } // If no conflicts were found, write the resolved content to the storage. - if (!(await dependencies.fileHandler.dbToStorage(path, stripAllPrefixes(path), true))) { + const documentPath = parseDocumentPath(path); + const { path: filePath } = splitDocumentPath(documentPath); + if (!(await dependencies.fileHandler.dbToStorage(documentPath, filePath, true))) { dependencies.log(`Could not write the resolved content to the storage: ${path}`, LOG_LEVEL_NOTICE); return MISSING_OR_ERROR; } diff --git a/src/serviceFeatures/documentHistoryRestoration.ts b/src/serviceFeatures/documentHistoryRestoration.ts index ac82ed58..48233c70 100644 --- a/src/serviceFeatures/documentHistoryRestoration.ts +++ b/src/serviceFeatures/documentHistoryRestoration.ts @@ -2,7 +2,7 @@ import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync- import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils"; import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess"; import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler"; -import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path"; +import { parseDocumentPath, splitDocumentPath } from "@vrtmrz/livesync-commonlib/storage"; export type DocumentHistoryRestorationCore = { databaseFileAccess: Pick< @@ -54,8 +54,8 @@ export async function restoreDocumentHistoryRevision( } const body = readAsBlob(source); - const storagePath = stripAllPrefixes(current.path); - if (storagePath !== current.path || !isPathValid(storagePath)) { + const { prefix, path: storagePath } = splitDocumentPath(parseDocumentPath(current.path)); + if (prefix !== "" || !isPathValid(storagePath)) { return { status: "unsupported-path" }; } const file: UXFileInfo = { diff --git a/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts b/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts index 6be2da7c..4a1a3ca8 100644 --- a/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts +++ b/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts @@ -1,3 +1,5 @@ +import { getStoragePathComparisonKey, type FilePath, type PathComparisonKey } from "@vrtmrz/livesync-commonlib/storage"; + /** * Keeps storage from being changed through a name which the file system resolves but no directory lists. * @@ -20,7 +22,13 @@ export interface PathAliasGuardHost { /** How long the names which a directory listed are taken as still listed. A name which is missed is listed afresh. */ const LISTING_LIFETIME_MS = 1_000; -const foldName = (name: string) => name.normalize("NFC").toLowerCase(); +// Host alias checks retain their fixed NFC and case-insensitive policy in both identity modes. +// Original host names keep the host vocabulary; comparison does not apply rooted-storage input validation. +const foldName = (name: string) => + getStoragePathComparisonKey(name as FilePath, { + pathNormalizationVersion: 1, + handleFilenameCaseSensitive: false, + }); const nameOf = (entry: string) => { const segments = entry.split("/").filter((segment) => segment !== ""); @@ -28,14 +36,14 @@ const nameOf = (entry: string) => { }; export class PathAliasGuard { - private readonly listings = new Map; listedAt: number }>(); + private readonly listings = new Map>; listedAt: number }>(); constructor( private readonly host: PathAliasGuardHost, private readonly now: () => number = Date.now ) {} - private recentListing(directory: string): Set | undefined { + private recentListing(directory: string): Set> | undefined { const listing = this.listings.get(directory); if (!listing) return undefined; if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names; @@ -43,7 +51,7 @@ export class PathAliasGuard { return undefined; } - private async listAfresh(directory: string): Promise> { + private async listAfresh(directory: string): Promise>> { const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry)))); this.listings.set(directory, { names, listedAt: this.now() }); return names; diff --git a/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts b/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts index b459081c..5c7c1645 100644 --- a/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts +++ b/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts @@ -15,7 +15,7 @@ import type { App, TFile } from "obsidian"; import { afterAll, describe, expect, it, vi } from "vitest"; import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter"; import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter"; -import { createPathAliasGuard } from "./pathAliasGuard"; +import { createPathAliasGuard, PathAliasGuard } from "./pathAliasGuard"; const REFUSAL = "Refusing a path which reaches an existing entry by a name its folder does not list."; const CONFIG_DIRECTORY = ".obsidian"; @@ -141,6 +141,30 @@ describe("paths which resolve to an existing entry under an unlisted name", () = expect(calls.trashFile).not.toHaveBeenCalled(); }); + it("allows NFC and case equivalents of names exposed by the host listing", async () => { + const folder = "Café".normalize("NFD"); + const guard = new PathAliasGuard({ + isIndexed: () => false, + exists: async () => true, + list: async (path) => (path === "" ? [folder] : [`${folder}/Note.md`]), + }); + + await expect(guard.assertNoAlias("CAFÉ/note.md")).resolves.toBeUndefined(); + }); + + it.each(["x:cache", "host\\name"])( + "allows a listed target beside the unrelated host-listed name %s", + async (unrelatedName) => { + const guard = new PathAliasGuard({ + isIndexed: () => false, + exists: async () => true, + list: async () => [unrelatedName, "note.md"], + }); + + await expect(guard.assertNoAlias("note.md")).resolves.toBeUndefined(); + } + ); + it("allows listed names, case-only differences, indexed paths, and new files", async () => { const { app, calls } = fakeApp(); const adapter = new ObsidianStorageAdapter(app); diff --git a/test/e2e-cli/test-mirror.ts b/test/e2e-cli/test-mirror.ts index 1dd19622..91f70782 100644 --- a/test/e2e-cli/test-mirror.ts +++ b/test/e2e-cli/test-mirror.ts @@ -21,7 +21,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { copyFile, mkdir, readFile, stat, utimes, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, readFile, readdir, stat, utimes, writeFile } from "node:fs/promises"; import { TempDir } from "./helpers/temp.ts"; import { runCliOrFail, runCliWithInputOrFail } from "./helpers/cli.ts"; @@ -136,7 +136,10 @@ test("mirror: storage <-> DB synchronisation", async (t) => { // Reflect the shared base into the actual Vault before editing it. await runMirror(); - assert.deepStrictEqual(await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"), "old content\n"); + assert.deepStrictEqual( + await readFile(workDir.join("vault", "test", "sync-storage-newer.md"), "utf8"), + "old content\n" + ); // Write new content to storage with a timestamp 1 hour in the future const storageFile = workDir.join("vault", "test", "sync-storage-newer.md"); @@ -249,3 +252,37 @@ async function exists(path: string): Promise { return false; } } + +test("mirror: NFC identity retains physical Unicode spelling", async () => { + await using workDir = await TempDir.create("livesync-cli-nfc-paths"); + const vault = workDir.join("vault"); + const database = workDir.join("db"); + const settings = workDir.join("settings.json"); + const parent = "Café".normalize("NFD"); + const leaf = "Résumé.md".normalize("NFD"); + const logical = "Café/Résumé.md"; + const physical = workDir.join("vault", parent, leaf); + await mkdir(workDir.join("vault", parent), { recursive: true }); + await mkdir(database); + await initSettingsFile(settings); + await markSettingsConfigured(settings); + const data = JSON.parse(await readFile(settings, "utf8")); + data.pathNormalizationVersion = 1; + await writeFile(settings, JSON.stringify(data)); + await writeFile(physical, "original Unicode content\n"); + await utimes(physical, new Date(), new Date(Date.now() - 3600_000)); + + const run = (...args: string[]) => runCliOrFail(database, "--settings", settings, ...args); + await run("mirror", vault); + const received = workDir.join("received.md"); + await run("pull", logical, received); + assert.equal(await readFile(received, "utf8"), "original Unicode content\n"); + + const replacement = workDir.join("replacement.md"); + await writeFile(replacement, "updated Unicode content\n"); + await run("push", replacement, logical); + await run("mirror", vault); + assert.equal(await readFile(physical, "utf8"), "updated Unicode content\n"); + assert.deepEqual(await readdir(vault), [parent]); + assert.deepEqual(await readdir(workDir.join("vault", parent)), [leaf]); +}); diff --git a/updates.md b/updates.md index a8ecdf26..784e6591 100644 --- a/updates.md +++ b/updates.md @@ -23,6 +23,12 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi - The notice shown when a translation is available now displays the **Display Language** setting label correctly in English, French, Hebrew, Russian, and Simplified Chinese. (#1298) Thank you to @cwatanab for the contribution! - Hebrew interface messages also display their referenced labels correctly. +### CLI + +#### Fixed + +- NFC file lookups and directory scans report permission and I/O failures instead of treating unreadable files as absent or returning a successful partial scan. A scan retains the comparison settings with which it began. + ## 1.0.44 1st November, 2026