Root API

Writing

Writing

The Root handle exposes a tight set of mutation verbs. Replacement writes (write, writeJson, and copyIn) publish with a sibling-temp commit so no half-written replacement appears at the destination. Create-only writes (create, createJson, and write with overwrite: false) use sibling-temp staging with an atomic no-replace rename only on backends that provide one — the native binding, which require mode guarantees and auto mode uses when the binding loads. Ordinary buffered creation in the pure-JavaScript fallback claims the final name exclusively with O_EXCL and writes content in place, so a concurrent observer can see the new file before its content is complete. Buffered create and createJson accept atomic: true to stage complete content on this fallback too. Streamed creation already stages before publication. append and openWritable intentionally modify an opened file in place; move, remove, and mkdir mutate directory entries rather than file bytes. Each verb applies the boundary checks appropriate to its operation.

await fs.write("state.json", body);
await fs.create("seed.json", initial);   // throws if exists
await fs.writeJson("config.json", state);
await fs.append("logs/today.log", line);
await fs.copyIn("inbox/upload.bin", "/tmp/upload.bin");
await fs.move("notes/draft.md", "notes/published.md");
await fs.remove("logs/yesterday.log");
await fs.mkdir("snapshots/2026/05");

#What replacement writes do

  1. Resolve the relative target against the canonical root and reject anything that escapes (outside-workspace).
  2. If mkdir: true, create missing parent directories relative to a pinned parent fd in the native path, or with per-component identity guards in the JavaScript fallback. When denyMutations or an explicit mutationSymlinks policy is present, each missing POSIX-native component is authorized before creation and its opened descriptor is authorized again before descent.
  3. Pin or guard the parent directory for the selected mechanism. Native operations use a parent fd and reapply configured mutation policy to the actual canonical destination selected by that descriptor; the pinned JavaScript fallback verifies directory identity before and after mutation and performs the same policy revalidation before its pathname dispatch. The JavaScript check cannot make the intervening pathname syscall atomic, so a same-privilege peer that can replace the parent may cause an out-of-root side effect before detection. Use native require mode for that threat model; see the security model.
  4. Write data to a sibling temp file in the same directory.
  5. Atomically rename the temp file over the destination.
  6. Stat the resulting fd and verify identity.

Private sibling temporary names are independent of the destination basename, so staging does not add a suffix to an otherwise valid long filename.

Write paths reject path-alias when symlinks and parent components select a different target before and after lexical normalization, such as link/../file where link points into a deeper directory. This avoids silently modifying the wrong file. Resolve an intended alias explicitly with Root.resolve() before passing its canonical path to a mutation.

A failure before the final rename leaves the destination at its previous contents. A successful rename publishes the complete replacement. Buffered Windows replacement writes also keep a missing destination absent while staging; they do not reserve an empty file at the final name. An ordinary writable file created concurrently may be replaced under overwrite: true. Immediately before rename, the writer rechecks the selected destination's type and link count, admits a raced file's access, and verifies retained file/parent identities and mutation policy.

The old-or-new guarantee does not apply to append() or openWritable(), which write in place, or to lower-level atomic helpers when their explicitly non-atomic permission-error copy fallback is enabled.

Post-publication verification can still reject after a complete replacement has been committed. Rejection does not promise that a successful rename was rolled back; the published file or a raced replacement may remain at the destination.

Failed-write cleanup compares exact parent and file identities, including large Windows file indexes. Replaced paths and paths whose ownership cannot be verified are preserved.

With mutationSymlinks omitted, Windows buffered replacement write() and writeJson() calls (overwrite omitted or true) currently differ by implementation. The native pinned path rejects a final file symlink with path-alias. The legacy JavaScript path can follow an unchanged contained final link and replace its admitted target, preserving the link itself. It reauthorizes the original link's target before staging and publication when mutation policy is present, and retains its file/parent identity checks. The legacy path is used by native mode off, by auto without a binding, and by the explicit renameIdentity: "verify-content-with-lock" policy.

An omitted mutation policy also leaves parent-junction behavior implementation dependent: native Windows parent admission refuses reparse traversal and can report invalid-path, while the legacy writer can use the junction's admitted contained target. This does not authorize an escaping target or bypass deny policy. Set mutationSymlinks: "reject" explicitly for uniform link rejection across these implementations, either in Root defaults or per call:

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

const files = await root("C:/workspace", { mutationSymlinks: "reject" });
await files.write("state.json", "{}");

These are existing compatibility differences; omitted policy does not currently provide uniform Windows link handling. See write-side link policy.

#Denying mutations

All mutation verbs accept denyMutations?: DenyMutationPolicy, either as a root default or per-call option:

const fs = await root("/srv/workspace", {
  denyMutations: {
    paths: ["/srv/workspace/.env"],
    prefixes: ["/srv/workspace/.ssh"],
  },
});

await fs.write(".env", "x");       // throws FsSafeError code "denied-path"
await fs.remove(".ssh/id_rsa");    // throws FsSafeError code "denied-path"

paths blocks exact absolute paths. prefixes blocks absolute paths and everything below them. For POSIX pinned write, create, and copyIn, an existing exact-path directory does not implicitly deny a mutation to its descendants, but creating a missing directory at that exact path is itself denied. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a mutation through a symlinked ancestor to a denied path is still denied. Root-level and per-call policies are additive; per-call policy can add denies, but cannot clear root defaults. Those POSIX pinned operations snapshot the merged policy before their first path observation, then authorize the actual descriptor-selected parent before staging or publication. A caller mutation of the original arrays, or a contained parent redirect after preflight, cannot clear that admission check.

