@openclaw/fs-safe 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +6 -4
  3. package/dist/advanced.d.ts +1 -0
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -0
  6. package/dist/device-path.d.ts +13 -0
  7. package/dist/device-path.d.ts.map +1 -0
  8. package/dist/device-path.js +112 -0
  9. package/dist/errors.d.ts +1 -1
  10. package/dist/errors.d.ts.map +1 -1
  11. package/dist/file-identity.d.ts +1 -0
  12. package/dist/file-identity.d.ts.map +1 -1
  13. package/dist/file-identity.js +10 -2
  14. package/dist/fsync.d.ts +2 -0
  15. package/dist/fsync.d.ts.map +1 -0
  16. package/dist/fsync.js +21 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/move-path.d.ts +3 -0
  20. package/dist/move-path.d.ts.map +1 -1
  21. package/dist/move-path.js +13 -1
  22. package/dist/path.d.ts +1 -0
  23. package/dist/path.d.ts.map +1 -1
  24. package/dist/path.js +1 -0
  25. package/dist/pinned-open.d.ts.map +1 -1
  26. package/dist/pinned-open.js +7 -0
  27. package/dist/pinned-write.d.ts +9 -1
  28. package/dist/pinned-write.d.ts.map +1 -1
  29. package/dist/pinned-write.js +80 -4
  30. package/dist/regular-file.d.ts.map +1 -1
  31. package/dist/regular-file.js +3 -0
  32. package/dist/root-impl.d.ts +4 -1
  33. package/dist/root-impl.d.ts.map +1 -1
  34. package/dist/root-impl.js +53 -47
  35. package/dist/root.d.ts +1 -1
  36. package/dist/root.d.ts.map +1 -1
  37. package/dist/secure-file.d.ts.map +1 -1
  38. package/dist/secure-file.js +2 -0
  39. package/dist/test-hooks.d.ts +1 -0
  40. package/dist/test-hooks.d.ts.map +1 -1
  41. package/dist/walk.d.ts +13 -2
  42. package/dist/walk.d.ts.map +1 -1
  43. package/dist/walk.js +29 -6
  44. package/docs/contributing.md +1 -1
  45. package/docs/errors.md +3 -0
  46. package/docs/install.md +3 -3
  47. package/docs/path.md +13 -0
  48. package/docs/reading.md +6 -4
  49. package/docs/root.md +2 -1
  50. package/docs/security-model.md +4 -2
  51. package/docs/types.md +2 -1
  52. package/docs/walk.md +11 -1
  53. package/docs/writing.md +23 -3
  54. package/package.json +23 -17
package/docs/walk.md CHANGED
@@ -25,6 +25,7 @@ type WalkDirectoryResult = {
25
25
  entries: WalkDirectoryEntry[];
26
26
  scannedEntryCount: number;
27
27
  truncated: boolean;
28
+ failedDirs?: WalkDirectoryFailure[];
28
29
  };
29
30
 
