Better FS Tools
Filesystems

Node filesystem

The Node backends. Read and write local files with nodeFileSystem, and set its roots, deny roots, symlink policy, hard-link policy, and file modes.

@better-fs-tools/node has three backends. It has no tools of its own. Pass each backend to any tool or bundle:

BackendPass it asDoes
nodeFileSystem()fsReads and writes local files
nodeCommandRunner()bash runnerRuns bash -c on the local machine. See Runners and environment
nodeDigest()digestSHA-256 on node:crypto. It gives the same values as the default sha256Digest(), and is faster

The package needs Node 24 or later, or Bun. nodeFileSystem() works on POSIX systems only, and throws a TypeError on Windows.

npm install @better-fs-tools/node @better-fs-tools/read @better-fs-tools/write
import { nodeFileSystem } from "@better-fs-tools/node";
import { createReadTool } from "@better-fs-tools/read";

const cwd = "/srv/app";
const fs = nodeFileSystem({
  cwd,
  allowedRoots: [cwd],
  denyRoots: [`${cwd}/.git`, `${cwd}/secrets`],
  symlinks: "reject",
  hardLinks: "refuse",
  newFileMode: 0o600,
  newDirectoryMode: 0o700,
});

export const read = createReadTool({ fs });

Give it to a bundle as fs in the same way. This bundle has read, edit, write, and bash over one folder:

import { nodeCommandRunner, nodeDigest, nodeFileSystem } from "@better-fs-tools/node";
import { shellEnv } from "@better-fs-tools/shell";
import { createFsTools } from "@better-fs-tools/write";

const cwd = process.cwd();
const tools = createFsTools({
  fs: nodeFileSystem({ cwd, allowedRoots: [cwd] }),
  digest: nodeDigest(),
  bash: { runner: nodeCommandRunner({ cwd }), env: shellEnv(() => process.env) },
});

Install @better-fs-tools/shell for shellEnv(). digest: nodeDigest() is optional. Records made with nodeDigest() and with sha256Digest() are valid for each other. On Pi, piCwdFileSystem() from @better-fs-tools/pi/node creates a nodeFileSystem() for each call, rooted at the working directory of the call. See Pi and Choose an integration.

Options

OptionDefaultMeaning
allowedRootsRequiredAt least one folder. A path outside every root gives OUTSIDE_ALLOWED_ROOTS
cwdprocess.cwd()Relative paths and relative roots resolve against it. A relative cwd resolves against process.cwd()
denyRootsNoneRefused as DANGEROUS_PATH. The backend always adds /dev, /proc, and /sys
symlinks"follow-within-roots""reject" refuses a link in any part of the path
identity"required"The only value. Node always reports a device and an inode
id"node"Appears in result.file.backend and in versions
hardLinks"in-place"How a write replaces a file that has more than one hard link. "refuse" refuses it
newFileMode0o666 less the umaskThe mode of a new file
newDirectoryMode0o777 less the umaskThe mode of a folder that a write creates

The filesystem also exposes the resolved cwd, allowedRoots, denyRoots, symlinks, and identity. The paths are absolute, and denyRoots includes /dev, /proc, and /sys.

Allowed roots and deny roots

The backend checks the path twice. It checks the path as the model sent it, before it touches the disk. Then it resolves links and checks the real path. A path outside every allowed root gives OUTSIDE_ALLOWED_ROOTS. A path inside a deny root gives DANGEROUS_PATH, with the deny root in the note:

[read:outside-allowed-roots] /etc/hostname is outside every configured allowed root.
[read:dangerous-path] .env is refused by policy (/tmp/project/.env). Choose another path.
[read:dangerous-path] /dev/null is refused by policy (/dev). Choose another path.

A deny root can be a folder or a file. It has no glob patterns. A relative deny root resolves against cwd, so denyRoots: [".env"] refuses .env in cwd only. To refuse every .env file, use denyPaths() in authorize. See Permissions.

With the default, "follow-within-roots", the backend follows a link when its real path is inside the allowed roots and outside the deny roots. A link that leaves the roots gives OUTSIDE_ALLOWED_ROOTS.

With symlinks: "reject", a link in any part of the path gives DENIED:

[read:denied] link.txt was refused by policy (the path contains a symbolic link and policy rejects symlinks).

The write tools use the same rules. A write follows a link inside the roots and changes the real file. The link stays. A write refuses a link out of the roots and a link to a file that does not exist.

A write usually replaces a file with a new file and a rename. When the file has more than one hard link, a rename would split the links. hardLinks sets what happens then:

ValueDoes
"in-place"The default. Writes the new bytes into the existing file, so every link sees them. This write is not atomic
"refuse"Refuses the write with DENIED
[write:denied] a.txt was refused by policy (the file has more than one hard link).

A file with one link is always replaced with a temporary file and a rename.

File modes

A new file gets 0o666 less the process umask. A folder that a write creates gets 0o777 less the umask. Under the usual umask 0o022, these are 0o644 and 0o755.

Set newFileMode and newDirectoryMode to set the modes exactly. The umask does not apply to a mode that you set. Each value must be an integer from 0 to 0o7777:

const fs = nodeFileSystem({
  cwd,
  allowedRoots: [cwd],
  newFileMode: 0o600,
  newDirectoryMode: 0o700,
});

A replace keeps the permission bits of the old file. It does not keep the owner, the group, extended attributes, or ACLs, because the rename publishes a new file that belongs to the user who writes it.

How it opens a file

open() resolves the path, checks the roots, and opens one file descriptor with O_NOFOLLOW | O_NONBLOCK. It then checks the file type on that descriptor. A FIFO cannot block the call, and a file that another process swaps in after the check is not read. The backend refuses folders, FIFOs, sockets, and devices before it reads a byte.

The read streams from the descriptor. No content leaves the disk before authorize runs. verify() checks the same descriptor after the read, and compares its device, inode, size, and change times.

How it writes a file

A write goes to a temporary file with mode 0o600 next to the target. The backend writes and syncs the bytes, and gives the file the old mode or the new-file mode. Then it takes a lock for the real path, and checks the precondition against a fresh lstat. A new file is published with link(), so a second writer that creates the same file fails with exists. A replace is published with rename(). When a step fails, the backend removes the temporary file and the folders that the write created.

writeCapabilities is { compareAndSwap: true }, and stage() and remove() exist. Some limits apply:

  • The lock works inside one process only. Another process can change the file between the last check and the rename, and that change is lost.
  • A process that stops between stage() and publish() leaves a .<name>.<random>.tmp file next to the target. Nothing removes it later. An apply_patch call stages every file first, so a process that stops during the commit can leave more than one.
  • For a short time during a create, the new file has two links: the temporary name and the target.

A read-only filesystem gives read-only. A full disk or a full quota gives no-space.

On this page