#Write verbs

#fs.write(rel, data, options?)

Overwrite or create. Replacement is atomic; buffered overwrite: false can expose incomplete content on the JavaScript fallback. Use create() with atomic: true when create-only publication must wait for complete content.

await fs.write("state/last-run.json", JSON.stringify(run));
await fs.write("notes/today.txt", "hello\n", { encoding: "utf8" });

data accepts string | Buffer. mode sets the file's POSIX mode. If neither the call nor RootDefaults supplies it, a replacement preserves the existing file mode and a new file uses 0o600. mkdir and overwrite both default to true; set overwrite: false for the same no-clobber behavior as create().

#Write options

OptionTypeDefault / behavior
denyMutationsDenyMutationPolicyMerged with root-level denies.
durablebooleantrue; use false to skip file and parent fsync.
encodingBufferEncoding"utf8" for strings.
mkdirbooleantrue; creates missing parents.
modenumberInherited on replacement, otherwise 0o600.
overwritebooleantrue; false is create-only.
renameIdentityRenameIdentityPolicy"strict".

mkdir: false requires existing parents and never creates a missing parent. On POSIX, otherwise permitted relative in-root parent aliases remain available to buffered and streamed writes and copies with native support enabled or disabled. An explicit mutation symlink policy still applies.

write, create, writeJson, createJson, append, and copyIn accept durable. Precedence is per-call option, then Root.defaults.durable, then true; an explicitly undefined call option preserves the root default. durable: false keeps the sibling-temp replace/rename behavior of replacement writes but skips file and parent-directory fsync calls. Create-only and append publication behavior, permissions, identity checks, and error codes are unchanged. Use it only for reconstructible data: a crash may lose the write or leave the previous file. move and streaming openWritable do not use this option. Native and pure-JavaScript Windows writers honor the option. Replacement writes sync staged content before rename and the final mode through the retained file handle. Directory sync remains best-effort.

For create and createJson, durable: "file" requires each file sync to succeed, including on EPERM; it overrides a disabled Root durability default. Parent directory synchronization retains the existing best-effort policy. This also applies to streamed creation and is independent of publication strategy. Boolean durability options keep their existing behavior, including compatibility paths that tolerate EPERM. A failed file sync before staged publication prevents publication; a failure after publication can leave the complete file present.

POSIX modes without read permission, including 0o000 and 0o200, succeed: final verification uses a descriptor retained by the writer rather than reopening the published file. The requested mode is not relaxed for verification. Publication verification compares exact bigint descriptor and pathname identities, including large file indexes that cannot be represented by a JavaScript number. With durability enabled (the default), native publication syncs content before rename and syncs the parent directory. Modes that retain owner read/write skip the extra mode-only file sync: after a crash, the file may retain staged mode 0o600 instead of the wider requested mode. Modes that remove owner read or write, and corrections of observed wider staging permissions, keep the post-chmod file sync. Later reads still obey OS permissions, and access checks on a pre-existing destination are unchanged. The explicit FUSE compatibility policy still requires a readable destination to prove matching content when rename changes its identity.

When Windows cannot report a pathname's identity, the publication verifier reopens the name and compares that descriptor's exact identity with the original retained file, without reading bytes. It also rechecks links and root/parent containment. This write-specific proof does not relax ordinary reads: unknown pathname metadata alone is never proof that the name still refers to the expected file.

#fs.create(rel, data, options?)

Don't-clobber variant of write(). Throws already-exists if the target is there. An existing regular file or directory reports already-exists on every backend, including buffered, atomic and streamed creation. Rejected directories and their contents remain unchanged. Other non-regular types retain not-file; boundary, explicit symlink policy, deny and hardlink failures retain their precedence. Create-only preflight preserves boundary, alias, hardlink, and type checks without opening an existing target to inherit its mode; a fresh file uses the requested mode or the normal new-file default. When the native binding is in use (require mode, or auto mode with a successfully loaded binding), content is staged privately and published with an atomic no-replace rename, so the name never appears before its bytes. In the pure-JavaScript fallback the name is claimed exclusively first and content is written afterward, so observers can briefly see an empty file; failure cleanup removes a claimed file only when its identity is unchanged.

After a successful create-only write, failure to close its owned file handle rejects the operation and leaves the complete file present. Ordinary buffered creation in the JavaScript fallback preserves an earlier write or verification failure if close also fails. Native, atomic, and streamed creation retain their existing publication and cleanup diagnostics.

try {
  await fs.create("config/seed.json", initial);
} catch (err) {
  if (!(err instanceof FsSafeError) || err.code !== "already-exists") throw err;
}

#Atomic buffered creation

await fs.create("config/seed.json", initial, { atomic: true });
await fs.createJson("config/settings.json", { enabled: true }, { atomic: true });
await fs.create("config/flushed.json", initial, { atomic: true, durable: "file" });

