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.
This commit is contained in:
vorotamoroz committed 2026-11-03 08:44:31 +00:00
1 parent 2716d68b3d
commit d557298ceb
13 files changed
+301 -272

No files matched your search

+4 -3
View File
@@ -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
+10
View File
@@ -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 |
+92 -24
View File
@@ -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<NodeFile, NodeF
readonly vault: NodeVaultAdapter;
private fileCache = new Map<string, NodeFile>();
private readonly isCaseInsensitiveSetting: () => boolean;
constructor(
private basePath: string,
@@ -31,6 +38,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
this.path = new NodePathAdapter();
this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter();
this.isCaseInsensitiveSetting = isCaseInsensitive;
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
this.vault = new NodeVaultAdapter(this.storage);
}
@@ -39,8 +47,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
return path.join(this.basePath, p);
}
private normalisePath(p: FilePath | string): string {
return this.path.normalisePath(p);
private normalisePath(p: FilePath | string): FilePath {
return parseFilePath(this.path.normalisePath(p));
}
private async hasExactPathCase(pathStr: string): Promise<boolean> {
@@ -122,15 +130,28 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
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<NodeFile, NodeF
}
}
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
private async refreshResolvedFile(resolvedPath: StoragePath): Promise<NodeFile | null> {
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<NodeFile, NodeF
this.fileCache.set(resolvedPath, file);
return file;
} catch (error) {
if (error instanceof AmbiguousNormalisedPathError) throw error;
// Evict so a deleted file is not returned by subsequent cache scans.
this.fileCache.delete(resolvedPath);
return null;
throw error;
}
}
@@ -169,34 +188,83 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
* Helper method to recursively scan directory and populate file cache
*/
async scanDirectory(relativePath: string = ""): Promise<void> {
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
const files = new Map<string, NodeFile>();
await this.scanDirectoryInto(relativePath, files);
for (const [path, file] of files) this.fileCache.set(path, file);
}
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
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<string, NodeFile>
): Promise<void> {
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<string, NodeFile>): Promise<void> {
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);
}
}
@@ -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");
});
+15 -14
View File
@@ -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<NodeFile> {
async rename(file: NodeFile, newPath: string): Promise<void> {
await this.storage.rename(file.path, newPath);
file.path = newPath as FilePath;
file.path = parseFilePath(newPath);
}
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
@@ -74,16 +74,17 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
}
private async toNodeFile(path: string): Promise<NodeFile> {
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 };
}
}
@@ -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 },
};
@@ -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<string, string>();
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<string | null> {
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<boolean> {
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<void> {
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<void> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
await super.remove(resolvedPath ?? path);
}
override async read(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<string> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
return await super.read(resolvedPath ?? path);
}
override async readBinary(path: string, caseInsensitive = this.isCaseInsensitive()): Promise<ArrayBuffer> {
const resolvedPath = await this.resolveLogicalPath(path, caseInsensitive, false);
return await super.readBinary(resolvedPath ?? path);
}
override async write(
path: string,
data: string,
options?: Parameters<NodeStorageAdapter["write"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
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<NodeStorageAdapter["writeBinary"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
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<NodeStorageAdapter["append"]>[2],
caseInsensitive = this.isCaseInsensitive()
): Promise<void> {
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<void> {
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<string> {
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";
@@ -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;
}
@@ -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 = {
@@ -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<string, { names: Set<string>; listedAt: number }>();
private readonly listings = new Map<string, { names: Set<PathComparisonKey<"storage">>; listedAt: number }>();
constructor(
private readonly host: PathAliasGuardHost,
private readonly now: () => number = Date.now
) {}
private recentListing(directory: string): Set<string> | undefined {
private recentListing(directory: string): Set<PathComparisonKey<"storage">> | 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<Set<string>> {
private async listAfresh(directory: string): Promise<Set<PathComparisonKey<"storage">>> {
const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry))));
this.listings.set(directory, { names, listedAt: this.now() });
return names;
@@ -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);
+39 -2
View File
@@ -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<boolean> {
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]);
});
+6
View File
@@ -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