30
31
  type WalkDirectoryEntry = {
@@ -35,10 +36,19 @@ type WalkDirectoryEntry = {
35
36
  kind: "file" | "directory" | "symlink" | "other";
36
37
  dirent: import("node:fs").Dirent;
37
38
  };
39
+
40
+ type WalkDirectoryFailure = {
41
+ path: string;
42
+ relativePath: string;
43
+ depth: number;
44
+ error: unknown;
45
+ };
38
46
  ```
39
47
 
40
48
  `depth` starts at `1` for direct children of `rootDir`. `relativePath` is always relative to the supplied root. `scannedEntryCount` counts directory entries examined, including entries filtered out by `include`.
41
49
 
50
+ `walkDirectory()` and `walkDirectorySync()` always return `failedDirs`; the property remains optional on the exported `WalkDirectoryResult` type so existing callers that manually construct the legacy result shape remain source-compatible. It lists every directory whose `realpath`/`readdir` threw, so its contents are absent from `entries`. `error` is the thrown value (a `NodeJS.ErrnoException` at runtime), so callers can distinguish a benign missing-directory race (`ENOENT`) from a real read failure (`EACCES`, `EIO`, `ESTALE`, …). The walk-root failure has an empty `relativePath` and `depth: 0`. Failures resolving a symlink's target kind are not reported here.
51
+
42
52
  ## Options
43
53
 
44
54
  ```ts
@@ -55,7 +65,7 @@ type WalkDirectoryOptions = {
55
65
 
56
66
  `include` controls which entries are returned. `descend` controls which directory entries are traversed. A skipped directory can still be returned if `include` accepts it.
57
67
 
58
- Unreadable directories are skipped. This makes the helper suitable for best-effort inventories and pruning jobs; use a stricter root-bounded operation when every entry must be accounted for.
68
+ Unreadable directories are skipped rather than throwing, but every skipped directory is recorded in `failedDirs`. This keeps the helper suitable for best-effort inventories while letting pruning jobs tell an incomplete scan from an empty one: a destructive reconcile that deletes state for paths missing from `entries` must first confirm `failedDirs` holds no real read failures, or a transient `EIO`/`EACCES` blip would be mistaken for mass deletion. Use a stricter root-bounded operation when every entry must be accounted for.
59
69
 
60
70
  ## See also
61
71
 
package/docs/writing.md CHANGED
@@ -93,14 +93,14 @@ type RootWriteJsonOptions = {
93
93
 
94
94
  ### `fs.append(rel, data, options?)`
95
95
 
96
- Open in append mode, write, close. Honors `mkdir` for the parent directory. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
96
+ Open in append mode, write, sync the file handle, and close. Honors `mkdir` for the parent directory and syncs the parent directory when the append creates the file. Pass `prependNewlineIfNeeded: true` to insert a `\n` if the file does not already end in one.
97
97
 
98
98
  ```ts
99
99
  await fs.append("logs/today.log", `[${ts}] ${line}\n`);
100
100
  await fs.append("notes/scratch.md", "* new bullet", { prependNewlineIfNeeded: true });
101
101
  ```
102
102
 
103
- For high-volume logging, consider [`openWritable`](#openwritable) and a long-lived append handle.
103
+ For high-volume logging, consider [`openWritable`](#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.
104
104
 
105
105
  ### `fs.copyIn(rel, sourceAbsPath, options?)`
106
106
 
@@ -192,7 +192,7 @@ await fs.write("data/blob.bin", buffer, { mkdir: false }); // override
192
192
  | `not-found` | Parent does not exist and `mkdir` is false. |
193
193
  | `not-empty` | `remove()` on a non-empty directory. |
194
194
  | `not-removable` | `remove()` could not unlink/rmdir (typically permissions or device busy). |
195
- | `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer. |
195
+ | `path-mismatch` | Post-write fd identity check did not match. Almost always a parallel writer, or a FUSE mount with unstable inode numbers — see `renameIdentity` below. |
196
196
  | `too-large` | `copyIn()` source exceeded `maxBytes`. |
197
197
  | `symlink` | A path component is a symlink and policy is `reject`. |
198
198
  | `hardlink` | `sourceHardlinks: "reject"` saw `nlink > 1`. |
@@ -232,6 +232,26 @@ try {
232
232
  await fs.append(today, line);
233
233
  ```
234
234
 
235
+ ## FUSE mounts and unstable inode numbers
236
+
237
+ 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`.
238
+
239
+ 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:
240
+
241
+ ```ts
242
+ const fs = await root("/mnt/rclone-workspace", {
243
+ renameIdentity: "verify-content-with-lock",
244
+ });
245
+
246
+ await fs.write("state.json", body); // succeeds on rclone FUSE
247
+ ```
248
+
249
+ **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 bypasses the stricter fd-relative Python helper because that helper requires rename to preserve inode identity. The lock is released before the call returns.
250
+
251
+ **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 the Python helper's 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.
252
+
253
+ 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. Remove a stale sidecar only after proving that its holder can no longer write, using the application-owned recovery guidance in [File lock](sidecar-lock.md#stale-recovery-remove-if-unchanged).
254
+
235
255
  ## See also
236
256
 
237
257
  - [Atomic writes](atomic.md) — the lower-level `replaceFileAtomic` and friends.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -97,29 +97,35 @@
97
97
  "default": "./dist/test-hooks.js"
98
98
  }
99
99
  },
100
+ "optionalDependencies": {
101
+ "jszip": "^3.10.1",
102
+ "tar": "7.5.19"
103
+ },
104
+ "devDependencies": {
105
+ "@types/node": "^22.20.0",
106
+ "@vitest/coverage-v8": "4.1.9",
107
+ "typescript": "^5.9.3",
108
+ "vite": "7.3.5",
109
+ "vitest": "^4.1.9"
110
+ },
111
+ "engines": {
112
+ "node": ">=22"
113
+ },
100
114
  "scripts": {
101
115
  "benchmark": "node scripts/benchmark.mjs",
102
116
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
103
117
  "lint:file-size": "node scripts/check-file-size.mjs",
104
118
  "lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
105
- "prepack": "node scripts/prepack-build.mjs",
106
119
  "test": "vitest run",
107
120
  "test:coverage": "vitest run --coverage",
108
121
  "test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
109
122
  "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
110
- "docs:site": "node scripts/build-docs-site.mjs"
111
- },
112
- "optionalDependencies": {
113
- "jszip": "^3.10.1",
114
- "tar": "7.5.13"
115
- },
116
- "devDependencies": {
117
- "@types/node": "^22.15.19",
118
- "@vitest/coverage-v8": "4.1.6",
119
- "typescript": "^5.8.3",
120
- "vitest": "^4.1.6"
121
- },
122
- "engines": {
123
- "node": ">=20.11"
123
+ "docs:site": "node scripts/build-docs-site.mjs",
124
+ "check:changed": "pnpm run check",
125
+ "test:changed": "pnpm run test",
126
+ "crabbox:hydrate": "crabbox actions hydrate",
127
+ "crabbox:run": "crabbox run",
128
+ "crabbox:stop": "crabbox stop",
129
+ "crabbox:warmup": "crabbox warmup"
124
130
  }
125
- }
131
+ }