mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-23 13:02:58 +00:00
Use rooted Commonlib storage in the CLI
This commit is contained in:
@@ -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<NodeFile, NodeF
|
||||
this.typeGuard = new NodeTypeGuardAdapter();
|
||||
this.conversion = new NodeConversionAdapter();
|
||||
this.storage = new NodeStorageAdapter(basePath);
|
||||
this.vault = new NodeVaultAdapter(basePath);
|
||||
this.vault = new NodeVaultAdapter(this.storage);
|
||||
}
|
||||
|
||||
private resolvePath(p: FilePath | string): string {
|
||||
@@ -43,11 +43,12 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
private async hasExactPathCase(pathStr: string): Promise<boolean> {
|
||||
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<NodeFile, NodeF
|
||||
async refreshFile(p: string): Promise<NodeFile | null> {
|
||||
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<NodeFile, NodeF
|
||||
path: pathStr as FilePath,
|
||||
stat: {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
ctime: Math.floor(stat.ctimeMs),
|
||||
mtime: stat.mtime,
|
||||
ctime: stat.ctime,
|
||||
type: "file",
|
||||
},
|
||||
};
|
||||
@@ -149,27 +149,21 @@ export class NodeFileSystemAdapter implements IFileSystemAdapter<NodeFile, NodeF
|
||||
async scanDirectory(relativePath: string = ""): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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<NodeFile> {
|
||||
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<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.
|
||||
// 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<NodeFile> {
|
||||
}
|
||||
|
||||
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.
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<NodeFile> {
|
||||
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<NodeFile> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -128,4 +71,10 @@ export class NodeVaultAdapter implements IVaultAdapter<NodeFile> {
|
||||
// No-op in CLI version (no event system)
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user