root()
root() is the primary entry point. It takes a trusted directory and returns a 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.
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.
#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 |
|---|---|
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.