mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-23 01:23:22 +00:00
Add opt-in Unicode NFC path identity for CouchDB
This commit is contained in:
1 parent
72dc88c7ef
commit
ddb4d52931
39 files changed
+2245
-97
No files matched your search
@@ -3,7 +3,7 @@ import type { IFileSystemAdapter } from "@vrtmrz/livesync-commonlib/compat/servi
|
||||
import { NodePathAdapter } from "./NodePathAdapter";
|
||||
import { NodeTypeGuardAdapter } from "./NodeTypeGuardAdapter";
|
||||
import { NodeConversionAdapter } from "./NodeConversionAdapter";
|
||||
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
|
||||
import { AmbiguousNormalisedPathError, UnicodeResolvingNodeStorageAdapter } from "./UnicodeResolvingNodeStorageAdapter";
|
||||
import { NodeVaultAdapter } from "./NodeVaultAdapter";
|
||||
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes";
|
||||
import { path } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -16,19 +16,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
readonly path: NodePathAdapter;
|
||||
readonly typeGuard: NodeTypeGuardAdapter;
|
||||
readonly conversion: NodeConversionAdapter;
|
||||
readonly storage: NodeStorageAdapter;
|
||||
readonly storage: UnicodeResolvingNodeStorageAdapter;
|
||||
readonly vault: NodeVaultAdapter;
|
||||
|
||||
private fileCache = new Map<string, NodeFile>();
|
||||
|
||||
constructor(
|
||||
private basePath: string,
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined
|
||||
private reportDiagnostic: CliDiagnosticReporter = () => undefined,
|
||||
getPathNormalizationVersion: () => number = () => 0,
|
||||
isCaseInsensitive: () => boolean = () => false
|
||||
) {
|
||||
this.path = new NodePathAdapter();
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.conversion = new NodeConversionAdapter();
|
||||
this.storage = new NodeStorageAdapter(basePath);
|
||||
this.storage = new UnicodeResolvingNodeStorageAdapter(basePath, getPathNormalizationVersion, isCaseInsensitive);
|
||||
this.vault = new NodeVaultAdapter(this.storage);
|
||||
}
|
||||
|
||||
@@ -58,6 +60,14 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
|
||||
async getAbstractFileByPath(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, false, false);
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
if (!this.fileCache.has(pathStr) && !(await this.hasExactPathCase(pathStr))) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
@@ -68,6 +78,15 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
async getAbstractFileByPathInsensitive(p: FilePath | string): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
|
||||
if (this.storage.isUnicodePathNormalizationEnabled) {
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(pathStr, true, false);
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
}
|
||||
|
||||
const exact = await this.getAbstractFileByPath(pathStr);
|
||||
if (exact) {
|
||||
return exact;
|
||||
@@ -115,17 +134,34 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
// This is used by Obsidian to sync internal file metadata
|
||||
}
|
||||
|
||||
async refreshFile(p: string): Promise<NodeFile | null> {
|
||||
async refreshFile(p: string, caseInsensitive = false): Promise<NodeFile | null> {
|
||||
const pathStr = this.normalisePath(p);
|
||||
try {
|
||||
const stat = await this.storage.stat(pathStr);
|
||||
if (stat?.type !== "file") {
|
||||
const resolvedPath = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.resolveLogicalPath(pathStr, caseInsensitive, false)
|
||||
: pathStr;
|
||||
if (resolvedPath === null) {
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
return await this.refreshResolvedFile(resolvedPath);
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
this.fileCache.delete(pathStr);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshResolvedFile(resolvedPath: string): Promise<NodeFile | null> {
|
||||
try {
|
||||
const stat = await this.storage.statExact(resolvedPath);
|
||||
if (stat?.type !== "file") {
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
const file: NodeFile = {
|
||||
path: pathStr as FilePath,
|
||||
path: resolvedPath as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: stat.mtime,
|
||||
@@ -133,11 +169,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
this.fileCache.set(pathStr, file);
|
||||
this.fileCache.set(resolvedPath, file);
|
||||
return file;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof AmbiguousNormalisedPathError) throw error;
|
||||
// Evict so a deleted file is not returned by subsequent cache scans.
|
||||
this.fileCache.delete(pathStr);
|
||||
this.fileCache.delete(resolvedPath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -146,18 +183,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
* Helper method to recursively scan directory and populate file cache
|
||||
*/
|
||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||
await this.scanDirectoryInto(relativePath, this.fileCache);
|
||||
const resolvedPath = await this.storage.resolveLogicalPath(relativePath, false, false);
|
||||
if (resolvedPath !== null) await this.scanDirectoryInto(resolvedPath, this.fileCache);
|
||||
}
|
||||
|
||||
private async scanDirectoryInto(relativePath: string, files: Map<string, NodeFile>): Promise<void> {
|
||||
const fullPath = this.resolvePath(relativePath);
|
||||
try {
|
||||
const directoryStat = await this.storage.stat(relativePath);
|
||||
const directoryStat = this.storage.isUnicodePathNormalizationEnabled
|
||||
? await this.storage.statExact(relativePath)
|
||||
: await this.storage.stat(relativePath, false);
|
||||
if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`);
|
||||
const entries = await this.storage.list(relativePath);
|
||||
const entries = await this.storage.listExact(relativePath);
|
||||
|
||||
for (const entryPath of entries.files) {
|
||||
const stat = await this.storage.stat(entryPath);
|
||||
const stat = await this.storage.statExact(entryPath);
|
||||
if (stat?.type !== "file") continue;
|
||||
const file: NodeFile = {
|
||||
path: entryPath as FilePath,
|
||||
@@ -169,6 +209,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
await this.scanDirectoryInto(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,5 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fsPromises as fs, os, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
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 {
|
||||
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 { NodeFileSystemAdapter } from "./NodeFileSystemAdapter";
|
||||
|
||||
describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
@@ -116,3 +123,382 @@ describe("NodeFileSystemAdapter file enumeration", () => {
|
||||
await expect(adapter.getFiles()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeFileSystemAdapter Unicode path resolution", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createVault() {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "livesync-cli-unicode-"));
|
||||
tempDirs.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
function createNormalisingAdapter(directory: string, caseInsensitive = false) {
|
||||
return new NodeFileSystemAdapter(
|
||||
directory,
|
||||
undefined,
|
||||
() => 1,
|
||||
() => caseInsensitive
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
it.each([0, 1])("keeps an NFD-only file in the storage listing in identity mode %s", async (version) => {
|
||||
const directory = await createVault();
|
||||
const originalPath = "café.md".normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, originalPath), "original");
|
||||
const report = vi.fn();
|
||||
const adapter = new NodeFileSystemAdapter(directory, report, () => version);
|
||||
|
||||
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([originalPath]);
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
await expect(fs.readFile(path.join(directory, originalPath), "utf8")).resolves.toBe("original");
|
||||
expect(await fs.readdir(directory)).toEqual([originalPath]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"enumerates nested mixed names once per directory with case-insensitive matching %s",
|
||||
async (caseInsensitive) => {
|
||||
const directory = await createVault();
|
||||
const parent = "café".normalize("NFD");
|
||||
const child = `${parent}/Étage`;
|
||||
await fs.mkdir(path.join(directory, child), { recursive: true });
|
||||
const names = Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) =>
|
||||
`${child}/${index}-résumé.md`
|
||||
.normalize(index % 2 ? "NFC" : "NFD")
|
||||
.split("/")
|
||||
.pop()!
|
||||
);
|
||||
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");
|
||||
try {
|
||||
const files = await adapter.getFiles();
|
||||
expect(files.map((file) => file.path).sort()).toEqual(names.map((name) => `${child}/${name}`).sort());
|
||||
expect(list).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("uses the existing mixed parent during directory preparation and preserves the new leaf spelling", async () => {
|
||||
const directory = await createVault();
|
||||
const parent = "café".normalize("NFD");
|
||||
const child = `${parent}/Étage`;
|
||||
await fs.mkdir(path.join(directory, child), { recursive: true });
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
await adapter.storage.mkdir("café");
|
||||
await adapter.storage.mkdir("café/Étage");
|
||||
const newName = "résumé.md".normalize("NFD");
|
||||
const file = await adapter.vault.create(`café/Étage/${newName}`, "created");
|
||||
|
||||
expect(file.path).toBe(`${child}/${newName}`);
|
||||
expect(await fs.readdir(directory)).toEqual([parent]);
|
||||
expect(await fs.readdir(path.join(directory, parent))).toEqual(["Étage"]);
|
||||
expect(await fs.readdir(path.join(directory, child))).toEqual([newName]);
|
||||
await expect(adapter.storage.stat(`café/Étage/${newName.normalize("NFC")}`)).resolves.toMatchObject({
|
||||
size: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an NFC-equivalent file and directory before scanning or writing a child", async () => {
|
||||
const directory = await createVault();
|
||||
const composed = "café";
|
||||
const decomposed = composed.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, composed), "file");
|
||||
await fs.mkdir(path.join(directory, decomposed));
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.storage.write(`${composed}/new.md`, "new")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, composed), "utf8")).resolves.toBe("file");
|
||||
expect(await fs.readdir(path.join(directory, decomposed))).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"enumerates each directory once for a flat NFC scan with case-insensitive matching %s",
|
||||
async (caseInsensitive) => {
|
||||
const directory = await createVault();
|
||||
for (let index = 0; index < 12; index++) {
|
||||
await fs.writeFile(path.join(directory, `${index}.md`), "content");
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, caseInsensitive);
|
||||
const list = vi.spyOn(NodeStorageAdapter.prototype, "list");
|
||||
try {
|
||||
expect(await adapter.getFiles()).toHaveLength(12);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it("reads, updates, and deletes an NFD file through its NFC path", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "café";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
const nfcName = "Ångström.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfdFolder, nfdName), "before", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
const file = await adapter.getAbstractFileByPath(`${nfcFolder}/${nfcName}`);
|
||||
expect(file?.path).toBe(`${nfdFolder}/${nfdName}`);
|
||||
if (!file) throw new Error("Expected the NFC path to resolve to the NFD file");
|
||||
await expect(adapter.vault.read(file)).resolves.toBe("before");
|
||||
await adapter.vault.modify(file, "after");
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, nfdName), "utf8")).resolves.toBe("after");
|
||||
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
|
||||
expect(await fs.readdir(path.join(directory, nfdFolder))).toEqual([nfdName]);
|
||||
|
||||
await adapter.vault.delete(file);
|
||||
|
||||
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("creates a new file inside an existing NFD directory using that directory spelling", async () => {
|
||||
const directory = await createVault();
|
||||
const nfdFolder = "café".normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
const file = await adapter.vault.create("café/new.md", "content");
|
||||
|
||||
expect(file.path).toBe(`${nfdFolder}/new.md`);
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "new.md"), "utf8")).resolves.toBe("content");
|
||||
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
|
||||
});
|
||||
|
||||
it("renames a file by case inside an NFD directory while preserving that directory spelling", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "Caf\u00e9";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfdFolder, "Note.md"), "content", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
const file = await adapter.getAbstractFileByPathInsensitive(`${nfcFolder}/Note.md`);
|
||||
if (!file) throw new Error("Expected the NFC path to resolve to the NFD file");
|
||||
|
||||
const renamedFile = await adapter.renameFile(file, `${nfcFolder}/note.md`);
|
||||
|
||||
expect(renamedFile.path).toBe(`${nfdFolder}/note.md`);
|
||||
expect(await fs.readdir(directory)).toEqual([nfdFolder]);
|
||||
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual(["note.md"]);
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "note.md"), "utf8")).resolves.toBe("content");
|
||||
});
|
||||
|
||||
it("rejects an NFC/NFD file collision for lookup and vault mutations", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcName = "café.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfcName), "NFC", "utf8");
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
const exactFile = {
|
||||
path: nfcName as FilePath,
|
||||
stat: { ctime: 1, mtime: 2, size: 3, type: "file" as const },
|
||||
};
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.vault.modify(exactFile, "changed")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.vault.delete(exactFile)).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
|
||||
expect((await fs.readdir(directory)).sort()).toEqual([nfcName, nfdName].sort());
|
||||
});
|
||||
|
||||
it("rejects an NFC/NFD directory collision before creating a child", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "café";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfcFolder));
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfcFolder, "inside.md"), "NFC", "utf8");
|
||||
await fs.writeFile(path.join(directory, nfdFolder, "inside.md"), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(`${nfcFolder}/inside.md`)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.storage.write(`${nfcFolder}/created.md`, "created")).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
await expect(fs.readdir(path.join(directory, nfcFolder))).resolves.toEqual(["inside.md"]);
|
||||
await expect(fs.readdir(path.join(directory, nfdFolder))).resolves.toEqual(["inside.md"]);
|
||||
await expect(fs.readFile(path.join(directory, nfcFolder, "inside.md"), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "inside.md"), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
|
||||
it("rejects enumeration when NFC-equivalent directories contain different files", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcFolder = "café";
|
||||
const nfdFolder = nfcFolder.normalize("NFD");
|
||||
await fs.mkdir(path.join(directory, nfcFolder));
|
||||
await fs.mkdir(path.join(directory, nfdFolder));
|
||||
await fs.writeFile(path.join(directory, nfcFolder, "a.md"), "NFC", "utf8");
|
||||
await fs.writeFile(path.join(directory, nfdFolder, "b.md"), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
|
||||
|
||||
await expect(fs.readFile(path.join(directory, nfcFolder, "a.md"), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdFolder, "b.md"), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
|
||||
it("rechecks the directory after a unique file is cached", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcName = "café.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).resolves.toMatchObject({ path: nfdName });
|
||||
await fs.writeFile(path.join(directory, nfcName), "NFC", "utf8");
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Foo", "foo"],
|
||||
["Café", "café".normalize("NFD")],
|
||||
])("rejects case-insensitive parent aliases %s and %s containing different files", async (first, second) => {
|
||||
const directory = await createVault();
|
||||
for (const [parent, name] of [
|
||||
[first, "one.md"],
|
||||
[second, "two.md"],
|
||||
]) {
|
||||
await fs.mkdir(path.join(directory, parent));
|
||||
await fs.writeFile(path.join(directory, parent, name), name);
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, first, "one.md"), "utf8")).resolves.toBe("one.md");
|
||||
await expect(fs.readFile(path.join(directory, second, "two.md"), "utf8")).resolves.toBe("two.md");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ version: 0, caseInsensitive: true },
|
||||
{ version: 1, caseInsensitive: false },
|
||||
])(
|
||||
"compatibility: retains distinct case paths in mode $version with case-insensitive matching $caseInsensitive",
|
||||
async ({ version, caseInsensitive }) => {
|
||||
const directory = await createVault();
|
||||
const names = ["Café/one.md", "café/two.md".normalize("NFD")];
|
||||
for (const name of names) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, name)));
|
||||
await fs.writeFile(path.join(directory, name), name);
|
||||
}
|
||||
const adapter = new NodeFileSystemAdapter(
|
||||
directory,
|
||||
undefined,
|
||||
() => version,
|
||||
() => caseInsensitive
|
||||
);
|
||||
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual([...names].sort());
|
||||
for (const name of names) await expect(adapter.storage.read(name)).resolves.toBe(name);
|
||||
}
|
||||
);
|
||||
|
||||
it("refuses reconstruction before starting remote activity when parent aliases are ambiguous", async () => {
|
||||
const directory = await createVault();
|
||||
for (const name of ["Café/one.md", "café/two.md".normalize("NFD")]) {
|
||||
await fs.mkdir(path.dirname(path.join(directory, name)));
|
||||
await fs.writeFile(path.join(directory, name), name);
|
||||
}
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
const settings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
pathNormalizationVersion: 1 as const,
|
||||
handleFilenameCaseSensitive: false,
|
||||
};
|
||||
const setting = { currentSettings: () => settings, suspendAllSync: { addHandler: vi.fn() } };
|
||||
const runBoundedRemoteActivity = vi.fn(async () => undefined);
|
||||
const rebuilder = new ServiceRebuilder({
|
||||
setting,
|
||||
storageAccess: { getFileNames: async () => (await adapter.getFiles()).map((file) => file.path) },
|
||||
database: {
|
||||
onDatabaseReset: { addHandler: vi.fn() },
|
||||
isDatabaseReady: () => true,
|
||||
localDatabase: { findAllDocs: async function* () {} },
|
||||
},
|
||||
vault: { isTargetFile: async () => true },
|
||||
path: new PathServiceCompat(new ServiceContext(), { settingService: setting as never }),
|
||||
replicator: { runBoundedRemoteActivity },
|
||||
UI: { showMarkdownDialog: vi.fn(async () => undefined) },
|
||||
} as unknown as ServiceRebuilderDependencies);
|
||||
|
||||
await expect(rebuilder.$rebuildEverything()).rejects.toThrow(/ambiguous/i);
|
||||
expect(runBoundedRemoteActivity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses fresh listings after external additions, renames, deletions, and collisions", async () => {
|
||||
const directory = await createVault();
|
||||
const nfdName = "café.md".normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfdName), "original");
|
||||
const adapter = createNormalisingAdapter(directory);
|
||||
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([nfdName]);
|
||||
|
||||
const renamed = "résumé.md".normalize("NFD");
|
||||
await fs.rename(path.join(directory, nfdName), path.join(directory, renamed));
|
||||
await fs.writeFile(path.join(directory, "new.md"), "new");
|
||||
expect((await adapter.getFiles()).map((file) => file.path).sort()).toEqual(["new.md", renamed]);
|
||||
|
||||
await fs.rm(path.join(directory, "new.md"));
|
||||
expect((await adapter.getFiles()).map((file) => file.path)).toEqual([renamed]);
|
||||
await fs.writeFile(path.join(directory, renamed.normalize("NFC")), "collision");
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
});
|
||||
|
||||
it("retains the storage boundary for exact metadata reads and directory scans", async () => {
|
||||
const directory = await createVault();
|
||||
const outside = await createVault();
|
||||
await fs.writeFile(path.join(outside, "note.md"), "outside");
|
||||
await fs.symlink(outside, path.join(directory, "linked"), "dir");
|
||||
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.write("linked/note.md", "changed")).rejects.toThrow(/symbolic links/i);
|
||||
await expect(fs.readFile(path.join(outside, "note.md"), "utf8")).resolves.toBe("outside");
|
||||
});
|
||||
|
||||
it("detects NFC and case collisions when lookup is case-insensitive", async () => {
|
||||
const directory = await createVault();
|
||||
const upperName = "Café.md";
|
||||
const lowerNfdName = "café.md".normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, upperName), "upper", "utf8");
|
||||
await fs.writeFile(path.join(directory, lowerNfdName), "lower", "utf8");
|
||||
const adapter = createNormalisingAdapter(directory, true);
|
||||
|
||||
await expect(adapter.getAbstractFileByPathInsensitive("CAFÉ.md")).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.getFiles()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(adapter.scanDirectory()).rejects.toThrow(/ambiguous/i);
|
||||
await expect(fs.readFile(path.join(directory, upperName), "utf8")).resolves.toBe("upper");
|
||||
await expect(fs.readFile(path.join(directory, lowerNfdName), "utf8")).resolves.toBe("lower");
|
||||
});
|
||||
|
||||
it("retains exact-path behaviour when path normalisation is disabled", async () => {
|
||||
const directory = await createVault();
|
||||
const nfcName = "café.md";
|
||||
const nfdName = nfcName.normalize("NFD");
|
||||
await fs.writeFile(path.join(directory, nfdName), "NFD", "utf8");
|
||||
const adapter = new NodeFileSystemAdapter(directory);
|
||||
|
||||
await expect(adapter.getAbstractFileByPath(nfcName)).resolves.toBeNull();
|
||||
await adapter.storage.write(nfcName, "NFC");
|
||||
|
||||
await expect(fs.readFile(path.join(directory, nfcName), "utf8")).resolves.toBe("NFC");
|
||||
await expect(fs.readFile(path.join(directory, nfdName), "utf8")).resolves.toBe("NFD");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/co
|
||||
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";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Node.js
|
||||
@@ -73,8 +74,16 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
}
|
||||
|
||||
private async toNodeFile(path: string): Promise<NodeFile> {
|
||||
const stat = await this.storage.stat(path);
|
||||
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);
|
||||
if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`);
|
||||
return { path: path as FilePath, stat };
|
||||
return { path: resolvedPath as FilePath, stat };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user