atomic: true finishes all bytes in a sibling stage before publication. The native backend uses its no-replace rename; the JavaScript fallback hardlinks the completed stage and unlinks its temporary name in the same JavaScript turn. When the OS refuses hardlinks, it uses the placeholder-rename fallback. Other processes can briefly observe both linked names, or an empty placeholder before the atomic replacement. Entries present at the exclusive claim are preserved, including dangling symlinks; ordinary confinement, type, hardlink, and symlink-policy rejections still apply. assertBeforeMutation retains its live checks through content writes and publication.

Omitted or false preserves the existing buffered behavior. The option belongs to buffered create and createJson, not replacement writes or Root defaults. Streamed creation has no atomic opt-out. atomic changes visibility, not the existing durable file/directory synchronization policy; it does not turn best-effort synchronization into a strict crash-durability guarantee or strengthen JavaScript pathname containment.

Wider option objects do not enable create-only atomic or private behavior on write or writeJson. Buffered Root writes and creates also ignore an extra maxBytes property; that byte limit belongs to streamed creation and copyIn. File synchronization follows each method's documented durable option.

Atomic and streamed creates settle owned cleanup and close operations before returning. Failed or unverifiable cleanup is reported rather than silently discarded. Errors after publication and incomplete-settlement errors carry the existing StagedFileFailureDetails publication/cleanup receipts where the writer can establish them; native disposal can retain them inside a SuppressedError cause. Preserve those details when handling errors: a rejection can follow complete publication, and an indeterminate link or native rename must preserve names for recovery. A cleanup or close failure also retains the original operation failure. The JavaScript fallback checks the destination again immediately before attempting publication. A collision observed there leaves publication unattempted and cleans the owned stage. A later collision or unclassified error from the link call remains indeterminate, including EEXIST; the error code alone does not prove that the filesystem left both names unchanged. No later verification, mode, or synchronization failure authorizes deleting an already published complete destination. See receipt meanings.

#Streamed creation

Pass an AsyncIterable<Uint8Array> to create() when bytes come from a database, network response, or another incremental producer. Buffers are accepted chunks. The writer borrows Uint8Array slices without copying their payloads and counts their actual byte bounds. It consumes each chunk completely before requesting the next one; it does not collect the full input in memory or expose a writable descriptor.

async function* snapshotChunks(): AsyncGenerator<Uint8Array> {
  yield Buffer.from("first stored chunk\n");
  yield Buffer.from("second stored chunk\n");
}

await fs.create("restored/config.txt", snapshotChunks(), {
  mode: 0o600,
  maxBytes: 8 * 1024 * 1024,
  durable: false,
  signal: AbortSignal.timeout(30_000),
});

RootCreateStreamOptions keeps mkdir, mode, durable, assertBeforeMutation, denyMutations, and mutationSymlinks from buffered creation and adds maxBytes and signal. Byte chunks have no encoding option; streamed creation uses strict publication identity and does not support renameIdentity: "verify-content-with-lock". Existing Root defaults apply, including explicit maxBytes; with no byte cap at either level, input size is unlimited. Zero permits an empty input only. Invalid limits reject before I/O.

Unlike buffered creation's JavaScript fallback, streamed creation stages all chunks before publishing the final name. Native mode uses no-replace rename; the JavaScript fallback hardlinks the completed stage and removes its temporary name in the same JavaScript turn. If hardlinks are refused, it uses the placeholder-rename fallback; other processes may briefly observe an empty placeholder. A destination present at the exclusive claim is preserved. Existing-target preflight does not consume the input. The final mode and durability policy use the same guarded writer as other Root operations.

Cancellation checks run before and after producer pulls, before content writes, and before publication. assertBeforeMutation also rechecks current application authority after producer waits and before each partial write. The operation waits for any pending producer pull or filesystem write, then awaits the producer's return() and cleans only the owned unpublished stage. Pass the same signal into a producer that may stall: an arbitrary async iterator cannot be forcibly interrupted, so cancellation waits for its pending work and cleanup to settle. Do not mutate a yielded chunk until the next pull. Producer errors retain their original value when cleanup succeeds.

Streamed creation retains the signal and assertBeforeMutation callback selected when the call starts. Replacing or deleting those options during a producer wait does not change the in-flight operation. Abort the original signal or update the live authority state checked by the original callback to revoke it; the callback continues to receive the original options object as this.

An aborted or failed operation can leave created parent directories. If a stage's identity or parent cannot be verified during cleanup, the existing guarded cleanup preserves it. After publication, later verification or cleanup failures preserve the destination; rejection does not prove that no file was created. Cancellation arriving after publication does not undo the completed file. Application recovery remains caller-owned.

#fs.writeJson(rel, value, options?)

JSON.stringify(value, replacer, space) + atomic write. Adds a trailing newline by default.

await fs.writeJson("config.json", state, { space: 2 });
await fs.writeJson("compact.json", state, { trailingNewline: false });

Options:

type RootWriteJsonOptions = RootWriteOptions & {
  replacer?: (this: any, key: string, value: any) => any | (number | string)[];
  space?: number | string;
  trailingNewline?: boolean; // default true
};

createJson(rel, value, options?) is the don't-clobber variant.

#fs.append(rel, data, options?)

Open in append mode, write, sync the file handle, and close. Honors mkdir for the parent directory and syncs that directory when creating the file. durable: false skips both syncs. Pass prependNewlineIfNeeded: true to separate existing content from appended text when neither side supplies a newline. Strings use their encoding for the newline check, including UTF-16LE; Buffers use a single LF byte. Empty strings and Buffers add no separator; an empty append still creates a missing file.

