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

@@ -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);