Use rooted Commonlib storage in the CLI

This commit is contained in:
vorotamoroz
2026-07-22 00:17:03 +00:00
parent 1ef8c88139
commit fc74638642
6 changed files with 128 additions and 110 deletions
@@ -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 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. 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 ### Standard input and output
+4 -4
View File
@@ -22,7 +22,7 @@
"@smithy/querystring-builder": "^4.2.9", "@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@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", "@vrtmrz/obsidian-plugin-kit": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -4898,9 +4898,9 @@
} }
}, },
"node_modules/@vrtmrz/livesync-commonlib": { "node_modules/@vrtmrz/livesync-commonlib": {
"version": "0.1.0-rc.6", "version": "0.1.0-rc.7",
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.6.tgz", "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.0-rc.7.tgz",
"integrity": "sha512-tE8f0zfqejYmDufUx0NwnyNq0JEVJJTnHFM0DY+vjS0ZDafDXP6VTuSNzGrPQeSLF+Sg1SFvuk8ELoIs1YpWXA==", "integrity": "sha512-h96phhogCQXdj7C5Jk5/Zhvc5qyvTB7H1VMWOxGf/lbYeU+2YZeTWcvuzzQNh5AyWhBnjMYq1hhCaF9ia8XMjg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.808.0", "@aws-sdk/client-s3": "^3.808.0",
+1 -1
View File
@@ -161,7 +161,7 @@
"@smithy/querystring-builder": "^4.2.9", "@smithy/querystring-builder": "^4.2.9",
"@smithy/types": "^4.14.3", "@smithy/types": "^4.14.3",
"@smithy/util-retry": "^4.4.5", "@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", "@vrtmrz/obsidian-plugin-kit": "0.1.2",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"fflate": "^0.8.2", "fflate": "^0.8.2",
+25 -31
View File
@@ -6,7 +6,7 @@ import { NodeConversionAdapter } from "./NodeConversionAdapter";
import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node"; import { NodeStorageAdapter } from "@vrtmrz/livesync-commonlib/node";
import { NodeVaultAdapter } from "./NodeVaultAdapter"; import { NodeVaultAdapter } from "./NodeVaultAdapter";
import type { NodeFile, NodeFolder, NodeStat } from "./NodeTypes"; 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"; import type { CliDiagnosticReporter } from "@/apps/cli/cliOutput";
/** /**
@@ -29,7 +29,7 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
this.typeGuard = new NodeTypeGuardAdapter(); this.typeGuard = new NodeTypeGuardAdapter();
this.conversion = new NodeConversionAdapter(); this.conversion = new NodeConversionAdapter();
this.storage = new NodeStorageAdapter(basePath); this.storage = new NodeStorageAdapter(basePath);
this.vault = new NodeVaultAdapter(basePath); this.vault = new NodeVaultAdapter(this.storage);
} }
private resolvePath(p: FilePath | string): string { private resolvePath(p: FilePath | string): string {
@@ -43,11 +43,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
private async hasExactPathCase(pathStr: string): Promise<boolean> { private async hasExactPathCase(pathStr: string): Promise<boolean> {
try { try {
const segments = pathStr.split("/").filter((segment) => segment !== ""); const segments = pathStr.split("/").filter((segment) => segment !== "");
let currentPath = this.basePath; let currentPath = "";
for (const segment of segments) { for (const segment of segments) {
const entries = await fs.readdir(currentPath); const entries = await this.storage.list(currentPath);
if (!entries.includes(segment)) return false; const candidatePath = currentPath === "" ? segment : `${currentPath}/${segment}`;
currentPath = path.join(currentPath, segment); if (!entries.files.includes(candidatePath) && !entries.folders.includes(candidatePath)) return false;
currentPath = candidatePath;
} }
return segments.length > 0; return segments.length > 0;
} catch { } catch {
@@ -118,9 +119,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
async refreshFile(p: string): Promise<NodeFile | null> { async refreshFile(p: string): Promise<NodeFile | null> {
const pathStr = this.normalisePath(p); const pathStr = this.normalisePath(p);
try { try {
const fullPath = this.resolvePath(pathStr); const stat = await this.storage.stat(pathStr);
const stat = await fs.stat(fullPath); if (stat?.type !== "file") {
if (!stat.isFile()) {
this.fileCache.delete(pathStr); this.fileCache.delete(pathStr);
return null; return null;
} }
@@ -129,8 +129,8 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
path: pathStr as FilePath, path: pathStr as FilePath,
stat: { stat: {
size: stat.size, size: stat.size,
mtime: Math.floor(stat.mtimeMs), mtime: stat.mtime,
ctime: Math.floor(stat.ctimeMs), ctime: stat.ctime,
type: "file", type: "file",
}, },
}; };
@@ -149,27 +149,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
async scanDirectory(relativePath: string = ""): Promise<void> { async scanDirectory(relativePath: string = ""): Promise<void> {
const fullPath = this.resolvePath(relativePath); const fullPath = this.resolvePath(relativePath);
try { 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) { for (const entryPath of entries.files) {
const entryRelativePath = path.join(relativePath, entry.name).replace(/\\/g, "/"); const stat = await this.storage.stat(entryPath);
if (stat?.type !== "file") continue;
if (entry.isDirectory()) { const file: NodeFile = {
await this.scanDirectory(entryRelativePath); path: entryPath as FilePath,
} else if (entry.isFile()) { stat,
const entryFullPath = this.resolvePath(entryRelativePath); };
const stat = await fs.stat(entryFullPath); this.fileCache.set(entryPath, file);
const file: NodeFile = { }
path: entryRelativePath as FilePath, for (const entryPath of entries.folders) {
stat: { await this.scanDirectory(entryPath);
size: stat.size,
mtime: Math.floor(stat.mtimeMs),
ctime: Math.floor(stat.ctimeMs),
type: "file",
},
};
this.fileCache.set(entryRelativePath, file);
}
} }
} catch (error) { } catch (error) {
// Directory doesn't exist or is not readable // Directory doesn't exist or is not readable
+23 -74
View File
@@ -1,20 +1,21 @@
import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types"; import type { FilePath, UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters"; import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
import type { NodeFile, NodeFolder } from "./NodeTypes"; 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 * Vault adapter implementation for Node.js
*/ */
export class NodeVaultAdapter implements IVaultAdapter<NodeFile> { export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
constructor(private basePath: string) {} private readonly storage: NodeStorageAdapter;
private resolvePath(p: string): string { constructor(rootPathOrStorage: string | NodeStorageAdapter) {
return path.join(this.basePath, p); this.storage =
typeof rootPathOrStorage === "string" ? new NodeStorageAdapter(rootPathOrStorage) : rootPathOrStorage;
} }
async read(file: NodeFile): Promise<string> { async read(file: NodeFile): Promise<string> {
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. // 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 // The downstream document integrity check compares stat.size to content length, so
// they must agree or other clients reject the file as corrupted. // they must agree or other clients reject the file as corrupted.
@@ -28,95 +29,37 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
} }
async readBinary(file: NodeFile): Promise<ArrayBuffer> { async readBinary(file: NodeFile): Promise<ArrayBuffer> {
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. // Same correction as read() — ensure stat.size matches actual byte length.
file.stat.size = buffer.length; file.stat.size = buffer.byteLength;
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; return buffer;
} }
async modify(file: NodeFile, data: string, options?: UXDataWriteOptions): Promise<void> { async modify(file: NodeFile, data: string, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(file.path); await this.storage.write(file.path, data, options);
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);
}
} }
async modifyBinary(file: NodeFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> { async modifyBinary(file: NodeFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
const fullPath = this.resolvePath(file.path); await this.storage.writeBinary(file.path, data, options);
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);
}
} }
async create(p: string, data: string, options?: UXDataWriteOptions): Promise<NodeFile> { async create(p: string, data: string, options?: UXDataWriteOptions): Promise<NodeFile> {
const fullPath = this.resolvePath(p); await this.storage.write(p, data, options);
await fs.mkdir(path.dirname(fullPath), { recursive: true }); return await this.toNodeFile(p);
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",
},
};
} }
async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<NodeFile> { async createBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<NodeFile> {
const fullPath = this.resolvePath(p); await this.storage.writeBinary(p, data, options);
await fs.mkdir(path.dirname(fullPath), { recursive: true }); return await this.toNodeFile(p);
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",
},
};
} }
async rename(file: NodeFile, newPath: string): Promise<void> { async rename(file: NodeFile, newPath: string): Promise<void> {
const targetPath = this.resolvePath(newPath); await this.storage.rename(file.path, newPath);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.rename(this.resolvePath(file.path), targetPath);
file.path = newPath as FilePath; file.path = newPath as FilePath;
} }
async delete(file: NodeFile | NodeFolder, force = false): Promise<void> { async delete(file: NodeFile | NodeFolder, force = false): Promise<void> {
const fullPath = this.resolvePath(file.path); await this.storage.remove(file.path);
const stat = await fs.stat(fullPath);
if (stat.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force });
} else {
await fs.unlink(fullPath);
}
} }
async trash(file: NodeFile | NodeFolder, force = false): Promise<void> { async trash(file: NodeFile | NodeFolder, force = false): Promise<void> {
@@ -128,4 +71,10 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
// No-op in CLI version (no event system) // No-op in CLI version (no event system)
return undefined; return undefined;
} }
private async toNodeFile(path: string): Promise<NodeFile> {
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 };
}
} }
@@ -24,6 +24,59 @@ describe("NodeVaultAdapter.rename", () => {
await fsPromises.rm(directory, { recursive: true, force: true }); 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", () => { describe("NodeFileSystemAdapter path case", () => {
@@ -64,4 +117,24 @@ describe("NodeFileSystemAdapter path case", () => {
await fsPromises.rm(directory, { recursive: true, force: true }); 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 });
}
});
}); });