mode selects the creation mode, defaulting to 0o600 when neither the call nor the Root supplies it. On POSIX, the process umask can further restrict that mode; for example, mode: 0o640 with umask 0o077 creates a 0o600 file. Existing files are not chmodded, even when an explicit mode is supplied. Empty appends use the same creation rules.

await fs.append("logs/today.log", `[${ts}] ${line}\n`);
await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });

For high-volume logging, consider openWritable and a long-lived append handle. Direct append-mode writes preserve kernel append semantics, but they are not atomic against external rotators that rename or unlink the target.

#fs.copyIn(rel, sourceAbsPath, options?)

Synchronous module hooks can use copyRootFileSync from @openclaw/fs-safe/advanced to create an absent destination and retain its descriptor for hashing. Root itself remains asynchronous.

copyIn accepts a RootCopySource: a trusted absolute source path or a file within another Root. The guarded form supplies root with only its open and stat read capabilities, plus relativePath:

const source = await root("/srv/templates");
const destination = await root("/srv/workspace");
await destination.copyIn("config/settings.json", {
  root: source,
  relativePath: "config/settings.json",
}, {
  overwrite: false,
  clone: "auto",
  mode: 0o600,
  signal: AbortSignal.timeout(30_000),
});

The source Root applies its read policies, including confinement and symlink handling. sourceHardlinks overrides its hardlink policy only when supplied; otherwise the source Root default is retained. The admitted source descriptor stays open through copying and source-identity verification; copying does not consume its current file position. Both forms enforce maxBytes while reading, including when a file grows after admission, and use bounded buffers. Copies have independent file data; changing either file cannot change the other. Set preserveSourceMode: true to select the mode from the admitted source descriptor. An explicit numeric mode, including Root.defaults.mode, takes precedence. By default, copying retains the existing destination-mode rules. The operation verifies source identity, not a coherent snapshot of concurrent in-place edits. Keep the source unchanged when snapshot consistency is required.

overwrite defaults to true, preserving the existing replacement behavior. With overwrite: false, an existing destination produces already-exists and is never altered. Copying prepares a private sibling file before publishing its completed contents. Native mode uses no-replace rename. The guarded JavaScript fallback links the completed stage and removes its temporary name in the same JavaScript turn. If the OS refuses hardlinks, it uses the placeholder-rename fallback below. Other processes can briefly observe both linked names or an empty placeholder. The source is never hardlinked to the destination.

preserveMetadata: true restores metadata to the completed staging entry before publication. It defaults to false; it does not select permissions, ownership, ACLs, or extended attributes. Use the existing preserveSourceMode option as well when source permissions should be selected. An explicit mode or Root mode default continues to take precedence over preserveSourceMode.

StatepreserveMetadata: truePermission selection
POSIX access and modification timesPreservedNot affected by mode
POSIX permission and executable bitsNot selected by this optionExisting mode / preserveSourceMode rules
Windows creation, access and write timesPreserved by native copyingNot affected by mode
Windows hidden, system, archive, temporary, offline, not-content-indexedPreserved by native copyingNot affected by mode
Windows readonlyNot selected by this optionExisting mode / preserveSourceMode rules
Ownership, ACLs, POSIX extended attributesNot copiedExisting destination policy
Windows sparse/integrity flagsNot set through metadata copyingOwned by the clone/FSCTL mechanism

This applies to clones, native byte copies, and JavaScript byte copies. On POSIX, native timestamp restoration retains nanoseconds; JavaScript restoration uses Node's timestamp precision, as the portable tree copier does. On Windows without the native metadata helper, JavaScript can restore only access and write times; creation time and the non-permission attributes are not preserved. Native require mode rejects a missing helper before publication. The Windows attribute mask and POSIX timestamp conversion share the tree-copy implementation; native tree copying has additional metadata guarantees that this narrower option does not request.

sourceSymlinks defaults to "reject", preserving the existing rejection of symlinked absolute string sources with code symlink and message symlink not allowed. There is no follow mode. Set sourceSymlinks: "copy-link" to copy a source link itself, including a dangling file or directory link. Its literal target remains data: copying never resolves it. Windows uses the link's own directory attribute and copies the reparse payload through the same native mechanism as copyTree.

await destination.copyIn("template-link", "/srv/templates/template-link", {
  sourceSymlinks: "copy-link",
  preserveMetadata: true,
});

Link destinations are always exclusive. Omit overwrite or set it to false; explicit overwrite: true with a link source fails with invalid-path before mutation. Regular files retain the usual overwrite behavior. Destination Root confinement, denied paths, parent-link policy, and mutation-authority checks apply to copied links too. macOS link permissions use the selected mode on the link itself; Linux link permissions remain fixed by the kernel. The link target's permissions are never changed.

"copy-link" requires an absolute-path string source. A { root, relativePath } source fails with invalid-path before either source method is called or a destination is mutated: its open and stat capabilities cannot expose literal link text or Windows link type without following the link. With the default "reject", capability sources continue to use their own Root read policies.

