Reading
The Root handle exposes five read shapes. Pick the narrowest one that gives you what you need — narrower shapes do less work and surface fewer footguns.
const result = await fs.read("notes/today.txt"); // { buffer, containment, realPath, stat }
const text = await fs.readText("notes/today.txt"); // string
const bytes = await fs.readBytes("image.png"); // Buffer
const json = await fs.readJson<Config>("config.json"); // T
const opened = await fs.open("large.log"); // FileHandle for streaming
#What every read does
Regardless of shape, every read goes through the same boundary checks:
- Resolve the input lexically against the canonical real root.
- Reject a lexically explicit unsafe device or process-fd namespace (
device-path). This check precedes component alias policy because paths such as/dev/fdare themselves symlinks on common Linux hosts. - Resolve path components and reject anything that escapes the root (
outside-workspace). - Reject
..traversal and absolute spellings when they resolve outside the root. In-root absolute spellings remain accepted;readAbsolutemakes that intent explicit. - Open with
O_NOFOLLOWwhere available. Any remaining symlink in the path triggerssymlinkunless the call'ssymlinkspolicy isfollow-within-root. - Compare the pre-open path identity, the open fd, and the post-open resolved path (
sameFileIdentity). A swap mid-call triggerspath-mismatch. - If
hardlinks: "reject", refuse files withnlink > 1(hardlink). - If
maxBytesis set, refuse reads larger than the cap (too-large).
#Read shapes
#fs.read(rel, options?)
The full result. Use it when you need both the bytes and the verified realPath or stat:
const { buffer, containment, realPath, stat } = await fs.read("notes/today.txt");
console.log(`${stat.size} bytes at ${realPath}`);
#fs.readText(rel, options?)
buffer.toString(encoding). Defaults to "utf8"; encoding is a per-call text option, not a RootDefaults field:
const utf16 = await fs.readText("doc.txt", { encoding: "utf16le" });
#fs.readBytes(rel, options?)
The buffer alone. Useful when you don't care about the realPath or stat:
const png = await fs.readBytes("image.png");
#fs.readJson<T>(rel, options?)
readText + JSON.parse. The generic is a cast, not a validator — validate the parsed value at your application boundary if it came from a less-trusted source.
type Config = { tokens: string[] };
const config = await fs.readJson<Config>("config.json");
For tighter control over malformed-or-missing JSON, use the standalone helpers in @openclaw/fs-safe/json: tryReadJson (returns null on missing/invalid) vs readJson (throws).
#fs.open(rel, options?)
Returns a FileHandle plus containment: "best-effort", the verified realPath, and stat. Use this for streaming or partial reads, and always close the handle:
const opened = await fs.open("large.log");
try {
const stream = opened.handle.createReadStream();
for await (const chunk of stream) {
process.stdout.write(chunk);
}
} finally {
await opened.handle.close();
}
#Read options
type RootReadOptions = {
hardlinks?: "reject" | "allow"; // override defaults.hardlinks
maxBytes?: number; // refuse reads larger than this many bytes
nonBlockingRead?: boolean; // compatibility hint; safe opens are already nonblocking where supported
symlinks?: "reject" | "follow-within-root"; // override defaults.symlinks
};
maxBytes is enforced eagerly: the library reads up to maxBytes + 1 and throws too-large if there is more, so a hostile target cannot silently exhaust memory.
nonBlockingRead remains as a compatibility hint. Safe reads always add the platform's nonblocking open flag where available so a raced FIFO cannot pin a worker indefinitely; regular-file descriptor reads retain normal Node behavior.
#readAbsolute() and reader()
Some APIs hand you an absolute path that the caller has already produced. Going back to a relative form just to call read() is awkward, so the library exposes:
fs.readAbsolute(absPath, options?) // ReadResult, abs path must be inside the root
fs.reader(options?) // (path) => Promise<Buffer>
readAbsolute accepts absolute paths. Anything outside the root throws outside-workspace. It also accepts relative paths for compatibility, but use read()/readBytes() when the input contract is explicitly relative.
reader() returns a closure that takes either a relative or an absolute path and returns a Buffer. Useful for plugging fs-safe into framework loader hooks:
const load = fs.reader({ maxBytes: 4 * 1024 * 1024 });
await someLibrary.parseTemplate({ load });
#Inspection vs reading
fs.exists, fs.stat, and fs.list are advisory. They are safe to call to drive UI or decisions, but they do not pin the file:
if (await fs.exists("notes/today.txt")) {
// the file existed when stat() ran — it may not now
const text = await fs.readText("notes/today.txt"); // this is the call that pins
}
A symlink swap between exists and readText is checked again by the read; the boundary and its documented race window are per-call.
#Streaming patterns
#Read into a writable stream
import { pipeline } from "node:stream/promises";
const opened = await fs.open("large.log");
try {
await pipeline(opened.handle.createReadStream(), process.stdout);
} finally {
await opened.handle.close();
}
#Read in chunks
const opened = await fs.open("large.bin");
try {
const buf = Buffer.alloc(64 * 1024);
let off = 0;
while (true) {
const { bytesRead } = await opened.handle.read(buf, 0, buf.length, off);
if (bytesRead === 0) break;
consume(buf.subarray(0, bytesRead));
off += bytesRead;
}
} finally {
await opened.handle.close();
}
#Common errors
outside-workspace— relative path escaped the root, orreadAbsolutegot an absolute path outside.not-found— the file is gone.not-file— you read a directory or a non-regular file (FIFO, socket, …).device-path— the path targets a known unsafe device or process fd path.symlink— a path component is a symlink and the policy isreject.path-mismatch— opened fd identity did not match the resolved path. Almost always a TOCTOU swap by something else.hardlink—hardlinks: "reject"sawnlink > 1.too-large— read exceededmaxBytes.
See Errors for the full list.
#See also
- Writing — companion verbs for produce-side I/O.
- JSON files — standalone strict/lenient JSON helpers.
- Secure file reads — pinned absolute file reads with permission checks.