From cc0757aa82849f56ae4e8e42a1720bda89e27370 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 6 May 2026 03:29:31 +0100 Subject: [PATCH] refactor: split root internals and security test helpers --- docs/testing.md | 16 + package.json | 4 +- scripts/check-file-size.mjs | 46 + src/root-context.ts | 80 + src/root-errors.ts | 24 + src/root-impl.ts | 1743 +++++++++++++++++++ src/root.ts | 1860 +-------------------- test/helpers/security.ts | 111 ++ test/openclaw-read-bypass-parity.test.ts | 61 +- test/openclaw-write-bypass-parity.test.ts | 85 +- 10 files changed, 2073 insertions(+), 1957 deletions(-) create mode 100644 scripts/check-file-size.mjs create mode 100644 src/root-context.ts create mode 100644 src/root-errors.ts create mode 100644 src/root-impl.ts create mode 100644 test/helpers/security.ts diff --git a/docs/testing.md b/docs/testing.md index bb2d6b3..9177743 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -145,6 +145,22 @@ it("writes and reads through the boundary", async () => { For tests that need a private temp workspace, [`withTempWorkspace`](temp.md) makes the setup-and-teardown story trivial. +## Repo test shards + +Run the full local gate before handoff: + +```sh +pnpm check +``` + +Run only the security boundary corpus while iterating on root/path/archive/temp hardening: + +```sh +pnpm test:security +``` + +`pnpm check` also runs `pnpm lint:file-size`. New source and test files should stay under 500 lines. Existing larger files have explicit budgets in `scripts/check-file-size.mjs`; do not increase those budgets as part of unrelated work. + ## See also - [Security model](security-model.md) — what the boundary is supposed to defend; design tests around the same threats. diff --git a/package.json b/package.json index 5442385..d3cd967 100644 --- a/package.json +++ b/package.json @@ -95,10 +95,12 @@ "scripts": { "benchmark": "node scripts/benchmark.mjs", "build": "tsc -p tsconfig.json", + "lint:file-size": "node scripts/check-file-size.mjs", "prepack": "node scripts/prepack-build.mjs", "test": "vitest run", "test:coverage": "vitest run --coverage", - "check": "pnpm build && pnpm test", + "test:security": "vitest run test/fs-safe.test.ts test/openclaw-read-bypass-parity.test.ts test/openclaw-write-bypass-parity.test.ts test/additional-bypass-parity.test.ts", + "check": "pnpm lint:file-size && pnpm build && pnpm test", "docs:site": "node scripts/build-docs-site.mjs" }, "optionalDependencies": { diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs new file mode 100644 index 0000000..2e72a8e --- /dev/null +++ b/scripts/check-file-size.mjs @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import path from "node:path"; + +const DEFAULT_MAX_LINES = 500; +const LINE_BUDGETS = new Map([ + ["src/file-store.ts", 580], + ["src/permissions.ts", 566], + ["src/pinned-python.ts", 655], + ["src/root-impl.ts", 1744], + ["src/root-path.ts", 862], + ["test/api-coverage.test.ts", 982], + ["test/new-primitives.test.ts", 998], +]); + +function walk(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + return entries.flatMap((entry) => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return walk(fullPath); + } + return fullPath.endsWith(".ts") ? [fullPath] : []; + }); +} + +const rootDir = process.cwd(); +const files = [...walk("src"), ...walk("test")].sort(); +const failures = []; + +for (const file of files) { + const normalized = file.split(path.sep).join("/"); + const text = fs.readFileSync(path.join(rootDir, file), "utf8"); + const lines = text.length === 0 ? 0 : text.split("\n").length; + const budget = LINE_BUDGETS.get(normalized) ?? DEFAULT_MAX_LINES; + if (lines > budget) { + failures.push(`${normalized}: ${lines} lines > ${budget} budget`); + } +} + +if (failures.length > 0) { + console.error("File size budget exceeded:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} diff --git a/src/root-context.ts b/src/root-context.ts new file mode 100644 index 0000000..a4c070f --- /dev/null +++ b/src/root-context.ts @@ -0,0 +1,80 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { FsSafeError } from "./errors.js"; +import { expandHomePrefix } from "./home-dir.js"; +import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js"; + +export type RootContext = { + rootDir: string; + rootReal: string; + rootWithSep: string; +}; + +export const ensureTrailingSep = (value: string) => + value.endsWith(path.sep) ? value : value + path.sep; + +export function assertValidRootRelativePath(relativePath: string): void { + assertNoNulPathInput(relativePath, "relative path contains a NUL byte"); +} + +let cachedHomePath: { raw: string; real: string } | undefined; + +export async function expandRelativePathWithHome(relativePath: string): Promise { + const rawHome = process.env.HOME || process.env.USERPROFILE || os.homedir(); + if (cachedHomePath?.raw !== rawHome) { + let realHome = rawHome; + try { + realHome = await fs.realpath(rawHome); + } catch { + // If the home dir cannot be canonicalized, keep lexical expansion behavior. + } + cachedHomePath = { raw: rawHome, real: realHome }; + } + return expandHomePrefix(relativePath, { home: cachedHomePath.real }); +} + +export async function resolveRootContext(rootDir: string): Promise { + assertNoNulPathInput(rootDir, "root dir contains a NUL byte"); + let rootReal: string; + try { + rootReal = await fs.realpath(rootDir); + const rootStat = await fs.stat(rootReal); + if (!rootStat.isDirectory()) { + throw new FsSafeError("invalid-path", "root dir is not a directory"); + } + } catch (err) { + if (err instanceof FsSafeError) { + throw err; + } + if (isNotFoundPathError(err)) { + throw new FsSafeError("not-found", "root dir not found"); + } + throw err; + } + return { + rootDir: path.resolve(rootDir), + rootReal, + rootWithSep: ensureTrailingSep(rootReal), + }; +} + +export async function resolvePathInRoot( + root: RootContext, + relativePath: string, +): Promise<{ rootReal: string; rootWithSep: string; resolved: string }> { + assertValidRootRelativePath(relativePath); + const expanded = await expandRelativePathWithHome(relativePath); + const resolved = path.resolve(root.rootWithSep, expanded); + if (!isPathInside(root.rootWithSep, resolved)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved }; +} + +export async function resolvePathWithinRoot(params: { + rootDir: string; + relativePath: string; +}): Promise<{ rootReal: string; rootWithSep: string; resolved: string }> { + return await resolvePathInRoot(await resolveRootContext(params.rootDir), params.relativePath); +} diff --git a/src/root-errors.ts b/src/root-errors.ts new file mode 100644 index 0000000..355efb6 --- /dev/null +++ b/src/root-errors.ts @@ -0,0 +1,24 @@ +import { FsSafeError } from "./errors.js"; +import { hasNodeErrorCode } from "./path.js"; + +export function isAlreadyExistsError(error: unknown): boolean { + return hasNodeErrorCode(error, "EEXIST") || /File exists|EEXIST/i.test(String(error)); +} + +export function normalizePinnedWriteError(error: unknown): Error { + if (error instanceof FsSafeError) { + return error; + } + return new FsSafeError("invalid-path", "path is not a regular file under root", { + cause: error instanceof Error ? error : undefined, + }); +} + +export function normalizePinnedPathError(error: unknown): Error { + if (error instanceof FsSafeError) { + return error; + } + return new FsSafeError("path-alias", "path is not under root", { + cause: error instanceof Error ? error : undefined, + }); +} diff --git a/src/root-impl.ts b/src/root-impl.ts new file mode 100644 index 0000000..67e7d26 --- /dev/null +++ b/src/root-impl.ts @@ -0,0 +1,1743 @@ +import { randomUUID } from "node:crypto"; +import type { Stats } from "node:fs"; +import { constants as fsConstants } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import { FsSafeError } from "./errors.js"; +import { sameFileIdentity } from "./file-identity.js"; +import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js"; +import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js"; +import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js"; +import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js"; +import { + assertNoNulPathInput, + hasNodeErrorCode, + isNotFoundPathError, + isPathInside, + isSymlinkOpenError, +} from "./path.js"; +import { + helperReaddir, + helperStat, + runPinnedHelper, +} from "./pinned-helper.js"; +import { resolveRootPath } from "./root-path.js"; +import { + assertValidRootRelativePath, + ensureTrailingSep, + expandRelativePathWithHome, + resolvePathInRoot, + resolveRootContext, + type RootContext, +} from "./root-context.js"; +import { + isAlreadyExistsError, + normalizePinnedPathError, + normalizePinnedWriteError, +} from "./root-errors.js"; +import { getFsSafeTestHooks } from "./test-hooks.js"; +import type { DirEntry, PathStat } from "./types.js"; +import { registerTempPathForExit } from "./temp-cleanup.js"; +import { serializePathWrite } from "./write-queue.js"; + +export type OpenResult = { + handle: FileHandle; + realPath: string; + stat: Stats; + [Symbol.asyncDispose](): Promise; +}; + +export type ReadResult = { + buffer: Buffer; + realPath: string; + stat: Stats; +}; + +export type RootOptions = { + rootDir: string; + defaults?: RootDefaults; +}; + +export type SymlinkPolicy = "reject" | "follow-within-root"; +export type HardlinkPolicy = "reject" | "allow"; +export type WritableOpenMode = "replace" | "append" | "update"; + +export type RootDefaults = { + hardlinks?: HardlinkPolicy; + maxBytes?: number; + mkdir?: boolean; + mode?: number; + nonBlockingRead?: boolean; + symlinks?: SymlinkPolicy; +}; + +export type RootReadOptions = Pick< + RootDefaults, + "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks" +>; + +export type RootOpenOptions = Omit; + +export type RootWriteOptions = Pick & { + encoding?: BufferEncoding; + overwrite?: boolean; +}; + +export type RootOpenWritableOptions = Pick & { + writeMode?: WritableOpenMode; +}; + +export type RootCopyOptions = Pick & { + sourceHardlinks?: HardlinkPolicy; +}; + +export type RootWriteJsonOptions = RootWriteOptions & { + replacer?: Parameters[1]; + space?: Parameters[2]; + trailingNewline?: boolean; +}; + +export type RootCreateOptions = Omit; +export type RootCreateJsonOptions = Omit; + +export type RootAppendOptions = RootWriteOptions & { + prependNewlineIfNeeded?: boolean; +}; + +type RootReadParams = RootReadOptions; + +function logWarn(message: string): void { + if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") { + console.warn(message); + } +} + +const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; +const NONBLOCK_OPEN_FLAG = "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0; +const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_READ_NONBLOCK_FLAGS = OPEN_READ_FLAGS | NONBLOCK_OPEN_FLAG; +const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY; +const OPEN_READ_FOLLOW_NONBLOCK_FLAGS = OPEN_READ_FOLLOW_FLAGS | NONBLOCK_OPEN_FLAG; +const OPEN_WRITE_EXISTING_FLAGS = + fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_WRITE_CREATE_FLAGS = + fsConstants.O_WRONLY | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_APPEND_EXISTING_FLAGS = + fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); +const OPEN_APPEND_CREATE_FLAGS = + fsConstants.O_RDWR | + fsConstants.O_APPEND | + fsConstants.O_CREAT | + fsConstants.O_EXCL | + (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); + +export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024; + +function closeHandleForDispose(handle: FileHandle): Promise { + return handle.close().catch(() => undefined); +} + +function openResult(params: { + handle: FileHandle; + realPath: string; + stat: Stats; +}): OpenResult { + return { + handle: params.handle, + realPath: params.realPath, + stat: params.stat, + [Symbol.asyncDispose]: async () => { + await closeHandleForDispose(params.handle); + }, + }; +} + +async function openVerifiedLocalFile( + filePath: string, + options?: { + hardlinks?: HardlinkPolicy; + nonBlockingRead?: boolean; + symlinks?: SymlinkPolicy; + }, +): Promise { + const fsSafeTestHooks = getFsSafeTestHooks(); + // Reject directories before opening so we never surface EISDIR to callers (e.g. tool + // results that get sent to messaging channels). See openclaw/openclaw#31186. + try { + const preStat = await fs.lstat(filePath); + if (preStat.isDirectory()) { + throw new FsSafeError("not-file", "not a file"); + } + await fsSafeTestHooks?.afterPreOpenLstat?.(filePath); + } catch (err) { + if (err instanceof FsSafeError) { + throw err; + } + // ENOENT and other lstat errors: fall through and let fs.open handle. + } + + let handle: FileHandle; + try { + const openFlags = options?.symlinks === "follow-within-root" + ? options?.nonBlockingRead + ? OPEN_READ_FOLLOW_NONBLOCK_FLAGS + : OPEN_READ_FOLLOW_FLAGS + : options?.nonBlockingRead + ? OPEN_READ_NONBLOCK_FLAGS + : OPEN_READ_FLAGS; + await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags); + handle = await fs.open(filePath, openFlags); + try { + await fsSafeTestHooks?.afterOpen?.(filePath, handle); + } catch (err) { + await handle.close().catch(() => {}); + throw err; + } + } catch (err) { + if (isNotFoundPathError(err)) { + throw new FsSafeError("not-found", "file not found"); + } + if (isSymlinkOpenError(err)) { + throw new FsSafeError("symlink", "symlink open blocked", { cause: err }); + } + // Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks. + if (hasNodeErrorCode(err, "EISDIR")) { + throw new FsSafeError("not-file", "not a file"); + } + throw err; + } + + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new FsSafeError("not-file", "not a file"); + } + if (options?.hardlinks === "reject" && stat.nlink > 1) { + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + + if (options?.symlinks === "follow-within-root") { + const pathStat = await fs.stat(filePath); + if (!sameFileIdentity(stat, pathStat)) { + throw new FsSafeError("path-mismatch", "path changed during read"); + } + } else { + const pathStat = await fs.lstat(filePath); + if (pathStat.isSymbolicLink()) { + throw new FsSafeError("symlink", "symlink not allowed"); + } + if (!sameFileIdentity(stat, pathStat)) { + throw new FsSafeError("path-mismatch", "path changed during read"); + } + } + + const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath); + const realStat = await fs.stat(realPath); + if (options?.hardlinks === "reject" && realStat.nlink > 1) { + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + if (!sameFileIdentity(stat, realStat)) { + throw new FsSafeError("path-mismatch", "path mismatch"); + } + + return openResult({ handle, realPath, stat }); + } catch (err) { + await handle.close().catch(() => {}); + if (err instanceof FsSafeError) { + throw err; + } + if (isNotFoundPathError(err)) { + throw new FsSafeError("not-found", "file not found"); + } + throw err; + } +} + +export interface Root { + readonly rootDir: string; + readonly rootReal: string; + readonly rootWithSep: string; + readonly defaults: RootDefaults; + + resolve(relativePath: string): Promise; + open(relativePath: string, options?: RootOpenOptions): Promise; + read(relativePath: string, options?: RootReadOptions): Promise; + readBytes(relativePath: string, options?: RootReadOptions): Promise; + readText( + relativePath: string, + options?: RootReadOptions & { encoding?: BufferEncoding }, + ): Promise; + readJson( + relativePath: string, + options?: RootReadOptions & { encoding?: BufferEncoding }, + ): Promise; + readAbsolute(filePath: string, options?: RootReadOptions): Promise; + reader(options?: RootReadOptions): (filePath: string) => Promise; + openWritable( + relativePath: string, + options?: RootOpenWritableOptions, + ): Promise; + append( + relativePath: string, + data: string | Buffer, + options?: RootAppendOptions, + ): Promise; + remove(relativePath: string): Promise; + mkdir(relativePath: string): Promise; + ensureRoot(): Promise; + write( + relativePath: string, + data: string | Buffer, + options?: RootWriteOptions, + ): Promise; + create( + relativePath: string, + data: string | Buffer, + options?: RootCreateOptions, + ): Promise; + writeJson( + relativePath: string, + data: unknown, + options?: RootWriteJsonOptions, + ): Promise; + createJson( + relativePath: string, + data: unknown, + options?: RootCreateJsonOptions, + ): Promise; + copyIn(relativePath: string, sourcePath: string, options?: RootCopyOptions): Promise; + exists(relativePath: string): Promise; + stat(relativePath: string): Promise; + list(relativePath: string, options?: { withFileTypes?: false }): Promise; + list(relativePath: string, options: { withFileTypes: true }): Promise; + move( + fromRelative: string, + toRelative: string, + options?: { overwrite?: boolean }, + ): Promise; +} + +class RootHandle implements Root { + readonly rootDir: string; + readonly rootReal: string; + readonly rootWithSep: string; + readonly defaults: RootDefaults; + + constructor(context: RootContext, defaults: RootDefaults = {}) { + this.rootDir = context.rootDir; + this.rootReal = context.rootReal; + this.rootWithSep = context.rootWithSep; + this.defaults = defaults; + } + + private get context(): RootContext { + return { + rootDir: this.rootDir, + rootReal: this.rootReal, + rootWithSep: this.rootWithSep, + }; + } + + async resolve(relativePath: string): Promise { + return (await resolvePathInRoot(this.context, relativePath)).resolved; + } + + async open(relativePath: string, options: RootOpenOptions = {}): Promise { + return await openFileInRoot(this.context, { + relativePath, + ...readDefaults(this.defaults), + ...options, + }); + } + + async read( + relativePath: string, + options: RootReadOptions = {}, + ): Promise { + return await readFileInRoot(this.context, { + relativePath, + ...readDefaults(this.defaults), + ...options, + }); + } + + async readBytes(relativePath: string, options: RootReadOptions = {}): Promise { + return (await this.read(relativePath, options)).buffer; + } + + async readText( + relativePath: string, + options: RootReadOptions & { encoding?: BufferEncoding } = {}, + ): Promise { + const { encoding = "utf8", ...readOptions } = options; + return (await this.read(relativePath, readOptions)).buffer.toString(encoding); + } + + async readJson( + relativePath: string, + options: RootReadOptions & { encoding?: BufferEncoding } = {}, + ): Promise { + return JSON.parse(await this.readText(relativePath, options)) as T; + } + + async readAbsolute( + filePath: string, + options: RootReadOptions = {}, + ): Promise { + return await readPathInRoot(this.context, { + filePath, + ...readDefaults(this.defaults), + ...options, + }); + } + + reader(options: RootReadOptions = {}) { + return async (filePath: string): Promise => { + return (await this.readAbsolute(filePath, options)).buffer; + }; + } + + async openWritable( + relativePath: string, + options: RootOpenWritableOptions = {}, + ): Promise { + const writeMode = options.writeMode ?? "replace"; + return await openWritableFileInRoot(this.context, { + relativePath, + mkdir: this.defaults.mkdir, + mode: this.defaults.mode, + ...options, + append: writeMode === "append", + truncateExisting: writeMode === "replace", + }); + } + + async append( + relativePath: string, + data: string | Buffer, + options: RootAppendOptions = {}, + ): Promise { + await appendFileInRoot(this.context, { + relativePath, + data, + mkdir: this.defaults.mkdir, + mode: this.defaults.mode, + ...options, + }); + } + + async remove(relativePath: string): Promise { + assertValidRootRelativePath(relativePath); + await removePathInRoot(this.context, relativePath); + } + + async mkdir(relativePath: string): Promise { + assertValidRootRelativePath(relativePath); + await mkdirPathInRoot(this.context, { relativePath }); + } + + async ensureRoot(): Promise { + await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true }); + } + + async write( + relativePath: string, + data: string | Buffer, + options: RootWriteOptions = {}, + ): Promise { + await writeFileInRoot(this.context, { + relativePath, + data, + mkdir: this.defaults.mkdir, + mode: this.defaults.mode, + ...options, + }); + } + + async create( + relativePath: string, + data: string | Buffer, + options: RootCreateOptions = {}, + ): Promise { + await writeFileInRoot(this.context, { + relativePath, + data, + mkdir: this.defaults.mkdir, + mode: this.defaults.mode, + ...options, + overwrite: false, + }); + } + + async writeJson( + relativePath: string, + data: unknown, + options: RootWriteJsonOptions = {}, + ): Promise { + const { replacer, space, trailingNewline = true, ...writeOptions } = options; + const json = JSON.stringify(data, replacer, space); + await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions); + } + + async createJson( + relativePath: string, + data: unknown, + options: RootCreateJsonOptions = {}, + ): Promise { + const { replacer, space, trailingNewline = true, ...writeOptions } = options; + const json = JSON.stringify(data, replacer, space); + await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions); + } + + async copyIn( + relativePath: string, + sourcePath: string, + options: RootCopyOptions = {}, + ): Promise { + assertValidRootRelativePath(relativePath); + await copyFileInRoot(this.context, { + sourcePath, + relativePath, + maxBytes: this.defaults.maxBytes, + mkdir: this.defaults.mkdir, + mode: this.defaults.mode, + ...options, + }); + } + + async exists(relativePath: string): Promise { + try { + await this.stat(relativePath); + return true; + } catch (err) { + if (err instanceof FsSafeError && err.code === "not-found") { + return false; + } + throw err; + } + } + + async stat(relativePath: string): Promise { + assertValidRootRelativePath(relativePath); + try { + return await helperStat(this.rootReal, relativePath); + } catch (error) { + if (canFallbackFromPythonError(error)) { + return await statPathFallback(this.context, relativePath); + } + throw error; + } + } + + async list(relativePath: string, options?: { withFileTypes?: false }): Promise; + async list(relativePath: string, options: { withFileTypes: true }): Promise; + async list( + relativePath: string, + options: { withFileTypes?: boolean } = {}, + ): Promise { + assertValidRootRelativePath(relativePath); + try { + return options.withFileTypes === true + ? await helperReaddir(this.rootReal, relativePath, true) + : await helperReaddir(this.rootReal, relativePath, false); + } catch (error) { + if (canFallbackFromPythonError(error)) { + return await listPathFallback(this.context, relativePath, options.withFileTypes === true); + } + throw error; + } + } + + async move( + fromRelative: string, + toRelative: string, + options: { overwrite?: boolean } = {}, + ): Promise { + assertValidRootRelativePath(fromRelative); + assertValidRootRelativePath(toRelative); + try { + await runPinnedHelper("rename", this.rootReal, { + from: fromRelative, + overwrite: options.overwrite ?? false, + to: toRelative, + }); + } catch (error) { + if (canFallbackFromPythonError(error)) { + await movePathFallback(this.context, { + fromRelative, + overwrite: options.overwrite ?? false, + toRelative, + }); + return; + } + throw error; + } + } +} + +function readDefaults(defaults: RootDefaults): RootReadParams { + return { + hardlinks: defaults.hardlinks, + maxBytes: defaults.maxBytes ?? DEFAULT_ROOT_MAX_BYTES, + nonBlockingRead: defaults.nonBlockingRead, + symlinks: defaults.symlinks, + }; +} + +export async function root( + rootDir: string, + defaults: RootDefaults = {}, +): Promise { + return new RootHandle(await resolveRootContext(rootDir), defaults); +} + +async function openFileInRoot( + root: RootContext, + params: { + relativePath: string; + hardlinks?: HardlinkPolicy; + nonBlockingRead?: boolean; + symlinks?: SymlinkPolicy; + }, +): Promise { + const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath); + + let opened: OpenResult; + try { + opened = await openVerifiedLocalFile(resolved, { + nonBlockingRead: params.nonBlockingRead, + symlinks: params.symlinks, + }); + } catch (err) { + if (err instanceof FsSafeError) { + throw err; + } + throw err; + } + + if (params.hardlinks !== "allow" && opened.stat.nlink > 1) { + await opened.handle.close().catch(() => {}); + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + + if (!isPathInside(rootWithSep, opened.realPath)) { + await opened.handle.close().catch(() => {}); + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + + return opened; +} + +async function readFileInRoot( + root: RootContext, + params: { + relativePath: string; + hardlinks?: HardlinkPolicy; + nonBlockingRead?: boolean; + symlinks?: SymlinkPolicy; + maxBytes?: number; + }, +): Promise { + const opened = await openFileInRoot(root, params); + try { + return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes }); + } finally { + await opened.handle.close().catch(() => {}); + } +} + +async function readPathInRoot( + root: RootContext, + params: { + filePath: string; + hardlinks?: HardlinkPolicy; + maxBytes?: number; + nonBlockingRead?: boolean; + symlinks?: SymlinkPolicy; + }, +): Promise { + const rootDir = root.rootDir; + const candidatePath = path.isAbsolute(params.filePath) + ? path.resolve(params.filePath) + : path.resolve(rootDir, params.filePath); + const relativePath = path.relative(rootDir, candidatePath); + return await readFileInRoot(root, { + relativePath, + hardlinks: params.hardlinks, + maxBytes: params.maxBytes, + nonBlockingRead: params.nonBlockingRead, + symlinks: params.symlinks, + }); +} + +export async function readLocalFileSafely(params: { + filePath: string; + maxBytes?: number; +}): Promise { + const opened = await openLocalFileSafely({ filePath: params.filePath }); + try { + return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes }); + } finally { + await opened.handle.close().catch(() => {}); + } +} + +export async function openLocalFileSafely(params: { filePath: string }): Promise { + assertNoNulPathInput(params.filePath, "file path contains a NUL byte"); + return await openVerifiedLocalFile(params.filePath); +} + +async function readOpenedFileSafely(params: { + opened: OpenResult; + maxBytes?: number; +}): Promise { + if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) { + throw new FsSafeError( + "too-large", + `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`, + ); + } + const buffer = await params.opened.handle.readFile(); + if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) { + throw new FsSafeError( + "too-large", + `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`, + ); + } + return { + buffer, + realPath: params.opened.realPath, + stat: params.opened.stat, + }; +} + +export type WritableOpenResult = { + handle: FileHandle; + createdForWrite: boolean; + realPath: string; + stat: Stats; + [Symbol.asyncDispose](): Promise; +}; + +function emitWriteBoundaryWarning(reason: string) { + logWarn(`security: fs-safe write boundary warning (${reason})`); +} + +function buildAtomicWriteTempPath(targetPath: string): string { + const dir = path.dirname(targetPath); + const base = path.basename(targetPath); + return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`); +} + +function rootWriteQueueKey(root: RootContext, relativePath: string): string { + return `${root.rootReal}\0${relativePath}`; +} + +function createMaxBytesTransform(maxBytes: number): Transform { + let bytes = 0; + return new Transform({ + transform(chunk, _encoding, callback) { + const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk as Uint8Array); + bytes += buffer.byteLength; + if (bytes > maxBytes) { + callback( + new FsSafeError( + "too-large", + `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`, + ), + ); + return; + } + callback(null, buffer); + }, + }); +} + +function createBoundedReadStream(opened: OpenResult, maxBytes: number | undefined) { + const stream = opened.handle.createReadStream(); + return maxBytes === undefined ? stream : stream.pipe(createMaxBytesTransform(maxBytes)); +} + +async function writeTempFileForAtomicReplace(params: { + tempPath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mode: number; +}): Promise { + const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode); + try { + if (typeof params.data === "string") { + await tempHandle.writeFile(params.data, params.encoding ?? "utf8"); + } else { + await tempHandle.writeFile(params.data); + } + return await tempHandle.stat(); + } finally { + await tempHandle.close().catch(() => {}); + } +} + +async function verifyAtomicWriteResult(params: { + root: RootContext; + targetPath: string; + expectedIdentity: { dev: number | bigint; ino: number | bigint }; +}): Promise { + const opened = await openVerifiedLocalFile(params.targetPath, { hardlinks: "reject" }); + try { + if (!sameFileIdentity(opened.stat, params.expectedIdentity)) { + throw new FsSafeError("path-mismatch", "path changed during write"); + } + if (!isPathInside(params.root.rootWithSep, opened.realPath)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + } finally { + await opened.handle.close().catch(() => {}); + } +} + +export async function resolveOpenedFileRealPathForHandle( + handle: FileHandle, + ioPath: string, +): Promise { + const handleStat = await handle.stat(); + const fdCandidates = + process.platform === "linux" + ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`] + : process.platform === "win32" + ? [] + : [`/dev/fd/${handle.fd}`]; + for (const fdPath of fdCandidates) { + try { + const fdRealPath = await fs.realpath(fdPath); + const fdRealStat = await fs.stat(fdRealPath); + if (sameFileIdentity(handleStat, fdRealStat)) { + return fdRealPath; + } + } catch { + // try next fd path + } + } + + try { + const ioRealPath = await fs.realpath(ioPath); + const ioRealStat = await fs.stat(ioRealPath); + if (sameFileIdentity(handleStat, ioRealStat)) { + return ioRealPath; + } + } catch (err) { + if (!isNotFoundPathError(err)) { + throw err; + } + } + const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath); + if (parentResolved) { + return parentResolved; + } + throw new FsSafeError("path-mismatch", "unable to resolve opened file path"); +} + +async function resolveOpenedFileRealPathFromParent( + handleStat: Stats, + ioPath: string, +): Promise { + let parentReal: string; + try { + parentReal = await fs.realpath(path.dirname(ioPath)); + } catch (err) { + if (isNotFoundPathError(err)) { + return null; + } + throw err; + } + + let entries: string[]; + try { + entries = await fs.readdir(parentReal); + } catch (err) { + if (isNotFoundPathError(err)) { + return null; + } + throw err; + } + + for (const entry of entries.toSorted()) { + const candidatePath = path.join(parentReal, entry); + try { + const candidateStat = await fs.lstat(candidatePath); + if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) { + return await fs.realpath(candidatePath); + } + } catch (err) { + if (!isNotFoundPathError(err)) { + throw err; + } + } + } + return null; +} + +async function openWritableFileInRoot( + root: RootContext, + params: { + relativePath: string; + mkdir?: boolean; + mode?: number; + truncateExisting?: boolean; + append?: boolean; + }, +): Promise { + const { rootReal, rootWithSep, resolved } = await resolvePathInRoot( + root, + params.relativePath, + ); + try { + await assertNoPathAliasEscape({ + absolutePath: resolved, + rootPath: rootReal, + boundaryLabel: "root", + }); + } catch (err) { + throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); + } + if (params.mkdir !== false) { + await fs.mkdir(path.dirname(resolved), { recursive: true }); + } + + let ioPath = resolved; + try { + const resolvedRealPath = await fs.realpath(resolved); + if (!isPathInside(rootWithSep, resolvedRealPath)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + ioPath = resolvedRealPath; + } catch (err) { + if (err instanceof FsSafeError) { + throw err; + } + if (!isNotFoundPathError(err)) { + throw err; + } + } + + const mode = params.mode ?? 0o600; + + let handle: FileHandle; + let createdForWrite = false; + const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS; + const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS; + try { + try { + handle = await fs.open(ioPath, existingFlags, mode); + } catch (err) { + if (!isNotFoundPathError(err)) { + throw err; + } + handle = await fs.open(ioPath, createFlags, mode); + createdForWrite = true; + } + } catch (err) { + if (isNotFoundPathError(err)) { + throw new FsSafeError("not-found", "file not found"); + } + if (isSymlinkOpenError(err)) { + throw new FsSafeError("symlink", "symlink open blocked", { cause: err }); + } + if (hasNodeErrorCode(err, "EISDIR")) { + throw new FsSafeError("not-file", "not a file", { cause: err }); + } + throw err; + } + + let realPathForCleanup: string | null = null; + try { + const stat = await handle.stat(); + if (!stat.isFile()) { + throw new FsSafeError("invalid-path", "path is not a regular file under root"); + } + if (stat.nlink > 1) { + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + + try { + const lstat = await fs.lstat(ioPath); + if (lstat.isSymbolicLink() || !lstat.isFile()) { + throw new FsSafeError( + lstat.isSymbolicLink() ? "symlink" : "not-file", + "path is not a regular file under root", + ); + } + if (!sameFileIdentity(stat, lstat)) { + throw new FsSafeError("path-mismatch", "path changed during write"); + } + } catch (err) { + if (!isNotFoundPathError(err)) { + throw err; + } + } + + const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath); + realPathForCleanup = realPath; + const realStat = await fs.stat(realPath); + if (!sameFileIdentity(stat, realStat)) { + throw new FsSafeError("path-mismatch", "path mismatch"); + } + if (realStat.nlink > 1) { + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + if (!isPathInside(rootWithSep, realPath)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + + // Truncate only after boundary and identity checks complete. This avoids + // irreversible side effects if a symlink target changes before validation. + if (params.append !== true && params.truncateExisting !== false && !createdForWrite) { + await handle.truncate(0); + } + return { + handle, + createdForWrite, + realPath, + stat, + [Symbol.asyncDispose]: async () => { + await closeHandleForDispose(handle); + }, + }; + } catch (err) { + const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError; + const cleanupPath = realPathForCleanup ?? ioPath; + await handle.close().catch(() => {}); + if (cleanupCreatedPath) { + await fs.rm(cleanupPath, { force: true }).catch(() => {}); + } + throw err; + } +} + +async function appendFileInRoot( + root: RootContext, + params: { + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; + mode?: number; + prependNewlineIfNeeded?: boolean; + }, +): Promise { + const target = await openWritableFileInRoot(root, { + relativePath: params.relativePath, + mkdir: params.mkdir, + mode: params.mode, + truncateExisting: false, + append: true, + }); + try { + let prefix = ""; + if ( + params.prependNewlineIfNeeded === true && + !target.createdForWrite && + target.stat.size > 0 && + ((typeof params.data === "string" && !params.data.startsWith("\n")) || + (Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a)) + ) { + const lastByte = Buffer.alloc(1); + const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.stat.size - 1); + if (bytesRead === 1 && lastByte[0] !== 0x0a) { + prefix = "\n"; + } + } + + if (typeof params.data === "string") { + await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8"); + return; + } + + const payload = + prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data; + await target.handle.appendFile(payload); + } finally { + await target.handle.close().catch(() => {}); + } +} + +async function removePathInRoot(root: RootContext, relativePath: string): Promise { + const resolved = await resolvePinnedRemovePathInRoot(root, relativePath); + if (process.platform === "win32") { + await removePathFallback(resolved); + return; + } + try { + await runPinnedPathHelper({ + operation: "remove", + rootPath: resolved.rootReal, + relativePath: resolved.relativePosix, + }); + } catch (error) { + if (isPinnedPathHelperSpawnError(error)) { + await removePathFallback(resolved); + return; + } + throw normalizePinnedPathError(error); + } +} + +async function mkdirPathInRoot( + root: RootContext, + params: { + relativePath: string; + allowRoot?: boolean; + }, +): Promise { + const resolved = await resolvePinnedPathInRoot(root, params); + if (process.platform === "win32") { + await mkdirPathFallback(resolved); + return; + } + try { + await runPinnedPathHelper({ + operation: "mkdirp", + rootPath: resolved.rootReal, + relativePath: resolved.relativePosix, + }); + } catch (error) { + if (isPinnedPathHelperSpawnError(error)) { + await mkdirPathFallback(resolved); + return; + } + throw normalizePinnedPathError(error); + } +} + +async function writeFileInRoot( + root: RootContext, + params: { + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; + mode?: number; + overwrite?: boolean; + }, +): Promise { + if (process.platform === "win32") { + await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => { + await writeFileFallback(root, params); + }); + return; + } + + const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode); + + await serializePathWrite(pinned.targetPath, async () => { + let identity; + try { + identity = await runPinnedWriteHelper({ + rootPath: pinned.rootReal, + relativeParentPath: pinned.relativeParentPath, + basename: pinned.basename, + mkdir: params.mkdir !== false, + mode: params.mode ?? pinned.mode, + overwrite: params.overwrite, + input: { + kind: "buffer", + data: params.data, + encoding: params.encoding, + }, + }); + } catch (error) { + if (params.overwrite === false && isAlreadyExistsError(error)) { + throw new FsSafeError("already-exists", "file already exists", { + cause: error instanceof Error ? error : undefined, + }); + } + throw normalizePinnedWriteError(error); + } + + try { + await verifyAtomicWriteResult({ + root, + targetPath: pinned.targetPath, + expectedIdentity: identity, + }); + } catch (err) { + emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`); + throw err; + } + }); +} + +async function copyFileInRoot( + root: RootContext, + params: { + sourcePath: string; + relativePath: string; + maxBytes?: number; + mkdir?: boolean; + mode?: number; + sourceHardlinks?: HardlinkPolicy; + }, +): Promise { + assertValidRootRelativePath(params.relativePath); + assertNoNulPathInput(params.sourcePath, "source path contains a NUL byte"); + const source = await openVerifiedLocalFile(params.sourcePath, { + hardlinks: params.sourceHardlinks, + }); + if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) { + await source.handle.close().catch(() => {}); + throw new FsSafeError( + "too-large", + `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`, + ); + } + + try { + if (process.platform === "win32") { + await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => { + await copyFileFallback(root, params, source); + }); + return; + } + + const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode); + await serializePathWrite(pinned.targetPath, async () => { + let identity; + try { + if (getFsSafePythonConfig().mode === "off") { + await copyFileFallback(root, params, source); + return; + } + identity = await runPinnedCopyHelper({ + rootPath: pinned.rootReal, + relativeParentPath: pinned.relativeParentPath, + basename: pinned.basename, + mkdir: params.mkdir !== false, + mode: pinned.mode, + overwrite: true, + maxBytes: params.maxBytes, + sourcePath: source.realPath, + sourceIdentity: { dev: source.stat.dev, ino: source.stat.ino }, + }); + } catch (error) { + if (canFallbackFromPythonError(error)) { + await copyFileFallback(root, params, source); + return; + } + throw normalizePinnedWriteError(error); + } + try { + await verifyAtomicWriteResult({ + root, + targetPath: pinned.targetPath, + expectedIdentity: identity, + }); + } catch (err) { + emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`); + throw err; + } + }); + } finally { + await source.handle.close().catch(() => {}); + } +} + +async function resolvePinnedWriteTargetInRoot( + root: RootContext, + relativePath: string, + requestedMode?: number, +): Promise<{ + rootReal: string; + targetPath: string; + relativeParentPath: string; + basename: string; + mode: number; +}> { + const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath); + try { + await assertNoPathAliasEscape({ + absolutePath: resolved, + rootPath: rootReal, + boundaryLabel: "root", + }); + } catch (err) { + throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); + } + + const relativeResolved = path.relative(rootReal, resolved); + if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + const relativePosix = relativeResolved + ? relativeResolved.split(path.sep).join(path.posix.sep) + : ""; + const basename = path.posix.basename(relativePosix); + if (!basename || basename === "." || basename === "/") { + throw new FsSafeError("invalid-path", "invalid target path"); + } + let mode = requestedMode ?? 0o600; + try { + const opened = await openFileInRoot(root, { + relativePath, + hardlinks: "reject", + nonBlockingRead: true, + }); + try { + mode = requestedMode ?? (opened.stat.mode & 0o777); + if (!isPathInside(rootWithSep, opened.realPath)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + } finally { + await opened.handle.close().catch(() => {}); + } + } catch (err) { + if (!(err instanceof FsSafeError) || err.code !== "not-found") { + throw err; + } + } + + return { + rootReal, + targetPath: resolved, + relativeParentPath: + path.posix.dirname(relativePosix) === "." ? "" : path.posix.dirname(relativePosix), + basename, + mode: mode || 0o600, + }; +} + +async function resolvePinnedPathInRoot( + root: RootContext, + params: { + relativePath: string; + allowRoot?: boolean; + }, +): Promise<{ rootReal: string; resolved: string; relativePosix: string }> { + return await resolvePinnedOperationPathInRoot(root, { + allowRoot: params.allowRoot, + relativePath: params.relativePath, + policy: PATH_ALIAS_POLICIES.strict, + }); +} + +async function resolvePinnedRemovePathInRoot( + root: RootContext, + relativePath: string, +): Promise<{ rootReal: string; resolved: string; relativePosix: string }> { + return await resolvePinnedOperationPathInRoot(root, { + relativePath, + policy: PATH_ALIAS_POLICIES.unlinkTarget, + }); +} + +async function resolvePinnedOperationPathInRoot( + root: RootContext, + params: { + relativePath: string; + policy: (typeof PATH_ALIAS_POLICIES)[keyof typeof PATH_ALIAS_POLICIES]; + allowRoot?: boolean; + }, +): Promise<{ rootReal: string; resolved: string; relativePosix: string }> { + const resolved = await resolvePinnedRootPathInRoot(root, { + relativePath: params.relativePath, + policy: params.policy, + }); + const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath); + if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) { + return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" }; + } + if ( + relativeResolved === "" || + relativeResolved === "." || + relativeResolved.startsWith("..") || + path.isAbsolute(relativeResolved) + ) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep); + if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) { + throw new FsSafeError("outside-workspace", "file is outside workspace root"); + } + + return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix }; +} + +async function resolvePinnedRootPathInRoot( + root: RootContext, + params: { + relativePath: string; + policy: (typeof PATH_ALIAS_POLICIES)[keyof typeof PATH_ALIAS_POLICIES]; + }, +): Promise<{ rootReal: string; rootWithSep: string; canonicalPath: string }> { + const rootReal = root.rootReal; + let resolved; + try { + resolved = await resolveRootPath({ + absolutePath: path.resolve(rootReal, await expandRelativePathWithHome(params.relativePath)), + rootPath: rootReal, + rootCanonicalPath: rootReal, + boundaryLabel: "root", + policy: params.policy, + }); + } catch (err) { + throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); + } + const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath); + return { + rootReal: resolved.rootCanonicalPath, + rootWithSep, + canonicalPath: resolved.canonicalPath, + }; +} + +async function removePathFallback(resolved: { resolved: string }): Promise { + await fs.rm(resolved.resolved); +} + +async function mkdirPathFallback(resolved: { resolved: string }): Promise { + await fs.mkdir(resolved.resolved, { recursive: true }); +} + +function pathStatFromStats(stat: Stats): PathStat { + return { + dev: Number(stat.dev), + gid: Number(stat.gid), + ino: Number(stat.ino), + isDirectory: stat.isDirectory(), + isFile: stat.isFile(), + isSymbolicLink: stat.isSymbolicLink(), + mode: stat.mode, + mtimeMs: stat.mtimeMs, + nlink: stat.nlink, + size: stat.size, + uid: stat.uid, + }; +} + +async function statPathFallback(root: RootContext, relativePath: string): Promise { + const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true }); + try { + return pathStatFromStats(await fs.lstat(resolved.resolved)); + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "file not found", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } +} + +async function listPathFallback( + root: RootContext, + relativePath: string, + withFileTypes: boolean, +): Promise { + const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true }); + try { + const names = await fs.readdir(resolved.resolved); + const sortedNames = names.toSorted(); + if (!withFileTypes) { + return sortedNames; + } + const entries: DirEntry[] = []; + for (const name of sortedNames) { + entries.push({ + name, + ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))), + }); + } + return entries; + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "directory not found", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } +} + +async function movePathFallback( + root: RootContext, + params: { + fromRelative: string; + toRelative: string; + overwrite: boolean; + }, +): Promise { + const source = await resolvePathInRoot(root, params.fromRelative); + await resolvePinnedRootPathInRoot(root, { + relativePath: params.fromRelative, + policy: PATH_ALIAS_POLICIES.strict, + }); + const target = await resolvePathInRoot(root, params.toRelative); + await resolvePinnedRootPathInRoot(root, { + relativePath: params.toRelative, + policy: PATH_ALIAS_POLICIES.unlinkTarget, + }); + try { + await assertNoPathAliasEscape({ + absolutePath: target.resolved, + rootPath: target.rootReal, + boundaryLabel: "root", + }); + } catch (error) { + throw new FsSafeError("path-alias", "path alias escape blocked", { + cause: error instanceof Error ? error : undefined, + }); + } + + let sourceStat: Stats; + try { + sourceStat = await fs.lstat(source.resolved); + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "file not found", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } + if (sourceStat.isSymbolicLink()) { + throw new FsSafeError("symlink", "symlink not allowed"); + } + if (sourceStat.isFile() && sourceStat.nlink > 1) { + throw new FsSafeError("hardlink", "hardlinked path not allowed"); + } + + if (!params.overwrite) { + try { + await fs.lstat(target.resolved); + throw new FsSafeError("already-exists", "destination exists"); + } catch (error) { + if (error instanceof FsSafeError) { + throw error; + } + if (!isNotFoundPathError(error)) { + throw error; + } + } + } + + try { + await fs.rename(source.resolved, target.resolved); + } catch (error) { + if (isNotFoundPathError(error)) { + throw new FsSafeError("not-found", "file not found", { + cause: error instanceof Error ? error : undefined, + }); + } + if (hasNodeErrorCode(error, "EEXIST")) { + throw new FsSafeError("already-exists", "destination exists", { + cause: error instanceof Error ? error : undefined, + }); + } + throw error; + } +} + +async function writeFileFallback( + root: RootContext, + params: { + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; + mode?: number; + overwrite?: boolean; + }, +): Promise { + if (params.overwrite === false) { + await writeMissingFileFallback(root, params); + return; + } + + const target = await openWritableFileInRoot(root, { + relativePath: params.relativePath, + mkdir: params.mkdir, + mode: params.mode, + truncateExisting: false, + }); + const destinationPath = target.realPath; + const mode = params.mode ?? (target.stat.mode & 0o777); + await target.handle.close().catch(() => {}); + let tempPath: string | null = null; + let unregisterTempPath: (() => void) | null = null; + try { + tempPath = buildAtomicWriteTempPath(destinationPath); + unregisterTempPath = registerTempPathForExit(tempPath); + const writtenStat = await writeTempFileForAtomicReplace({ + tempPath, + data: params.data, + encoding: params.encoding, + mode: mode || 0o600, + }); + await fs.rename(tempPath, destinationPath); + tempPath = null; + unregisterTempPath(); + unregisterTempPath = null; + try { + await verifyAtomicWriteResult({ + root, + targetPath: destinationPath, + expectedIdentity: writtenStat, + }); + } catch (err) { + emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`); + throw err; + } + } finally { + if (tempPath) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } + unregisterTempPath?.(); + } +} + +async function writeMissingFileFallback( + root: RootContext, + params: { + relativePath: string; + data: string | Buffer; + encoding?: BufferEncoding; + mkdir?: boolean; + mode?: number; + }, +): Promise { + const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath); + try { + await assertNoPathAliasEscape({ + absolutePath: resolved, + rootPath: rootReal, + boundaryLabel: "root", + }); + } catch (err) { + throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); + } + if (params.mkdir !== false) { + await fs.mkdir(path.dirname(resolved), { recursive: true }); + } + + let handle: FileHandle | null = null; + let created = false; + try { + handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600); + created = true; + if (typeof params.data === "string") { + await handle.writeFile(params.data, params.encoding ?? "utf8"); + } else { + await handle.writeFile(params.data); + } + const writtenStat = await handle.stat(); + await handle.close(); + handle = null; + await verifyAtomicWriteResult({ + root, + targetPath: resolved, + expectedIdentity: writtenStat, + }); + created = false; + } catch (err) { + if (hasNodeErrorCode(err, "EEXIST")) { + throw new FsSafeError("already-exists", "file already exists", { + cause: err instanceof Error ? err : undefined, + }); + } + throw err; + } finally { + await handle?.close().catch(() => undefined); + if (created) { + await fs.rm(resolved, { force: true }).catch(() => undefined); + } + } +} + +async function copyFileFallback( + root: RootContext, + params: { + sourcePath: string; + relativePath: string; + maxBytes?: number; + mkdir?: boolean; + mode?: number; + sourceHardlinks?: HardlinkPolicy; + }, + source: OpenResult, +): Promise { + let target: WritableOpenResult | null = null; + let sourceClosedByStream = false; + let targetClosedByUs = false; + let tempHandle: FileHandle | null = null; + let tempPath: string | null = null; + let unregisterTempPath: (() => void) | null = null; + let tempClosedByStream = false; + try { + target = await openWritableFileInRoot(root, { + relativePath: params.relativePath, + mkdir: params.mkdir, + mode: params.mode, + truncateExisting: false, + }); + const destinationPath = target.realPath; + const mode = params.mode ?? (target.stat.mode & 0o777); + await target.handle.close().catch(() => {}); + targetClosedByUs = true; + + tempPath = buildAtomicWriteTempPath(destinationPath); + unregisterTempPath = registerTempPathForExit(tempPath); + tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600); + const sourceStream = createBoundedReadStream(source, params.maxBytes); + const targetStream = tempHandle.createWriteStream(); + sourceStream.once("close", () => { + sourceClosedByStream = true; + }); + targetStream.once("close", () => { + tempClosedByStream = true; + }); + await pipeline(sourceStream, targetStream); + const writtenStat = await fs.stat(tempPath); + if (!tempClosedByStream) { + await tempHandle.close().catch(() => {}); + tempClosedByStream = true; + } + tempHandle = null; + await fs.rename(tempPath, destinationPath); + tempPath = null; + unregisterTempPath(); + unregisterTempPath = null; + try { + await verifyAtomicWriteResult({ + root, + targetPath: destinationPath, + expectedIdentity: writtenStat, + }); + } catch (err) { + emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`); + throw err; + } + } catch (err) { + if (target?.createdForWrite) { + await fs.rm(target.realPath, { force: true }).catch(() => {}); + } + throw err; + } finally { + if (!sourceClosedByStream) { + await source.handle.close().catch(() => {}); + } + if (tempHandle && !tempClosedByStream) { + await tempHandle.close().catch(() => {}); + } + if (tempPath) { + await fs.rm(tempPath, { force: true }).catch(() => {}); + } + unregisterTempPath?.(); + if (target && !targetClosedByUs) { + await target.handle.close().catch(() => {}); + } + } +} diff --git a/src/root.ts b/src/root.ts index 3fab3f2..f505aa9 100644 --- a/src/root.ts +++ b/src/root.ts @@ -1,1835 +1,25 @@ -import { randomUUID } from "node:crypto"; -import type { Stats } from "node:fs"; -import { constants as fsConstants } from "node:fs"; -import type { FileHandle } from "node:fs/promises"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { Transform } from "node:stream"; -import { pipeline } from "node:stream/promises"; -import { FsSafeError } from "./errors.js"; -import { sameFileIdentity } from "./file-identity.js"; -import { isPinnedPathHelperSpawnError, runPinnedPathHelper } from "./pinned-path.js"; -import { runPinnedCopyHelper, runPinnedWriteHelper } from "./pinned-write.js"; -import { canFallbackFromPythonError, getFsSafePythonConfig } from "./pinned-python-config.js"; -import { expandHomePrefix } from "./home-dir.js"; -import { assertNoPathAliasEscape, PATH_ALIAS_POLICIES } from "./path-policy.js"; -import { - assertNoNulPathInput, - hasNodeErrorCode, - isNotFoundPathError, - isPathInside, - isSymlinkOpenError, -} from "./path.js"; -import { - helperReaddir, - helperStat, - runPinnedHelper, -} from "./pinned-helper.js"; -import { resolveRootPath } from "./root-path.js"; -import { getFsSafeTestHooks } from "./test-hooks.js"; -import type { DirEntry, PathStat } from "./types.js"; -import { registerTempPathForExit } from "./temp-cleanup.js"; -import { serializePathWrite } from "./write-queue.js"; - -export type OpenResult = { - handle: FileHandle; - realPath: string; - stat: Stats; - [Symbol.asyncDispose](): Promise; -}; - -export type ReadResult = { - buffer: Buffer; - realPath: string; - stat: Stats; -}; - -type RootContext = { - rootDir: string; - rootReal: string; - rootWithSep: string; -}; - -export type RootOptions = { - rootDir: string; - defaults?: RootDefaults; -}; - -export type SymlinkPolicy = "reject" | "follow-within-root"; -export type HardlinkPolicy = "reject" | "allow"; -export type WritableOpenMode = "replace" | "append" | "update"; - -export type RootDefaults = { - hardlinks?: HardlinkPolicy; - maxBytes?: number; - mkdir?: boolean; - mode?: number; - nonBlockingRead?: boolean; - symlinks?: SymlinkPolicy; -}; - -export type RootReadOptions = Pick< - RootDefaults, - "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks" ->; - -export type RootOpenOptions = Omit; - -export type RootWriteOptions = Pick & { - encoding?: BufferEncoding; - overwrite?: boolean; -}; - -export type RootOpenWritableOptions = Pick & { - writeMode?: WritableOpenMode; -}; - -export type RootCopyOptions = Pick & { - sourceHardlinks?: HardlinkPolicy; -}; - -export type RootWriteJsonOptions = RootWriteOptions & { - replacer?: Parameters[1]; - space?: Parameters[2]; - trailingNewline?: boolean; -}; - -export type RootCreateOptions = Omit; -export type RootCreateJsonOptions = Omit; - -export type RootAppendOptions = RootWriteOptions & { - prependNewlineIfNeeded?: boolean; -}; - -type RootReadParams = RootReadOptions; - -function logWarn(message: string): void { - if (process.env.FS_SAFE_DEBUG_WARNINGS === "1") { - console.warn(message); - } -} - -const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; -const NONBLOCK_OPEN_FLAG = "O_NONBLOCK" in fsConstants ? fsConstants.O_NONBLOCK : 0; -const OPEN_READ_FLAGS = fsConstants.O_RDONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); -const OPEN_READ_NONBLOCK_FLAGS = OPEN_READ_FLAGS | NONBLOCK_OPEN_FLAG; -const OPEN_READ_FOLLOW_FLAGS = fsConstants.O_RDONLY; -const OPEN_READ_FOLLOW_NONBLOCK_FLAGS = OPEN_READ_FOLLOW_FLAGS | NONBLOCK_OPEN_FLAG; -const OPEN_WRITE_EXISTING_FLAGS = - fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); -const OPEN_WRITE_CREATE_FLAGS = - fsConstants.O_WRONLY | - fsConstants.O_CREAT | - fsConstants.O_EXCL | - (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); -const OPEN_APPEND_EXISTING_FLAGS = - fsConstants.O_RDWR | fsConstants.O_APPEND | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); -const OPEN_APPEND_CREATE_FLAGS = - fsConstants.O_RDWR | - fsConstants.O_APPEND | - fsConstants.O_CREAT | - fsConstants.O_EXCL | - (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); - -export const DEFAULT_ROOT_MAX_BYTES = 16 * 1024 * 1024; - -function closeHandleForDispose(handle: FileHandle): Promise { - return handle.close().catch(() => undefined); -} - -function openResult(params: { - handle: FileHandle; - realPath: string; - stat: Stats; -}): OpenResult { - return { - handle: params.handle, - realPath: params.realPath, - stat: params.stat, - [Symbol.asyncDispose]: async () => { - await closeHandleForDispose(params.handle); - }, - }; -} - -const ensureTrailingSep = (value: string) => (value.endsWith(path.sep) ? value : value + path.sep); - -function assertValidRootRelativePath(relativePath: string): void { - assertNoNulPathInput(relativePath, "relative path contains a NUL byte"); -} - -let cachedHomePath: { raw: string; real: string } | undefined; - -async function expandRelativePathWithHome(relativePath: string): Promise { - const rawHome = process.env.HOME || process.env.USERPROFILE || os.homedir(); - if (cachedHomePath?.raw !== rawHome) { - let realHome = rawHome; - try { - realHome = await fs.realpath(rawHome); - } catch { - // If the home dir cannot be canonicalized, keep lexical expansion behavior. - } - cachedHomePath = { raw: rawHome, real: realHome }; - } - return expandHomePrefix(relativePath, { home: cachedHomePath.real }); -} - -async function openVerifiedLocalFile( - filePath: string, - options?: { - hardlinks?: HardlinkPolicy; - nonBlockingRead?: boolean; - symlinks?: SymlinkPolicy; - }, -): Promise { - const fsSafeTestHooks = getFsSafeTestHooks(); - // Reject directories before opening so we never surface EISDIR to callers (e.g. tool - // results that get sent to messaging channels). See openclaw/openclaw#31186. - try { - const preStat = await fs.lstat(filePath); - if (preStat.isDirectory()) { - throw new FsSafeError("not-file", "not a file"); - } - await fsSafeTestHooks?.afterPreOpenLstat?.(filePath); - } catch (err) { - if (err instanceof FsSafeError) { - throw err; - } - // ENOENT and other lstat errors: fall through and let fs.open handle. - } - - let handle: FileHandle; - try { - const openFlags = options?.symlinks === "follow-within-root" - ? options?.nonBlockingRead - ? OPEN_READ_FOLLOW_NONBLOCK_FLAGS - : OPEN_READ_FOLLOW_FLAGS - : options?.nonBlockingRead - ? OPEN_READ_NONBLOCK_FLAGS - : OPEN_READ_FLAGS; - await fsSafeTestHooks?.beforeOpen?.(filePath, openFlags); - handle = await fs.open(filePath, openFlags); - try { - await fsSafeTestHooks?.afterOpen?.(filePath, handle); - } catch (err) { - await handle.close().catch(() => {}); - throw err; - } - } catch (err) { - if (isNotFoundPathError(err)) { - throw new FsSafeError("not-found", "file not found"); - } - if (isSymlinkOpenError(err)) { - throw new FsSafeError("symlink", "symlink open blocked", { cause: err }); - } - // Defensive: if open still throws EISDIR (e.g. race), sanitize so it never leaks. - if (hasNodeErrorCode(err, "EISDIR")) { - throw new FsSafeError("not-file", "not a file"); - } - throw err; - } - - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw new FsSafeError("not-file", "not a file"); - } - if (options?.hardlinks === "reject" && stat.nlink > 1) { - throw new FsSafeError("hardlink", "hardlinked path not allowed"); - } - - if (options?.symlinks === "follow-within-root") { - const pathStat = await fs.stat(filePath); - if (!sameFileIdentity(stat, pathStat)) { - throw new FsSafeError("path-mismatch", "path changed during read"); - } - } else { - const pathStat = await fs.lstat(filePath); - if (pathStat.isSymbolicLink()) { - throw new FsSafeError("symlink", "symlink not allowed"); - } - if (!sameFileIdentity(stat, pathStat)) { - throw new FsSafeError("path-mismatch", "path changed during read"); - } - } - - const realPath = await resolveOpenedFileRealPathForHandle(handle, filePath); - const realStat = await fs.stat(realPath); - if (options?.hardlinks === "reject" && realStat.nlink > 1) { - throw new FsSafeError("hardlink", "hardlinked path not allowed"); - } - if (!sameFileIdentity(stat, realStat)) { - throw new FsSafeError("path-mismatch", "path mismatch"); - } - - return openResult({ handle, realPath, stat }); - } catch (err) { - await handle.close().catch(() => {}); - if (err instanceof FsSafeError) { - throw err; - } - if (isNotFoundPathError(err)) { - throw new FsSafeError("not-found", "file not found"); - } - throw err; - } -} - -async function resolveRootContext(rootDir: string): Promise { - assertNoNulPathInput(rootDir, "root dir contains a NUL byte"); - let rootReal: string; - try { - rootReal = await fs.realpath(rootDir); - const rootStat = await fs.stat(rootReal); - if (!rootStat.isDirectory()) { - throw new FsSafeError("invalid-path", "root dir is not a directory"); - } - } catch (err) { - if (err instanceof FsSafeError) { - throw err; - } - if (isNotFoundPathError(err)) { - throw new FsSafeError("not-found", "root dir not found"); - } - throw err; - } - return { - rootDir: path.resolve(rootDir), - rootReal, - rootWithSep: ensureTrailingSep(rootReal), - }; -} - -async function resolvePathInRoot( - root: RootContext, - relativePath: string, -): Promise<{ rootReal: string; rootWithSep: string; resolved: string }> { - assertValidRootRelativePath(relativePath); - const expanded = await expandRelativePathWithHome(relativePath); - const resolved = path.resolve(root.rootWithSep, expanded); - if (!isPathInside(root.rootWithSep, resolved)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - return { rootReal: root.rootReal, rootWithSep: root.rootWithSep, resolved }; -} - -async function resolvePathWithinRoot(params: { - rootDir: string; - relativePath: string; -}): Promise<{ rootReal: string; rootWithSep: string; resolved: string }> { - return await resolvePathInRoot( - await resolveRootContext(params.rootDir), - params.relativePath, - ); -} - -export interface Root { - readonly rootDir: string; - readonly rootReal: string; - readonly rootWithSep: string; - readonly defaults: RootDefaults; - - resolve(relativePath: string): Promise; - open(relativePath: string, options?: RootOpenOptions): Promise; - read(relativePath: string, options?: RootReadOptions): Promise; - readBytes(relativePath: string, options?: RootReadOptions): Promise; - readText( - relativePath: string, - options?: RootReadOptions & { encoding?: BufferEncoding }, - ): Promise; - readJson( - relativePath: string, - options?: RootReadOptions & { encoding?: BufferEncoding }, - ): Promise; - readAbsolute(filePath: string, options?: RootReadOptions): Promise; - reader(options?: RootReadOptions): (filePath: string) => Promise; - openWritable( - relativePath: string, - options?: RootOpenWritableOptions, - ): Promise; - append( - relativePath: string, - data: string | Buffer, - options?: RootAppendOptions, - ): Promise; - remove(relativePath: string): Promise; - mkdir(relativePath: string): Promise; - ensureRoot(): Promise; - write( - relativePath: string, - data: string | Buffer, - options?: RootWriteOptions, - ): Promise; - create( - relativePath: string, - data: string | Buffer, - options?: RootCreateOptions, - ): Promise; - writeJson( - relativePath: string, - data: unknown, - options?: RootWriteJsonOptions, - ): Promise; - createJson( - relativePath: string, - data: unknown, - options?: RootCreateJsonOptions, - ): Promise; - copyIn(relativePath: string, sourcePath: string, options?: RootCopyOptions): Promise; - exists(relativePath: string): Promise; - stat(relativePath: string): Promise; - list(relativePath: string, options?: { withFileTypes?: false }): Promise; - list(relativePath: string, options: { withFileTypes: true }): Promise; - move( - fromRelative: string, - toRelative: string, - options?: { overwrite?: boolean }, - ): Promise; -} - -class RootHandle implements Root { - readonly rootDir: string; - readonly rootReal: string; - readonly rootWithSep: string; - readonly defaults: RootDefaults; - - constructor(context: RootContext, defaults: RootDefaults = {}) { - this.rootDir = context.rootDir; - this.rootReal = context.rootReal; - this.rootWithSep = context.rootWithSep; - this.defaults = defaults; - } - - private get context(): RootContext { - return { - rootDir: this.rootDir, - rootReal: this.rootReal, - rootWithSep: this.rootWithSep, - }; - } - - async resolve(relativePath: string): Promise { - return (await resolvePathInRoot(this.context, relativePath)).resolved; - } - - async open(relativePath: string, options: RootOpenOptions = {}): Promise { - return await openFileInRoot(this.context, { - relativePath, - ...readDefaults(this.defaults), - ...options, - }); - } - - async read( - relativePath: string, - options: RootReadOptions = {}, - ): Promise { - return await readFileInRoot(this.context, { - relativePath, - ...readDefaults(this.defaults), - ...options, - }); - } - - async readBytes(relativePath: string, options: RootReadOptions = {}): Promise { - return (await this.read(relativePath, options)).buffer; - } - - async readText( - relativePath: string, - options: RootReadOptions & { encoding?: BufferEncoding } = {}, - ): Promise { - const { encoding = "utf8", ...readOptions } = options; - return (await this.read(relativePath, readOptions)).buffer.toString(encoding); - } - - async readJson( - relativePath: string, - options: RootReadOptions & { encoding?: BufferEncoding } = {}, - ): Promise { - return JSON.parse(await this.readText(relativePath, options)) as T; - } - - async readAbsolute( - filePath: string, - options: RootReadOptions = {}, - ): Promise { - return await readPathInRoot(this.context, { - filePath, - ...readDefaults(this.defaults), - ...options, - }); - } - - reader(options: RootReadOptions = {}) { - return async (filePath: string): Promise => { - return (await this.readAbsolute(filePath, options)).buffer; - }; - } - - async openWritable( - relativePath: string, - options: RootOpenWritableOptions = {}, - ): Promise { - const writeMode = options.writeMode ?? "replace"; - return await openWritableFileInRoot(this.context, { - relativePath, - mkdir: this.defaults.mkdir, - mode: this.defaults.mode, - ...options, - append: writeMode === "append", - truncateExisting: writeMode === "replace", - }); - } - - async append( - relativePath: string, - data: string | Buffer, - options: RootAppendOptions = {}, - ): Promise { - await appendFileInRoot(this.context, { - relativePath, - data, - mkdir: this.defaults.mkdir, - mode: this.defaults.mode, - ...options, - }); - } - - async remove(relativePath: string): Promise { - assertValidRootRelativePath(relativePath); - await removePathInRoot(this.context, relativePath); - } - - async mkdir(relativePath: string): Promise { - assertValidRootRelativePath(relativePath); - await mkdirPathInRoot(this.context, { relativePath }); - } - - async ensureRoot(): Promise { - await mkdirPathInRoot(this.context, { relativePath: "", allowRoot: true }); - } - - async write( - relativePath: string, - data: string | Buffer, - options: RootWriteOptions = {}, - ): Promise { - await writeFileInRoot(this.context, { - relativePath, - data, - mkdir: this.defaults.mkdir, - mode: this.defaults.mode, - ...options, - }); - } - - async create( - relativePath: string, - data: string | Buffer, - options: RootCreateOptions = {}, - ): Promise { - await writeFileInRoot(this.context, { - relativePath, - data, - mkdir: this.defaults.mkdir, - mode: this.defaults.mode, - ...options, - overwrite: false, - }); - } - - async writeJson( - relativePath: string, - data: unknown, - options: RootWriteJsonOptions = {}, - ): Promise { - const { replacer, space, trailingNewline = true, ...writeOptions } = options; - const json = JSON.stringify(data, replacer, space); - await this.write(relativePath, trailingNewline ? `${json}\n` : json, writeOptions); - } - - async createJson( - relativePath: string, - data: unknown, - options: RootCreateJsonOptions = {}, - ): Promise { - const { replacer, space, trailingNewline = true, ...writeOptions } = options; - const json = JSON.stringify(data, replacer, space); - await this.create(relativePath, trailingNewline ? `${json}\n` : json, writeOptions); - } - - async copyIn( - relativePath: string, - sourcePath: string, - options: RootCopyOptions = {}, - ): Promise { - assertValidRootRelativePath(relativePath); - await copyFileInRoot(this.context, { - sourcePath, - relativePath, - maxBytes: this.defaults.maxBytes, - mkdir: this.defaults.mkdir, - mode: this.defaults.mode, - ...options, - }); - } - - async exists(relativePath: string): Promise { - try { - await this.stat(relativePath); - return true; - } catch (err) { - if (err instanceof FsSafeError && err.code === "not-found") { - return false; - } - throw err; - } - } - - async stat(relativePath: string): Promise { - assertValidRootRelativePath(relativePath); - try { - return await helperStat(this.rootReal, relativePath); - } catch (error) { - if (canFallbackFromPythonError(error)) { - return await statPathFallback(this.context, relativePath); - } - throw error; - } - } - - async list(relativePath: string, options?: { withFileTypes?: false }): Promise; - async list(relativePath: string, options: { withFileTypes: true }): Promise; - async list( - relativePath: string, - options: { withFileTypes?: boolean } = {}, - ): Promise { - assertValidRootRelativePath(relativePath); - try { - return options.withFileTypes === true - ? await helperReaddir(this.rootReal, relativePath, true) - : await helperReaddir(this.rootReal, relativePath, false); - } catch (error) { - if (canFallbackFromPythonError(error)) { - return await listPathFallback(this.context, relativePath, options.withFileTypes === true); - } - throw error; - } - } - - async move( - fromRelative: string, - toRelative: string, - options: { overwrite?: boolean } = {}, - ): Promise { - assertValidRootRelativePath(fromRelative); - assertValidRootRelativePath(toRelative); - try { - await runPinnedHelper("rename", this.rootReal, { - from: fromRelative, - overwrite: options.overwrite ?? false, - to: toRelative, - }); - } catch (error) { - if (canFallbackFromPythonError(error)) { - await movePathFallback(this.context, { - fromRelative, - overwrite: options.overwrite ?? false, - toRelative, - }); - return; - } - throw error; - } - } -} - -function readDefaults(defaults: RootDefaults): RootReadParams { - return { - hardlinks: defaults.hardlinks, - maxBytes: defaults.maxBytes ?? DEFAULT_ROOT_MAX_BYTES, - nonBlockingRead: defaults.nonBlockingRead, - symlinks: defaults.symlinks, - }; -} - -export async function root( - rootDir: string, - defaults: RootDefaults = {}, -): Promise { - return new RootHandle(await resolveRootContext(rootDir), defaults); -} - -async function openFileInRoot( - root: RootContext, - params: { - relativePath: string; - hardlinks?: HardlinkPolicy; - nonBlockingRead?: boolean; - symlinks?: SymlinkPolicy; - }, -): Promise { - const { rootWithSep, resolved } = await resolvePathInRoot(root, params.relativePath); - - let opened: OpenResult; - try { - opened = await openVerifiedLocalFile(resolved, { - nonBlockingRead: params.nonBlockingRead, - symlinks: params.symlinks, - }); - } catch (err) { - if (err instanceof FsSafeError) { - throw err; - } - throw err; - } - - if (params.hardlinks !== "allow" && opened.stat.nlink > 1) { - await opened.handle.close().catch(() => {}); - throw new FsSafeError("hardlink", "hardlinked path not allowed"); - } - - if (!isPathInside(rootWithSep, opened.realPath)) { - await opened.handle.close().catch(() => {}); - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - - return opened; -} - -async function readFileInRoot( - root: RootContext, - params: { - relativePath: string; - hardlinks?: HardlinkPolicy; - nonBlockingRead?: boolean; - symlinks?: SymlinkPolicy; - maxBytes?: number; - }, -): Promise { - const opened = await openFileInRoot(root, params); - try { - return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes }); - } finally { - await opened.handle.close().catch(() => {}); - } -} - -async function readPathInRoot( - root: RootContext, - params: { - filePath: string; - hardlinks?: HardlinkPolicy; - maxBytes?: number; - nonBlockingRead?: boolean; - symlinks?: SymlinkPolicy; - }, -): Promise { - const rootDir = root.rootDir; - const candidatePath = path.isAbsolute(params.filePath) - ? path.resolve(params.filePath) - : path.resolve(rootDir, params.filePath); - const relativePath = path.relative(rootDir, candidatePath); - return await readFileInRoot(root, { - relativePath, - hardlinks: params.hardlinks, - maxBytes: params.maxBytes, - nonBlockingRead: params.nonBlockingRead, - symlinks: params.symlinks, - }); -} - -export async function readLocalFileSafely(params: { - filePath: string; - maxBytes?: number; -}): Promise { - const opened = await openLocalFileSafely({ filePath: params.filePath }); - try { - return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes }); - } finally { - await opened.handle.close().catch(() => {}); - } -} - -export async function openLocalFileSafely(params: { filePath: string }): Promise { - assertNoNulPathInput(params.filePath, "file path contains a NUL byte"); - return await openVerifiedLocalFile(params.filePath); -} - -async function readOpenedFileSafely(params: { - opened: OpenResult; - maxBytes?: number; -}): Promise { - if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) { - throw new FsSafeError( - "too-large", - `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`, - ); - } - const buffer = await params.opened.handle.readFile(); - if (params.maxBytes !== undefined && buffer.byteLength > params.maxBytes) { - throw new FsSafeError( - "too-large", - `file exceeds limit of ${params.maxBytes} bytes (got ${buffer.byteLength})`, - ); - } - return { - buffer, - realPath: params.opened.realPath, - stat: params.opened.stat, - }; -} - -export type WritableOpenResult = { - handle: FileHandle; - createdForWrite: boolean; - realPath: string; - stat: Stats; - [Symbol.asyncDispose](): Promise; -}; - -function emitWriteBoundaryWarning(reason: string) { - logWarn(`security: fs-safe write boundary warning (${reason})`); -} - -function buildAtomicWriteTempPath(targetPath: string): string { - const dir = path.dirname(targetPath); - const base = path.basename(targetPath); - return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`); -} - -function rootWriteQueueKey(root: RootContext, relativePath: string): string { - return `${root.rootReal}\0${relativePath}`; -} - -function createMaxBytesTransform(maxBytes: number): Transform { - let bytes = 0; - return new Transform({ - transform(chunk, _encoding, callback) { - const buffer = chunk instanceof Buffer ? chunk : Buffer.from(chunk as Uint8Array); - bytes += buffer.byteLength; - if (bytes > maxBytes) { - callback( - new FsSafeError( - "too-large", - `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`, - ), - ); - return; - } - callback(null, buffer); - }, - }); -} - -function createBoundedReadStream(opened: OpenResult, maxBytes: number | undefined) { - const stream = opened.handle.createReadStream(); - return maxBytes === undefined ? stream : stream.pipe(createMaxBytesTransform(maxBytes)); -} - -async function writeTempFileForAtomicReplace(params: { - tempPath: string; - data: string | Buffer; - encoding?: BufferEncoding; - mode: number; -}): Promise { - const tempHandle = await fs.open(params.tempPath, OPEN_WRITE_CREATE_FLAGS, params.mode); - try { - if (typeof params.data === "string") { - await tempHandle.writeFile(params.data, params.encoding ?? "utf8"); - } else { - await tempHandle.writeFile(params.data); - } - return await tempHandle.stat(); - } finally { - await tempHandle.close().catch(() => {}); - } -} - -async function verifyAtomicWriteResult(params: { - root: RootContext; - targetPath: string; - expectedIdentity: { dev: number | bigint; ino: number | bigint }; -}): Promise { - const opened = await openVerifiedLocalFile(params.targetPath, { hardlinks: "reject" }); - try { - if (!sameFileIdentity(opened.stat, params.expectedIdentity)) { - throw new FsSafeError("path-mismatch", "path changed during write"); - } - if (!isPathInside(params.root.rootWithSep, opened.realPath)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - } finally { - await opened.handle.close().catch(() => {}); - } -} - -export async function resolveOpenedFileRealPathForHandle( - handle: FileHandle, - ioPath: string, -): Promise { - const handleStat = await handle.stat(); - const fdCandidates = - process.platform === "linux" - ? [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`] - : process.platform === "win32" - ? [] - : [`/dev/fd/${handle.fd}`]; - for (const fdPath of fdCandidates) { - try { - const fdRealPath = await fs.realpath(fdPath); - const fdRealStat = await fs.stat(fdRealPath); - if (sameFileIdentity(handleStat, fdRealStat)) { - return fdRealPath; - } - } catch { - // try next fd path - } - } - - try { - const ioRealPath = await fs.realpath(ioPath); - const ioRealStat = await fs.stat(ioRealPath); - if (sameFileIdentity(handleStat, ioRealStat)) { - return ioRealPath; - } - } catch (err) { - if (!isNotFoundPathError(err)) { - throw err; - } - } - const parentResolved = await resolveOpenedFileRealPathFromParent(handleStat, ioPath); - if (parentResolved) { - return parentResolved; - } - throw new FsSafeError("path-mismatch", "unable to resolve opened file path"); -} - -async function resolveOpenedFileRealPathFromParent( - handleStat: Stats, - ioPath: string, -): Promise { - let parentReal: string; - try { - parentReal = await fs.realpath(path.dirname(ioPath)); - } catch (err) { - if (isNotFoundPathError(err)) { - return null; - } - throw err; - } - - let entries: string[]; - try { - entries = await fs.readdir(parentReal); - } catch (err) { - if (isNotFoundPathError(err)) { - return null; - } - throw err; - } - - for (const entry of entries.toSorted()) { - const candidatePath = path.join(parentReal, entry); - try { - const candidateStat = await fs.lstat(candidatePath); - if (candidateStat.isFile() && sameFileIdentity(handleStat, candidateStat)) { - return await fs.realpath(candidatePath); - } - } catch (err) { - if (!isNotFoundPathError(err)) { - throw err; - } - } - } - return null; -} - -async function openWritableFileInRoot( - root: RootContext, - params: { - relativePath: string; - mkdir?: boolean; - mode?: number; - truncateExisting?: boolean; - append?: boolean; - }, -): Promise { - const { rootReal, rootWithSep, resolved } = await resolvePathInRoot( - root, - params.relativePath, - ); - try { - await assertNoPathAliasEscape({ - absolutePath: resolved, - rootPath: rootReal, - boundaryLabel: "root", - }); - } catch (err) { - throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); - } - if (params.mkdir !== false) { - await fs.mkdir(path.dirname(resolved), { recursive: true }); - } - - let ioPath = resolved; - try { - const resolvedRealPath = await fs.realpath(resolved); - if (!isPathInside(rootWithSep, resolvedRealPath)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - ioPath = resolvedRealPath; - } catch (err) { - if (err instanceof FsSafeError) { - throw err; - } - if (!isNotFoundPathError(err)) { - throw err; - } - } - - const mode = params.mode ?? 0o600; - - let handle: FileHandle; - let createdForWrite = false; - const existingFlags = params.append ? OPEN_APPEND_EXISTING_FLAGS : OPEN_WRITE_EXISTING_FLAGS; - const createFlags = params.append ? OPEN_APPEND_CREATE_FLAGS : OPEN_WRITE_CREATE_FLAGS; - try { - try { - handle = await fs.open(ioPath, existingFlags, mode); - } catch (err) { - if (!isNotFoundPathError(err)) { - throw err; - } - handle = await fs.open(ioPath, createFlags, mode); - createdForWrite = true; - } - } catch (err) { - if (isNotFoundPathError(err)) { - throw new FsSafeError("not-found", "file not found"); - } - if (isSymlinkOpenError(err)) { - throw new FsSafeError("symlink", "symlink open blocked", { cause: err }); - } - if (hasNodeErrorCode(err, "EISDIR")) { - throw new FsSafeError("not-file", "not a file", { cause: err }); - } - throw err; - } - - let realPathForCleanup: string | null = null; - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw new FsSafeError("invalid-path", "path is not a regular file under root"); - } - if (stat.nlink > 1) { - throw new FsSafeError("hardlink", "hardlinked path not allowed"); - } - - try { - const lstat = await fs.lstat(ioPath); - if (lstat.isSymbolicLink() || !lstat.isFile()) { - throw new FsSafeError( - lstat.isSymbolicLink() ? "symlink" : "not-file", - "path is not a regular file under root", - ); - } - if (!sameFileIdentity(stat, lstat)) { - throw new FsSafeError("path-mismatch", "path changed during write"); - } - } catch (err) { - if (!isNotFoundPathError(err)) { - throw err; - } - } - - const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath); - realPathForCleanup = realPath; - const realStat = await fs.stat(realPath); - if (!sameFileIdentity(stat, realStat)) { - throw new FsSafeError("path-mismatch", "path mismatch"); - } - if (realStat.nlink > 1) { - throw new FsSafeError("hardlink", "hardlinked path not allowed"); - } - if (!isPathInside(rootWithSep, realPath)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - - // Truncate only after boundary and identity checks complete. This avoids - // irreversible side effects if a symlink target changes before validation. - if (params.append !== true && params.truncateExisting !== false && !createdForWrite) { - await handle.truncate(0); - } - return { - handle, - createdForWrite, - realPath, - stat, - [Symbol.asyncDispose]: async () => { - await closeHandleForDispose(handle); - }, - }; - } catch (err) { - const cleanupCreatedPath = createdForWrite && err instanceof FsSafeError; - const cleanupPath = realPathForCleanup ?? ioPath; - await handle.close().catch(() => {}); - if (cleanupCreatedPath) { - await fs.rm(cleanupPath, { force: true }).catch(() => {}); - } - throw err; - } -} - -async function appendFileInRoot( - root: RootContext, - params: { - relativePath: string; - data: string | Buffer; - encoding?: BufferEncoding; - mkdir?: boolean; - mode?: number; - prependNewlineIfNeeded?: boolean; - }, -): Promise { - const target = await openWritableFileInRoot(root, { - relativePath: params.relativePath, - mkdir: params.mkdir, - mode: params.mode, - truncateExisting: false, - append: true, - }); - try { - let prefix = ""; - if ( - params.prependNewlineIfNeeded === true && - !target.createdForWrite && - target.stat.size > 0 && - ((typeof params.data === "string" && !params.data.startsWith("\n")) || - (Buffer.isBuffer(params.data) && params.data.length > 0 && params.data[0] !== 0x0a)) - ) { - const lastByte = Buffer.alloc(1); - const { bytesRead } = await target.handle.read(lastByte, 0, 1, target.stat.size - 1); - if (bytesRead === 1 && lastByte[0] !== 0x0a) { - prefix = "\n"; - } - } - - if (typeof params.data === "string") { - await target.handle.appendFile(`${prefix}${params.data}`, params.encoding ?? "utf8"); - return; - } - - const payload = - prefix.length > 0 ? Buffer.concat([Buffer.from(prefix, "utf8"), params.data]) : params.data; - await target.handle.appendFile(payload); - } finally { - await target.handle.close().catch(() => {}); - } -} - -async function removePathInRoot(root: RootContext, relativePath: string): Promise { - const resolved = await resolvePinnedRemovePathInRoot(root, relativePath); - if (process.platform === "win32") { - await removePathFallback(resolved); - return; - } - try { - await runPinnedPathHelper({ - operation: "remove", - rootPath: resolved.rootReal, - relativePath: resolved.relativePosix, - }); - } catch (error) { - if (isPinnedPathHelperSpawnError(error)) { - await removePathFallback(resolved); - return; - } - throw normalizePinnedPathError(error); - } -} - -async function mkdirPathInRoot( - root: RootContext, - params: { - relativePath: string; - allowRoot?: boolean; - }, -): Promise { - const resolved = await resolvePinnedPathInRoot(root, params); - if (process.platform === "win32") { - await mkdirPathFallback(resolved); - return; - } - try { - await runPinnedPathHelper({ - operation: "mkdirp", - rootPath: resolved.rootReal, - relativePath: resolved.relativePosix, - }); - } catch (error) { - if (isPinnedPathHelperSpawnError(error)) { - await mkdirPathFallback(resolved); - return; - } - throw normalizePinnedPathError(error); - } -} - -async function writeFileInRoot( - root: RootContext, - params: { - relativePath: string; - data: string | Buffer; - encoding?: BufferEncoding; - mkdir?: boolean; - mode?: number; - overwrite?: boolean; - }, -): Promise { - if (process.platform === "win32") { - await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => { - await writeFileFallback(root, params); - }); - return; - } - - const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode); - - await serializePathWrite(pinned.targetPath, async () => { - let identity; - try { - identity = await runPinnedWriteHelper({ - rootPath: pinned.rootReal, - relativeParentPath: pinned.relativeParentPath, - basename: pinned.basename, - mkdir: params.mkdir !== false, - mode: params.mode ?? pinned.mode, - overwrite: params.overwrite, - input: { - kind: "buffer", - data: params.data, - encoding: params.encoding, - }, - }); - } catch (error) { - if (params.overwrite === false && isAlreadyExistsError(error)) { - throw new FsSafeError("already-exists", "file already exists", { - cause: error instanceof Error ? error : undefined, - }); - } - throw normalizePinnedWriteError(error); - } - - try { - await verifyAtomicWriteResult({ - root, - targetPath: pinned.targetPath, - expectedIdentity: identity, - }); - } catch (err) { - emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`); - throw err; - } - }); -} - -async function copyFileInRoot( - root: RootContext, - params: { - sourcePath: string; - relativePath: string; - maxBytes?: number; - mkdir?: boolean; - mode?: number; - sourceHardlinks?: HardlinkPolicy; - }, -): Promise { - assertValidRootRelativePath(params.relativePath); - assertNoNulPathInput(params.sourcePath, "source path contains a NUL byte"); - const source = await openVerifiedLocalFile(params.sourcePath, { - hardlinks: params.sourceHardlinks, - }); - if (params.maxBytes !== undefined && source.stat.size > params.maxBytes) { - await source.handle.close().catch(() => {}); - throw new FsSafeError( - "too-large", - `file exceeds limit of ${params.maxBytes} bytes (got ${source.stat.size})`, - ); - } - - try { - if (process.platform === "win32") { - await serializePathWrite(rootWriteQueueKey(root, params.relativePath), async () => { - await copyFileFallback(root, params, source); - }); - return; - } - - const pinned = await resolvePinnedWriteTargetInRoot(root, params.relativePath, params.mode); - await serializePathWrite(pinned.targetPath, async () => { - let identity; - try { - if (getFsSafePythonConfig().mode === "off") { - await copyFileFallback(root, params, source); - return; - } - identity = await runPinnedCopyHelper({ - rootPath: pinned.rootReal, - relativeParentPath: pinned.relativeParentPath, - basename: pinned.basename, - mkdir: params.mkdir !== false, - mode: pinned.mode, - overwrite: true, - maxBytes: params.maxBytes, - sourcePath: source.realPath, - sourceIdentity: { dev: source.stat.dev, ino: source.stat.ino }, - }); - } catch (error) { - if (canFallbackFromPythonError(error)) { - await copyFileFallback(root, params, source); - return; - } - throw normalizePinnedWriteError(error); - } - try { - await verifyAtomicWriteResult({ - root, - targetPath: pinned.targetPath, - expectedIdentity: identity, - }); - } catch (err) { - emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`); - throw err; - } - }); - } finally { - await source.handle.close().catch(() => {}); - } -} - -async function resolvePinnedWriteTargetInRoot( - root: RootContext, - relativePath: string, - requestedMode?: number, -): Promise<{ - rootReal: string; - targetPath: string; - relativeParentPath: string; - basename: string; - mode: number; -}> { - const { rootReal, rootWithSep, resolved } = await resolvePathInRoot(root, relativePath); - try { - await assertNoPathAliasEscape({ - absolutePath: resolved, - rootPath: rootReal, - boundaryLabel: "root", - }); - } catch (err) { - throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); - } - - const relativeResolved = path.relative(rootReal, resolved); - if (relativeResolved.startsWith("..") || path.isAbsolute(relativeResolved)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - const relativePosix = relativeResolved - ? relativeResolved.split(path.sep).join(path.posix.sep) - : ""; - const basename = path.posix.basename(relativePosix); - if (!basename || basename === "." || basename === "/") { - throw new FsSafeError("invalid-path", "invalid target path"); - } - let mode = requestedMode ?? 0o600; - try { - const opened = await openFileInRoot(root, { - relativePath, - hardlinks: "reject", - nonBlockingRead: true, - }); - try { - mode = requestedMode ?? (opened.stat.mode & 0o777); - if (!isPathInside(rootWithSep, opened.realPath)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - } finally { - await opened.handle.close().catch(() => {}); - } - } catch (err) { - if (!(err instanceof FsSafeError) || err.code !== "not-found") { - throw err; - } - } - - return { - rootReal, - targetPath: resolved, - relativeParentPath: - path.posix.dirname(relativePosix) === "." ? "" : path.posix.dirname(relativePosix), - basename, - mode: mode || 0o600, - }; -} - -async function resolvePinnedPathInRoot( - root: RootContext, - params: { - relativePath: string; - allowRoot?: boolean; - }, -): Promise<{ rootReal: string; resolved: string; relativePosix: string }> { - const resolved = await resolvePinnedRootPathInRoot(root, { - relativePath: params.relativePath, - policy: PATH_ALIAS_POLICIES.strict, - }); - const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath); - if ((relativeResolved === "" || relativeResolved === ".") && params.allowRoot === true) { - return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix: "" }; - } - if ( - relativeResolved === "" || - relativeResolved === "." || - relativeResolved.startsWith("..") || - path.isAbsolute(relativeResolved) - ) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - - const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep); - if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - - return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix }; -} - -async function resolvePinnedRemovePathInRoot( - root: RootContext, - relativePath: string, -): Promise<{ rootReal: string; resolved: string; relativePosix: string }> { - const resolved = await resolvePinnedRootPathInRoot(root, { - relativePath, - policy: PATH_ALIAS_POLICIES.unlinkTarget, - }); - const relativeResolved = path.relative(resolved.rootReal, resolved.canonicalPath); - if ( - relativeResolved === "" || - relativeResolved === "." || - relativeResolved.startsWith("..") || - path.isAbsolute(relativeResolved) - ) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - const relativePosix = relativeResolved.split(path.sep).join(path.posix.sep); - if (!isPathInside(resolved.rootWithSep, resolved.canonicalPath)) { - throw new FsSafeError("outside-workspace", "file is outside workspace root"); - } - - const parentRelative = path.posix.dirname(relativePosix); - if (parentRelative === "." || parentRelative === "") { - return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix }; - } - return { rootReal: resolved.rootReal, resolved: resolved.canonicalPath, relativePosix }; -} - -async function resolvePinnedRootPathInRoot( - root: RootContext, - params: { - relativePath: string; - policy: (typeof PATH_ALIAS_POLICIES)[keyof typeof PATH_ALIAS_POLICIES]; - }, -): Promise<{ rootReal: string; rootWithSep: string; canonicalPath: string }> { - const rootReal = root.rootReal; - let resolved; - try { - resolved = await resolveRootPath({ - absolutePath: path.resolve(rootReal, await expandRelativePathWithHome(params.relativePath)), - rootPath: rootReal, - rootCanonicalPath: rootReal, - boundaryLabel: "root", - policy: params.policy, - }); - } catch (err) { - throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); - } - const rootWithSep = ensureTrailingSep(resolved.rootCanonicalPath); - return { - rootReal: resolved.rootCanonicalPath, - rootWithSep, - canonicalPath: resolved.canonicalPath, - }; -} - -function normalizePinnedWriteError(error: unknown): Error { - if (error instanceof FsSafeError) { - return error; - } - return new FsSafeError("invalid-path", "path is not a regular file under root", { - cause: error instanceof Error ? error : undefined, - }); -} - -function isAlreadyExistsError(error: unknown): boolean { - return hasNodeErrorCode(error, "EEXIST") || /File exists|EEXIST/i.test(String(error)); -} - -function normalizePinnedPathError(error: unknown): Error { - if (error instanceof FsSafeError) { - return error; - } - return new FsSafeError("path-alias", "path is not under root", { - cause: error instanceof Error ? error : undefined, - }); -} - -async function removePathFallback(resolved: { resolved: string }): Promise { - await fs.rm(resolved.resolved); -} - -async function mkdirPathFallback(resolved: { resolved: string }): Promise { - await fs.mkdir(resolved.resolved, { recursive: true }); -} - -function pathStatFromStats(stat: Stats): PathStat { - return { - dev: Number(stat.dev), - gid: Number(stat.gid), - ino: Number(stat.ino), - isDirectory: stat.isDirectory(), - isFile: stat.isFile(), - isSymbolicLink: stat.isSymbolicLink(), - mode: stat.mode, - mtimeMs: stat.mtimeMs, - nlink: stat.nlink, - size: stat.size, - uid: stat.uid, - }; -} - -async function statPathFallback(root: RootContext, relativePath: string): Promise { - const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true }); - try { - return pathStatFromStats(await fs.lstat(resolved.resolved)); - } catch (error) { - if (isNotFoundPathError(error)) { - throw new FsSafeError("not-found", "file not found", { - cause: error instanceof Error ? error : undefined, - }); - } - throw error; - } -} - -async function listPathFallback( - root: RootContext, - relativePath: string, - withFileTypes: boolean, -): Promise { - const resolved = await resolvePinnedPathInRoot(root, { relativePath, allowRoot: true }); - try { - const names = await fs.readdir(resolved.resolved); - const sortedNames = names.toSorted(); - if (!withFileTypes) { - return sortedNames; - } - const entries: DirEntry[] = []; - for (const name of sortedNames) { - entries.push({ - name, - ...pathStatFromStats(await fs.lstat(path.join(resolved.resolved, name))), - }); - } - return entries; - } catch (error) { - if (isNotFoundPathError(error)) { - throw new FsSafeError("not-found", "directory not found", { - cause: error instanceof Error ? error : undefined, - }); - } - throw error; - } -} - -async function movePathFallback( - root: RootContext, - params: { - fromRelative: string; - toRelative: string; - overwrite: boolean; - }, -): Promise { - const source = await resolvePathInRoot(root, params.fromRelative); - await resolvePinnedRootPathInRoot(root, { - relativePath: params.fromRelative, - policy: PATH_ALIAS_POLICIES.strict, - }); - const target = await resolvePathInRoot(root, params.toRelative); - await resolvePinnedRootPathInRoot(root, { - relativePath: params.toRelative, - policy: PATH_ALIAS_POLICIES.unlinkTarget, - }); - try { - await assertNoPathAliasEscape({ - absolutePath: target.resolved, - rootPath: target.rootReal, - boundaryLabel: "root", - }); - } catch (error) { - throw new FsSafeError("path-alias", "path alias escape blocked", { - cause: error instanceof Error ? error : undefined, - }); - } - - let sourceStat: Stats; - try { - sourceStat = await fs.lstat(source.resolved); - } catch (error) { - if (isNotFoundPathError(error)) { - throw new FsSafeError("not-found", "file not found", { - cause: error instanceof Error ? error : undefined, - }); - } - throw error; - } - if (sourceStat.isSymbolicLink()) { - throw new FsSafeError("symlink", "symlink not allowed"); - } - if (sourceStat.isFile() && sourceStat.nlink > 1) { - throw new FsSafeError("hardlink", "hardlinked path not allowed"); - } - - if (!params.overwrite) { - try { - await fs.lstat(target.resolved); - throw new FsSafeError("already-exists", "destination exists"); - } catch (error) { - if (error instanceof FsSafeError) { - throw error; - } - if (!isNotFoundPathError(error)) { - throw error; - } - } - } - - try { - await fs.rename(source.resolved, target.resolved); - } catch (error) { - if (isNotFoundPathError(error)) { - throw new FsSafeError("not-found", "file not found", { - cause: error instanceof Error ? error : undefined, - }); - } - if (hasNodeErrorCode(error, "EEXIST")) { - throw new FsSafeError("already-exists", "destination exists", { - cause: error instanceof Error ? error : undefined, - }); - } - throw error; - } -} - -async function writeFileFallback( - root: RootContext, - params: { - relativePath: string; - data: string | Buffer; - encoding?: BufferEncoding; - mkdir?: boolean; - mode?: number; - overwrite?: boolean; - }, -): Promise { - if (params.overwrite === false) { - await writeMissingFileFallback(root, params); - return; - } - - const target = await openWritableFileInRoot(root, { - relativePath: params.relativePath, - mkdir: params.mkdir, - mode: params.mode, - truncateExisting: false, - }); - const destinationPath = target.realPath; - const mode = params.mode ?? (target.stat.mode & 0o777); - await target.handle.close().catch(() => {}); - let tempPath: string | null = null; - let unregisterTempPath: (() => void) | null = null; - try { - tempPath = buildAtomicWriteTempPath(destinationPath); - unregisterTempPath = registerTempPathForExit(tempPath); - const writtenStat = await writeTempFileForAtomicReplace({ - tempPath, - data: params.data, - encoding: params.encoding, - mode: mode || 0o600, - }); - await fs.rename(tempPath, destinationPath); - tempPath = null; - unregisterTempPath(); - unregisterTempPath = null; - try { - await verifyAtomicWriteResult({ - root, - targetPath: destinationPath, - expectedIdentity: writtenStat, - }); - } catch (err) { - emitWriteBoundaryWarning(`post-write verification failed: ${String(err)}`); - throw err; - } - } finally { - if (tempPath) { - await fs.rm(tempPath, { force: true }).catch(() => {}); - } - unregisterTempPath?.(); - } -} - -async function writeMissingFileFallback( - root: RootContext, - params: { - relativePath: string; - data: string | Buffer; - encoding?: BufferEncoding; - mkdir?: boolean; - mode?: number; - }, -): Promise { - const { rootReal, resolved } = await resolvePathInRoot(root, params.relativePath); - try { - await assertNoPathAliasEscape({ - absolutePath: resolved, - rootPath: rootReal, - boundaryLabel: "root", - }); - } catch (err) { - throw new FsSafeError("path-alias", "path alias escape blocked", { cause: err }); - } - if (params.mkdir !== false) { - await fs.mkdir(path.dirname(resolved), { recursive: true }); - } - - let handle: FileHandle | null = null; - let created = false; - try { - handle = await fs.open(resolved, OPEN_WRITE_CREATE_FLAGS, params.mode ?? 0o600); - created = true; - if (typeof params.data === "string") { - await handle.writeFile(params.data, params.encoding ?? "utf8"); - } else { - await handle.writeFile(params.data); - } - const writtenStat = await handle.stat(); - await handle.close(); - handle = null; - await verifyAtomicWriteResult({ - root, - targetPath: resolved, - expectedIdentity: writtenStat, - }); - created = false; - } catch (err) { - if (hasNodeErrorCode(err, "EEXIST")) { - throw new FsSafeError("already-exists", "file already exists", { - cause: err instanceof Error ? err : undefined, - }); - } - throw err; - } finally { - await handle?.close().catch(() => undefined); - if (created) { - await fs.rm(resolved, { force: true }).catch(() => undefined); - } - } -} - -async function copyFileFallback( - root: RootContext, - params: { - sourcePath: string; - relativePath: string; - maxBytes?: number; - mkdir?: boolean; - mode?: number; - sourceHardlinks?: HardlinkPolicy; - }, - source: OpenResult, -): Promise { - let target: WritableOpenResult | null = null; - let sourceClosedByStream = false; - let targetClosedByUs = false; - let tempHandle: FileHandle | null = null; - let tempPath: string | null = null; - let unregisterTempPath: (() => void) | null = null; - let tempClosedByStream = false; - try { - target = await openWritableFileInRoot(root, { - relativePath: params.relativePath, - mkdir: params.mkdir, - mode: params.mode, - truncateExisting: false, - }); - const destinationPath = target.realPath; - const mode = params.mode ?? (target.stat.mode & 0o777); - await target.handle.close().catch(() => {}); - targetClosedByUs = true; - - tempPath = buildAtomicWriteTempPath(destinationPath); - unregisterTempPath = registerTempPathForExit(tempPath); - tempHandle = await fs.open(tempPath, OPEN_WRITE_CREATE_FLAGS, mode || 0o600); - const sourceStream = createBoundedReadStream(source, params.maxBytes); - const targetStream = tempHandle.createWriteStream(); - sourceStream.once("close", () => { - sourceClosedByStream = true; - }); - targetStream.once("close", () => { - tempClosedByStream = true; - }); - await pipeline(sourceStream, targetStream); - const writtenStat = await fs.stat(tempPath); - if (!tempClosedByStream) { - await tempHandle.close().catch(() => {}); - tempClosedByStream = true; - } - tempHandle = null; - await fs.rename(tempPath, destinationPath); - tempPath = null; - unregisterTempPath(); - unregisterTempPath = null; - try { - await verifyAtomicWriteResult({ - root, - targetPath: destinationPath, - expectedIdentity: writtenStat, - }); - } catch (err) { - emitWriteBoundaryWarning(`post-copy verification failed: ${String(err)}`); - throw err; - } - } catch (err) { - if (target?.createdForWrite) { - await fs.rm(target.realPath, { force: true }).catch(() => {}); - } - throw err; - } finally { - if (!sourceClosedByStream) { - await source.handle.close().catch(() => {}); - } - if (tempHandle && !tempClosedByStream) { - await tempHandle.close().catch(() => {}); - } - if (tempPath) { - await fs.rm(tempPath, { force: true }).catch(() => {}); - } - unregisterTempPath?.(); - if (target && !targetClosedByUs) { - await target.handle.close().catch(() => {}); - } - } -} +export { + DEFAULT_ROOT_MAX_BYTES, + openLocalFileSafely, + readLocalFileSafely, + resolveOpenedFileRealPathForHandle, + root, + type HardlinkPolicy, + type OpenResult, + type ReadResult, + type Root, + type RootAppendOptions, + type RootCopyOptions, + type RootCreateJsonOptions, + type RootCreateOptions, + type RootDefaults, + type RootOpenOptions, + type RootOpenWritableOptions, + type RootOptions, + type RootReadOptions, + type RootWriteJsonOptions, + type RootWriteOptions, + type SymlinkPolicy, + type WritableOpenMode, + type WritableOpenResult, +} from "./root-impl.js"; diff --git a/test/helpers/security.ts b/test/helpers/security.ts new file mode 100644 index 0000000..2163cc2 --- /dev/null +++ b/test/helpers/security.ts @@ -0,0 +1,111 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; +import { FsSafeError } from "../../src/index.js"; + +export type TempLayout = { + outside: string; + outsideFile: string; + root: string; +}; + +export const TRAVERSAL_PAYLOADS = [ + "../secret.txt", + "../../secret.txt", + "nested/../../secret.txt", + "nested/../../../secret.txt", + "./../secret.txt", + "nested/..//../secret.txt", + "nested/%2e%2e/secret.txt", + "%2e%2e/secret.txt", + "%2e%2e%2fsecret.txt", + "..%2fsecret.txt", + "%252e%252e%252fsecret.txt", + "..%00/secret.txt", + "..\\secret.txt", + "nested\\..\\..\\secret.txt", + "C:\\Windows\\win.ini", + "\\\\server\\share\\secret.txt", +] as const; + +export const LIST_TRAVERSAL_PAYLOADS = [ + "..", + "../", + "../../", + "nested/../..", + "nested/../../outside", + "%2e%2e", + "%2e%2e%2f", + "..\\", + "C:\\Windows", + "\\\\server\\share", +] as const; + +export const ESCAPING_WRITE_PAYLOADS = [ + "../pwned.txt", + "../../pwned.txt", + "nested/../../pwned.txt", + "nested/../../../pwned.txt", + "./../pwned.txt", + "nested/..//../pwned.txt", +] as const; + +export const LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [ + "nested/%2e%2e/pwned.txt", + "%2e%2e/pwned.txt", + "%2e%2e%2fpwned.txt", + "%252e%252e%252fpwned.txt", +] as const; + +export const POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [ + "nested\\..\\..\\pwned.txt", + "C:\\Windows\\win.ini", + "\\\\server\\share\\pwned.txt", +] as const; + +export const SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS = [ + "..%2fpwned.txt", + "..%00/pwned.txt", + "..\\pwned.txt", +] as const; + +export const ESCAPING_DIRECTORY_PAYLOADS = [ + "..", + "../", + "../../", + "nested/../..", + "nested/../../outside", +] as const; + +export const LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS = ["%2e%2e", "%2e%2e%2f"] as const; +export const SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = ["..\\"] as const; + +export const WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = [ + "C:\\Windows", + "\\\\server\\share", +] as const; + +export async function makeTempLayout( + prefix: string, + tempDirs: string[], +): Promise { + const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`)); + const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`)); + tempDirs.push(root, outside); + const outsideFile = path.join(outside, "secret.txt"); + await fsp.writeFile(outsideFile, "outside secret"); + return { outside, outsideFile, root }; +} + +export function expectFsSafeCode(error: unknown, codes: readonly string[]): void { + expect(error).toBeInstanceOf(FsSafeError); + expect(codes).toContain((error as FsSafeError).code); +} + +export async function expectNoOutsideWrite( + layout: TempLayout, + expected = "outside secret", +): Promise { + await expect(fsp.readFile(layout.outsideFile, "utf8")).resolves.toBe(expected); +} diff --git a/test/openclaw-read-bypass-parity.test.ts b/test/openclaw-read-bypass-parity.test.ts index 4c04859..7bc8c01 100644 --- a/test/openclaw-read-bypass-parity.test.ts +++ b/test/openclaw-read-bypass-parity.test.ts @@ -1,61 +1,23 @@ import fs from "node:fs"; import fsp from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { resolveAbsolutePathForRead } from "../src/absolute-path.js"; -import { FsSafeError, root as openRoot } from "../src/index.js"; +import { root as openRoot } from "../src/index.js"; import { openPinnedFileSync } from "../src/pinned-open.js"; import { pathScope } from "../src/root-paths.js"; import { openRootFile, openRootFileSync } from "../src/root-file.js"; - -type TempLayout = { - outside: string; - outsideFile: string; - root: string; -}; +import { + expectFsSafeCode, + LIST_TRAVERSAL_PAYLOADS, + makeTempLayout as makeSecurityTempLayout, + TRAVERSAL_PAYLOADS, +} from "./helpers/security.js"; const tempDirs: string[] = []; -const TRAVERSAL_PAYLOADS = [ - "../secret.txt", - "../../secret.txt", - "nested/../../secret.txt", - "nested/../../../secret.txt", - "./../secret.txt", - "nested/..//../secret.txt", - "nested/%2e%2e/secret.txt", - "%2e%2e/secret.txt", - "%2e%2e%2fsecret.txt", - "..%2fsecret.txt", - "%252e%252e%252fsecret.txt", - "..%00/secret.txt", - "..\\secret.txt", - "nested\\..\\..\\secret.txt", - "C:\\Windows\\win.ini", - "\\\\server\\share\\secret.txt", -] as const; - -const LIST_TRAVERSAL_PAYLOADS = [ - "..", - "../", - "../../", - "nested/../..", - "nested/../../outside", - "%2e%2e", - "%2e%2e%2f", - "..\\", - "C:\\Windows", - "\\\\server\\share", -] as const; - -async function makeTempLayout(prefix: string): Promise { - const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`)); - const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`)); - tempDirs.push(root, outside); - const outsideFile = path.join(outside, "secret.txt"); - await fsp.writeFile(outsideFile, "outside secret"); - return { outside, outsideFile, root }; +async function makeTempLayout(prefix: string) { + return await makeSecurityTempLayout(prefix, tempDirs); } async function closeIfOpen(value: unknown): Promise { @@ -67,11 +29,6 @@ async function closeIfOpen(value: unknown): Promise { } } -function expectFsSafeCode(error: unknown, codes: readonly string[]): void { - expect(error).toBeInstanceOf(FsSafeError); - expect(codes).toContain((error as FsSafeError).code); -} - afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fsp.rm(dir, { force: true, recursive: true }))); }); diff --git a/test/openclaw-write-bypass-parity.test.ts b/test/openclaw-write-bypass-parity.test.ts index 0c625b8..b1b7335 100644 --- a/test/openclaw-write-bypass-parity.test.ts +++ b/test/openclaw-write-bypass-parity.test.ts @@ -1,78 +1,25 @@ import fsp from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { FsSafeError, root as openRoot } from "../src/index.js"; - -type TempLayout = { - outside: string; - outsideFile: string; - root: string; -}; +import { root as openRoot } from "../src/index.js"; +import { + ESCAPING_DIRECTORY_PAYLOADS, + ESCAPING_WRITE_PAYLOADS, + expectFsSafeCode, + expectNoOutsideWrite, + LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS, + LITERAL_SUSPICIOUS_WRITE_PAYLOADS, + makeTempLayout as makeSecurityTempLayout, + POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS, + SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS, + SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS, + WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS, +} from "./helpers/security.js"; const tempDirs: string[] = []; -const ESCAPING_WRITE_PAYLOADS = [ - "../pwned.txt", - "../../pwned.txt", - "nested/../../pwned.txt", - "nested/../../../pwned.txt", - "./../pwned.txt", - "nested/..//../pwned.txt", -] as const; - -const LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [ - "nested/%2e%2e/pwned.txt", - "%2e%2e/pwned.txt", - "%2e%2e%2fpwned.txt", - "%252e%252e%252fpwned.txt", -] as const; - -const POSIX_LITERAL_SUSPICIOUS_WRITE_PAYLOADS = [ - "nested\\..\\..\\pwned.txt", - "C:\\Windows\\win.ini", - "\\\\server\\share\\pwned.txt", -] as const; - -const SAFE_REJECTED_SUSPICIOUS_WRITE_PAYLOADS = [ - "..%2fpwned.txt", - "..%00/pwned.txt", - "..\\pwned.txt", -] as const; - -const ESCAPING_DIRECTORY_PAYLOADS = [ - "..", - "../", - "../../", - "nested/../..", - "nested/../../outside", -] as const; - -const LITERAL_SUSPICIOUS_DIRECTORY_PAYLOADS = ["%2e%2e", "%2e%2e%2f"] as const; - -const SAFE_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = ["..\\"] as const; - -const WINDOWS_REJECTED_SUSPICIOUS_DIRECTORY_PAYLOADS = [ - "C:\\Windows", - "\\\\server\\share", -] as const; - -async function makeTempLayout(prefix: string): Promise { - const root = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-root-`)); - const outside = await fsp.mkdtemp(path.join(os.tmpdir(), `${prefix}-outside-`)); - tempDirs.push(root, outside); - const outsideFile = path.join(outside, "secret.txt"); - await fsp.writeFile(outsideFile, "outside secret"); - return { outside, outsideFile, root }; -} - -function expectFsSafeCode(error: unknown, codes: readonly string[]): void { - expect(error).toBeInstanceOf(FsSafeError); - expect(codes).toContain((error as FsSafeError).code); -} - -async function expectNoOutsideWrite(layout: TempLayout, expected = "outside secret"): Promise { - await expect(fsp.readFile(layout.outsideFile, "utf8")).resolves.toBe(expected); +async function makeTempLayout(prefix: string) { + return await makeSecurityTempLayout(prefix, tempDirs); } afterEach(async () => {