macOS and Windows link copying requires native support and otherwise reports helper-unavailable before destination mutation. macOS link(2) follows its source, so it cannot publish dangling symlink inodes exclusively. Linux can publish the staged symlink inode with guarded hardlink/unlink when native support is off or automatic no-replace rename is unavailable. This has the same documented pathname identity windows as the JavaScript write fallback; no operation follows the link target. Successful native publication uses no-replace rename. No link publication uses the regular-file placeholder fallback.

For staged JavaScript no-replace copies and writes, EACCES, EPERM, EXDEV, ENOTSUP, EOPNOTSUPP, and ENOSYS from the link operation permit a placeholder-rename fallback, including Android/Termux hardlink restrictions. It exclusively creates a mode-0600, no-follow destination, closes it after capturing its exact identity, rechecks the parent and stage, and requires the same empty, singly linked regular placeholder before renaming the completed stage over it. A real creation-permission failure still rejects. EEXIST from the link operation does not permit fallback or establish non-publication.

An empty placeholder is observable before the atomic replacement, and a crash can leave that placeholder and the completed stage. Detected placeholder swaps reject without removing foreign entries; failed pre-rename operations remove only a verified owned empty placeholder. Failed cleanup reports helper-failed with details.publication: "not-published" and details.cleanup: "failed". After rename, verification requires the stage name to be absent and the target to retain the staged identity; the complete published file is preserved on failure.

These pathname checks are best-effort, not compare-and-swap: a same-directory writer can replace the placeholder after the final check, and rename can replace that intervening file or symlink (without following it). Protect the parent from untrusted mutation when strict concurrent no-clobber is required. This fallback preserves existing receipt shapes; RootCopyPublicationReceipt reports identity and path, with no method or fallback field.

clone chooses the file-data transfer strategy through CopyCloneMode, shared with copyTree. File copies default to "never"; tree copies default to "auto":

ValueBehavior
neverCopy regular file bytes using reads and writes, without explicit cloning or copy offload.
autoTry native file cloning, then copy offload or ordinary byte copying when cloning is unavailable.
alwaysRequire native cloning; fail when the binding or filesystem cannot provide it.

Native file cloning supports APFS, supported Linux filesystems, and Windows ReFS (including Dev Drive). On Linux, a FICLONE request denied with EPERM, as container seccomp profiles commonly do, counts as unavailable cloning: auto copies bytes and always fails with unsupported-platform. On Windows, always requires source and destination on the same ReFS volume; other volumes report unsupported-platform. auto falls back to bytes only for unavailable cloning capabilities, and never copies bytes. ReFS named streams are rejected rather than silently discarded by cloning. For successful native ReFS clones through either copyIn or copyTree, all full clusters are block-cloned; a final partial cluster may be copied. Exact tail bytes and logical file size are preserved. See the shared ReFS contract and Microsoft's cluster-alignment and EOF requirements. Clone choice does not change modes, durability, root confinement, or source and publication identity checks. The shared strategy does not replace Root's guarded regular-file contract with copyTree's caller-owned immutable-tree and metadata contract.

An already aborted signal prevents I/O. Cancellation during copying waits for admitted reads and native work to settle, then cleans only the owned unpublished stage. The final authority check runs before publication. Once publication has occurred, later cancellation or verification failure preserves the destination. The synchronous optional onDestinationPublished callback receives a frozen RootCopyPublicationReceipt containing { path, dev, ino }, with exact bigint identity immediately after publication, before later checks can fail. Callback errors also preserve the published file and retain their original thrown value when cleanup succeeds, including errors whose metadata cannot be inspected. Promise, thenable, and synchronous or asynchronous generator results reject with TypeError; returned generators are never advanced. Other synchronous return values are ignored. This receipt records an outcome; it does not authorize removing a file that another actor may have edited. Application recovery and cooperative locking remain caller-owned.

Existing copyIn callers must account for completed destinations retained after a post-publication source-verification failure, even without the new options. Recovery must inspect current destination state rather than assume a rejected copy left no file.

durable follows the Root default (true when omitted at both levels); false skips file and parent-directory syncs. Use sourceHardlinks: "reject" to refuse hardlinked sources. There is no encoding option: copying preserves bytes.

#fs.move(from, to, options?)

Rename one path inside the root to another. Defaults to no clobber:

await fs.move("incoming/foo.txt", "archive/foo.txt");
await fs.move("incoming/foo.txt", "archive/foo.txt", { overwrite: true });

Both from and to are bounded; .. in either is rejected.

Mutation policy is captured at call start; changes to caller-owned denial arrays apply to later moves. For live cancellation or revocation, throw from assertBeforeMutation immediately before dispatch.

The default no-clobber mode supports regular files and directories and requires the native helper. It admits both parent directory descriptors and first uses descriptor-relative no-replace rename. If the helper or safe parent admission is unavailable, the call fails with helper-unavailable. After dispatch it rechecks both parent identities; rejection does not imply rollback.

On Linux filesystems that reject renameat2(RENAME_NOREPLACE), auto uses the same retained parents and rechecks the source identity before falling back:

  • Regular files use linkat without following symlinks, verify the exact
  • source/target inode pair with two links, then recheck the source before unlinkat. Existing targets cannot be overwritten. Only this intermediate pair permits two links; admission and completed publication require one. Denied or unsupported hard links fail closed, naming both capabilities.

  • Directories recheck that the target is absent and use plain renameat.
  • A concurrent non-empty directory or non-directory target rejects as already-exists. A concurrently created empty directory can be replaced. A trailing slash on the direct-child source requires a directory at syscall time, even after a file or symlink substitution. Source identity checks and rename/unlink are separate syscalls, not source-name compare-and-swap; use exclusive namespace ownership or OS isolation against hostile actors in the final check-to-syscall window.

