Security model
fs-safe is a library-level guardrail: a capability-style root handle for Node.js code that handles untrusted relative paths. It assumes the calling process already has whatever filesystem permissions it needs and aims to stop trivial path tricks from broadening that authority. It is not a sandbox and does not replace operating-system isolation.
The same shape exists in other languages: Go's os.Root / OpenInRoot and Rust's cap-std both expose a root handle whose operations refuse to escape it. fs-safe is the Node-side equivalent: a single root() capability that carries the boundary across every read, write, move, and remove, instead of leaving each call site to redo path.resolve(...).startsWith(...) and hope.
#Affected versions / exposure
In published releases through 0.4.7, the exported resolveRootPath() and resolveRootPathSync() helpers validated a lexically normalized path spelling. A caller-supplied path traversing an in-root symlink could therefore pass validation while resolving outside the root. Version 0.5 fixes this with component-wise alias resolution, resolving each alias before applying later path components.
root() handles were not affected: their operations have contained this case since 5ddca80. Exposure is limited to consumers that call resolveRootPath() or resolveRootPathSync() directly.
#Threat model
You hand a root() boundary to a piece of code that takes caller-controlled relative paths. The library defends against a caller that:
- supplies
..traversal segments to escape the boundary - supplies an absolute path outside a configured root, or to an API whose input
- replaces a path component with a symlink between check and use (TOCTOU)
- replaces the destination directory with a symlink right before a write
- creates a hardlink that aliases an out-of-tree inode and asks you to read or replace it
- asks a read/open primitive to target a known unsafe device or process-fd path
- uses an NTFS alternate-stream or directory-index pathname to alias a different
- triggers a partial write that leaves a half-written file at the destination
- ships an archive with
..paths, absolute paths, or symlinks pointing outside the destination
contract is strictly relative/portable
Windows filesystem object than the visible path suggests
It does not defend against:
- a process running with permissions to write anywhere on the filesystem and choosing to ignore the library
- another process with the same UID racing to mutate the same directory between two separate
fs-safecalls — the boundary is per-call, not per-session - arbitrary traversal across filesystem boundaries, bind mounts, or virtual filesystems beyond the known unsafe read device paths
- container escape, TOCTOU between fork and exec of helpers, or kernel-level vulnerabilities
- semantic content checks: file types, archive payload schemas, signature verification
If you need full sandboxing, run the worker under reduced privileges (uid, container, seccomp, chroot, jail) and use fs-safe inside the sandbox to keep the worker honest about its own workspace.
#Defenses, by failure mode
#Path traversal and absolute paths
Every path is resolved against the canonicalized real path of the root, then checked at that boundary. On Windows, an exact-case structural Root prefix stays on the lexical fast path; a prefix accepted only by case folding must have the Root's exact directory identity and is rebased onto the trusted Root spelling before use. Alias resolution walks components before applying a later .., so a symlink cannot change what that parent segment means after validation. Parent traversal, an absolute spelling, or any alias whose canonical result is outside the root throws outside-workspace; absolute spellings that remain inside the root are accepted.
On Windows, Root, root-file readers, pathScope, isPathInside, secret-file writers, sibling-temp output, trash admission, and archive output preparation reject foreign share or device roots before filesystem access can contact an attacker-chosen host. Shares are compared by exact host and share name with ASCII-only case folding, so Unicode case folding (for example the Kelvin sign for k) cannot make another host look trusted. Namespaced drive roots keep their existing handling, and explicitly selected shares, such as a Root, allowed trash root, or destination on \\server\share, remain supported. \\?\ and \\.\ spellings under GLOBALROOT/Global, or whose components Windows would rewrite (empty or trailing-dot/space authority components, or dot segments climbing above the drive, share, or device), are foreign unless spelled exactly under the trusted boundary, because their target cannot be identified from the spelling.
An absolute path on a different share or device is rejected even when a filesystem alias would resolve it back inside the boundary. Pass a path relative to the boundary, or spell it on the boundary's own share.
Guarded pathname APIs reject Windows : namespace aliases before normalization or filesystem access. The only colon admitted in a Windows filesystem path is the rooted ASCII drive designator (including extended-drive syntax); relative paths admit none. This rule does not authorize device or network paths, which retain their independent restrictions. Pure formatters, descriptor-only calls, and walkDirectory (documented as a non-boundary traversal helper) are outside this pathname-admission guarantee. Output helpers continue to sanitize an untrusted basename, then validate the resulting path.
The low-level existing-object readers openRootFile() and openRootFileSync() preserve their historical Windows drive-relative input: they anchor its leading drive designator before this admission check. The raw suffix remains unnormalized, and any additional colon is still rejected before filesystem access.
Trusted-path standalone publication APIs retain the same drive-relative compatibility. This includes the atomic file, text, JSON, JSON store/direct queue writer, directory-replacement, move, and exclusive-publication helpers. They capture the drive's current directory at publication entry and carry the anchored spelling through their remaining checks, locks, callbacks, receipts, publication, and cleanup. File writers preserve the raw suffix. Root-relative APIs and caller-constructed relative directory receipts continue to reject drive designators.
#Portable Windows security transport
The portable fallback runs a prebuilt .NET Framework helper for each security check. Node passes the borrowed fd as the child's stdin; C# inspects that exact handle using GetStdHandle(-10). Replacement descriptors and every verification across callbacks, publication, link-count changes, and chmod retain the same ordering and ownership boundaries as the native fallback protocol. No facts are cached between calls. A persistent helper cannot inherit later-opened fds, and portable Node does not expose the OS HANDLE or DuplicateHandle needed to send them. See the reproducible build and execution policy requirements.
#Exclusive creation on Windows
Windows Node exclusive creation can follow a dangling file symlink before an opened-descriptor check can reject it. Fallback creators therefore inspect the final leaf before opening it, including create-only Root writes, standalone creators, exclusive copy publication, lock records, and internal staging files. This preserves an unchanged link and its missing target, including a target outside the intended directory. Random staging names and freshly created workspaces reduce the opportunity to preplace such a link, but are not a replacement for this check. The preflight is best-effort: a concurrent replacement between inspection and open can still redirect a pathname operation in native auto/off mode. Use the documented native require creation paths and OS isolation when hostile concurrency is in scope.
#Symlinks (read side)
open() and read() use fs.open with O_NOFOLLOW on POSIX where available. The library then fstats the open fd and realpaths the original input, asserting the two refer to the same inode. A symlink swap that happens between resolve and open will fail at the identity check rather than silently following the new target.
Per-call symlinks: "follow-within-root" allows symlinks whose final target is still inside the root. The default is "reject".
symlinks: "follow-parents-within-root" allows contained parent directory aliases while rejecting a final symlink, including dangling links. Reads open the checked canonical parent plus the final basename with the usual no-follow and identity checks, so callers do not need their own parent canonicalization.
Guarded root reads compare lossless bigint identities from before open, the opened descriptor, the input path, and the canonical target; numeric public Stats receipts are not used as identity evidence. Before returning a handle or reading bytes, one best-effort final admission fence checks the originally captured root identity, freshly compares the policy-aware pathname with the opened descriptor, canonicalizes and re-admits that current target inside the captured root, compares the canonical target's exact bigint identity without following a final symlink with the descriptor, and checks the root identity again. The two final pathname observations remain independent even when the spellings match. Unknown Windows device/inode values receive one re-inspection without reopening the file. A definite mismatch or persistent unknown identity rejects with path-mismatch; escaped fresh containment rejects with outside-workspace. This is not an atomic kernel pathname/open primitive, so the namespace can still change after the final observation.
Other regular-file readers, root-file adapters, and archive input staging retain their documented descriptor/path admission. The exported unrooted openLocalFileSafely() and readLocalFileSafely() helpers have no captured Root identity and therefore do not provide the replacement-root fence. copyIn() retains the admitted source identity for its checks before and after copying, independently of its numeric metadata receipt.
The low-level openRootFile() adapters additionally retain the canonical root's exact identity from before component traversal. After their existing pathname and descriptor checks, they verify the root, freshly resolve and re-admit the consumed pathname, compare that canonical leaf with the descriptor, and verify the root again before returning ownership. A failed final fence closes the descriptor without reading. The checks detect substitutions at each observation boundary; they do not make pathname confinement atomic against a continuously racing peer.
#Symlinks (write side)
With the native binding loaded, write(), create(), and copyIn() use a sibling temp file and create parents and publish the target relative to pinned directory descriptors. Replacement uses descriptor-relative rename just like no-replace publication, so replacing the parent pathname does not divert the mutation.
When either denyMutations or an explicit mutationSymlinks policy applies, the POSIX writer binds that exact policy snapshot to parent admission. An existing parent is canonicalized and identity-matched to its retained descriptor before the actual destination is authorized. A missing-parent walk authorizes each prospective directory before mkdirat, opens it without following a newly introduced link, and authorizes the opened object before continuing. This prevents a contained Linux openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) redirect from reusing policy approval for a different in-root subtree. For an ordinary unchanged route, operation-local observations may carry that admission across a direct-child creation only after exact parent and child fences and a synchronous full-epoch validation. The resulting operation-local token authorizes the opened child without an intervening await; stale, redirected, incomplete, or foreign evidence returns to the full ordered admission. An already-complete fallback parent is likewise retained only after full target admission and a fresh exact guard fence. Native acceleration additionally requires an exclusive direct-child mkdir result proving that this syscall created the name; a collision, legacy helper, or malformed result performs the guarded walk but cannot advance the receipt. That boolean is admission provenance only and does not grant ownership for cleanup by pathname.
The opt-in mutationSymlinks policy applies independently of read policy. "reject" rejects symlink components; "follow-parents-within-root" resolves contained directory aliases and rejects final symlinks. Publication checks the final component again after awaited staging and parent fences, immediately before the rename or exclusive open. These are best-effort symlink checks, not an atomic expected-entry/CAS replacement: a concurrent process can still replace the final entry between its check and rename. Existing parent containment guarantees remain as described below. Omitting mutationSymlinks preserves each implementation's existing mutation behavior; it does not currently provide uniform Windows link handling.
For Windows buffered replacement write() and writeJson() calls (overwrite omitted or true), the native pinned path rejects a final file symlink with path-alias. The legacy JavaScript path can follow an unchanged contained final link, keep the link itself, and replace the admitted target. Its configured mutation policy reauthorizes the original target before staging and publication, and its file/parent identity checks remain in force. Likewise, native Windows parent admission refuses junction/reparse traversal and can report invalid-path, while the legacy path can write through an admitted contained parent-junction target.
The legacy writer is selected by native off, missing-binding auto, or renameIdentity: "verify-content-with-lock". Callers requiring the same link rejection across implementations must set mutationSymlinks: "reject" explicitly. See Windows link modes.
The JavaScript fallback used by off, by auto when no binding loads, and by the explicit renameIdentity: "verify-content-with-lock" compatibility policy cannot provide that guarantee because Node exposes no mkdirat or renameat. It asserts directory identity around a pathname mutation and detects many swaps, but detection occurs after the kernel may already have followed a new parent symlink. A same-privilege peer with write access to the parent can therefore cause an out-of-root side effect before the operation throws. Use native require mode to refuse implicit pathname mutation fallbacks, then check the operation capabilities below. Loading an addon alone does not establish confinement for every method.
The separate retained-directory staging lifecycle keeps abort cleanup anchored to the original directory after a parent or ancestor move. It preserves observed substituted temporary entries and never cleans a recorded publication. Directory anchoring is not expected-inode/CAS replacement, and identity-check-then-unlink is not atomic conditional unlink. The guarantee requires the temporary name to remain owned and removal to remain permitted; application authorization and cooperative coordination remain necessary.
#Hardlink aliasing
When hardlinks: "reject" is set, reads stat the target and refuse if nlink > 1, on the conservative assumption that a hardlinked file might alias an out-of-tree inode. This is defense-in-depth: the link count check is best-effort and platform-dependent. Treat it as a tripwire, not authorization.
#TOCTOU between resolve and use
resolve(), exists(), stat(), and list() are explicitly advisory — they answer a question and return. stat() checks the exact selected target and parent while collecting metadata, and list() checks the exact selected directory around its complete batch, so detectable descendant redirection rejects before results are returned. Those checks do not preserve identity after the call. To act on a path with operation-local identity checks, use read(), open(), write(), create(), copyIn(), move(), or remove(). The containment table below states which opens are kernel-atomic and which remain best-effort.
A root() handle also remembers the canonical root directory identity. Calls fail with path-mismatch if that canonical pathname is replaced, including advisory inspection and walking calls, rather than following a replacement root into another tree.
#Denied mutations
denyMutations is an opt-in application policy for root() mutation methods. It blocks exact absolute paths with paths and whole subtrees with prefixes, merging root defaults with per-call entries so a call cannot clear root-level denies. POSIX pinned write, create, and copyIn copy the merged entries before awaiting preflight and reapply them to their admitted canonical parent, including before missing parent creation. This is not an OS permission boundary: code with access to node:fs, a shell, or another process with the same filesystem privileges can bypass it.
#Atomic writes
replaceFileAtomic writes to a sibling temp file in the destination directory, optionally fsyncs it, optionally fsyncs the parent directory after rename, and atomically renames over the destination. On failure mid-write, the destination is either the old contents (rename never happened) or the new contents (rename succeeded). There is no half-written intermediate state visible at the destination path unless the caller explicitly enables copyFallbackOnPermissionError, whose default copyFallbackRestore: "none" contract may leave a partial destination after a failed in-place fallback.
Within one process, async writes to the same target are queued so their temp-write/rename phases do not overlap. Cross-process writers still need an external protocol such as the sidecar lock helpers.
#Directory durability
pinDirectory() opens a directory without following its final component on POSIX, verifies the descriptor against the pathname identity and canonical path, and repeats those checks around synchronization. ensureDurableDirectory() pins the nearest existing ancestor and each newly created segment before synchronizing every new directory edge from the leaf upward.
Known Windows directory-flush limitations are returned as an explicit unsupported outcome. POSIX and other I/O failures propagate from the strict API. The separately named best-effort helpers intentionally provide no crash durability guarantee.
#Archive extraction
extractArchive first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against .. and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
TAR extraction and bounded reads validate the complete decompressed raw framing before parser normalization: directory/link sizes must be zero, EOF must contain two consecutive zero blocks, and only zero padding may follow EOF. This rejects hidden headers and body/header smuggling consistently across the JavaScript and native backends. The meter checks logical member count before consuming bodies, even for unrequested or filtered members. Per-entry/cumulative payload budgets apply only to entries accepted after strip/filter policy, outside the raw meter. A separate absolute decoded cap includes every member body, headers, metadata, all padding, and EOF, closing cumulative-metadata and zero-tail bypasses. Native extraction/read passes drain through physical EOF before publication or returning selected bytes, even when the TAR parser stops earlier. Bounded reads use default archive admission limits and keep maxBytes scoped to the requested output. Metadata retains its per-record bound. See archive framing for supported metadata and compression formats.
#What "library-level" means
A library cannot revoke its own caller's authority. If your code chooses to bypass fs-safe and call fs.writeFile directly with the same path, you bypass the defenses too. The contract fs-safe enforces is: every filesystem operation that touches caller-controlled input goes through the boundary. That contract is yours to keep.
The library does not modify or constrain the global Node.js fs namespace, and it does not patch the runtime. Other code in the same process retains its normal filesystem authority.
#Containment guarantees by platform
openBeneath() and JavaScript open results report one of two factual containment classes:
| Mechanism | Reported containment | Boundary | |
|---|---|---|---|
Linux native with openat2 | kernel-atomic | `openat2(RESOLVE_BENEATH | RESOLVENOMAGICLINKS)` resolves and opens under the root in one kernel operation. |
Linux native without openat2 | best-effort | A no-follow openat component walk retains each parent, follows admitted in-root relative symlinks, and rechecks exact directory/link identities and link targets before and after the final open. | |
| macOS native | best-effort | macOS 15.4 and newer use O_RESOLVE_BENEATH first; older kernels use the guarded openat(O_NOFOLLOW) component walk. Both verify the opened descriptor with F_GETPATH. | |
| Windows native | best-effort | Handle-relative NtCreateFile rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. | |
| JavaScript fallback | best-effort | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
The macOS F_GETPATH verification is an escape detector, not a race-atomic guarantee. A hostile same-UID process can rename a directory after O_RESOLVE_BENEATH or the manual walk and race the post-open sample or a later descriptor-relative mutation. The native result therefore remains best-effort on macOS even when the kernel flag is available. No policy decision is attached to these labels; callers can inspect the fact and decide what their own threat model requires.
The Linux fallback's identity checks are also detection-based: a directory can be renamed between chain samples or before a later descriptor-relative mutation. It cannot provide atomic resolution, and rejection after a mutating open does not promise rollback. See Linux without openat2 for the cached capability probe, conservative symlink rejection, and operations that remain unavailable.
The public OpenResult, ReadResult, and WritableOpenResult expose containment. Those root APIs currently report best-effort; direct native openBeneath() reports the platform value above. No-replace publication uses renameat2(RENAME_NOREPLACE) on Linux, renameatx_np(RENAME_EXCL) on macOS, and FileRenameInfoEx with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
#Native Root mutation capabilities
For removal, recursive removal, mkdir, overwrite move, and writable-open creation, require selects the hardened native capability or rejects with helper-unavailable; it does not silently perform the corresponding Node pathname mutation. Default auto keeps its previous best-effort paths for these operations, even with a native addon loaded. auto does not confine these operations under hostile concurrency. The matrix below describes require. The existing native publication paths for write, create, writeJson, and copyIn are unchanged, and no-clobber move still requires native support in every mode.
| Root operation | Linux with openat2 | Linux guarded fallback | macOS native | Windows native |
|---|---|---|---|---|
write, create, writeJson, copyIn publication | Retained parent descriptors | Retained parents; best-effort admission | Retained parents; best-effort admission | Handle-relative publication; best-effort admission |
remove file, symlink, or empty directory | Identity check and unlinkat at retained parent | Same unlink; best-effort parent admission | Same unlink; best-effort parent admission | Handle-relative open and identity-checked FileDispositionInfoEx; best-effort parent admission |
Recursive remove | Descriptor-relative enumeration and deletion; RESOLVE_NO_XDEV on descent | require rejects; auto uses JavaScript | Descriptor-relative traversal with mount-identity checks; best-effort admission | require rejects; auto uses JavaScript |
mkdir | mkdirat and retained child identity checks | Same creation; best-effort admission | Same creation plus guarded admission | Handle-relative directory creation, including the existing protected private creator; best-effort admission |
move with overwrite | Retained parents, source identity check, renameat | Same rename; best-effort admission | Retained parents and rename; best-effort admission | Source handle identity check and handle-relative rename; best-effort admission |
append / openWritable creating a file | Beneath exclusive open, no-follow final component, identity-checked FileHandle handoff | Same creation through guarded native open; best-effort admission | Guarded native creation and identity-checked FileHandle handoff; best-effort admission | Handle-relative exclusive creation, identity-checked FileHandle handoff and handle-bound cleanup; best-effort admission |
These guarantees have a per-call cost in require: removal pays for retained parent admission and entry checks, recursive removal additionally fences each visited directory, mkdir admits and verifies each created parent, overwrite move retains both parents, and open-create verifies the FileHandle handoff. Adjacent native steps are combined where no authorization callback must intervene, but the identity and policy fences remain. auto keeps its previous operation paths and avoids this added cost.
On Linux, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) admits the parent under the Root atomically. Later mutations use that retained parent, so replacing its pathname with a symlink cannot redirect the syscall to the link's target. Recursive removal never follows an enumerated symlink; it unlinks the link itself unless an explicit mutation policy rejects it. The fallback Linux walk rejects symlinks and keeps its best-effort label. macOS uses the existing guarded O_RESOLVE_BENEATH/F_GETPATH admission and also remains best-effort.
Directory pinning is not an atomic check of the entire namespace at mutation time. A peer can move an admitted directory, and POSIX has no expected-inode conditional unlink or rename: a final name can change after its identity check. Post-operation rejection does not roll back a completed mutation. Policy and identity checks remain defense in depth; OS isolation is needed for stronger same-privilege namespace or authorization guarantees.
Writable-open creation retains a native descriptor while reopening the same inode as a Node FileHandle, without create or truncate flags. If the requested mode or umask cannot be preserved through initial creation and that handoff, require rejects rather than widening permissions. auto keeps its existing JavaScript creation path. The native creation syscall receives the requested mode, preserving inherited ACLs. If the resulting kernel permissions prevent handoff, the call fails closed and attempts identity-bound cleanup of its empty created file; it never broadens those permissions with chmod. Existing-file writable opens, reads, advisory methods, and caller operations on returned handles retain their existing best-effort contracts. Explicit renameIdentity: "verify-content-with-lock" also retains its documented JavaScript compatibility path, including in require mode.
Compatibility: an older or incomplete addon, unavailable mount-bounded descent, or another missing operation capability now causes helper-unavailable in require, including cases that previously fell through to JavaScript. The public result types and containment labels are unchanged.
#Limitations to keep in mind
| Limitation | What it means |
|---|---|
| Not ambient authority removal | Code that can import node:fs can still bypass the handle. Keep caller-controlled path operations behind root() by convention, review, and tests. |
| Absolute paths are escape hatches | APIs that accept or return absolute paths exist for audit, ingest, and advanced composition. Prefer root-relative names in normal application flow. |
| Not a mount boundary | root() keeps path traversal inside the directory tree and blocks known unsafe read device paths, but it does not make bind mounts or virtual filesystems safe to expose wholesale. |
| Per-call, not per-session | Another process with the same privileges can still mutate the tree between calls, and best-effort mechanisms retain documented same-call race windows. Use one verb method to minimize the window and inspect its reported containment class. |
| JavaScript mutations are detection-based | Without the native binding, Node pathname mutations retain a check-to-syscall race. A writable parent can be swapped so a create, rename, or removal affects an out-of-root path before the fallback detects identity drift. |
| Hardlink rejection is best-effort | Link-count checks depend on platform metadata. Treat hardlinks: "reject" as a tripwire, not an authorization primitive. |
| Mode bits are not a full policy engine | replaceFileAtomic and secret-file helpers set requested modes, but you should still set umask and inspect modes when policy requires it. |
| Archive extraction is path safety, not content safety | Unsafe entry paths and links are rejected; malicious payload contents remain your application layer's problem. |
| Native package unavailable | helper-unavailable falls back in auto mode and fails closed in require mode for native-backed operations. Guarded JavaScript atomicity and identity checks remain. |
| FUSE mounts with rename-unstable inode numbers | Some FUSE mounts (rclone is a confirmed example) do not preserve source inode identity at the rename destination. The explicit renameIdentity: "verify-content-with-lock" compatibility mode verifies content under a cooperative lock for that boundary only; subsequent path identity checks and the default remain strict. See Writing for the weaker opt-in contract. |
#Recommended deployment shape
- Run worker code under a dedicated UID with the smallest filesystem privileges that still allow the worker to do its job.
- Mount the workspace directory writable; mount everything else read-only or not at all.
- Use
fs-safe'sroot()for that workspace. - For credentials, use secret files (mode 0600 in mode-0700 dirs) rather than the workspace.
- For scratch space, use a private temp workspace — don't reuse the workspace root.
#Reporting issues
Suspected security issues belong in private disclosure first. See SECURITY.md in the repo for the current contact path.
#Exact receipts and legacy numeric metadata
Identity comparisons require bigint or safe-integer components. Unsafe Number IDs remain rejected: converting a rounded Number to bigint cannot recover the lost bits. This matters on NTFS, whose file IDs can exceed Number precision.
Public open/read results, standalone publication results, directory durability receipts, and temp-workspace receipts carry exactIdentity. Publication failures carry exactTargetIdentity when the created target is known. Retain and pass those exact companions back to identity-checking APIs. Existing numeric fields keep their types and serve as legacy metadata views. Both views come from the same admitted observation, never an extra pathname stat to recover identity. sameFileIdentity() and standalone publication prefer an explicit exactIdentity on a whole receipt. They do not guess between nested source, target, directory, or staged identities.
Root.stat(), typed list(), and entries() expose a non-enumerable exactIdentity, preserving their established numeric JSON representation. Object spread and JSON serialization omit that field: retain the returned object or copy its exact companion explicitly. Other bigint receipt fields also require an application-defined encoding for JSON. Exact receipts record identity at an observation; they add no atomic rename, deletion, ownership, or durability guarantee.