mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-06 00:08:23 +00:00
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:
1 parent
2716d68b3d
commit
d557298ceb
13 files changed
+301
-272
No files matched your search
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user