require does not take these fallbacks. Use isNoReplaceUnsupported(error) to classify the capability refusal. File fallback failures after link publication leave the target intact and report details.publication: "published" plus details.sourceRemoval; callers must inspect that partial state before retrying. There is no automatic rollback or crash-durability guarantee.

Linux without openat2 uses the guarded native parent walk. The move uses renameat2(RENAME_NOREPLACE) or the Linux auto fallbacks above; parent resolution reports the documented best-effort containment class. Disabling the addon still makes no-clobber moves unavailable.

Both selected canonical endpoints are admitted inside the retained Root after native parent admission. With mutationSymlinks: "reject", both full operation paths are rechecked after the live mutation-authority callback and before dispatch. The Root and retained parents are fenced again after any such callback. These checks retain the documented final check-to-syscall race.

For { overwrite: true }, require mode retains both parents, checks the source identity relative to its parent, and renames through those descriptors (or the corresponding Windows handles). It then rechecks the parents and destination identity. require rejects with helper-unavailable if this operation's native entry point is absent. In off and default auto, the JavaScript path checks both parent directories before and after the rename. A failed post-operation check rejects even though the rename may already have completed; rejection does not imply rollback.

#fs.remove(rel)

Unlink a file or rmdir an empty directory. Non-empty directories throw not-empty. For atomic directory replacement, use replaceDirectoryAtomic.

Before a nonrecursive JavaScript fallback removal, fs-safe retains exact Root-to-parent directory identities, with canonical endpoint checks at Root and the immediate parent. It rejects a parent redirected through a symlink or junction before unlink or rmdir, even with force: true, and rechecks the retained ancestry after the operation settles. The entry may already have been removed when that final verification rejects.

await fs.remove("logs/yesterday.log");
await fs.remove("snapshots/empty-dir"); // ok
await fs.remove("snapshots/full-dir");  // throws not-empty

For a tree, opt into bounded recursive removal:

await fs.remove("scratch/finished-job", {
  recursive: true,
  force: true,
  maxEntries: 20_000,
  maxDepth: 32,
  mutationSymlinks: "reject",
  signal: AbortSignal.timeout(30_000),
  assertBeforeMutation: () => assertJobLeaseCurrent(),
});
OptionDefault / behavior
recursivefalse; opt in to removing non-empty directories.
forcefalse; true tolerates missing targets and vanished child entries. Other errors still reject.
order"filesystem"; "sorted" collects and sorts child names lexicographically before descending. Requires recursive: true.
maxEntries100_000 in recursive mode; counts the requested target and every encountered child, including directories and symlinks.
maxDepth64 in recursive mode; the requested target has depth 0 and each child adds one. An empty directory at the limit can be removed.
signalStops traversal and new mutations when aborted. Already dispatched work settles and directory handles close before rejection.

Budgets must be non-negative safe integers or explicit Infinity, and require recursive: true. Omitted budgets retain their finite defaults. An entry or depth limit throws too-large before processing the over-budget entry. Once the parent is admitted, maxEntries: 0 rejects before inspecting even a missing requested entry; force: true does not bypass that budget.

The default filesystem order streams each directory once, using memory and open handles proportional to depth rather than directory width. Sorted order also enumerates each directory once, but collects its names before descending and deletes directories after their children. It uses JavaScript's default string sort, not locale collation. Collected names consume the shared entry budget, including sibling names still pending while an earlier directory is traversed. With a finite budget, collection overflow rejects before processing that directory's children; one extra name distinguishes an exact limit from overflow. An Infinity entry budget uses a bulk name read while retaining the opened directory handle and identity checks. Sorted mode retains collected names, so unlimited budgets also permit unlimited name storage.

Sorted traversal preserves lexicographic processing, not the incidental syscall timing of a caller that repeatedly rescans parent directories. Each child is inspected when visited. Newly added entries can make the final rmdir fail with not-empty; the operation does not retry indefinitely.

Recursive removal never follows a discovered symlink or junction. With an omitted mutationSymlinks policy it unlinks that entry, preserving the existing nonrecursive behavior. Both explicit mutation policies reject discovered links. follow-parents-within-root permits aliases only in the requested target's parents, and still rejects a final link. Root and per-call denyMutations policies remain additive; a denied descendant prevents removal of the enclosing requested tree before any entry is removed.

The operation retains exact identities for the traversal directories and each observed target. Swapped or missing ancestors reject even with force: true; an abort reason or authority refusal carrying ENOENT is not treated as absence. Authority is rechecked immediately before each direct unlink or rmdir dispatch. Directory handles close before their directories are removed, including on Windows. If both traversal and close fail, SuppressedError retains both failures. Directory-stream filesystem errors use the same removal codes as unlink and rmdir, with the original error in cause; caller abort and authority refusals retain their original values.

When force: true encounters a missing directory during an opendir or read, it closes any open stream and reaches the ordinary final target check. The surviving ancestors and original target identity are checked again; a replacement is never accepted as a missing directory. An actually vanished child does not prevent processing later siblings.

