From fc74638642c138ae7bef7fc30df5da2af283b75c Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 22 Jul 2026 00:17:03 +0000 Subject: [PATCH] Use rooted Commonlib storage in the CLI --- ...2026_07_common_library_package_boundary.md | 2 + package-lock.json | 8 +- package.json | 2 +- .../cli/adapters/NodeFileSystemAdapter.ts | 56 +++++------ src/apps/cli/adapters/NodeVaultAdapter.ts | 97 +++++-------------- .../adapters/NodeVaultAdapter.unit.spec.ts | 73 ++++++++++++++ 6 files changed, 128 insertions(+), 110 deletions(-) diff --git a/docs/adr/2026_07_common_library_package_boundary.md b/docs/adr/2026_07_common_library_package_boundary.md index 972415a1..9432d1a7 100644 --- a/docs/adr/2026_07_common_library_package_boundary.md +++ b/docs/adr/2026_07_common_library_package_boundary.md @@ -156,6 +156,8 @@ A later composition may place the constructed storage contract on a host-specifi The paired adapters run against the same contract suite for metadata, text and binary access, append, listing, removal, missing paths, parent creation, empty-root handling, and traversal rejection. Timestamp fidelity remains platform-specific because the File System Access API does not provide the same creation-time and timestamp-setting facilities as Node. +The Node adapter rejects symbolic links in adapter paths and opens file entries without following the final link where the platform supports that flag. The CLI delegates its Vault reads, writes, discovery, deletion, and atomic rename to this rooted adapter rather than maintaining a second direct `fs` path. This keeps remote-derived paths within the host-selected Vault root while retaining case-only and cross-directory rename behaviour. + The Node entry also centralises direct Node built-in access needed by trusted headless application code. This is a package and scanner boundary, not an assertion that Node and browser APIs are interchangeable. Cross-platform behaviour belongs in a shared contract with separate implementations, as demonstrated by rooted storage. ### Standard input and output diff --git a/package-lock.json b/package-lock.json index 4e17e543..98cb3d7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.6", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.7", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", @@ -4898,9 +4898,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.0-rc.6", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.6.tgz", - "integrity": "sha512-tE8f0zfqejYmDufUx0NwnyNq0JEVJJTnHFM0DY+vjS0ZDafDXP6VTuSNzGrPQeSLF+Sg1SFvuk8ELoIs1YpWXA==", + "version": "0.1.0-rc.7", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.7.tgz", + "integrity": "sha512-h96phhogCQXdj7C5Jk5/Zhvc5qyvTB7H1VMWOxGf/lbYeU+2YZeTWcvuzzQNh5AyWhBnjMYq1hhCaF9ia8XMjg==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index f67fde1d..645c51da 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "@smithy/querystring-builder": "^4.2.9", "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", - "@vrtmrz/livesync-commonlib": "0.1.0-rc.6", + "@vrtmrz/livesync-commonlib": "0.1.0-rc.7", "@vrtmrz/obsidian-plugin-kit": "0.1.2", "diff-match-patch": "^1.0.5", "fflate": "^0.8.2", diff --git a/src/apps/cli/adapters/NodeFileSystemAdapter.ts b/src/apps/cli/adapters/NodeFileSystemAdapter.ts index 85a387f8..acc7596e 100644 --- a/src/apps/cli/adapters/NodeFileSystemAdapter.ts +++ b/src/apps/cli/adapters/NodeFileSystemAdapter.ts @@ -6,7 +6,7 @@ import { NodeConversionAdapter } from "./NodeConversionAdapter"; import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node"; import { NodeVaultAdapter } from "./NodeVaultAdapter"; import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes"; -import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; +import { path } from "@vrtmrz/livesync-commonlib/node"; import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput"; /** @@ -29,7 +29,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { try { const segments = pathStr.split("/").filter((segment) => segment !== ""); - let currentPath = this.basePath; + let currentPath = ""; for (const segment of segments) { - const entries = await fs.readdir(currentPath); - if (!entries.includes(segment)) return false; - currentPath = path.join(currentPath, segment); + const entries = await this.storage.list(currentPath); + const candidatePath = currentPath === "" ? segment : `${currentPath}/${segment}`; + if (!entries.files.includes(candidatePath) && !entries.folders.includes(candidatePath)) return false; + currentPath = candidatePath; } return segments.length > 0; } catch { @@ -118,9 +119,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { const pathStr = this.normalisePath(p); try { - const fullPath = this.resolvePath(pathStr); - const stat = await fs.stat(fullPath); - if (!stat.isFile()) { + const stat = await this.storage.stat(pathStr); + if (stat?.type !== "file") { this.fileCache.delete(pathStr); return null; } @@ -129,8 +129,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter { const fullPath = this.resolvePath(relativePath); try { - const entries = await fs.readdir(fullPath, { withFileTypes: true }); + const directoryStat = await this.storage.stat(relativePath); + if (directoryStat?.type !== "folder") throw new Error(`Directory does not exist: ${fullPath}`); + const entries = await this.storage.list(relativePath); - for (const entry of entries) { - const entryRelativePath = path.join(relativePath, entry.name).replace(/\\/g, "/"); - - if (entry.isDirectory()) { - await this.scanDirectory(entryRelativePath); - } else if (entry.isFile()) { - const entryFullPath = this.resolvePath(entryRelativePath); - const stat = await fs.stat(entryFullPath); - const file: NodeFile = { - path: entryRelativePath as FilePath, - stat: { - size: stat.size, - mtime: Math.floor(stat.mtimeMs), - ctime: Math.floor(stat.ctimeMs), - type: "file", - }, - }; - this.fileCache.set(entryRelativePath, file); - } + for (const entryPath of entries.files) { + const stat = await this.storage.stat(entryPath); + if (stat?.type !== "file") continue; + const file: NodeFile = { + path: entryPath as FilePath, + stat, + }; + this.fileCache.set(entryPath, file); + } + for (const entryPath of entries.folders) { + await this.scanDirectory(entryPath); } } catch (error) { // Directory doesn't exist or is not readable diff --git a/src/apps/cli/adapters/NodeVaultAdapter.ts b/src/apps/cli/adapters/NodeVaultAdapter.ts index 1245471e..c605eeb7 100644 --- a/src/apps/cli/adapters/NodeVaultAdapter.ts +++ b/src/apps/cli/adapters/NodeVaultAdapter.ts @@ -1,20 +1,21 @@ import type { FilePath, 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 { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node"; +import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node"; /** * Vault adapter implementation for Node.js */ export class NodeVaultAdapter implements IVaultAdapter { - constructor(private basePath: string) {} + private readonly storage: NodeStorageAdapter; - private resolvePath(p: string): string { - return path.join(this.basePath, p); + constructor(rootPathOrStorage: string | NodeStorageAdapter) { + this.storage = + typeof rootPathOrStorage === "string" ? new NodeStorageAdapter(rootPathOrStorage) : rootPathOrStorage; } async read(file: NodeFile): Promise { - const content = await fs.readFile(this.resolvePath(file.path), "utf-8"); + const content = await this.storage.read(file.path); // Correct stale stat.size — chokidar stats may be from a poll before the final write. // The downstream document integrity check compares stat.size to content length, so // they must agree or other clients reject the file as corrupted. @@ -28,95 +29,37 @@ export class NodeVaultAdapter implements IVaultAdapter { } async readBinary(file: NodeFile): Promise { - const buffer = await fs.readFile(this.resolvePath(file.path)); + const buffer = await this.storage.readBinary(file.path); // Same correction as read() — ensure stat.size matches actual byte length. - file.stat.size = buffer.length; - return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; + file.stat.size = buffer.byteLength; + return buffer; } async modify(file: NodeFile, data: string, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(file.path); - await fs.writeFile(fullPath, data, "utf-8"); - - if (options?.mtime || options?.ctime) { - const atime = options.mtime ? new Date(options.mtime) : new Date(); - const mtime = options.mtime ? new Date(options.mtime) : new Date(); - await fs.utimes(fullPath, atime, mtime); - } + await this.storage.write(file.path, data, options); } async modifyBinary(file: NodeFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(file.path); - await fs.writeFile(fullPath, new Uint8Array(data)); - - if (options?.mtime || options?.ctime) { - const atime = options.mtime ? new Date(options.mtime) : new Date(); - const mtime = options.mtime ? new Date(options.mtime) : new Date(); - await fs.utimes(fullPath, atime, mtime); - } + await this.storage.writeBinary(file.path, data, options); } async create(p: string, data: string, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(p); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, data, "utf-8"); - - if (options?.mtime || options?.ctime) { - const atime = options.mtime ? new Date(options.mtime) : new Date(); - const mtime = options.mtime ? new Date(options.mtime) : new Date(); - await fs.utimes(fullPath, atime, mtime); - } - - const stat = await fs.stat(fullPath); - return { - path: p as FilePath, - stat: { - size: stat.size, - mtime: Math.floor(stat.mtimeMs), - ctime: Math.floor(stat.ctimeMs), - type: "file", - }, - }; + await this.storage.write(p, data, options); + return await this.toNodeFile(p); } async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise { - const fullPath = this.resolvePath(p); - await fs.mkdir(path.dirname(fullPath), { recursive: true }); - await fs.writeFile(fullPath, new Uint8Array(data)); - - if (options?.mtime || options?.ctime) { - const atime = options.mtime ? new Date(options.mtime) : new Date(); - const mtime = options.mtime ? new Date(options.mtime) : new Date(); - await fs.utimes(fullPath, atime, mtime); - } - - const stat = await fs.stat(fullPath); - return { - path: p as FilePath, - stat: { - size: stat.size, - mtime: Math.floor(stat.mtimeMs), - ctime: Math.floor(stat.ctimeMs), - type: "file", - }, - }; + await this.storage.writeBinary(p, data, options); + return await this.toNodeFile(p); } async rename(file: NodeFile, newPath: string): Promise { - const targetPath = this.resolvePath(newPath); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - await fs.rename(this.resolvePath(file.path), targetPath); + await this.storage.rename(file.path, newPath); file.path = newPath as FilePath; } async delete(file: NodeFile | NodeFolder, force = false): Promise { - const fullPath = this.resolvePath(file.path); - const stat = await fs.stat(fullPath); - if (stat.isDirectory()) { - await fs.rm(fullPath, { recursive: true, force }); - } else { - await fs.unlink(fullPath); - } + await this.storage.remove(file.path); } async trash(file: NodeFile | NodeFolder, force = false): Promise { @@ -128,4 +71,10 @@ export class NodeVaultAdapter implements IVaultAdapter { // No-op in CLI version (no event system) return undefined; } + + private async toNodeFile(path: string): Promise { + const stat = await this.storage.stat(path); + if (stat?.type !== "file") throw new Error(`Could not read created file metadata: ${path}`); + return { path: path as FilePath, stat }; + } } diff --git a/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts b/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts index 25d999a6..9d648957 100644 --- a/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts +++ b/src/apps/cli/adapters/NodeVaultAdapter.unit.spec.ts @@ -24,6 +24,59 @@ describe("NodeVaultAdapter.rename", () => { await fsPromises.rm(directory, { recursive: true, force: true }); } }); + + it("does not move a file through a symbolic link outside the vault root", async () => { + const directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-rename-root-")); + const outsideDirectory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-rename-outside-")); + try { + await fsPromises.writeFile(path.join(directory, "source.md"), "content", "utf8"); + await fsPromises.symlink( + outsideDirectory, + path.join(directory, "linked"), + process.platform === "win32" ? "junction" : "dir" + ); + const adapter = new NodeVaultAdapter(directory); + const file = { + path: "source.md" as FilePath, + stat: { ctime: 1, mtime: 2, size: 7, type: "file" as const }, + }; + + await expect(adapter.rename(file, "linked/moved.md")).rejects.toThrow(/symbolic link/i); + + await expect(fsPromises.readFile(path.join(directory, "source.md"), "utf8")).resolves.toBe("content"); + await expect(fsPromises.stat(path.join(outsideDirectory, "moved.md"))).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await fsPromises.rm(directory, { recursive: true, force: true }); + await fsPromises.rm(outsideDirectory, { recursive: true, force: true }); + } + }); + + it("does not modify a file through a symbolic link outside the vault root", async () => { + const directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-modify-root-")); + const outsideDirectory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-modify-outside-")); + try { + await fsPromises.writeFile(path.join(outsideDirectory, "victim.md"), "before", "utf8"); + await fsPromises.symlink( + outsideDirectory, + path.join(directory, "linked"), + process.platform === "win32" ? "junction" : "dir" + ); + const adapter = new NodeVaultAdapter(directory); + const file = { + path: "linked/victim.md" as FilePath, + stat: { ctime: 1, mtime: 2, size: 6, type: "file" as const }, + }; + + await expect(adapter.modify(file, "after")).rejects.toThrow(/symbolic link/i); + + await expect(fsPromises.readFile(path.join(outsideDirectory, "victim.md"), "utf8")).resolves.toBe("before"); + } finally { + await fsPromises.rm(directory, { recursive: true, force: true }); + await fsPromises.rm(outsideDirectory, { recursive: true, force: true }); + } + }); }); describe("NodeFileSystemAdapter path case", () => { @@ -64,4 +117,24 @@ describe("NodeFileSystemAdapter path case", () => { await fsPromises.rm(directory, { recursive: true, force: true }); } }); + + it("does not discover a file through a symbolic link outside the vault root", async () => { + const directory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-discovery-root-")); + const outsideDirectory = await fsPromises.mkdtemp(path.join(os.tmpdir(), "livesync-discovery-outside-")); + try { + await fsPromises.writeFile(path.join(outsideDirectory, "outside.md"), "content", "utf8"); + await fsPromises.symlink( + outsideDirectory, + path.join(directory, "linked"), + process.platform === "win32" ? "junction" : "dir" + ); + const adapter = new NodeFileSystemAdapter(directory); + + await expect(adapter.getAbstractFileByPath("linked/outside.md")).resolves.toBeNull(); + await expect(adapter.getFiles()).resolves.toEqual([]); + } finally { + await fsPromises.rm(directory, { recursive: true, force: true }); + await fsPromises.rm(outsideDirectory, { recursive: true, force: true }); + } + }); });