@openclaw/fs-safe 0.20.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 (100) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +9 -1
  3. package/dist/advanced.d.ts +2 -0
  4. package/dist/advanced.js +1 -0
  5. package/dist/archive-zip-directory.js +4 -0
  6. package/dist/archive-zip-loader.js +3 -1
  7. package/dist/archive-zip-manifest.js +3 -1
  8. package/dist/atomic.d.ts +1 -1
  9. package/dist/copy-publication.d.ts +1 -3
  10. package/dist/copy-publication.js +2 -6
  11. package/dist/deny-mutation-match.d.ts +2 -0
  12. package/dist/deny-mutation-match.js +71 -0
  13. package/dist/deny-mutations.js +4 -3
  14. package/dist/file-identity.js +10 -2
  15. package/dist/file-lock-sync-admission.js +5 -1
  16. package/dist/file-lock-sync-root-io.d.ts +2 -2
  17. package/dist/file-lock-sync-root-io.js +1 -1
  18. package/dist/file-lock-sync.js +2 -2
  19. package/dist/file-store-prune.js +4 -4
  20. package/dist/mutation-authority.js +5 -0
  21. package/dist/native-binding.d.ts +33 -0
  22. package/dist/native-pinned-write.js +8 -2
  23. package/dist/pinned-mutation-admission.js +4 -2
  24. package/dist/replace-file-buffer.d.ts +4 -0
  25. package/dist/replace-file-buffer.js +36 -0
  26. package/dist/replace-file-copy-fallback.d.ts +2 -0
  27. package/dist/replace-file-copy-fallback.js +66 -38
  28. package/dist/replace-file-descriptor.d.ts +4 -0
  29. package/dist/replace-file-descriptor.js +9 -1
  30. package/dist/replace-file-destination.d.ts +21 -0
  31. package/dist/replace-file-destination.js +123 -0
  32. package/dist/replace-file-mutation.d.ts +26 -0
  33. package/dist/replace-file-mutation.js +47 -0
  34. package/dist/replace-file-temp-owner.d.ts +2 -2
  35. package/dist/replace-file-temp-owner.js +16 -4
  36. package/dist/replace-file-types.d.ts +55 -0
  37. package/dist/replace-file-types.js +1 -0
  38. package/dist/replace-file.d.ts +3 -55
  39. package/dist/replace-file.js +31 -11
  40. package/dist/retained-file-types.d.ts +50 -0
  41. package/dist/retained-file-types.js +1 -0
  42. package/dist/retained-file.d.ts +3 -0
  43. package/dist/retained-file.js +121 -0
  44. package/dist/root-directory-entry.d.ts +9 -0
  45. package/dist/root-directory-entry.js +28 -0
  46. package/dist/root-directory-list.d.ts +9 -1
  47. package/dist/root-directory-list.js +88 -37
  48. package/dist/root-handle-context.d.ts +4 -0
  49. package/dist/root-handle-context.js +12 -0
  50. package/dist/root-impl.d.ts +3 -3
  51. package/dist/root-impl.js +8 -2
  52. package/dist/root-move-noreplace.js +3 -3
  53. package/dist/root-walk.d.ts +19 -12
  54. package/dist/root-walk.js +49 -18
  55. package/dist/sidecar-lock-acquire.js +3 -3
  56. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  57. package/dist/sidecar-lock-reclaim.js +6 -6
  58. package/dist/sidecar-lock.js +4 -4
  59. package/dist/staged-symlink-types.d.ts +4 -14
  60. package/dist/temp-target.js +3 -2
  61. package/dist/temp-workspace-admission.js +22 -21
  62. package/dist/temp-workspace-child-admission.d.ts +1 -1
  63. package/dist/temp-workspace-child-admission.js +14 -9
  64. package/dist/temp-workspace-ownership.d.ts +8 -0
  65. package/dist/temp-workspace-ownership.js +52 -0
  66. package/dist/test-hooks.d.ts +4 -0
  67. package/dist/watch-alias.d.ts +6 -0
  68. package/dist/watch-alias.js +88 -0
  69. package/dist/watch-hints.d.ts +9 -0
  70. package/dist/watch-hints.js +93 -0
  71. package/dist/watch-native.d.ts +36 -0
  72. package/dist/watch-native.js +73 -0
  73. package/dist/watch-scan.d.ts +28 -0
  74. package/dist/watch-scan.js +300 -0
  75. package/dist/watch-stream.d.ts +8 -0
  76. package/dist/watch-stream.js +32 -0
  77. package/dist/watch-types.d.ts +60 -0
  78. package/dist/watch-types.js +1 -0
  79. package/dist/watch.d.ts +5 -0
  80. package/dist/watch.js +530 -0
  81. package/docs/advanced.md +1 -0
  82. package/docs/archive.md +6 -0
  83. package/docs/atomic.md +82 -0
  84. package/docs/contributing.md +74 -6
  85. package/docs/durability.md +7 -0
  86. package/docs/index.md +1 -0
  87. package/docs/install.md +2 -0
  88. package/docs/native-helper.md +9 -0
  89. package/docs/native.md +24 -6
  90. package/docs/public-api.md +23 -2
  91. package/docs/retained-file.md +115 -0
  92. package/docs/root.md +25 -8
  93. package/docs/sidecar-lock.md +1 -1
  94. package/docs/staged-symlink.md +2 -1
  95. package/docs/temp.md +24 -4
  96. package/docs/testing.md +186 -4
  97. package/docs/types.md +6 -0
  98. package/docs/walk.md +22 -1
  99. package/docs/watch.md +251 -0
  100. package/package.json +12 -8
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
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
+
29
+ ## 0.21.0 - 2026-09-26
30
+
31
+ ### Highlights
32
+
33
+ - **Guarded filesystem observation:** `@openclaw/fs-safe/watch` observes literal entry and tree scopes beneath an admitted Root and delivers bounded advisory invalidations; guarded scans stay authoritative. One shared Rust event thread per process uses inotify, FSEvents and ReadDirectoryChangesW on Linux, macOS and Windows, sleeps while idle, and falls back to portable polling under `mode: "auto"`. Subscriptions become ready after one baseline scan, stay available under sustained writes, fence stale generations on `setScopes()`, join native work on `close()`, and support `persistent: false` so one-shot Node commands can exit. Thanks @vincentkoc. ([#690](https://github.com/openclaw/fs-safe/pull/690), [#695](https://github.com/openclaw/fs-safe/pull/695), [#697](https://github.com/openclaw/fs-safe/pull/697), [#703](https://github.com/openclaw/fs-safe/pull/703), [#707](https://github.com/openclaw/fs-safe/pull/707))
34
+ - **Linux user namespaces:** private temp workspaces now work beneath unmapped ancestors, including systemd user services with `PrivateUsers=true`, while UID/GID mappings and ancestor permissions are still rechecked. ([#693](https://github.com/openclaw/fs-safe/pull/693))
35
+
36
+ ### Features
37
+
38
+ - **Revocable atomic writes:** atomic replacement can recheck caller authority before new effects and report retained destination identities for partial writes and publication, without treating receipts as permission to roll back. ([#694](https://github.com/openclaw/fs-safe/pull/694))
39
+ - **Root walking:** `symlinkPolicy: "include"` reports links without following their targets, preserving sorted traversal, entry budgets, and the existing skip/follow result types. Directory-to-symlink substitutions fail before descent. ([#692](https://github.com/openclaw/fs-safe/pull/692))
40
+ - **Windows file retirement:** the advanced surface can retire existing NTFS files through a retained handle, with exact producer identity, current authority, and separate disposition and settlement facts. There is no persistence guarantee. ([#706](https://github.com/openclaw/fs-safe/pull/706))
41
+
42
+ ### Performance
43
+
44
+ - **Temporary filename sanitization:** skip duplicate normalization after reserved-device suffixing while preserving filename admission and fallback rules. ([#689](https://github.com/openclaw/fs-safe/pull/689))
45
+
46
+ ### Compatibility
47
+
48
+ - Supplied temp workspace roots must be owned by the effective user and must not be group- or world-writable; use a private per-user root instead of shared `/tmp`. A one-time warning reports host ownership that cannot be verified. ([#693](https://github.com/openclaw/fs-safe/pull/693))
49
+ - On Windows, an events-mode watch holds one handle on each watched Root, so the Root's own ancestor directories cannot be renamed while it is open; renaming inside the Root is unaffected. Use `mode: "poll"` where that matters. ([#697](https://github.com/openclaw/fs-safe/pull/697))
50
+ - `watch()` requires an explicit `mode` (`"auto"`, `"events"` or `"poll"`); `"events"` fails readiness with `helper-unavailable` when no native backend is available, and Bun currently selects polling under `"auto"`. ([#690](https://github.com/openclaw/fs-safe/pull/690))
51
+
5
52
  ## 0.20.0 - 2026-09-25
6
53
 
7
54
  ### Highlights
package/README.md CHANGED
@@ -279,6 +279,7 @@ contract. Low-level helpers that OpenClaw needs to compose higher-level APIs are
279
279
  | `@openclaw/fs-safe/secure-file` | fd-pinned absolute file reads with owner, mode, ACL, trusted-dir, size, and timeout checks |
280
280
  | `@openclaw/fs-safe/file-lock` | async/sync sidecar locks, root-bounded sidecars, ownership verification, and stale policy |
281
281
  | `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection, raw owner/ACE facts, private-directory creation, and remediation helpers |
282
+ | [`@openclaw/fs-safe/watch`](docs/watch.md) | Guarded observation with native event hints, bounded scans, and joined close |
282
283
  | `@openclaw/fs-safe/walk` | budget-bounded directory walking with symlink policy, filters, and truncation accounting; not root-bounded |
283
284
  | `@openclaw/fs-safe/copy` | directory copying with `clone: "auto"`, `"always"`, or `"never"`; native APFS, Btrfs, ReFS, XFS, and ZFS cloning, portable byte copying, and clone metadata; see [directory copying](docs/copy.md) |
284
285
  | `@openclaw/fs-safe/archive` | policy-driven ZIP/TAR extraction, clamp/filter policy, metadata/path-depth limits, gzip/zstd/bzip2 support, and bounded entry reads |
@@ -368,6 +369,12 @@ await replaceFileAtomic({
368
369
 
369
370
  `replaceFileAtomicSync()` covers the synchronous case with the same options shape. Both accept an injectable `fileSystem` for tests. Async adapters use `chmod()` on the `FileHandle` returned by their required `open()` operation; custom sync adapters using `mode` or `preserveExistingMode` provide the optional descriptor-bound `fchmodSync` operation.
370
371
 
372
+ Both variants accept `assertBeforeMutation` for revocable caller authority and
373
+ `onDestinationState` for observed removal, partial-write, and publication facts.
374
+ The observer receives exact bigint identities from retained descriptors, including
375
+ when later completion fails. These facts do not authorize rollback; the caller
376
+ still owns current authority and content checks. See [atomic write authority](docs/atomic.md#atomic-write-authority-and-destination-state).
377
+
371
378
  ## External outputs
372
379
 
373
380
  Use `writeExternalFileWithinRoot()` when a browser download, renderer, media
@@ -559,7 +566,8 @@ Check `scan.truncated` before treating the result as complete, and `scan.failedD
559
566
  `walkDirectory()` accepts asynchronous `include` and `descend` callbacks through `AsyncWalkDirectoryOptions`, so a marker lookup can prune a directory before its children are read. Decisions remain serial and retain the options object as their `this` receiver; `walkDirectorySync()` and its options remain synchronous. See [Directory walking](docs/walk.md) for callback timing, JavaScript result compatibility, and error handling.
560
567
 
561
568
  For caller-controlled paths, `Root.walk()` is the root-bounded async iterator.
562
- It supports entry/depth budgets, in-root symlink following, cancellation, and a
569
+ It supports entry/depth budgets, including links without following their targets,
570
+ in-root symlink following, cancellation, and a
563
571
  truncation marker (or typed error) when a budget is reached. Its `entryFilter`
564
572
  accepts `"include"`, `"skip"`, or `"skip-subtree"`, directly or through a Promise.
565
573
  After an awaited decision resolves, the walk rechecks cancellation and the
@@ -37,3 +37,5 @@ export { buildRandomTempFilePath, sanitizeTempFileName, type TempFile, tempFile,
37
37
  export { writeSiblingTempFile, writeViaSiblingTempPath, type WriteSiblingTempFileOptions, type WriteSiblingTempFileResult, } from "./sibling-temp.js";
38
38
  export { createIcaclsResetCommand, formatIcaclsResetCommand, formatWindowsAclSummary, inspectWindowsAcl, parseIcaclsOutput, resolveWindowsUserPrincipal, summarizeWindowsAcl, type IcaclsResetCommandOptions, type PermissionExec, type WindowsAclEntry, type WindowsAclSummary, } from "./permissions-windows.js";
39
39
  export type { PermissionCommandFailure } from "./permission-exec.js";
40
+ export { retainFileInDirectory } from "./retained-file.js";
41
+ export type { RetainedFile, RetainedFileAdmission, RetainedFileExpected, RetainedFileIssue, RetainedFileReceipt, RetainedFileResult, RetainFileInDirectoryOptions, } from "./retained-file-types.js";
package/dist/advanced.js CHANGED
@@ -37,3 +37,4 @@ export { appendRegularFile, appendRegularFileSync, readRegularFile, readRegularF
37
37
  export { buildRandomTempFilePath, sanitizeTempFileName, tempFile, withTempFile, } from "./temp-target.js";
38
38
  export { writeSiblingTempFile, writeViaSiblingTempPath, } from "./sibling-temp.js";
39
39
  export { createIcaclsResetCommand, formatIcaclsResetCommand, formatWindowsAclSummary, inspectWindowsAcl, parseIcaclsOutput, resolveWindowsUserPrincipal, summarizeWindowsAcl, } from "./permissions-windows.js";
40
+ export { retainFileInDirectory } from "./retained-file.js";
@@ -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
  }
package/dist/atomic.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { replaceFileAtomic, replaceFileAtomicSync, type ReplaceFileAtomicFileSystem, type ReplaceFileAtomicOptions, type ReplaceFileAtomicResult, type ReplaceFileAtomicSyncFileSystem, type ReplaceFileAtomicSyncOptions, } from "./replace-file.js";
1
+ export { replaceFileAtomic, replaceFileAtomicSync, type ReplaceFileAtomicFileSystem, type ReplaceFileAtomicOptions, type ReplaceFileAtomicResult, type ReplaceFileAtomicDestinationState, type ReplaceFileAtomicSyncFileSystem, type ReplaceFileAtomicSyncOptions, } from "./replace-file.js";
2
2
  export type { RenameIdentityPolicy } from "./pinned-write-types.js";
3
3
  export type { ReplaceFileAtomicRestoreCleanup, ReplaceFileAtomicRestoreFailureDetails, ReplaceFileCopyFallbackRestorePolicy, ReplaceFileDestinationHardlinkPolicy, } from "./replace-file-copy-fallback.js";
4
4
  export { writeTextAtomic, type WriteTextAtomicOptions } from "./text-atomic.js";
@@ -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)
@@ -1,3 +1,11 @@
1
+ import type { RetainedFileResult } from "./retained-file-types.js";
2
+ export interface NativeRetainedFile {
3
+ readonly admission: RetainedFileResult | {
4
+ status: "retained";
5
+ identity: string;
6
+ };
7
+ settle(remove: boolean): RetainedFileResult;
8
+ }
1
9
  import type { TarMeterLimits } from "./archive-limits.js";
2
10
  import type { ArchiveMemberKind } from "./archive-plan.js";
3
11
  import type { CopyCloneMode } from "./copy-policy.js";
@@ -95,6 +103,31 @@ type NativeTwoPathArgs = [
95
103
  targetRelPath: string
96
104
  ];
97
105
  export interface NativeBinding {
106
+ /** Windows-only, private handle custody; no borrowed/runtime descriptors. */
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
+ 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;
110
+ watchAdd?(id: number, directory: {
111
+ root: string;
112
+ relative: string;
113
+ rootDev: bigint;
114
+ rootIno: bigint;
115
+ dev: bigint;
116
+ ino: bigint;
117
+ }): void;
118
+ watchTestEvent?(id: number, path: string, flags: number): void;
119
+ watchUnregister?(id: number): void;
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
+ };
98
131
  /** Internal: consumes only a descriptor returned by this binding. */
99
132
  closeOwnedFd(fd: number): void;
100
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
  }
@@ -0,0 +1,4 @@
1
+ import type syncFs from "node:fs";
2
+ import type { FileHandle } from "node:fs/promises";
3
+ export declare function writeAtomicDestination(handle: FileHandle, data: Buffer, beforeWrite?: () => Promise<void>, assertBeforeMutation?: () => void, onWriting?: () => void): Promise<void>;
4
+ export declare function writeAtomicDestinationSync(fsModule: Pick<typeof syncFs, "ftruncateSync" | "writeSync">, fd: number, data: Buffer, beforeWrite?: () => void, onWriting?: () => void): void;
@@ -0,0 +1,36 @@
1
+ export async function writeAtomicDestination(handle, data, beforeWrite, assertBeforeMutation, onWriting) {
2
+ if (beforeWrite)
3
+ await beforeWrite();
4
+ assertBeforeMutation?.();
5
+ await handle.truncate(0);
6
+ onWriting?.();
7
+ let written = 0;
8
+ while (written < data.length) {
9
+ if (beforeWrite)
10
+ await beforeWrite();
11
+ assertBeforeMutation?.();
12
+ const result = await handle.write(data, written, data.length - written, written);
13
+ if (result.bytesWritten === 0)
14
+ throw new Error("Copy fallback write made no progress");
15
+ written += result.bytesWritten;
16
+ }
17
+ if (beforeWrite)
18
+ await beforeWrite();
19
+ assertBeforeMutation?.();
20
+ await handle.truncate(data.length);
21
+ }
22
+ export function writeAtomicDestinationSync(fsModule, fd, data, beforeWrite, onWriting) {
23
+ beforeWrite?.();
24
+ fsModule.ftruncateSync(fd, 0);
25
+ onWriting?.();
26
+ let written = 0;
27
+ while (written < data.length) {
28
+ beforeWrite?.();
29
+ const bytesWritten = fsModule.writeSync(fd, data, written, data.length - written, written);
30
+ if (bytesWritten === 0)
31
+ throw new Error("Copy fallback write made no progress");
32
+ written += bytesWritten;
33
+ }
34
+ beforeWrite?.();
35
+ fsModule.ftruncateSync(fd, data.length);
36
+ }
@@ -1,4 +1,5 @@
1
1
  import syncFs, { type BigIntStats } from "node:fs";
2
+ import { AtomicMutation } from "./replace-file-mutation.js";
2
3
  export type ReplaceFileDestinationHardlinkPolicy = "reject";
3
4
  export type ReplaceFileCopyFallbackRestorePolicy = "restore-original" | "none";
4
5
  export type ReplaceFileAtomicRestoreCleanup = "restored" | "restore-failed";
@@ -22,6 +23,7 @@ export declare function copyFallbackReplace(params: {
22
23
  maxRestoreBytes?: number;
23
24
  expectedSourceIdentity?: BigIntStats;
24
25
  sync: boolean;
26
+ mutation?: AtomicMutation;
25
27
  }): Promise<void>;
26
28
  export declare function copyFallbackReplaceSync(params: Omit<Parameters<typeof copyFallbackReplace>[0], "fsModule"> & {
27
29
  fsModule: SyncFallbackFs;