Filesystem failures normalized by the recursive removal owner and its direct symlink rejections carry additional FsSafeError.details context:

type RemoveFailureDetails = {
  operation: "remove";
  phase: "enumerate" | "inspect" | "remove";
  relativePath: string;
};

relativePath is relative to the requested removal target, using host path separators: "" identifies that target and "nested/link" identifies a child on POSIX. It does not replace caller spelling with the canonical Root path. enumerate covers directory stream operations, inspect covers initial child observations, and remove covers final identity checks and unlink/rmdir failures. The existing error codes, messages, and causes remain unchanged. Errors that already have their own identity, including caller authority and cancellation reasons, are propagated without adding or changing their details. Context is diagnostic; it is not permission to retry or mutate an entry.

Removal is incremental, not atomic. A later budget, cancellation, identity, or filesystem failure does not restore already removed entries. Required-mode POSIX removal checks the entry without following links and uses unlinkat relative to a retained parent; empty directories use AT_REMOVEDIR. Recursive native removal enumerates through retained directory descriptors and retains its ordering, budgets, abort checks, and denied-descendant preflight. Linux descent requires openat2 with RESOLVE_NO_XDEV; macOS checks mount identity.

Windows nonrecursive removal checks the identity of the exact handle opened relative to the parent and deletes that object with FileDispositionInfoEx, without following a final reparse point. require rejects recursive removal on Windows and on Linux without that mount-bounded capability. off and default auto retain the JavaScript implementation even when the addon is loaded: its pathname checks are best-effort against a hostile concurrent process and cannot prevent every outside side effect. POSIX native deletion is also not an atomic expected-inode conditional unlink. See the platform matrix for the precise parent-pinning guarantee and remaining same-call limitations.

#fs.mkdir(rel)

mkdir -p. Creates missing parents. An existing regular file at the requested directory name rejects with not-file in every native mode and remains unchanged.

require mode creates each missing component relative to a retained directory, opens the child without following a final symlink, and checks its identity before continuing. Windows private creation retains its protected native creator, which verifies the admitted parent identity and calls handle-relative NtCreateFile with a protected security descriptor. require refuses a missing native capability; default auto retains its existing best-effort path and does not confine mkdir under hostile concurrency.

await fs.mkdir("snapshots/2026/05");

Pass { private: true } to create missing components with private permissions. An existing requested directory must already satisfy that policy; fs-safe does not repair it or change existing ancestor permissions. Concurrent creators may reuse the winner only after it passes the same checks.

Buffered, streamed, and JSON create calls also accept private: true. New POSIX directories default to 0700 and files to 0600; conflicting group/world or privilege bits are rejected before creation. Restrictive owner-only file modes remain available through mode. On Windows, creation uses protected ACLs rather than interpreting POSIX mode bits as access rules. This does not change create's no-overwrite behavior or select its durability policy. See creation for supported backends and owned descriptors.

#fs.ensureRoot()

Treats "" / "." as the root itself. Useful when a generic helper computes a relative directory and might end up at the root.

const targetRel = path.relative(fs.rootReal, candidateAbs); // could be "" if candidateAbs === root
await fs.ensureRoot(); // accepts "" without throwing

#openWritable() for streaming

When write doesn't fit (very large outputs, slow producers), open a writable handle:

const opened = await fs.openWritable("logs/current.log", { writeMode: "append" });
try {
  for await (const chunk of source) {
    await opened.handle.appendFile(chunk);
  }
} finally {
  await opened.handle.close();
}

Options are { denyMutations?, mkdir?, mode?, writeMode? }, where writeMode is "replace" (default), "append", or "update". replace truncates existing files; update keeps existing contents. Streaming writes go directly to the destination — there is no atomic-rename step. For exclusive publication of a complete stream, use create(). For streamed replacement, the atomic helpers provide a staged writer.

When creating a missing file, require mode uses an exclusive no-follow open beneath a retained parent and verifies the same inode during handoff to the returned Node FileHandle. append() uses this path too. Required creation fails with helper-unavailable with an incomplete addon, or when a restrictive mode or umask prevents that handoff without widening initial permissions. Default auto retains its existing JavaScript creation path even when the addon is loaded, and does not confine creation under hostile concurrency. Required creation confinement does not upgrade the returned containment: "best-effort" label or provide a transaction around later caller writes.

On Windows, fallback creation rejects an observed dangling final symlink before opening it, preserving the missing referent even when it is outside the Root. Create-only writes and standalone exclusive creators apply the same preflight; exclusive creation treats an existing symlink as a collision. This check does not close the race between inspecting the leaf and opening it. An existing contained final symlink still follows the omitted-policy behavior described above.

The native creation syscall applies the requested mode and inherited ACLs. If those kernel-created permissions prevent the subsequent FileHandle handoff, the operation fails and attempts identity-bound cleanup; it does not widen an inherited ACL with a later chmod.

For all three write modes, mode only selects new-file creation permissions, defaulting to 0o600 when neither the call nor the Root supplies it. POSIX permissions remain subject to the process umask; existing files are not chmodded. The returned numeric stat records the admitted descriptor before caller writes.

On POSIX, existing-target opens use O_NONBLOCK as an admission safeguard so a no-reader FIFO cannot stall regular-file validation. This does not change ordinary regular-file write semantics. replace and update remain write-only opens, including for mode 0o200 files; replacement truncation happens only after type, identity, and boundary checks pass. Rejected existing paths are never cleanup-owned or unlinked.

