@openclaw/fs-safe 0.21.0 → 0.21.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 (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/archive-zip-directory.js +4 -0
  3. package/dist/archive-zip-loader.js +3 -1
  4. package/dist/archive-zip-manifest.js +3 -1
  5. package/dist/copy-publication.d.ts +1 -3
  6. package/dist/copy-publication.js +2 -6
  7. package/dist/deny-mutation-match.d.ts +2 -0
  8. package/dist/deny-mutation-match.js +71 -0
  9. package/dist/deny-mutations.js +4 -3
  10. package/dist/file-identity.js +10 -2
  11. package/dist/file-lock-sync-admission.js +5 -1
  12. package/dist/file-lock-sync-root-io.d.ts +2 -2
  13. package/dist/file-lock-sync-root-io.js +1 -1
  14. package/dist/file-lock-sync.js +2 -2
  15. package/dist/file-store-prune.js +4 -4
  16. package/dist/mutation-authority.js +5 -0
  17. package/dist/native-binding.d.ts +11 -0
  18. package/dist/native-pinned-write.js +8 -2
  19. package/dist/pinned-mutation-admission.js +4 -2
  20. package/dist/replace-file-copy-fallback.js +4 -4
  21. package/dist/replace-file-destination.d.ts +4 -0
  22. package/dist/replace-file-destination.js +67 -5
  23. package/dist/replace-file.js +2 -1
  24. package/dist/retained-file-types.d.ts +3 -14
  25. package/dist/root-directory-list.d.ts +2 -0
  26. package/dist/root-directory-list.js +46 -20
  27. package/dist/root-move-noreplace.js +3 -3
  28. package/dist/sidecar-lock-acquire.js +3 -3
  29. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  30. package/dist/sidecar-lock-reclaim.js +6 -6
  31. package/dist/sidecar-lock.js +4 -4
  32. package/dist/staged-symlink-types.d.ts +4 -14
  33. package/dist/test-hooks.d.ts +1 -0
  34. package/dist/watch-alias.js +11 -3
  35. package/dist/watch-hints.d.ts +2 -1
  36. package/dist/watch-hints.js +17 -1
  37. package/dist/watch-native.d.ts +4 -0
  38. package/dist/watch-native.js +18 -1
  39. package/dist/watch-scan.d.ts +5 -1
  40. package/dist/watch-scan.js +37 -6
  41. package/dist/watch-stream.d.ts +8 -0
  42. package/dist/watch-stream.js +32 -0
  43. package/dist/watch-types.d.ts +2 -0
  44. package/dist/watch.js +35 -7
  45. package/docs/archive.md +6 -0
  46. package/docs/atomic.md +23 -2
  47. package/docs/contributing.md +69 -6
  48. package/docs/install.md +2 -0
  49. package/docs/native.md +24 -6
  50. package/docs/public-api.md +23 -2
  51. package/docs/retained-file.md +4 -2
  52. package/docs/root.md +19 -7
  53. package/docs/sidecar-lock.md +1 -1
  54. package/docs/staged-symlink.md +2 -1
  55. package/docs/testing.md +132 -10
  56. package/docs/watch.md +77 -10
  57. package/package.json +8 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.21.1 - 2026-09-27
6
+
7
+ ### Fixes
8
+
9
+ - **Linux without `openat2`:** native opens now follow in-root relative symlinks exactly as `RESOLVE_BENEATH` does, rejecting absolute links, `..` escapes, procfs and `nosymfollow` links, and chains longer than 40. Denial and symlink policies report the same errors as the `openat2` path. Links inside sticky world-writable directories are refused. ([#717](https://github.com/openclaw/fs-safe/pull/717))
10
+ - **Deny policies on case-insensitive filesystems:** `denyMutations` now denies creating a not-yet-existing case or Unicode-normalization alias of a denied path (for example `case.txt` when `Case.txt` is denied on APFS, NTFS or casefold ext4). Where a filesystem's sensitivity cannot be proven, Unicode-equivalent names are denied conservatively. ([#720](https://github.com/openclaw/fs-safe/pull/720))
11
+ - **Directory listings:** `list()`, `entries()` and `walk()` reject names that are not valid UTF-8 with `invalid-path` instead of decoding them lossily, which could collide with a real `U+FFFD` name and report another entry's metadata. ([#715](https://github.com/openclaw/fs-safe/pull/715))
12
+ - **ZIP admission:** entries that differ only by case or Unicode normalization are rejected during preflight and member reads, matching extraction's collision policy. ([#716](https://github.com/openclaw/fs-safe/pull/716))
13
+ - **Atomic restore:** when a metadata call fails with `EIO` after the destination was truncated, restore-original writes the original bytes back through the retained, identity-verified descriptor instead of leaving the file empty. ([#718](https://github.com/openclaw/fs-safe/pull/718))
14
+ - **Lock and prune cleanup:** cleanup decisions compare exact bigint file identities. Legacy numeric receipts with IDs above 2^53 are refused instead of risking removal of the wrong file. ([#719](https://github.com/openclaw/fs-safe/pull/719))
15
+ - **Watch overflow:** subscriptions no longer publish spurious whole-scope overflow invalidations for excluded or unselected paths or for slow reconcile passes. macOS streams use OS-level exclusion paths and anchor-scoped streams, and Windows uses a 1 MiB change buffer on local volumes. Genuine OS event drops still invalidate. ([#724](https://github.com/openclaw/fs-safe/pull/724))
16
+ - **Mutation authority:** reject synchronous and asynchronous generator callback results before mutation, so lazy authority checks cannot be skipped. ([#711](https://github.com/openclaw/fs-safe/pull/711))
17
+ - **Synchronous atomic writes:** reject deferred `beforeRename` hooks before publication and clean up the owned stage. ([#728](https://github.com/openclaw/fs-safe/pull/728))
18
+ - **Copy publication callbacks:** reject deferred generator observers in `Root.copyIn()` while preserving the completed destination and source bytes. ([#726](https://github.com/openclaw/fs-safe/pull/726))
19
+ - **Native move diagnostics:** no-clobber `Root.move()` keeps the original native loader error as its cause, including missing libraries or incompatible glibc versions. ([#709](https://github.com/openclaw/fs-safe/pull/709))
20
+
21
+ ### Features
22
+
23
+ - **Watch polling interval:** `pollIntervalMs` sets the scan interval whenever polling is the selected transport, including `mode: "auto"` falling back to polling, without changing the events reconcile interval. ([#723](https://github.com/openclaw/fs-safe/pull/723))
24
+
25
+ ### Performance
26
+
27
+ - **Linux directory cloning:** reuse the admitted filesystem type when creating clone sources and cloning trees, avoiding a duplicate filesystem probe and allocation per operation. ([#732](https://github.com/openclaw/fs-safe/pull/732))
28
+
5
29
  ## 0.21.0 - 2026-09-26
6
30
 
7
31
  ### Highlights
@@ -1,5 +1,6 @@
1
1
  import { assertArchiveEntryCountWithinLimit, } from "./archive-limits.js";
2
2
  import { admitZipNames, zipExtraFields, zipFormat, zipUInt64 } from "./archive-zip-names.js";
3
+ import { createArchiveOutputPathTracker } from "./archive-entry.js";
3
4
  function entryKind(attributes, directory) {
4
5
  const type = (attributes >>> 16) & 0o170000;
5
6
  // High-word symlinks remain links regardless of the creator or directory bits.
@@ -186,6 +187,7 @@ export function* scanZipDirectory(size, limits, onEntry) {
186
187
  const directory = yield* layout(size);
187
188
  assertArchiveEntryCountWithinLimit(directory.count, limits);
188
189
  const seen = new Set();
190
+ const trackKnownPath = createArchiveOutputPathTracker();
189
191
  const spans = [];
190
192
  let at = directory.start;
191
193
  let count = 0;
@@ -226,6 +228,8 @@ export function* scanZipDirectory(size, limits, onEntry) {
226
228
  const localNames = yield* read(localAt + 30, localNameLength + localExtraLength, directory.start);
227
229
  const localExtra = zipExtraFields(localNames.subarray(localNameLength));
228
230
  const admittedName = admitZipNames({ central: centralName, local: localNames.subarray(0, localNameLength), flags, centralExtra, localExtra, seen });
231
+ if (admittedName.path !== undefined)
232
+ trackKnownPath(admittedName.path, admittedName.path);
229
233
  const localValues = wideValues(local, localExtra, false);
230
234
  const crc = central.readUInt32LE(16);
231
235
  if (!(flags & 8) && (local.readUInt32LE(14) !== crc || localValues.compressed !== values.compressed || localValues.uncompressed !== values.uncompressed)) {
@@ -1,5 +1,5 @@
1
1
  import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
2
- import { validateArchiveEntryPath } from "./archive-entry.js";
2
+ import { createArchiveOutputPathTracker, validateArchiveEntryPath } from "./archive-entry.js";
3
3
  import { createAdmittedZipEntry } from "./archive-zip-entry.js";
4
4
  import { zipPathKey } from "./archive-zip-names.js";
5
5
  export function assertZipEntryBinding(archive, record, path) {
@@ -27,7 +27,9 @@ function matchesData(value, physical) {
27
27
  /** Internal: the caller has admitted these unchanged bytes and their metadata. */
28
28
  export async function loadAdmittedZipArchive(buffer, admitted) {
29
29
  const physicalByPath = new Map();
30
+ const trackOutputPath = createArchiveOutputPathTracker();
30
31
  for (const entry of admitted) {
32
+ trackOutputPath(entry.portableKey, entry.portableKey);
31
33
  if (physicalByPath.has(entry.portableKey))
32
34
  collision();
33
35
  physicalByPath.set(entry.portableKey, entry);
@@ -1,10 +1,11 @@
1
1
  import { ArchiveFormatError, ArchiveSecurityError } from "./archive-errors.js";
2
- import { stripArchivePath } from "./archive-entry.js";
2
+ import { createArchiveOutputPathTracker, stripArchivePath } from "./archive-entry.js";
3
3
  /** Associate all native entries with physical admission before exposing any. */
4
4
  export function validateNativeZipManifest(manifest, admitted) {
5
5
  if (manifest.length !== admitted.length) {
6
6
  throw new ArchiveSecurityError("entry-path", "zip decoder collapsed entry names");
7
7
  }
8
+ const trackOutputPath = createArchiveOutputPathTracker();
8
9
  for (const [ordinal, entry] of manifest.entries()) {
9
10
  const physical = admitted[ordinal];
10
11
  const physicalPath = physical?.path;
@@ -18,5 +19,6 @@ export function validateNativeZipManifest(manifest, admitted) {
18
19
  (physical.creatorSystem === 3 && entry.mode !== physical.externalAttributes >>> 16)) {
19
20
  throw new ArchiveFormatError("ZIP decoder disagrees with admitted directory metadata");
20
21
  }
22
+ trackOutputPath(entry.path, entry.path);
21
23
  }
22
24
  }
@@ -1,9 +1,7 @@
1
1
  import type { BigIntStats } from "node:fs";
2
2
  import type { PinnedWriteParams, PublishedWriteIdentity } from "./pinned-write-types.js";
3
- export type RootCopyPublicationReceipt = Readonly<{
3
+ export type RootCopyPublicationReceipt = Readonly<PublishedWriteIdentity & {
4
4
  path: string;
5
- dev: bigint;
6
- ino: bigint;
7
5
  }>;
8
6
  export declare function createCopyPublicationObserver(path: string, notify?: (receipt: RootCopyPublicationReceipt) => void): {
9
7
  onPublished(identity: PublishedWriteIdentity): void;
@@ -1,16 +1,12 @@
1
1
  import { FsSafeError } from "./errors.js";
2
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
2
3
  export function createCopyPublicationObserver(path, notify) {
3
4
  let rejected;
4
5
  return {
5
6
  onPublished(identity) {
6
7
  const receipt = Object.freeze({ path, dev: identity.dev, ino: identity.ino });
7
8
  try {
8
- const result = notify?.(receipt);
9
- if (result !== null && (typeof result === "object" || typeof result === "function") &&
10
- "then" in result && typeof result.then === "function") {
11
- void Promise.resolve(result).catch(() => undefined);
12
- throw new TypeError("onDestinationPublished must be synchronous");
13
- }
9
+ assertSynchronousCallbackResult(notify?.(receipt), "onDestinationPublished");
14
10
  }
15
11
  catch (error) {
16
12
  rejected = { error };
@@ -0,0 +1,2 @@
1
+ /** Cache observations only within one synchronous policy check, never across mutations or callbacks. */
2
+ export declare function createMutationDenyMatcher(): (target: string, denied: string, prefix: boolean, protectAncestors?: boolean) => boolean;
@@ -0,0 +1,71 @@
1
+ import path from "node:path";
2
+ import { isPathInside } from "./path.js";
3
+ import { probePathCaseInsensitiveSync } from "./path-case.js";
4
+ import { observeMutationPath } from "./pinned-mutation-observation.js";
5
+ function foldedName(name) {
6
+ // Decompose before folding so combining marks precede an expanded Greek subscript.
7
+ // Default folding preserves dotless i even though its uppercase mapping is I.
8
+ return name.normalize("NFD").replace(/[^\u0131]+/gu, part => part.toLowerCase().toUpperCase().toLowerCase()).normalize("NFC");
9
+ }
10
+ function asciiCasePair(left, right) {
11
+ return /^[\x00-\x7f]*$/.test(left) && /^[\x00-\x7f]*$/.test(right) &&
12
+ left.toLowerCase() === right.toLowerCase();
13
+ }
14
+ /** Cache observations only within one synchronous policy check, never across mutations or callbacks. */
15
+ export function createMutationDenyMatcher() {
16
+ const observations = new Map();
17
+ const caseObservations = new Map();
18
+ const observe = (pathname) => {
19
+ if (!observations.has(pathname))
20
+ observations.set(pathname, observeMutationPath(pathname));
21
+ return observations.get(pathname);
22
+ };
23
+ const prospectiveInside = (parent, child) => {
24
+ const root = path.parse(parent).root;
25
+ if (root !== path.parse(child).root)
26
+ return false;
27
+ const left = parent.slice(root.length).split(path.sep);
28
+ const right = child.slice(root.length).split(path.sep).slice(0, left.length);
29
+ if (left.length !== right.length || !left.every((name, i) => foldedName(name) === foldedName(right[i]))) {
30
+ return false;
31
+ }
32
+ // Byte-identical relations were already handled without any observations.
33
+ if (left.every((name, i) => name === right[i]))
34
+ return false;
35
+ const alignedChild = path.join(root, ...right);
36
+ const a = observe(parent);
37
+ const b = observe(alignedChild);
38
+ if (!a || !b)
39
+ return true;
40
+ // Distinct existing ancestors are real objects, not prospective aliases.
41
+ if (a.canonicalAncestor !== b.canonicalAncestor ||
42
+ a.missingSegments.length === 0 || a.missingSegments.length !== b.missingSegments.length)
43
+ return false;
44
+ const missingLeft = a.missingSegments;
45
+ const missingRight = b.missingSegments;
46
+ if (!missingLeft.every((name, i) => foldedName(name) === foldedName(missingRight[i])))
47
+ return false;
48
+ const difference = missingLeft.findIndex((name, i) => name !== missingRight[i]);
49
+ if (difference === -1)
50
+ return true;
51
+ // A read-only ASCII observation can disprove aliasing at this existing parent.
52
+ // It cannot prove Unicode sensitivity or the behavior of a future directory.
53
+ if (difference === 0 && asciiCasePair(missingLeft[0], missingRight[0])) {
54
+ if (!caseObservations.has(a.canonicalAncestor)) {
55
+ caseObservations.set(a.canonicalAncestor, probePathCaseInsensitiveSync(path.join(a.canonicalAncestor, missingLeft[0]), { allowTemporaryProbe: false }));
56
+ }
57
+ if (caseObservations.get(a.canonicalAncestor) === false)
58
+ return false;
59
+ }
60
+ // Suffix probing writes directories. Policy admission has no authority for
61
+ // those side effects, so unproven case/normalization equivalence fails closed.
62
+ return true;
63
+ };
64
+ return (target, denied, prefix, protectAncestors = false) => {
65
+ if ((isPathInside(denied, target) && (prefix || isPathInside(target, denied))) ||
66
+ (protectAncestors && isPathInside(target, denied)))
67
+ return true;
68
+ return ((prefix || target.split(path.sep).length === denied.split(path.sep).length) &&
69
+ prospectiveInside(denied, target)) || (protectAncestors && prospectiveInside(target, denied));
70
+ };
71
+ }
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { createMutationDenyMatcher } from "./deny-mutation-match.js";
3
4
  import { FsSafeError } from "./errors.js";
4
- import { assertNoNulPathInput, isNotFoundPathError, isPathInside } from "./path.js";
5
+ import { assertNoNulPathInput, isNotFoundPathError } from "./path.js";
5
6
  import { realpathSync } from "./realpath.js";
6
7
  import { resolveExistingAncestor } from "./root-path-existing.js";
7
8
  import { assertNoWindowsPathAlias, pathForWindowsFilesystem, resolvePathPreservingWindowsRoot, } from "./windows-path-alias.js";
@@ -65,6 +66,7 @@ export function assertMutationNotDenied(filePath, policy, options = {}, mode = "
65
66
  const strict = mode === "sync-root-lock";
66
67
  const comparablePaths = strict ? strictMutationComparablePaths : resolveMutationComparablePaths;
67
68
  const targets = comparablePaths(filePath);
69
+ const matches = createMutationDenyMatcher();
68
70
  // Validate one complete phase at a time. A paths denial must not read or
69
71
  // canonicalize prefixes, and invalid later paths retain validation precedence.
70
72
  for (const kind of ["paths", "prefixes"]) {
@@ -72,8 +74,7 @@ export function assertMutationNotDenied(filePath, policy, options = {}, mode = "
72
74
  const deniedPaths = comparablePaths(entry);
73
75
  for (const target of targets) {
74
76
  for (const denied of deniedPaths) {
75
- if ((isPathInside(denied, target) && (kind === "prefixes" || isPathInside(target, denied))) ||
76
- (options.protectAncestors === true && isPathInside(target, denied))) {
77
+ if (matches(target, denied, kind === "prefixes", options.protectAncestors)) {
77
78
  throw new FsSafeError("denied-path", "path is denied by denyMutations policy");
78
79
  }
79
80
  }
@@ -22,9 +22,17 @@ export function sameFileIdentity(left, right, platform = process.platform) {
22
22
  return (!isStatValueProvablyDifferent(left.dev, right.dev, platform) &&
23
23
  !isStatValueProvablyDifferent(left.ino, right.ino, platform));
24
24
  }
25
+ function sameCleanupStatValue(left, right) {
26
+ // Converting an unsafe number to bigint cannot recover an already rounded ID.
27
+ if (typeof left === "number" && !Number.isSafeInteger(left))
28
+ return false;
29
+ if (typeof right === "number" && !Number.isSafeInteger(right))
30
+ return false;
31
+ return sameStatValue(left, right);
32
+ }
25
33
  export function sameFileIdentityForCleanup(left, right, platform = process.platform) {
26
34
  if (platform !== "win32") {
27
- return sameStatValue(left.dev, right.dev) && sameStatValue(left.ino, right.ino);
35
+ return sameCleanupStatValue(left.dev, right.dev) && sameCleanupStatValue(left.ino, right.ino);
28
36
  }
29
37
  // A zero Windows device or inode is unknown, not proof that a pathname still
30
38
  // names the object we created. Cleanup must fail closed rather than delete a
@@ -44,5 +52,5 @@ export function sameFileIdentityForCleanup(left, right, platform = process.platf
44
52
  const rightIno = right.ino;
45
53
  if (isZero(rightIno))
46
54
  return false;
47
- return sameStatValue(leftDev, rightDev) && sameStatValue(leftIno, rightIno);
55
+ return sameCleanupStatValue(leftDev, rightDev) && sameCleanupStatValue(leftIno, rightIno);
48
56
  }
@@ -30,7 +30,11 @@ export function defaultSyncShouldReclaim(snapshot, staleMs, nowMs) {
30
30
  const createdAtMs = sidecarLockPayloadCreatedAtMs(snapshot.payload);
31
31
  if (createdAtMs !== null)
32
32
  return nowMs - createdAtMs > staleMs;
33
- return !snapshot.stat || nowMs - snapshot.stat.mtimeMs > staleMs;
33
+ if (!snapshot.stat)
34
+ return true;
35
+ const mtimeMs = "mtimeNs" in snapshot.stat
36
+ ? Number(snapshot.stat.mtimeNs) / 1e6 : snapshot.stat.mtimeMs;
37
+ return nowMs - mtimeMs > staleMs;
34
38
  }
35
39
  export function syncReclaimGuardExists(reclaimGuardPath) {
36
40
  try {
@@ -1,4 +1,4 @@
1
- import { type BigIntStats, type Stats } from "node:fs";
1
+ import { type BigIntStats } from "node:fs";
2
2
  import type { RootDefaults } from "./root-options.js";
3
3
  import { type FileLockSyncRootPath } from "./file-lock-sync-root.js";
4
4
  export type ExactIdentity = Readonly<{
@@ -19,7 +19,7 @@ export type FileLockSyncRootDiskSnapshot = {
19
19
  ownershipToken?: never;
20
20
  payload: unknown;
21
21
  raw: string;
22
- stat: Stats;
22
+ stat: BigIntStats;
23
23
  };
24
24
  export type FileLockSyncRootSnapshot = Readonly<{
25
25
  receipt: FileLockSyncRootFileReceipt;
@@ -143,7 +143,7 @@ export function readFileLockSyncRootSnapshot(pathAuthority, options = {}) {
143
143
  throw error;
144
144
  }
145
145
  assertRegularFile(after, pathAuthority.authority.hardlinks);
146
- const stat = fs.fstatSync(fd);
146
+ const stat = fs.fstatSync(fd, { bigint: true });
147
147
  if (expectedReceipt) {
148
148
  assertRetainedParentCurrent(pathAuthority, parent);
149
149
  }
@@ -240,7 +240,7 @@ export function acquireFileLockSync(targetPath, options) {
240
240
  createdSnapshot = {
241
241
  raw,
242
242
  payload,
243
- stat: fs.fstatSync(fd),
243
+ stat: fs.fstatSync(fd, { bigint: true }),
244
244
  ownershipToken,
245
245
  };
246
246
  createdHeld = {
@@ -280,7 +280,7 @@ export function acquireFileLockSync(targetPath, options) {
280
280
  const failed = createdSnapshot ?? { payload: null };
281
281
  if (!failed.stat) {
282
282
  try {
283
- failed.stat = fs.fstatSync(fd);
283
+ failed.stat = fs.fstatSync(fd, { bigint: true });
284
284
  }
285
285
  catch {
286
286
  // Missing identity leaves the sidecar in place, but must not skip close.
@@ -23,10 +23,10 @@ export async function pruneExpiredStoreEntries(params) {
23
23
  const rootGuard = {
24
24
  dir: rootReal,
25
25
  realPath: rootReal,
26
- stat: fsSync.lstatSync(rootReal),
26
+ stat: fsSync.lstatSync(rootReal, { bigint: true }),
27
27
  };
28
28
  async function assertRootGuard() {
29
- const stat = fsSync.lstatSync(rootGuard.dir);
29
+ const stat = fsSync.lstatSync(rootGuard.dir, { bigint: true });
30
30
  if (stat.isSymbolicLink() ||
31
31
  !stat.isDirectory() ||
32
32
  stat.dev !== rootGuard.stat.dev ||
@@ -36,7 +36,7 @@ export async function pruneExpiredStoreEntries(params) {
36
36
  }
37
37
  }
38
38
  async function readStableDirectory(dir) {
39
- const before = observeOrNull(() => fsSync.lstatSync(dir));
39
+ const before = observeOrNull(() => fsSync.lstatSync(dir, { bigint: true }));
40
40
  if (!before || before.isSymbolicLink() || !before.isDirectory()) {
41
41
  return null;
42
42
  }
@@ -48,7 +48,7 @@ export async function pruneExpiredStoreEntries(params) {
48
48
  if (!entries) {
49
49
  return null;
50
50
  }
51
- const after = observeOrNull(() => fsSync.lstatSync(dir));
51
+ const after = observeOrNull(() => fsSync.lstatSync(dir, { bigint: true }));
52
52
  if (!after || before.dev !== after.dev || before.ino !== after.ino) {
53
53
  return null;
54
54
  }
@@ -1,3 +1,4 @@
1
+ import { types } from "node:util";
1
2
  import { FsSafeError } from "./errors.js";
2
3
  export class MutationAuthorityError extends FsSafeError {
3
4
  rejection;
@@ -14,6 +15,10 @@ export function assertSynchronousCallbackResult(returned, name) {
14
15
  void Promise.resolve(returned).catch(() => undefined);
15
16
  throw new TypeError(`${name} must be synchronous`);
16
17
  }
18
+ // A generator returns its iterator before running the callback body.
19
+ if (types.isGeneratorObject(returned)) {
20
+ throw new TypeError(`${name} must be synchronous`);
21
+ }
17
22
  }
18
23
  export function composeMutationAssertions(defaultAssertion, callAssertion) {
19
24
  if (!defaultAssertion && !callAssertion)
@@ -106,6 +106,7 @@ export interface NativeBinding {
106
106
  /** Windows-only, private handle custody; no borrowed/runtime descriptors. */
107
107
  retainWindowsFile?(directory: string, basename: string, parentDev: bigint, parentIno: bigint, dev: bigint, ino: bigint, size: bigint, mtimeNs: bigint, ctimeNs: bigint, sha256: string, maxBytes: number): NativeRetainedFile;
108
108
  watchRegister?(root: string, limit: number, callback: (batch: import("./watch-native.js").NativeWatchWireBatch) => void, persistent: boolean): number;
109
+ watchConfigure?(id: number, anchors: string[], exclusions: string[]): void;
109
110
  watchAdd?(id: number, directory: {
110
111
  root: string;
111
112
  relative: string;
@@ -117,6 +118,16 @@ export interface NativeBinding {
117
118
  watchTestEvent?(id: number, path: string, flags: number): void;
118
119
  watchUnregister?(id: number): void;
119
120
  watchThreadCount?(): number;
121
+ watchMemoryStats?(): {
122
+ registrations: number;
123
+ pendingSets: number;
124
+ payloadsLive: number;
125
+ payloadsCreated: number;
126
+ payloadsDestroyed: number;
127
+ threadsafeFunctionsLive: number;
128
+ threadsafeFunctionsCreated: number;
129
+ threadsafeFunctionsDestroyed: number;
130
+ };
120
131
  /** Internal: consumes only a descriptor returned by this binding. */
121
132
  closeOwnedFd(fd: number): void;
122
133
  /** Internal Darwin-only synchronous inspection; the caller retains its fd. */
@@ -182,8 +182,14 @@ async function capturePolicyAwarePosixParent(binding, params, rootFd, directoryF
182
182
  completeParentFd = binding.openBeneath(rootFd, params.relativeParentPath, directoryFlags).fd;
183
183
  }
184
184
  catch (error) {
185
- if (error?.code !== "ENOENT" || !params.mkdir)
186
- throw error;
185
+ if (error?.code !== "ENOENT" || !params.mkdir) {
186
+ // A mechanism rejection must not hide canonical denial/symlink policy.
187
+ // This admission only classifies failure; it cannot authorize a retry.
188
+ await authorizePinnedMutation(params, {
189
+ targetPath: initialTarget, mutationPath: initialTarget, phase: "parent",
190
+ });
191
+ throw normalizePolicyParentOpenError(error, params);
192
+ }
187
193
  }
188
194
  if (completeParentFd !== undefined) {
189
195
  const parentFd = completeParentFd;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { assertMutationNotDenied } from "./deny-mutations.js";
3
+ import { createMutationDenyMatcher } from "./deny-mutation-match.js";
3
4
  import { FsSafeError } from "./errors.js";
4
5
  import { isPathInside } from "./path.js";
5
6
  import { admitPathInsideRoot } from "./root-boundary.js";
@@ -100,8 +101,9 @@ function epochCurrent(epoch, directories) {
100
101
  getFsSafeNativeConfig().mode === epoch.mode;
101
102
  }
102
103
  function assertCachedNotDenied(target, epoch) {
103
- if (epoch.paths.some((paths) => paths.some((denied) => isPathInside(denied, target) && isPathInside(target, denied))) ||
104
- epoch.prefixes.some((paths) => paths.some((denied) => isPathInside(denied, target)))) {
104
+ const matches = createMutationDenyMatcher();
105
+ if (epoch.paths.some((paths) => paths.some((denied) => matches(target, denied, false))) ||
106
+ epoch.prefixes.some((paths) => paths.some((denied) => matches(target, denied, true)))) {
105
107
  throw new FsSafeError("denied-path", "path is denied by denyMutations policy");
106
108
  }
107
109
  }
@@ -177,9 +177,9 @@ async function replacePinnedWithRestore(fsModule, handle, replacement, maxRestor
177
177
  catch (writeError) {
178
178
  mutation.rethrowRefusal();
179
179
  try {
180
- await writeAtomicDestination(handle, original, destination?.beforeWrite, destination?.assertBeforeMutation, destination?.writing);
180
+ await writeAtomicDestination(handle, original, destination?.beforeRestore, destination?.assertBeforeMutation, destination?.writing);
181
181
  if (destination)
182
- await destination.verify();
182
+ await destination.verifyRestore();
183
183
  await handle.chmod(originalMode);
184
184
  await handle.sync();
185
185
  throw restoreFailure(writeError, "restored");
@@ -206,8 +206,8 @@ function replacePinnedWithRestoreSync(fsModule, fd, replacement, maxRestoreBytes
206
206
  catch (writeError) {
207
207
  mutation?.rethrowRefusal();
208
208
  try {
209
- writeAtomicDestinationSync(fsModule, fd, original, destination?.beforeWrite, destination?.writing);
210
- destination?.verify();
209
+ writeAtomicDestinationSync(fsModule, fd, original, destination?.beforeRestore, destination?.writing);
210
+ destination?.verifyRestore();
211
211
  fchmodSync?.(fd, originalMode);
212
212
  fsModule.fsyncSync(fd);
213
213
  throw restoreFailure(writeError, "restored");
@@ -6,6 +6,8 @@ export declare function captureAtomicDestination(fsModule: Pick<typeof fs, "lsta
6
6
  verify: () => Promise<void>;
7
7
  writing: () => void;
8
8
  beforeWrite: () => Promise<void>;
9
+ beforeRestore: () => Promise<void>;
10
+ verifyRestore: () => Promise<void>;
9
11
  assertBeforeMutation: () => void;
10
12
  published: () => void;
11
13
  }>;
@@ -13,5 +15,7 @@ export declare function captureAtomicDestinationSync(fsModule: Pick<typeof fsSyn
13
15
  verify: () => void;
14
16
  writing: () => void;
15
17
  beforeWrite: () => void;
18
+ beforeRestore: () => void;
19
+ verifyRestore: () => void;
16
20
  published: () => void;
17
21
  };
@@ -1,5 +1,6 @@
1
1
  import fsSync, {} from "node:fs";
2
2
  import fs, {} from "node:fs/promises";
3
+ import { hasErrorCode } from "./file-cleanup.js";
3
4
  import { FsSafeError } from "./errors.js";
4
5
  import { inspectFileIdentity, inspectFileIdentitySync } from "./strict-file-identity.js";
5
6
  function regular(stat, pathname, rejectHardlinks) {
@@ -17,10 +18,41 @@ export async function captureAtomicDestination(fsModule, handle, pathname, mutat
17
18
  ? fsSync.fstatSync(handle.fd, { bigint: true }) : handle.stat({ bigint: true });
18
19
  const identity = await inspectFileIdentity(inspect);
19
20
  regular(identity, pathname, rejectHardlinks);
21
+ let writing = false;
22
+ let restoreDescriptorOnly = false;
23
+ const verifyDescriptor = async () => {
24
+ await inspectFileIdentity(async () => regular(await inspect(), pathname, rejectHardlinks), identity);
25
+ };
20
26
  const verify = async () => {
27
+ let metadataFailure = false;
28
+ const observe = async (read) => {
29
+ try {
30
+ return await read();
31
+ }
32
+ catch (error) {
33
+ metadataFailure = hasErrorCode(error, "EIO");
34
+ throw error;
35
+ }
36
+ };
37
+ try {
38
+ await inspectFileIdentity(async () => regular(await observe(inspect), pathname, rejectHardlinks), identity);
39
+ await inspectFileIdentity(async () => regular(await observe(() => fsModule.lstat(pathname, { bigint: true })), pathname, rejectHardlinks), identity);
40
+ }
41
+ catch (error) {
42
+ if (writing && metadataFailure) {
43
+ // A metadata observation failed, but the already-owned descriptor can
44
+ // still restore its bytes after fresh exact descriptor verification.
45
+ restoreDescriptorOnly = true;
46
+ throw error;
47
+ }
48
+ mutation.refuse(error);
49
+ }
50
+ };
51
+ const verifyRestore = async () => {
52
+ if (!restoreDescriptorOnly)
53
+ return await verify();
21
54
  try {
22
- await inspectFileIdentity(async () => regular(await inspect(), pathname, rejectHardlinks), identity);
23
- await inspectFileIdentity(async () => regular(await fsModule.lstat(pathname, { bigint: true }), pathname, rejectHardlinks), identity);
55
+ await verifyDescriptor();
24
56
  }
25
57
  catch (error) {
26
58
  mutation.refuse(error);
@@ -28,11 +60,13 @@ export async function captureAtomicDestination(fsModule, handle, pathname, mutat
28
60
  };
29
61
  return {
30
62
  verify,
31
- writing: () => mutation.destination("writing", pathname, identity),
63
+ writing: () => { writing = true; mutation.destination("writing", pathname, identity); },
32
64
  beforeWrite: async () => {
33
65
  mutation.assert();
34
66
  await verify();
35
67
  },
68
+ beforeRestore: async () => { mutation.assert(); await verifyRestore(); },
69
+ verifyRestore,
36
70
  assertBeforeMutation: () => mutation.assert(),
37
71
  published: () => mutation.destination("published", pathname, identity),
38
72
  };
@@ -40,10 +74,36 @@ export async function captureAtomicDestination(fsModule, handle, pathname, mutat
40
74
  export function captureAtomicDestinationSync(fsModule, fd, pathname, mutation, rejectHardlinks) {
41
75
  const identity = inspectFileIdentitySync(() => fsModule.fstatSync(fd, { bigint: true }));
42
76
  regular(identity, pathname, rejectHardlinks);
77
+ let writing = false;
78
+ let restoreDescriptorOnly = false;
43
79
  const verify = () => {
80
+ let metadataFailure = false;
81
+ const observe = (read) => {
82
+ try {
83
+ return read();
84
+ }
85
+ catch (error) {
86
+ metadataFailure = hasErrorCode(error, "EIO");
87
+ throw error;
88
+ }
89
+ };
90
+ try {
91
+ inspectFileIdentitySync(() => regular(observe(() => fsModule.fstatSync(fd, { bigint: true })), pathname, rejectHardlinks), identity);
92
+ inspectFileIdentitySync(() => regular(observe(() => fsModule.lstatSync(pathname, { bigint: true })), pathname, rejectHardlinks), identity);
93
+ }
94
+ catch (error) {
95
+ if (writing && metadataFailure) {
96
+ restoreDescriptorOnly = true;
97
+ throw error;
98
+ }
99
+ mutation.refuse(error);
100
+ }
101
+ };
102
+ const verifyRestore = () => {
103
+ if (!restoreDescriptorOnly)
104
+ return verify();
44
105
  try {
45
106
  inspectFileIdentitySync(() => regular(fsModule.fstatSync(fd, { bigint: true }), pathname, rejectHardlinks), identity);
46
- inspectFileIdentitySync(() => regular(fsModule.lstatSync(pathname, { bigint: true }), pathname, rejectHardlinks), identity);
47
107
  }
48
108
  catch (error) {
49
109
  mutation.refuse(error);
@@ -51,11 +111,13 @@ export function captureAtomicDestinationSync(fsModule, fd, pathname, mutation, r
51
111
  };
52
112
  return {
53
113
  verify,
54
- writing: () => mutation.destination("writing", pathname, identity),
114
+ writing: () => { writing = true; mutation.destination("writing", pathname, identity); },
55
115
  beforeWrite: () => {
56
116
  mutation.assert();
57
117
  verify();
58
118
  },
119
+ beforeRestore: () => { mutation.assert(); verifyRestore(); },
120
+ verifyRestore,
59
121
  published: () => mutation.destination("published", pathname, identity),
60
122
  };
61
123
  }
@@ -14,6 +14,7 @@ import { sleep, sleepSync } from "./timing.js";
14
14
  import { serializePathWrite } from "./write-queue.js";
15
15
  import { hasErrorCode, readErrorCode } from "./file-cleanup.js";
16
16
  import { AtomicMutation } from "./replace-file-mutation.js";
17
+ import { assertSynchronousCallbackResult } from "./mutation-authority.js";
17
18
  async function renameWithRetry(params) {
18
19
  for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
19
20
  if (attempt > 0)
@@ -301,7 +302,7 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mu
301
302
  }));
302
303
  tempOwner.assertCurrent(fsModule);
303
304
  if (options.beforeRename) {
304
- options.beforeRename({ filePath, tempPath });
305
+ assertSynchronousCallbackResult(options.beforeRename({ filePath, tempPath }), "beforeRename");
305
306
  tempOwner.assertCurrent(fsModule);
306
307
  }
307
308
  if (options.destinationHardlinks === "reject") {
@@ -1,10 +1,6 @@
1
+ import type { StagedFileReceipt } from "./staged-file-types.js";
1
2
  /** Exact producer observations: never supply rounded Number identities. */
2
- export type RetainedFileExpected = Readonly<{
3
- dev: bigint;
4
- ino: bigint;
5
- size: bigint;
6
- mtimeNs: bigint;
7
- ctimeNs: bigint;
3
+ export type RetainedFileExpected = Readonly<Pick<StagedFileReceipt["identity"], "dev" | "ino" | "size" | "mtimeNs" | "ctimeNs"> & {
8
4
  /** Lowercase SHA-256 of the producer's expected bytes. */
9
5
  sha256: string;
10
6
  }>;
@@ -47,14 +43,7 @@ export type RetainedFileAdmission = Readonly<{
47
43
  status: "retained";
48
44
  file: RetainedFile;
49
45
  }> | RetainedFileResult;
50
- export type RetainFileInDirectoryOptions = Readonly<{
51
- directory: string;
52
- parent: Readonly<{
53
- dev: bigint;
54
- ino: bigint;
55
- }>;
56
- basename: string;
57
- expected: RetainedFileExpected;
46
+ export type RetainFileInDirectoryOptions = Readonly<Pick<RetainedFileReceipt, "directory" | "parent" | "basename" | "expected"> & {
58
47
  assertBeforeMutation: () => void;
59
48
  /** Synchronous verification budget, default 16 MiB; maximum 64 MiB. */
60
49
  maxBytes?: number;