mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-13 00:03:11 +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. */
|
/** Passing baseline shared by Node, FSAPI, and future storage adapters. */
|
||||||
export const storageAdapterContractCases: readonly StorageAdapterContractCase[] = [
|
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");
|
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 { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { NodeStat } from "./NodeTypes";
|
import type { NodeStat } from "./NodeTypes";
|
||||||
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
import { fsPromises as fs, path } from "@/apps/cli/node-compat";
|
||||||
|
import { validateStoragePath } from "@/apps/storagePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for Node.js
|
* Storage adapter implementation for Node.js
|
||||||
*/
|
*/
|
||||||
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
||||||
constructor(private basePath: string) {}
|
constructor(private readonly basePath: string) {}
|
||||||
|
|
||||||
private resolvePath(p: string): string {
|
private resolvePath(p: string, allowRoot: boolean = true): string {
|
||||||
return path.join(this.basePath, p);
|
return path.join(this.basePath, validateStoragePath(p, allowRoot));
|
||||||
}
|
}
|
||||||
|
|
||||||
async exists(p: string): Promise<boolean> {
|
async exists(p: string): Promise<boolean> {
|
||||||
|
const fullPath = this.resolvePath(p);
|
||||||
try {
|
try {
|
||||||
await fs.access(this.resolvePath(p));
|
await fs.access(fullPath);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -23,8 +25,9 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async trystat(p: string): Promise<NodeStat | null> {
|
async trystat(p: string): Promise<NodeStat | null> {
|
||||||
|
const fullPath = this.resolvePath(p);
|
||||||
try {
|
try {
|
||||||
const stat = await fs.stat(this.resolvePath(p));
|
const stat = await fs.stat(fullPath);
|
||||||
return {
|
return {
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
mtime: Math.floor(stat.mtimeMs),
|
mtime: Math.floor(stat.mtimeMs),
|
||||||
@@ -45,7 +48,7 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async remove(p: string): Promise<void> {
|
async remove(p: string): Promise<void> {
|
||||||
const fullPath = this.resolvePath(p);
|
const fullPath = this.resolvePath(p, false);
|
||||||
const stat = await fs.stat(fullPath);
|
const stat = await fs.stat(fullPath);
|
||||||
if (stat.isDirectory()) {
|
if (stat.isDirectory()) {
|
||||||
await fs.rm(fullPath, { recursive: true, force: true });
|
await fs.rm(fullPath, { recursive: true, force: true });
|
||||||
@@ -55,17 +58,17 @@ export class NodeStorageAdapter implements IStorageAdapter<NodeStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async read(p: string): Promise<string> {
|
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> {
|
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
|
// 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;
|
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(p: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
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.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, data, "utf-8");
|
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> {
|
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.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.writeFile(fullPath, new Uint8Array(data));
|
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> {
|
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.mkdir(path.dirname(fullPath), { recursive: true });
|
||||||
await fs.appendFile(fullPath, data, "utf-8");
|
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 os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { storageAdapterContractCases } from "@/apps/storageAdapterContract";
|
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||||
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
import { NodeStorageAdapter } from "./NodeStorageAdapter";
|
||||||
|
|
||||||
describe("NodeStorageAdapter", () => {
|
describe("NodeStorageAdapter", () => {
|
||||||
@@ -44,7 +44,4 @@ describe("NodeStorageAdapter", () => {
|
|||||||
expect(result.byteLength).toBe(expected.byteLength);
|
expect(result.byteLength).toBe(expected.byteLength);
|
||||||
expect(Array.from(new Uint8Array(result))).toEqual([0x10, 0x20, 0x30]);
|
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 { UXDataWriteOptions } from "@lib/common/types";
|
||||||
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
import type { IStorageAdapter } from "@lib/serviceModules/adapters";
|
||||||
import type { FSAPIStat } from "./FSAPITypes";
|
import type { FSAPIStat } from "./FSAPITypes";
|
||||||
|
import { validateStoragePath } from "@/apps/storagePath";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Storage adapter implementation for FileSystem API
|
* Storage adapter implementation for FileSystem API
|
||||||
*/
|
*/
|
||||||
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
||||||
constructor(private rootHandle: FileSystemDirectoryHandle) {}
|
constructor(private readonly rootHandle: FileSystemDirectoryHandle) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a path to directory and file handles
|
* Resolve a path to directory and file handles
|
||||||
@@ -15,11 +16,9 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
dirHandle: FileSystemDirectoryHandle;
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
|
validateStoragePath(p, false);
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
const fileName = parts[parts.length - 1];
|
const fileName = parts[parts.length - 1];
|
||||||
@@ -39,6 +38,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get file handle for a given path
|
* Get file handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
private async getFileHandle(p: string): Promise<FileSystemFileHandle | null> {
|
||||||
|
validateStoragePath(p);
|
||||||
|
if (p === "") return null;
|
||||||
const resolved = await this.resolvePath(p);
|
const resolved = await this.resolvePath(p);
|
||||||
if (!resolved) return null;
|
if (!resolved) return null;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
* Get directory handle for a given path
|
* Get directory handle for a given path
|
||||||
*/
|
*/
|
||||||
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
private async getDirectoryHandle(p: string): Promise<FileSystemDirectoryHandle | null> {
|
||||||
|
validateStoragePath(p);
|
||||||
try {
|
try {
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
@@ -75,8 +77,8 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
dirHandle: FileSystemDirectoryHandle;
|
dirHandle: FileSystemDirectoryHandle;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
} | null> {
|
} | null> {
|
||||||
|
validateStoragePath(p, false);
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
if (parts.length === 0) return null;
|
|
||||||
const fileName = parts.pop()!;
|
const fileName = parts.pop()!;
|
||||||
const parentPath = parts.join("/");
|
const parentPath = parts.join("/");
|
||||||
await this.mkdir(parentPath);
|
await this.mkdir(parentPath);
|
||||||
@@ -124,6 +126,7 @@ export class FSAPIStorageAdapter implements IStorageAdapter<FSAPIStat> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mkdir(p: string): Promise<void> {
|
async mkdir(p: string): Promise<void> {
|
||||||
|
validateStoragePath(p);
|
||||||
const parts = p.split("/").filter((part) => part !== "");
|
const parts = p.split("/").filter((part) => part !== "");
|
||||||
let currentHandle = this.rootHandle;
|
let currentHandle = this.rootHandle;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it } from "vitest";
|
import { describe, it } from "vitest";
|
||||||
import { storageAdapterContractCases } from "@/apps/storageAdapterContract";
|
import { storageAdapterContractCases } from "@/apps/_test/storageAdapterContract";
|
||||||
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
import { FSAPIStorageAdapter } from "./FSAPIStorageAdapter";
|
||||||
|
|
||||||
class MemoryFileHandle {
|
class MemoryFileHandle {
|
||||||
|
|||||||
Reference in New Issue
Block a user