Identity admission uses bigint descriptor and pathname receipts even though the public stat field remains a numeric Node Stats object. Windows retries an unknown device or file index once while retaining known components, then rejects persistent ambiguity. If admission of a newly created file fails, cleanup only unlinks a pathname that still has the exact created identity; rounded aliases, symlinks, and unknown identities are preserved.

#Write defaults vs per-call options

Set mkdir: true once on root(); pass text encodings per call when needed:

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

await fs.write("notes/today.txt", "ascii", { encoding: "utf8" });
await fs.write("data/blob.bin", buffer);     // mkdir true, no encoding because data is Buffer
await fs.write("data/blob.bin", buffer, { mkdir: false }); // override

#Errors you'll catch

CodeWhen
outside-workspaceTarget resolves outside the root.
already-existscreate() / createJson() / move({ overwrite: false }) hit an existing target.
not-foundParent does not exist and mkdir is false.
not-emptyremove() on a non-empty directory.
not-removableremove() could not unlink/rmdir (typically permissions or device busy).
path-mismatchPost-write fd identity check did not match. Almost always a parallel writer, or a FUSE mount with unstable inode numbers — see renameIdentity below.
too-largecopyIn() source exceeded maxBytes.
symlinkA path component is a symlink and policy is reject.
hardlinksourceHardlinks: "reject" saw nlink > 1.

Full list in Errors.

#Common patterns

#Replace if changed

const next = JSON.stringify(state);
const prev = await fs.readText("state.json").catch(() => "");
if (prev !== next) await fs.write("state.json", next);

#Stage many writes, then commit

const stagingDir = "snapshots/incoming";
await fs.mkdir(stagingDir);
for (const file of files) await fs.write(`${stagingDir}/${file.name}`, file.body);
await fs.move(stagingDir, "snapshots/2026-05-05", { overwrite: true });

For guarded whole-directory publication, use replaceDirectoryAtomic. Replacing an existing target is a two-rename protocol with a temporary target-absence interval and conditional no-replace rollback, not a transactional commit-or-rollback.

#Rotate logs

const today = `logs/${formatDate(new Date())}.log`;
try {
  await fs.create(today, "");
} catch (err) {
  if (!(err instanceof FsSafeError) || err.code !== "already-exists") throw err;
}
await fs.append(today, line);

#FUSE mounts and unstable inode numbers

Some FUSE mounts — rclone is a confirmed example — assign the destination a different inode number from the source temp file as a result of rename, even within a single process with zero concurrency. Repeated stats of an unchanged destination remain stable, but the source-to-destination (dev, ino) comparison always fails with path-mismatch.

Set renameIdentity: "verify-content-with-lock" on the root (or per call) to use a SHA-256 content comparison under a cooperative sidecar lock instead:

const fs = await root("/mnt/rclone-workspace", {
  renameIdentity: "verify-content-with-lock",
});

await fs.write("state.json", body); // succeeds on rclone FUSE

How it works. The full write runs under an exclusive per-target lock named .fs-safe-write-<sha256>.lock in the root. Keeping the lock in the already-canonical root avoids creating an unguarded lock path through a missing or raced target parent. The guarded Node fallback accepts the source-temp-to-destination inode mismatch only when the SHA-256 of the re-read bytes matches the SHA-256 of the bytes written. Subsequent path identity checks remain strict, so this mode requires an unchanged destination path to report stable identity. It deliberately stays on the guarded JavaScript path because content verification replaces the normal inode-preserving rename contract. The lock is released before the call returns.

Security note. verify-content-with-lock proves that the bytes observed after rename match the requested write and prevents cooperating writers from interleaving. It does not prove that the destination still names the temp-file object, retain fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.

Windows Root.write() and Root.writeJson() honor this policy both as a Root default and as a per-call option. The Windows buffered writer acquires the same Root compatibility lock before creating parents or staging content. It keeps staging writable until publication, then applies the final mode through the retained destination descriptor. When content verification accepts a changed rename identity, that destination remains pinned through file sync, parent sync, and final strict identity checks.

The Windows buffered compatibility path resolves permitted in-root aliases before choosing its lock and binds publication to that effective destination. With the existing lock protocol, effective path components beneath the Root must contain only lower-case ASCII letters, digits, ., _, or -, with no trailing .. Unsupported spellings, including missing upper-case or non-ASCII names, fail with path-alias before mutation; no filesystem case-sensitivity or Unicode-folding behavior is guessed. This restriction does not apply to strict writes. Opaque Windows pathname identities still use strict verification against the retained original descriptor: they never, by themselves, authorize content-based acceptance of a replacement.

The library's own lock-destination check permits the writer to reuse parent admission within that operation. The lock key and destination checks remain unchanged, and evidence is revoked when the locked operation finishes. Supplying an assertBeforeMutation callback retains full parent admission because caller code can change the filesystem before dispatch.

Lock recovery is fail-closed. If a process crashes and leaves the root-level .fs-safe-write-<sha256>.lock, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see File lock.

#See also

  • Atomic writes — the lower-level replaceFileAtomic and friends.
  • JSON files — standalone JSON helpers without going through root().
  • Reading — companion read API.
  • Errors — every code, when it fires.