From b71cc14022d15b364fa7cc72a09ca1251bf5e416 Mon Sep 17 00:00:00 2001 From: Kim Jansheden Date: Wed, 14 Oct 2026 17:15:20 +0200 Subject: [PATCH] Reject unlisted filesystem aliases before vault mutations (src/serviceModules/FileSystemAdapters/pathAliasGuard.ts) - Require every existing, unindexed path component to use a name listed by its parent directory. - Accept case-only and Unicode-normalisation differences while caching positive directory listings briefly. (src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts, src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts) - Apply the alias boundary before text, binary, append, directory, removal, create, modify, rename, delete, and trash operations. - Check both the source and destination of vault renames before calling the host API. (src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts) - Cover directory and file aliases across every mutating adapter route with synthetic host behaviour. - Exercise real host filesystem aliases where the platform exposes them, including new files and directories below an aliased folder. - Preserve listed names, case-only differences, indexed paths, and new-file behaviour. (src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts, src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts) - Extend existing adapter fixtures with the vault-index contract used by the guard. --- .../ObsidianStorageAdapter.ts | 11 +- .../ObsidianStorageAdapter.unit.spec.ts | 1 + .../ObsidianVaultAdapter.ts | 14 +- .../ObsidianVaultAdapter.unit.spec.ts | 1 + .../FileSystemAdapters/pathAliasGuard.ts | 100 ++++++++ .../pathAliasGuard.unit.spec.ts | 242 ++++++++++++++++++ 6 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 src/serviceModules/FileSystemAdapters/pathAliasGuard.ts create mode 100644 src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts index 0109e282..88c84892 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.ts @@ -3,13 +3,17 @@ import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceM import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import type { Stat, App } from "obsidian"; import { toIntegerTimestamps } from "./sanitizeWriteOptions"; +import { createPathAliasGuard, type PathAliasGuard } from "./pathAliasGuard"; /** * Storage adapter implementation for Obsidian */ export class ObsidianStorageAdapter implements IStorageAdapter { - constructor(private app: App) {} + constructor( + private app: App, + private readonly aliasGuard: PathAliasGuard = createPathAliasGuard(app) + ) {} async exists(path: string): Promise { return await this.app.vault.adapter.exists(path); @@ -25,10 +29,12 @@ export class ObsidianStorageAdapter implements IStorageAdapter { } async mkdir(path: string): Promise { + await this.aliasGuard.assertNoAlias(path); await this.app.vault.adapter.mkdir(path); } async remove(path: string): Promise { + await this.aliasGuard.assertNoAlias(path); await this.app.vault.adapter.remove(path); } @@ -41,14 +47,17 @@ export class ObsidianStorageAdapter implements IStorageAdapter { } async write(path: string, data: string, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(path); return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options)); } async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(path); return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options)); } async append(path: string, data: string, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(path); return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options)); } diff --git a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts index 7172879f..121cdc40 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianStorageAdapter.unit.spec.ts @@ -9,6 +9,7 @@ describe("ObsidianStorageAdapter", () => { const append = vi.fn().mockResolvedValue(undefined); const app = { vault: { + getAbstractFileByPath: () => ({}), adapter: { write, writeBinary, diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts index 96486a78..0ac5e127 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.ts @@ -3,12 +3,16 @@ import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceMod import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase"; import type { TFile, App, TFolder } from "obsidian"; import { toIntegerTimestamps } from "./sanitizeWriteOptions"; +import { createPathAliasGuard, type PathAliasGuard } from "./pathAliasGuard"; /** * Vault adapter implementation for Obsidian */ export class ObsidianVaultAdapter implements IVaultAdapter { - constructor(private app: App) {} + constructor( + private app: App, + private readonly aliasGuard: PathAliasGuard = createPathAliasGuard(app) + ) {} async read(file: TFile): Promise { // Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat. @@ -24,30 +28,38 @@ export class ObsidianVaultAdapter implements IVaultAdapter { } async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(file.path); return await this.app.vault.modify(file, data, toIntegerTimestamps(options)); } async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(file.path); return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options)); } async create(path: string, data: string, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(path); return await this.app.vault.create(path, data, toIntegerTimestamps(options)); } async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { + await this.aliasGuard.assertNoAlias(path); return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options)); } async rename(file: TFile, newPath: string): Promise { + await this.aliasGuard.assertNoAlias(file.path); + await this.aliasGuard.assertNoAlias(newPath); return await this.app.vault.rename(file, newPath); } async delete(file: TFile | TFolder): Promise { + await this.aliasGuard.assertNoAlias(file.path); return await this.app.fileManager.trashFile(file); } async trash(file: TFile | TFolder): Promise { + await this.aliasGuard.assertNoAlias(file.path); return await this.app.fileManager.trashFile(file); } diff --git a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts index 0c22f212..ebeabf39 100644 --- a/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts +++ b/src/serviceModules/FileSystemAdapters/ObsidianVaultAdapter.unit.spec.ts @@ -42,6 +42,7 @@ describe("ObsidianVaultAdapter", () => { const createBinary = vi.fn().mockResolvedValue({}); const app = { vault: { + getAbstractFileByPath: () => ({}), modify, modifyBinary, create, diff --git a/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts b/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts new file mode 100644 index 00000000..6be2da7c --- /dev/null +++ b/src/serviceModules/FileSystemAdapters/pathAliasGuard.ts @@ -0,0 +1,100 @@ +/** + * Keeps storage from being changed through a name which the file system resolves but no directory lists. + * + * A file system may reach one entry under several names: a short name beside the long one, a name with trailing + * dots or spaces, a name with code points it ignores. Every rule which decides by the spelling of a path, such as + * the one which keeps synchronised documents out of hidden folders, would then be passed by another spelling. So + * no segment of a path which exists on disk may be addressed by a name which its parent directory does not list. + * Names are compared without regard to case and Unicode normalisation, which are the differences the supported + * file systems fold by design. A symbolic link or junction is listed under its own name and is not an alias. + */ +export interface PathAliasGuardHost { + /** Whether the vault index holds an entry under exactly this path. Such an entry was listed under that name. */ + isIndexed(path: string): boolean; + /** Whether the file system resolves this path to an entry, under whatever name. */ + exists(path: string): Promise; + /** The names, or paths, of the entries which a directory lists. The vault root is the empty path. */ + list(directory: string): Promise; +} + +/** 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(); + +const nameOf = (entry: string) => { + const segments = entry.split("/").filter((segment) => segment !== ""); + return segments[segments.length - 1] ?? ""; +}; + +export class PathAliasGuard { + private readonly listings = new Map; listedAt: number }>(); + + constructor( + private readonly host: PathAliasGuardHost, + private readonly now: () => number = Date.now + ) {} + + private recentListing(directory: string): Set | undefined { + const listing = this.listings.get(directory); + if (!listing) return undefined; + if (this.now() - listing.listedAt <= LISTING_LIFETIME_MS) return listing.names; + this.listings.delete(directory); + return undefined; + } + + private async listAfresh(directory: string): Promise> { + const names = new Set((await this.host.list(directory)).map((entry) => foldName(nameOf(entry)))); + this.listings.set(directory, { names, listedAt: this.now() }); + return names; + } + + /** + * Refuse `path` when one of its segments exists on disk under a name which its parent directory does not list. + * + * Nothing is listed for a segment which the vault index holds, and nothing below the first segment which does + * not exist yet: an entry which is about to be created has no other name. Only a segment which exists outside + * the index, such as a hidden folder or one created a moment ago, has its parent listed. + */ + async assertNoAlias(path: string): Promise { + const segments = path.split("/").filter((segment) => segment !== ""); + let listed = 0; + for (let length = segments.length; length > 0; length--) { + if (this.host.isIndexed(segments.slice(0, length).join("/"))) { + listed = length; + break; + } + } + for (let index = listed; index < segments.length; index++) { + const parent = segments.slice(0, index).join("/"); + const name = foldName(segments[index]); + if (this.recentListing(parent)?.has(name)) continue; + if (!(await this.host.exists(segments.slice(0, index + 1).join("/")))) return; + if (!(await this.listAfresh(parent)).has(name)) { + throw new Error("Refusing a path which reaches an existing entry by a name its folder does not list."); + } + } + } +} + +/** The part of the Obsidian app which the guard consults. */ +export interface PathAliasGuardApp { + vault: { + getAbstractFileByPath(path: string): unknown; + adapter: { + exists(path: string): Promise; + list(path: string): Promise<{ files: string[]; folders: string[] }>; + }; + }; +} + +export function createPathAliasGuard(app: PathAliasGuardApp): PathAliasGuard { + return new PathAliasGuard({ + isIndexed: (path) => app.vault.getAbstractFileByPath(path) != null, + exists: (path) => app.vault.adapter.exists(path), + list: async (directory) => { + const listing = await app.vault.adapter.list(directory === "" ? "/" : directory); + return [...listing.files, ...listing.folders]; + }, + }); +} diff --git a/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts b/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts new file mode 100644 index 00000000..b459081c --- /dev/null +++ b/src/serviceModules/FileSystemAdapters/pathAliasGuard.unit.spec.ts @@ -0,0 +1,242 @@ +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 } 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([ + ["/", { 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 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); + } + ); +});