mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-12 15:53:12 +00:00
Enforce rooted storage path contracts
This commit is contained in:
@@ -16,6 +16,15 @@ function assertEqual(actual: unknown, expected: unknown, message: string): void
|
||||
}
|
||||
}
|
||||
|
||||
async function assertRejects(operation: () => Promise<unknown>, message: string): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
|
||||
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
|
||||
{
|
||||
@@ -74,4 +83,26 @@ export const storageAdapterContractCases: readonly StorageAdapterContractCase[]
|
||||
assertEqual(await adapter.exists("remove/folder"), false, "directory tree should be removed");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "keeps operations inside the configured root",
|
||||
async run(adapter) {
|
||||
await assertRejects(() => adapter.exists("../outside"), "parent traversal should be rejected");
|
||||
await assertRejects(() => adapter.write("nested/../outside", "content"), "nested traversal should be rejected");
|
||||
await assertRejects(() => adapter.read("/absolute"), "absolute paths should be rejected");
|
||||
await assertRejects(() => adapter.read("C:\\absolute"), "drive-qualified paths should be rejected");
|
||||
await assertRejects(() => adapter.read("nested\\outside"), "backslash-separated paths should be rejected");
|
||||
await assertRejects(() => adapter.remove(""), "removing the configured root should be rejected");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "uses the empty path only for root-safe operations",
|
||||
async run(adapter) {
|
||||
await adapter.mkdir("");
|
||||
assertEqual(await adapter.exists(""), true, "the configured root should exist");
|
||||
assertEqual((await adapter.stat(""))?.type, "folder", "the configured root should be a folder");
|
||||
assertEqual(await adapter.list(""), { files: [], folders: [] }, "the configured root should be listable");
|
||||
await assertRejects(() => adapter.write("", "content"), "writing over the configured root should be rejected");
|
||||
await assertRejects(() => adapter.append("", "content"), "appending to the configured root should be rejected");
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -2,20 +2,22 @@ import type { UXDataWriteOptions } from "@lib/common/types";
|
||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||
import type { NodeStat } from "./NodeTypes";
|
||||
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
||||
import { validateStoragePath } from "@/apps/storagePath";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for Node.js
|
||||
*/
|
||||
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
constructor(private basePath: string) {}
|
||||
constructor(private readonly basePath: string) {}
|
||||
|
||||
private resolvePath(p: string): string {
|
||||
return path.join(this.basePath, p);
|
||||
private resolvePath(p: string, allowRoot: boolean = true): string {
|
||||
return path.join(this.basePath, validateStoragePath(p, allowRoot));
|
||||
}
|
||||
|
||||
async exists(p: string): Promise<boolean> {
|
||||
const fullPath = this.resolvePath(p);
|
||||
try {
|
||||
await fs.access(this.resolvePath(p));
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -23,8 +25,9 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
}
|
||||
|
||||
async trystat(p: string): Promise<NodeStat | null> {
|
||||
const fullPath = this.resolvePath(p);
|
||||
try {
|
||||
const stat = await fs.stat(this.resolvePath(p));
|
||||
const stat = await fs.stat(fullPath);
|
||||
return {
|
||||
size: stat.size,
|
||||
mtime: Math.floor(stat.mtimeMs),
|
||||
@@ -45,7 +48,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
}
|
||||
|
||||
async remove(p: string): Promise<void> {
|
||||
const fullPath = this.resolvePath(p);
|
||||
const fullPath = this.resolvePath(p, false);
|
||||
const stat = await fs.stat(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.rm(fullPath, { recursive: true, force: true });
|
||||
@@ -55,17 +58,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
}
|
||||
|
||||
async read(p: string): Promise<string> {
|
||||
return await fs.readFile(this.resolvePath(p), "utf-8");
|
||||
return await fs.readFile(this.resolvePath(p, false), "utf-8");
|
||||
}
|
||||
|
||||
async readBinary(p: string): Promise<ArrayBuffer> {
|
||||
const buffer = await fs.readFile(this.resolvePath(p));
|
||||
const buffer = await fs.readFile(this.resolvePath(p, false));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- required in environments where Buffer.buffer is ArrayBufferLike
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
const fullPath = this.resolvePath(p);
|
||||
const fullPath = this.resolvePath(p, false);
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fs.writeFile(fullPath, data, "utf-8");
|
||||
|
||||
@@ -77,7 +80,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
}
|
||||
|
||||
async writeBinary(p: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
const fullPath = this.resolvePath(p);
|
||||
const fullPath = this.resolvePath(p, false);
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fs.writeFile(fullPath, new Uint8Array(data));
|
||||
|
||||
@@ -89,7 +92,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||
}
|
||||
|
||||
async append(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
const fullPath = this.resolvePath(p);
|
||||
const fullPath = this.resolvePath(p, false);
|
||||
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
||||
await fs.appendFile(fullPath, data, "utf-8");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { storageAdapterContractCases } from "@/apps/storageAdapterContract";
|
||||
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
||||
|
||||
describe("NodeStorageAdapter", () => {
|
||||
@@ -44,7 +44,4 @@ describe("NodeStorageAdapter", () => {
|
||||
expect(result.byteLength).toBe(expected.byteLength);
|
||||
expect(Array.from(new Uint8Array(result))).toEqual([0x10, 0x20, 0x30]);
|
||||
});
|
||||
|
||||
it.todo("rejects paths that escape the configured root");
|
||||
it.todo("rejects removing the configured root through an empty path");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Validate the platform-neutral path vocabulary used by rooted storage adapters.
|
||||
*
|
||||
* Paths are slash-separated and relative to the root bound to the adapter. Root
|
||||
* selection and authorisation happen before the adapter is constructed.
|
||||
*/
|
||||
export function validateStoragePath(storagePath: string, allowRoot: boolean = true): string {
|
||||
if (storagePath === "") {
|
||||
if (allowRoot) return storagePath;
|
||||
throw new Error("The storage root is not a valid entry path");
|
||||
}
|
||||
|
||||
if (storagePath.startsWith("/") || storagePath.startsWith("\\") || /^[A-Za-z]:/.test(storagePath)) {
|
||||
throw new Error(`Storage paths must be relative to the configured root: ${storagePath}`);
|
||||
}
|
||||
if (storagePath.includes("\\")) {
|
||||
throw new Error(`Storage paths must use forward slashes: ${storagePath}`);
|
||||
}
|
||||
|
||||
const segments = storagePath.split("/");
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
throw new Error(`Storage paths must not contain traversal segments: ${storagePath}`);
|
||||
}
|
||||
|
||||
return storagePath;
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { UXDataWriteOptions } from "@lib/common/types";
|
||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||
import type { FSAPIStat } from "./FSAPITypes";
|
||||
import { validateStoragePath } from "@/apps/storagePath";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for FileSystem API
|
||||
*/
|
||||
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
||||
constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
|
||||
|
||||
/**
|
||||
* Resolve a path to directory and file handles
|
||||
@@ -15,11 +16,9 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
dirHandle: FileSystemDirectoryHandle;
|
||||
fileName: string;
|
||||
} | null> {
|
||||
validateStoragePath(p, false);
|
||||
try {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentHandle = this.rootHandle;
|
||||
const fileName = parts[parts.length - 1];
|
||||
@@ -39,6 +38,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
* Get file handle for a given path
|
||||
*/
|
||||
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
||||
validateStoragePath(p);
|
||||
if (p === "") return null;
|
||||
const resolved = await this.resolvePath(p);
|
||||
if (!resolved) return null;
|
||||
|
||||
@@ -53,6 +54,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
* Get directory handle for a given path
|
||||
*/
|
||||
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
||||
validateStoragePath(p);
|
||||
try {
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
if (parts.length === 0) {
|
||||
@@ -75,8 +77,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
dirHandle: FileSystemDirectoryHandle;
|
||||
fileName: string;
|
||||
} | null> {
|
||||
validateStoragePath(p, false);
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
if (parts.length === 0) return null;
|
||||
const fileName = parts.pop()!;
|
||||
const parentPath = parts.join("/");
|
||||
await this.mkdir(parentPath);
|
||||
@@ -124,6 +126,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||
}
|
||||
|
||||
async mkdir(p: string): Promise<void> {
|
||||
validateStoragePath(p);
|
||||
const parts = p.split("/").filter((part) => part !== "");
|
||||
let currentHandle = this.rootHandle;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { storageAdapterContractCases } from "@/apps/storageAdapterContract";
|
||||
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
||||
|
||||
class MemoryFileHandle {
|
||||
|
||||
Reference in New Issue
Block a user