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