fs-safe/docs/errors.md
2026-05-05 23:25:07 +01:00

155 lines
7.0 KiB
Markdown

# Errors
Every failure that's the library's job to surface lands as an `FsSafeError` with a closed `code` union you can branch on. Catch by code, not by message text — messages may change, codes will not.
```ts
import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";
```
## Shape
```ts
class FsSafeError extends Error {
readonly name: "FsSafeError";
readonly code: FsSafeErrorCode;
readonly category: "policy" | "operational";
constructor(code: FsSafeErrorCode, message: string, options?: { cause?: unknown });
}
```
`cause` is available through the standard `Error` `cause` property when the failure was triggered by a `NodeJS.ErrnoException` (e.g. a wrapped `EACCES`). Inspect it for the original `code` / `errno` / `syscall` if you need finer-grained reporting.
`category` separates caller-policy failures from operational failures:
- `"policy"` — unsafe input or target state, such as `outside-workspace`, `symlink`, `hardlink`, or `too-large`.
- `"operational"` — environment/runtime failures, such as helper startup, platform support, timeout, or unverifiable permissions.
## Code union
```ts
type FsSafeErrorCode =
| "already-exists"
| "hardlink"
| "helper-failed"
| "helper-unavailable"
| "insecure-permissions"
| "invalid-path"
| "not-empty"
| "not-file"
| "not-found"
| "not-owned"
| "not-removable"
| "outside-workspace"
| "path-alias"
| "path-mismatch"
| "permission-unverified"
| "symlink"
| "timeout"
| "too-large"
| "unsupported-platform";
```
## Code reference
| Code | When it fires | Common causes |
|---|---|---|
| `already-exists` | `create()`, `createJson()`, `move({ overwrite: false })`. | Target file or directory already at the destination. |
| `hardlink` | Read or copy with `hardlinks: "reject"` saw `nlink > 1`. | File is hardlinked — possibly an alias of an out-of-tree inode. |
| `helper-failed` | Internal POSIX helper failed after startup. | Inspect `cause`; retrying may be unsafe if the operation may have partially completed. |
| `helper-unavailable` | Persistent Python helper was disabled or could not be spawned. | `FS_SAFE_PYTHON_MODE=off`, Python missing in PATH, restricted sandbox. `auto` falls back where possible; `require` fails closed. |
| `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
| `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows. |
| `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
| `not-file` | Read or copy targeted a non-regular file. | Target was a directory, FIFO, socket, device. |
| `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
| `not-owned` | A secure file owner check failed. | File is owned by another UID. |
| `not-removable` | `remove()` couldn't `unlink`/`rmdir` for a reason other than non-empty. | Permissions, device busy, immutable bit. |
| `outside-workspace` | Path resolves outside the configured root. | `..` traversal; absolute path outside the root; symlink resolved out. |
| `path-alias` | A path alias check failed (e.g. canonical-real-path moved out of the root). | Symlink resolution lands outside the root. |
| `path-mismatch` | Post-open identity check failed: the opened fd does not match the resolved path. | TOCTOU — something else swapped the path between resolve and open. |
| `permission-unverified` | A secure file check could not verify required permissions. | Windows ACL inspection failed; POSIX ownership/mode was unavailable. |
| `symlink` | Path component is a symlink, policy is `reject`. | Caller followed a symlink they shouldn't have, or `symlinks: "reject"` is set. |
| `timeout` | An operation with a wall-clock budget overran. | Secure file read or timed operation exceeded `timeoutMs`. |
| `too-large` | Read exceeded `maxBytes`. | Caller gave a too-permissive file or didn't size-cap correctly. |
| `unsupported-platform` | The requested operation is not supported on the current platform. | E.g. POSIX-only helper invoked on Windows. |
## Branching
```ts
import { FsSafeError } from "@openclaw/fs-safe";
try {
await fs.write("../escape.txt", "x");
} catch (err) {
if (!(err instanceof FsSafeError)) throw err;
switch (err.code) {
case "outside-workspace":
return reply(400, "path escapes workspace");
case "already-exists":
return reply(409, "exists");
case "too-large":
return reply(413, "too large");
case "not-found":
return reply(404, "missing");
case "symlink":
case "hardlink":
case "path-mismatch":
case "path-alias":
return reply(400, "unsafe path");
default:
throw err;
}
}
```
The compiler will flag missing cases when you exhaust the union — keep your switch up-to-date as the library adds new codes.
## Distinguishing from `NodeJS.ErrnoException`
Some failures bubble up as native Node errors (e.g. `EACCES`, `EISDIR`, `EBUSY`) when they don't map cleanly to a library code. Inspect both:
```ts
import { FsSafeError } from "@openclaw/fs-safe";
try {
await op();
} catch (err) {
if (err instanceof FsSafeError) {
handleFsSafe(err);
return;
}
if ((err as NodeJS.ErrnoException).code === "EACCES") {
handleAccess();
return;
}
throw err;
}
```
A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.
## Specialty errors
A handful of helpers throw their own typed errors instead of `FsSafeError`:
- `JsonFileReadError` — thrown by [`readJson`](json.md). Carries `cause` so you can distinguish missing (`ENOENT`) from invalid (`SyntaxError`).
- `ArchiveLimitError` — thrown by [`extractArchive`](archive.md) when an archive size, entry count, or extracted-byte budget is exceeded. The `code` field uses `ARCHIVE_LIMIT_ERROR_CODE` constants (e.g. `"ARCHIVE_SIZE_EXCEEDS_LIMIT"`).
- `ArchiveSecurityError` — thrown by extraction when an entry path violates safety rules (traversal, drive prefix, blocked link type). The `code` field uses `ArchiveSecurityErrorCode` values.
These are exported from their respective subpaths.
## Why `FsSafeError`?
Two reasons it isn't a richer hierarchy of subclasses:
1. **Switch on `code`, don't `instanceof` a tree.** `code` is a closed string union the TypeScript compiler can exhaust-check. Subclasses make `instanceof` ladders that drift over time.
2. **One catch handler.** Library callers often want a single "is this an `fs-safe` failure?" gate before deciding what to do — `instanceof FsSafeError` plus a switch is the cleanest expression of that.
## See also
- [`root()`](root.md) — every method documents the codes it can throw.
- [Reading](reading.md) — read-path codes.
- [Writing](writing.md) — write-path codes.
- [Archive extraction](archive.md) — `ArchiveLimitError` and `ArchiveSecurityError`.