fs-safe/docs/root.md
2026-05-06 03:04:45 +01:00

8.5 KiB

root()

root() is the primary entry point. It takes a trusted directory and returns a capability-style Root handle whose methods accept relative paths and refuse to escape the directory.

import { root } from "@openclaw/fs-safe";

const fs = await root("/srv/workspace", {
  hardlinks: "reject",
  symlinks: "reject",
  mkdir: true,
});

Signature

function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;

type RootDefaults = {
  hardlinks?: "reject" | "allow";  // refuse files with nlink > 1 on read; defaults to "reject"
  maxBytes?: number;               // refuse reads larger than this many bytes; defaults to 16 MiB
  mkdir?: boolean;                 // create missing parent dirs on write/openWritable/append
  mode?: number;                   // file mode applied to new writes; per-call override available
  nonBlockingRead?: boolean;       // schedule reads on a worker; useful for large files
  symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
};

root() resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws FsSafeError with code not-found, and malformed or non-directory roots throw invalid-path.

defaults apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.

The Root interface

Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.

Reads

fs.read(rel, options?)         // { buffer, realPath, stat }
fs.readBytes(rel, options?)    // Buffer
fs.readText(rel, options?)     // string
fs.readJson<T>(rel, options?)  // parsed T
fs.open(rel, options?)         // { handle, realPath, stat, [Symbol.asyncDispose] }
fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
fs.reader(options?)            // (path) => Promise<Buffer>; useful for loader APIs

open() returns a Node FileHandle for streaming. Prefer await using for cleanup:

await using opened = await fs.open("large.log");
{
  for await (const chunk of opened.handle.createReadStream()) {
    process.stdout.write(chunk);
  }
}

Writes

fs.write(rel, data, options?)            // overwrite-ok atomic write
fs.create(rel, data, options?)           // throws "already-exists" if target exists
fs.writeJson(rel, value, options?)       // JSON.stringify + atomic write
fs.createJson(rel, value, options?)      // create() variant of writeJson
fs.append(rel, data, options?)           // append text/buffer; respects mkdir default
fs.copyIn(rel, sourceAbsPath, options?)  // copy from outside the root, atomically, with size cap
fs.openWritable(rel, options?)           // FileHandle for streaming writes; supports await using
fs.move(from, to, options?)              // rename within the root; defaults to no clobber
fs.remove(rel)                           // unlink file or rmdir empty directory
fs.mkdir(rel)                            // mkdir -p (creates missing parents)
fs.ensureRoot()                          // accepts "" / "." as the root itself

write, create, append, writeJson, and createJson accept mode?: number; use 0o600 for credentials and other private state. writeJson also accepts the same options as JSON.stringify plus trailingNewline?: boolean (defaults true so the file ends in \n).

copyIn is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects maxBytes.

openWritable opens a writable file with options mode?: number and writeMode?: "replace" | "append" | "update". replace truncates existing files and is the default; update keeps existing contents. Use it for streaming output. Prefer await using for cleanup.

Inspection (advisory)

fs.exists(rel)                   // boolean
fs.stat(rel)                     // PathStat
fs.list(rel)                     // string[]
fs.list(rel, { withFileTypes })  // DirEntry[]
fs.resolve(rel)                  // absolute path inside the root, after canonicalization

These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.

Python helper mode

On POSIX, mutation and inspection methods that need fd-relative directory operations go through one persistent Python helper process. This avoids a spawn-per-call cost while still using openat/renameat/unlinkat-style operations that Node's fs API does not expose ergonomically.

import { configureFsSafePython } from "@openclaw/fs-safe/config";

configureFsSafePython({ mode: "off" });     // Node-only fallback path
configureFsSafePython({ mode: "require" }); // fail if fd-relative helper unavailable

auto is the default. Configure the mode before creating roots. Without the helper, root methods still run, but same-UID races that swap parent directories between validation and mutation are harder to close completely. Use require when that downgrade should be treated as a deployment failure. See Python helper policy for deployment guidance.

Properties

fs.rootDir       // the directory you passed in
fs.rootReal      // its canonical real path (after symlink resolution)
fs.rootWithSep   // rootReal with a trailing separator, for prefix comparisons
fs.defaults      // the RootDefaults you passed

Failure semantics

Every method throws FsSafeError with a code. Branch on err.code, not message text. Common codes:

Code When it fires
invalid-path The input path is malformed, including embedded NUL bytes.
outside-workspace The input resolves outside the root, or contains a .. segment that would escape it.
not-found The target does not exist (or its parent does not, with mkdir: false).
not-file A read or copy targeted a non-regular file (directory, FIFO, socket, …).
already-exists create() or move() without overwrite hit an existing target.
symlink A path component is a symlink, and the call's symlinks policy is reject.
hardlink The target's nlink > 1 and hardlinks policy is reject.
path-mismatch Post-open identity check failed — the opened fd does not match the resolved path.
too-large Read exceeded maxBytes.

Full list in the Errors reference.

Defaults vs per-call options

Defaults reduce repetition; per-call options handle exceptions:

const fs = await root("/srv/workspace", {
  symlinks: "reject",
  hardlinks: "reject",
  mkdir: true,
});

// Default: symlinks rejected.
await fs.readText("config.toml");

// One specific path needs to follow a symlink that lands inside the root.
await fs.readText("links/current.log", { symlinks: "follow-within-root" });

Text helpers default to UTF-8. Pass encoding per call to readText, readJson, write, create, or append when you need another encoding.

Common patterns

Read-only loader

const fs = await root("/srv/workspace", { symlinks: "reject", hardlinks: "reject" });
const load = fs.reader();
const a = await load("notes/today.txt");        // relative
const b = await load("/srv/workspace/state.bin"); // absolute, but inside the root

fs.reader() returns a (path) => Promise<Buffer> callback. Useful when wiring fs-safe into APIs that accept a generic loader function. Absolute paths outside the root are rejected with outside-workspace.

"Touch only if missing" seeding

try {
  await fs.create("config/seed.json", initialJson);
} catch (err) {
  if (err instanceof FsSafeError && err.code === "already-exists") {
    // existing config wins
  } else {
    throw err;
  }
}

Replace + verify

await fs.write("state.json", JSON.stringify(state, null, 2));
const echoed = await fs.readJson<State>("state.json");
assertDeepEqual(echoed, state);

write is atomic, so the file is either old or new — never half-written. Re-reading lets you detect a parallel writer, if one exists.

See also

  • Reading — read variants in depth, plus stream patterns.
  • Writing — write/create/move/remove in depth.
  • pathScope() — the same boundary semantics over an absolute path you already trust.
  • Atomic writes — the lower-level helpers used by fs.write.
  • Errors — the closed code union you'll be catching.