Files
obsidian-livesync/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts
T
vorotamoroz d557298ceb 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.
2026-11-03 08:44:31 +00:00

267 lines
12 KiB
TypeScript

import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmdirSync,
statSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { App, TFile } from "obsidian";
import { afterAll, describe, expect, it, vi } from "vitest";
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
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";
const PLUGIN_DIRECTORY = `${CONFIG_DIRECTORY}/plugins/obsidian-livesync`;
const PLUGIN_ENTRY = `${PLUGIN_DIRECTORY}/main.js`;
const DIRECTORY_ALIAS_ENTRY = "OBSIDI~1/plugins/obsidian-livesync/main.js";
const FILE_ALIAS_ENTRY = `${PLUGIN_DIRECTORY}/MAIN~1.JS`;
const fold = (value: string) => value.normalize("NFC").toLowerCase();
function fakeApp() {
const indexed = new Set(["notes", "notes/existing.md"].map(fold));
const existing = new Set(
[
CONFIG_DIRECTORY,
`${CONFIG_DIRECTORY}/plugins`,
PLUGIN_DIRECTORY,
PLUGIN_ENTRY,
"notes",
"notes/existing.md",
].map(fold)
);
const aliases = new Map([
[fold("OBSIDI~1"), CONFIG_DIRECTORY],
[fold(`${PLUGIN_DIRECTORY}/MAIN~1.JS`), PLUGIN_ENTRY],
]);
const resolveAlias = (path: string) => {
const direct = aliases.get(fold(path));
if (direct) return direct;
if (fold(path).startsWith("obsidi~1/")) return `${CONFIG_DIRECTORY}/${path.slice("OBSIDI~1/".length)}`;
return path;
};
const listings = new Map<string, { files: string[]; folders: string[] }>([
["/", { files: [], folders: [CONFIG_DIRECTORY, "notes"] }],
[fold(CONFIG_DIRECTORY), { files: [], folders: [`${CONFIG_DIRECTORY}/plugins`] }],
[fold(`${CONFIG_DIRECTORY}/plugins`), { files: [], folders: [PLUGIN_DIRECTORY] }],
[fold(PLUGIN_DIRECTORY), { files: [PLUGIN_ENTRY], folders: [] }],
[fold("notes"), { files: ["notes/existing.md"], folders: [] }],
]);
const calls = {
write: vi.fn().mockResolvedValue(undefined),
writeBinary: vi.fn().mockResolvedValue(undefined),
append: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
remove: vi.fn().mockResolvedValue(undefined),
modify: vi.fn().mockResolvedValue(undefined),
modifyBinary: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({}),
createBinary: vi.fn().mockResolvedValue({}),
rename: vi.fn().mockResolvedValue(undefined),
trashFile: vi.fn().mockResolvedValue(undefined),
};
const app = {
vault: {
getAbstractFileByPath: (path: string) => (indexed.has(fold(path)) ? {} : null),
adapter: {
exists: async (path: string) => existing.has(fold(resolveAlias(path))),
list: async (path: string) =>
listings.get(path === "/" ? "/" : fold(path)) ?? { files: [], folders: [] },
read: vi.fn(),
readBinary: vi.fn(),
stat: vi.fn(),
write: calls.write,
writeBinary: calls.writeBinary,
append: calls.append,
mkdir: calls.mkdir,
remove: calls.remove,
},
cachedRead: vi.fn(),
readBinary: vi.fn(),
modify: calls.modify,
modifyBinary: calls.modifyBinary,
create: calls.create,
createBinary: calls.createBinary,
rename: calls.rename,
trigger: vi.fn(),
},
fileManager: {
trashFile: calls.trashFile,
},
} as unknown as App;
return { app, calls };
}
describe("paths which resolve to an existing entry under an unlisted name", () => {
it("refuses every mutating storage-adapter operation", async () => {
const { app, calls } = fakeApp();
const adapter = new ObsidianStorageAdapter(app);
await expect(adapter.write(DIRECTORY_ALIAS_ENTRY, "replacement")).rejects.toThrow(REFUSAL);
await expect(adapter.writeBinary(FILE_ALIAS_ENTRY, new ArrayBuffer(1))).rejects.toThrow(REFUSAL);
await expect(adapter.append(FILE_ALIAS_ENTRY, "replacement")).rejects.toThrow(REFUSAL);
await expect(adapter.mkdir("OBSIDI~1/new-directory")).rejects.toThrow(REFUSAL);
await expect(adapter.remove(DIRECTORY_ALIAS_ENTRY)).rejects.toThrow(REFUSAL);
expect(calls.write).not.toHaveBeenCalled();
expect(calls.writeBinary).not.toHaveBeenCalled();
expect(calls.append).not.toHaveBeenCalled();
expect(calls.mkdir).not.toHaveBeenCalled();
expect(calls.remove).not.toHaveBeenCalled();
});
it("refuses every mutating vault-adapter operation", async () => {
const { app, calls } = fakeApp();
const adapter = new ObsidianVaultAdapter(app);
const aliasFile = { path: DIRECTORY_ALIAS_ENTRY } as TFile;
await expect(adapter.modify(aliasFile, "replacement")).rejects.toThrow(REFUSAL);
await expect(adapter.modifyBinary(aliasFile, new ArrayBuffer(1))).rejects.toThrow(REFUSAL);
await expect(adapter.create(DIRECTORY_ALIAS_ENTRY, "replacement")).rejects.toThrow(REFUSAL);
await expect(adapter.createBinary(FILE_ALIAS_ENTRY, new ArrayBuffer(1))).rejects.toThrow(REFUSAL);
await expect(adapter.rename({ path: "notes/existing.md" } as TFile, DIRECTORY_ALIAS_ENTRY)).rejects.toThrow(
REFUSAL
);
await expect(adapter.delete(aliasFile)).rejects.toThrow(REFUSAL);
await expect(adapter.trash(aliasFile)).rejects.toThrow(REFUSAL);
expect(calls.modify).not.toHaveBeenCalled();
expect(calls.modifyBinary).not.toHaveBeenCalled();
expect(calls.create).not.toHaveBeenCalled();
expect(calls.createBinary).not.toHaveBeenCalled();
expect(calls.rename).not.toHaveBeenCalled();
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);
await adapter.write(`${PLUGIN_DIRECTORY}/data.json`, "settings");
await adapter.write(".OBSIDIAN/PLUGINS/OBSIDIAN-LIVESYNC/new.json", "settings");
await adapter.write("notes/new.md", "note");
await adapter.write("notes/existing.md", "updated");
expect(calls.write).toHaveBeenCalledTimes(4);
});
});
const realRoot = mkdtempSync(join(tmpdir(), "path-alias-guard-"));
const REAL_FOLDER = "configuration-folder";
const REAL_FILE = "trusted-release-keys.json";
const realFolder = join(realRoot, REAL_FOLDER);
const realFile = join(realFolder, REAL_FILE);
mkdirSync(realFolder);
writeFileSync(realFile, "synthetic trusted keys");
function removeRealFixture(): void {
const optionalPaths = [join(realFolder, "other.json"), join(realFolder, "new-directory")];
if (existsSync(optionalPaths[0])) unlinkSync(optionalPaths[0]);
if (existsSync(optionalPaths[1])) rmdirSync(optionalPaths[1]);
if (existsSync(realFile)) unlinkSync(realFile);
if (existsSync(realFolder)) rmdirSync(realFolder);
if (existsSync(realRoot)) rmdirSync(realRoot);
}
afterAll(removeRealFixture);
function aliasCandidates(name: string): string[] {
const [base, extension = ""] = name.split(/\.(?=[^.]*$)/u);
const shortName = `${base.slice(0, 6).toUpperCase()}~1${extension ? `.${extension.slice(0, 3).toUpperCase()}` : ""}`;
return [shortName, `${name}.`, `${name} `];
}
function isAlias(folder: string, name: string): boolean {
const directory = join(realRoot, folder);
return existsSync(join(directory, name)) && !readdirSync(directory).some((listed) => fold(listed) === fold(name));
}
const realFolderAliases = aliasCandidates(REAL_FOLDER).filter((candidate) => isAlias("", candidate));
const realAliases = [
...realFolderAliases.map((candidate) => `${candidate}/${REAL_FILE}`),
...aliasCandidates(REAL_FILE)
.filter((candidate) => isAlias(REAL_FOLDER, candidate))
.map((candidate) => `${REAL_FOLDER}/${candidate}`),
];
function realApp() {
const full = (path: string) => join(realRoot, path === "/" ? "" : path);
return {
vault: {
getAbstractFileByPath: () => null,
adapter: {
exists: async (path: string) => existsSync(full(path)),
list: async (path: string) => {
const prefix = path === "/" || path === "" ? "" : `${path}/`;
const names = readdirSync(full(path));
const isFile = (name: string) => statSync(join(full(path), name)).isFile();
return {
files: names.filter(isFile).map((name) => `${prefix}${name}`),
folders: names.filter((name) => !isFile(name)).map((name) => `${prefix}${name}`),
};
},
write: async (path: string, data: string) => writeFileSync(full(path), data),
remove: async (path: string) => unlinkSync(full(path)),
mkdir: async (path: string) => mkdirSync(full(path)),
},
},
};
}
describe("the host file system", () => {
it.skipIf(realAliases.length === 0)(
"refuses names which resolve but are not listed by their directory",
async () => {
const app = realApp();
const adapter = new ObsidianStorageAdapter(app as unknown as App, createPathAliasGuard(app));
for (const aliasPath of realAliases) {
expect(readFileSync(join(realRoot, aliasPath), "utf8")).toBe("synthetic trusted keys");
await expect(adapter.write(aliasPath, "replacement")).rejects.toThrow(REFUSAL);
await expect(adapter.remove(aliasPath)).rejects.toThrow(REFUSAL);
await expect(adapter.mkdir(`${aliasPath}/new-directory`)).rejects.toThrow(REFUSAL);
}
for (const folderAlias of realFolderAliases) {
await expect(adapter.write(`${folderAlias}/new.json`, "replacement")).rejects.toThrow(REFUSAL);
await expect(adapter.mkdir(`${folderAlias}/new-directory`)).rejects.toThrow(REFUSAL);
}
expect(readFileSync(realFile, "utf8")).toBe("synthetic trusted keys");
expect(existsSync(join(realFolder, "new.json"))).toBe(false);
expect(existsSync(join(realFolder, "new-directory"))).toBe(false);
}
);
});