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:
| Backend | Pass it as | Does |
|---|---|---|
nodeFileSystem() | fs | Reads and writes local files |
nodeCommandRunner() | bash runner | Runs bash -c on the local machine. See Runners and environment |
nodeDigest() | digest | SHA-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/writeimport { 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
| Option | Default | Meaning |
|---|---|---|
allowedRoots | Required | At least one folder. A path outside every root gives OUTSIDE_ALLOWED_ROOTS |
cwd | process.cwd() | Relative paths and relative roots resolve against it. A relative cwd resolves against process.cwd() |
denyRoots | None | Refused 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 |
newFileMode | 0o666 less the umask | The mode of a new file |
newDirectoryMode | 0o777 less the umask | The 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.
Symbolic links
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.
Hard links
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:
| Value | Does |
|---|---|
"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()andpublish()leaves a.<name>.<random>.tmpfile next to the target. Nothing removes it later. Anapply_patchcall 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.