@openclaw/fs-safe 0.13.0 → 0.14.0

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 (59) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/copy-file-input.d.ts +1 -1
  3. package/dist/copy-file-input.d.ts.map +1 -1
  4. package/dist/copy-file-input.js +2 -0
  5. package/dist/file-store-sync-directory.d.ts.map +1 -1
  6. package/dist/file-store-sync-directory.js +14 -7
  7. package/dist/guest-dispatch-python.js +1 -1
  8. package/dist/native-binding.d.ts +9 -0
  9. package/dist/native-binding.d.ts.map +1 -1
  10. package/dist/native-binding.js +8 -1
  11. package/dist/native-operations.d.ts.map +1 -1
  12. package/dist/native-operations.js +6 -4
  13. package/dist/native-parent-admission.d.ts +1 -0
  14. package/dist/native-parent-admission.d.ts.map +1 -1
  15. package/dist/native-parent-admission.js +6 -3
  16. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  17. package/dist/native-pinned-write-windows.js +8 -6
  18. package/dist/native-pinned-write.d.ts.map +1 -1
  19. package/dist/native-pinned-write.js +200 -163
  20. package/dist/native-policy-directory-observation.d.ts +12 -0
  21. package/dist/native-policy-directory-observation.d.ts.map +1 -0
  22. package/dist/native-policy-directory-observation.js +61 -0
  23. package/dist/native-staged-file.d.ts +4 -4
  24. package/dist/native-staged-file.d.ts.map +1 -1
  25. package/dist/native-staged-file.js +20 -10
  26. package/dist/native.d.ts +1 -1
  27. package/dist/native.d.ts.map +1 -1
  28. package/dist/native.js +7 -2
  29. package/dist/pinned-mutation-admission.d.ts.map +1 -1
  30. package/dist/pinned-mutation-admission.js +13 -24
  31. package/dist/pinned-mutation-observation.d.ts +7 -1
  32. package/dist/pinned-mutation-observation.d.ts.map +1 -1
  33. package/dist/pinned-mutation-observation.js +87 -5
  34. package/dist/publish-file.d.ts.map +1 -1
  35. package/dist/publish-file.js +5 -3
  36. package/dist/root-directory-creation.d.ts.map +1 -1
  37. package/dist/root-directory-creation.js +5 -4
  38. package/dist/root-impl.d.ts.map +1 -1
  39. package/dist/root-impl.js +2 -1
  40. package/dist/root-path-existing.d.ts.map +1 -1
  41. package/dist/root-path-existing.js +1 -2
  42. package/dist/root-write-compatibility.d.ts +1 -2
  43. package/dist/root-write-compatibility.d.ts.map +1 -1
  44. package/dist/root-write-compatibility.js +17 -68
  45. package/dist/root-write-complete-parent.d.ts.map +1 -1
  46. package/dist/root-write-complete-parent.js +3 -1
  47. package/dist/root-write-lock-binding.d.ts +15 -0
  48. package/dist/root-write-lock-binding.d.ts.map +1 -0
  49. package/dist/root-write-lock-binding.js +162 -0
  50. package/dist/staged-directory.d.ts +5 -2
  51. package/dist/staged-directory.d.ts.map +1 -1
  52. package/dist/staged-directory.js +37 -1
  53. package/dist/temp-workspace-admission.d.ts.map +1 -1
  54. package/dist/temp-workspace-admission.js +28 -4
  55. package/docs/guest.md +1 -1
  56. package/docs/native-helper.md +14 -1
  57. package/docs/native.md +8 -0
  58. package/docs/writing.md +6 -0
  59. package/package.json +8 -8
@@ -1,74 +1,23 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { FsSafeError } from "./errors.js";
4
- import { isNotFoundPathError } from "./path.js";
5
- import { admitPathInsideRoot } from "./root-boundary.js";
6
1
  import { withPinnedWriteRenameIdentityLock } from "./pinned-write.js";
7
- import { realpathSync } from "./realpath.js";
2
+ import { createRootWriteLockBinding } from "./root-write-lock-binding.js";
8
3
  import { serializePathWrite } from "./write-queue.js";
9
- function unsupportedSpelling() {
10
- return new FsSafeError("path-alias", "Windows compatibility writes require lower-case ASCII destination components");
11
- }
12
- // The caller first applies the Root's ordinary path and mutation policies. This
13
- // extra admission rule is deliberately narrower than general Windows filenames:
14
- // the existing lock protocol has no portable case/Unicode equivalence key for a
15
- // missing name. Do not guess one, or switch keys when a placeholder appears.
16
- function effectiveDestination(rootPath, targetPath, rootIdentity) {
17
- let effective;
4
+ export async function withRootFallbackCompatibilityLock(params, run) {
5
+ const binding = createRootWriteLockBinding(params);
6
+ const callerAssertion = params.assertBeforeMutation;
7
+ const assertBeforeMutation = callerAssertion === undefined
8
+ ? binding.assertCurrent
9
+ : () => { callerAssertion.call(params); binding.assertCurrent(); };
10
+ // Keep the alias queue and historical lock hash before admitting the writer.
18
11
  try {
19
- effective = realpathSync.native(targetPath);
20
- }
21
- catch (error) {
22
- if (!isNotFoundPathError(error))
23
- throw error;
24
- let cursor = targetPath;
25
- const missing = [];
26
- for (;;) {
27
- try {
28
- fs.lstatSync(cursor);
29
- break;
30
- }
31
- catch (error) {
32
- if (!isNotFoundPathError(error) || cursor === rootPath)
33
- throw error;
34
- const parent = path.dirname(cursor);
35
- if (parent === cursor)
36
- throw error;
37
- missing.unshift(path.basename(cursor));
38
- cursor = parent;
39
- }
40
- }
41
- // An existing dangling link must fail resolution, not become a missing
42
- // lexical component. Keep the fallback bounded by the retained Root.
43
- effective = path.join(realpathSync.native(cursor), ...missing);
12
+ return await serializePathWrite(binding.targetPath, async () => await withPinnedWriteRenameIdentityLock({
13
+ rootPath: params.rootPath, targetPath: binding.targetPath, relativeTargetPath: binding.relativeLockPath,
14
+ }, async () => await run({
15
+ targetPath: binding.targetPath,
16
+ relativePath: binding.relativePath,
17
+ assertBeforeMutation,
18
+ })));
44
19
  }
45
- const admitted = admitPathInsideRoot({ rootPath, candidatePath: effective, rootIdentity });
46
- if (!admitted)
47
- throw unsupportedSpelling();
48
- const relative = admitted.relativePath;
49
- if (!relative || !relative.split(path.sep).every(component => /^[a-z0-9._-]+$/.test(component) && !component.endsWith(".")))
50
- throw unsupportedSpelling();
51
- return admitted.path;
52
- }
53
- export function assertRootFallbackWritePath(expected, actual) {
54
- if (expected !== undefined && actual !== expected) {
55
- throw new FsSafeError("path-mismatch", "compatibility write destination changed after lock selection");
20
+ finally {
21
+ binding.dispose();
56
22
  }
57
23
  }
58
- export async function withRootFallbackCompatibilityLock(params, run) {
59
- const targetPath = effectiveDestination(params.rootPath, params.targetPath, params.rootIdentity);
60
- const relativePath = path.relative(params.rootPath, targetPath).split(path.sep).join("/");
61
- const assertCurrent = () => assertRootFallbackWritePath(targetPath, effectiveDestination(params.rootPath, targetPath, params.rootIdentity));
62
- // Sidecars are reentrant within one manager; aliases also need the same local
63
- // queue before acquisition. The outer Root queue uses the caller's spelling.
64
- return await serializePathWrite(targetPath, async () => await withPinnedWriteRenameIdentityLock({
65
- rootPath: params.rootPath, targetPath, relativeTargetPath: relativePath,
66
- }, async () => {
67
- // The guarded open resolves again under the lock; each mutation rechecks
68
- // below. Lock selection is never reused as post-lock filesystem authority.
69
- return await run({
70
- targetPath, relativePath,
71
- assertBeforeMutation: () => { params.assertBeforeMutation?.(); assertCurrent(); },
72
- });
73
- }));
74
- }
@@ -1 +1 @@
1
- {"version":3,"file":"root-write-complete-parent.d.ts","sourceRoot":"","sources":["../src/root-write-complete-parent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAC;AAO9B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAKxE,KAAK,8BAA8B,GAAG,QAAQ,CAC1C;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAAE,CACtC,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C,mBAAmB,EAAE,MAAM,CAAC;IAC5B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,WAAW,EAAE,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,QAAQ,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrD,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,8BAA8B,CAAC;CACxC,CAAC,CAAC;AAEH,KAAK,2BAA2B,GAAG,QAAQ,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,sBAAsB,CAAC;IACtC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;CACnC,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,sBAAsB,CAAC,mBAAmB,CAAC,CAAC;IAC/D,cAAc,CAAC,EAAE,uBAAuB,CAAC;CAC1C,CAAC,CAAC;AA2GH,wBAAgB,oCAAoC,CAClD,QAAQ,EAAE,uBAAuB,EACjC,YAAY,UAAQ,GACnB,IAAI,CAqBN;AAwCD,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,2BAA2B,GAClC,OAAO,CAAC,qBAAqB,CAAC,CAqBhC"}
1
+ {"version":3,"file":"root-write-complete-parent.d.ts","sourceRoot":"","sources":["../src/root-write-complete-parent.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,OAAO,EAIL,KAAK,mBAAmB,EACzB,MAAM,sBAAsB,CAAC;AAO9B,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,2BAA2B,CAAC;AAMxE,KAAK,8BAA8B,GAAG,QAAQ,CAC1C;IAAE,MAAM,EAAE,KAAK,CAAA;CAAE,GACjB;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,WAAW,CAAA;CAAE,CACtC,CAAC;AAEF;;;;GAIG;AACH,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C,mBAAmB,EAAE,MAAM,CAAC;IAC5B,kBAAkB,EAAE,MAAM,CAAC;IAC3B,WAAW,EAAE,mBAAmB,CAAC,WAAW,CAAC,CAAC;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,QAAQ,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrD,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,8BAA8B,CAAC;CACxC,CAAC,CAAC;AAEH,KAAK,2BAA2B,GAAG,QAAQ,CAAC;IAC1C,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,sBAAsB,CAAC;IACtC,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,oBAAoB,CAAC,EAAE,MAAM,IAAI,CAAC;CACnC,CAAC,CAAC;AAEH,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,sBAAsB,CAAC,mBAAmB,CAAC,CAAC;IAC/D,cAAc,CAAC,EAAE,uBAAuB,CAAC;CAC1C,CAAC,CAAC;AA2GH,wBAAgB,oCAAoC,CAClD,QAAQ,EAAE,uBAAuB,EACjC,YAAY,UAAQ,GACnB,IAAI,CAqBN;AAwCD,wBAAsB,4BAA4B,CAChD,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,2BAA2B,GAClC,OAAO,CAAC,qBAAqB,CAAC,CAsBhC"}
@@ -8,6 +8,7 @@ import { hasNodeErrorCode } from "./path.js";
8
8
  import { realpathSync } from "./realpath.js";
9
9
  import { admitPathInsideRoot } from "./root-boundary.js";
10
10
  import { prepareRootWriteTarget } from "./root-directory-creation.js";
11
+ import { canReuseParentWithMutationAssertion } from "./root-write-lock-binding.js";
11
12
  import { isSafePathSegment } from "./safe-path-segment.js";
12
13
  import { inspectFileIdentitySync } from "./strict-file-identity.js";
13
14
  import { getFsSafeTestHooks } from "./test-hooks.js";
@@ -185,7 +186,8 @@ export async function prepareSharedRootWriteTarget(root, params) {
185
186
  if (mutationAdmission)
186
187
  await beforeParentAdmission?.(resolvedPath);
187
188
  const preparedParent = params.mkdir !== false &&
188
- params.assertBeforeMutation === undefined && beforeParentAdmission === undefined
189
+ canReuseParentWithMutationAssertion(params.assertBeforeMutation, root.rootReal, guardedTarget.targetPath) &&
190
+ beforeParentAdmission === undefined
189
191
  ? await prepareCompleteRootWriteParent(root, guardedTarget, params.relativePath)
190
192
  : undefined;
191
193
  const targetPath = params.mkdir === false
@@ -0,0 +1,15 @@
1
+ import { type RootBoundaryIdentity } from "./root-boundary.js";
2
+ export declare function canReuseParentWithMutationAssertion(assertion: (() => void) | undefined, rootPath: string, targetPath: string): boolean;
3
+ export declare function assertRootFallbackWritePath(expected: string | undefined, actual: string): void;
4
+ export declare function createRootWriteLockBinding(params: Readonly<{
5
+ rootPath: string;
6
+ targetPath: string;
7
+ rootIdentity?: RootBoundaryIdentity;
8
+ }>): Readonly<{
9
+ targetPath: string;
10
+ relativePath: string;
11
+ relativeLockPath: string;
12
+ assertCurrent: () => void;
13
+ dispose(): void;
14
+ }>;
15
+ //# sourceMappingURL=root-write-lock-binding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"root-write-lock-binding.d.ts","sourceRoot":"","sources":["../src/root-write-lock-binding.ts"],"names":[],"mappings":"AAKA,OAAO,EAAuB,KAAK,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAKpF,wBAAgB,mCAAmC,CACjD,SAAS,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,EACnC,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB,OAAO,CAIT;AA6GD,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAI9F;AAED,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,oBAAoB,CAAC;CACrC,CAAC;;;;;;GAqCD"}
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { inspectDirectoryIdentitySync } from "./directory-guard.js";
4
+ import { FsSafeError } from "./errors.js";
5
+ import { hasNodeErrorCode, isNotFoundPathError } from "./path.js";
6
+ import { admitPathInsideRoot } from "./root-boundary.js";
7
+ import { realpathSync } from "./realpath.js";
8
+ const lockObservations = new WeakMap();
9
+ export function canReuseParentWithMutationAssertion(assertion, rootPath, targetPath) {
10
+ if (assertion === undefined)
11
+ return true;
12
+ const binding = lockObservations.get(assertion);
13
+ return binding?.rootPath === rootPath && binding.targetPath === targetPath;
14
+ }
15
+ function unsupportedSpelling() {
16
+ return new FsSafeError("path-alias", "Windows compatibility writes require lower-case ASCII destination components");
17
+ }
18
+ // Missing names have no portable case/Unicode lock key. Keep this admission
19
+ // narrower than general Root paths and never change keys after acquisition.
20
+ function effectiveDestination(rootPath, targetPath, rootIdentity) {
21
+ let effective;
22
+ let missingHint;
23
+ try {
24
+ effective = realpathSync.native(targetPath);
25
+ }
26
+ catch (error) {
27
+ if (!isNotFoundPathError(error))
28
+ throw error;
29
+ let cursor = targetPath;
30
+ const missing = [];
31
+ for (;;) {
32
+ try {
33
+ fs.lstatSync(cursor);
34
+ break;
35
+ }
36
+ catch (error) {
37
+ if (!isNotFoundPathError(error) || cursor === rootPath)
38
+ throw error;
39
+ const parent = path.dirname(cursor);
40
+ if (parent === cursor)
41
+ throw error;
42
+ missing.unshift(path.basename(cursor));
43
+ cursor = parent;
44
+ }
45
+ }
46
+ // Existing dangling links must fail resolution, not become missing names.
47
+ const canonicalParent = realpathSync.native(cursor);
48
+ effective = path.join(canonicalParent, ...missing);
49
+ // The hint must describe the selected route exactly. The full resolver can
50
+ // normalize a missing raw `..` component out of the eventual lock path.
51
+ if (missing.length > 1 && path.resolve(canonicalParent) === canonicalParent &&
52
+ missing.every(component => /^[a-z0-9._-]+$/.test(component) &&
53
+ component !== "." && component !== ".." && !component.endsWith("."))) {
54
+ missingHint = Object.freeze({ parentPath: canonicalParent, parts: Object.freeze(missing), nextIndex: 0 });
55
+ }
56
+ }
57
+ const admitted = admitPathInsideRoot({ rootPath, candidatePath: effective, rootIdentity });
58
+ if (!admitted)
59
+ throw unsupportedSpelling();
60
+ const relative = admitted.relativePath;
61
+ if (!relative || !relative.split(path.sep).every(component => /^[a-z0-9._-]+$/.test(component) && !component.endsWith(".")))
62
+ throw unsupportedSpelling();
63
+ return {
64
+ path: admitted.path,
65
+ // Case-folded or rebased root spellings keep the full resolver and its
66
+ // existing root-identity admission at every mutation.
67
+ missing: admitted.admission === "exact" && admitted.path === effective ? missingHint : undefined,
68
+ };
69
+ }
70
+ // The hint stores only a location. Each call freshly binds the nearest existing
71
+ // prefix and its first absent child; no identity or authorization crosses calls.
72
+ function observeMissingDestination(hint) {
73
+ // A complete parent needs only the ordinary leaf check, not a prefix fence.
74
+ if (hint.parts.length - hint.nextIndex < 2)
75
+ return undefined;
76
+ try {
77
+ let parentPath = hint.parentPath;
78
+ let parent = inspectDirectoryIdentitySync(parentPath);
79
+ for (let nextIndex = hint.nextIndex; nextIndex < hint.parts.length; nextIndex++) {
80
+ const childPath = path.join(parentPath, hint.parts[nextIndex]);
81
+ let child;
82
+ try {
83
+ child = fs.lstatSync(childPath, { bigint: true });
84
+ }
85
+ catch (error) {
86
+ if (!hasNodeErrorCode(error, "ENOENT"))
87
+ return undefined;
88
+ if (realpathSync.native(parentPath) !== parentPath)
89
+ return undefined;
90
+ inspectDirectoryIdentitySync(parentPath, parent);
91
+ try {
92
+ fs.lstatSync(childPath);
93
+ }
94
+ catch (error) {
95
+ if (!hasNodeErrorCode(error, "ENOENT"))
96
+ return undefined;
97
+ // Absence can follow a renamed ancestor through a new alias while
98
+ // preserving the directory's identity. Finish at its current name.
99
+ if (realpathSync.native(parentPath) !== parentPath)
100
+ return undefined;
101
+ return Object.freeze({ parentPath, parts: hint.parts, nextIndex });
102
+ }
103
+ return undefined;
104
+ }
105
+ // An existing leaf always receives the historical full resolution,
106
+ // including final links, collisions and unsupported canonical spellings.
107
+ if (nextIndex === hint.parts.length - 1)
108
+ return undefined;
109
+ parent = inspectDirectoryIdentitySync(childPath, undefined, child);
110
+ parentPath = childPath;
111
+ }
112
+ }
113
+ catch {
114
+ // Removal, redirection and uncertain observations only discard the hint.
115
+ // The full resolver below still determines the destination and error.
116
+ }
117
+ return undefined;
118
+ }
119
+ export function assertRootFallbackWritePath(expected, actual) {
120
+ if (expected !== undefined && actual !== expected) {
121
+ throw new FsSafeError("path-mismatch", "compatibility write destination changed after lock selection");
122
+ }
123
+ }
124
+ export function createRootWriteLockBinding(params) {
125
+ const { rootPath } = params;
126
+ const rootIdentity = params.rootIdentity && Object.freeze({
127
+ dev: params.rootIdentity.dev, ino: params.rootIdentity.ino,
128
+ });
129
+ const selected = effectiveDestination(rootPath, params.targetPath, rootIdentity);
130
+ const targetPath = selected.path;
131
+ let missingHint = selected.missing;
132
+ const relativePath = path.relative(rootPath, targetPath);
133
+ let active = true;
134
+ const assertCurrent = () => {
135
+ if (!active)
136
+ throw new FsSafeError("path-mismatch", "compatibility lock observation expired");
137
+ const observed = missingHint && observeMissingDestination(missingHint);
138
+ if (observed) {
139
+ missingHint = observed;
140
+ return;
141
+ }
142
+ missingHint = undefined;
143
+ const current = effectiveDestination(rootPath, targetPath, rootIdentity);
144
+ assertRootFallbackWritePath(targetPath, current.path);
145
+ missingHint = current.missing;
146
+ };
147
+ // Only this library-owned, observation-only closure permits parent reuse.
148
+ // Callers cannot obtain that status by setting properties on their callback.
149
+ lockObservations.set(assertCurrent, Object.freeze({ rootPath, targetPath }));
150
+ return Object.freeze({
151
+ targetPath,
152
+ relativePath,
153
+ // The existing lock protocol uses slashes; the writer needs native spelling.
154
+ relativeLockPath: relativePath.split(path.sep).join("/"),
155
+ assertCurrent,
156
+ dispose() {
157
+ active = false;
158
+ missingHint = undefined;
159
+ lockObservations.delete(assertCurrent);
160
+ },
161
+ });
162
+ }
@@ -1,13 +1,16 @@
1
1
  import { type BigIntStats } from "node:fs";
2
2
  import type { DirectoryReceipt } from "./directory-durability.js";
3
3
  import type { FileIdentityStat } from "./file-identity.js";
4
- import { type MutationDirectoryObservation } from "./pinned-mutation-observation.js";
4
+ import type { NativeBinding } from "./native-binding.js";
5
+ import { type MutationDirectoryObservation, type MutationDirectoryObserver } from "./pinned-mutation-observation.js";
5
6
  import type { StagedFileReceipt } from "./staged-file-types.js";
6
7
  export type StagedDirectorySnapshot = StagedFileReceipt["directory"];
7
8
  export type PolicyStagedDirectory = Readonly<{
8
9
  directory: StagedDirectorySnapshot;
9
10
  observation: MutationDirectoryObservation;
10
11
  stat: BigIntStats;
12
+ observeCurrent?: MutationDirectoryObserver;
13
+ disposeObservation?(): void;
11
14
  }>;
12
15
  export declare function exactIdentityMatches(expected: FileIdentityStat, actual: Readonly<{
13
16
  dev: bigint;
@@ -15,7 +18,7 @@ export declare function exactIdentityMatches(expected: FileIdentityStat, actual:
15
18
  }>): boolean;
16
19
  export declare function describeStagedDirectory(fd: number, pathname: string): StagedDirectorySnapshot;
17
20
  export declare function assertStagedDirectoryCurrent(receipt: StagedDirectorySnapshot): BigIntStats;
18
- export declare function describePolicyStagedDirectory(fd: number, pathname: string): PolicyStagedDirectory;
21
+ export declare function describePolicyStagedDirectory(fd: number, pathname: string, binding?: NativeBinding): PolicyStagedDirectory;
19
22
  export declare function assertPolicyStagedDirectoryCurrent(captured: PolicyStagedDirectory): BigIntStats;
20
23
  export declare function refreshPolicyStagedDirectoryObservation(captured: PolicyStagedDirectory): MutationDirectoryObservation;
21
24
  export declare function openStagedDirectory(directory: string | DirectoryReceipt): {
@@ -1 +1 @@
1
- {"version":3,"file":"staged-directory.d.ts","sourceRoot":"","sources":["../src/staged-directory.ts"],"names":[],"mappings":"AAAA,OAAW,EAAE,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,OAAO,EAEL,KAAK,4BAA4B,EAClC,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAGhE,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAErE,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,SAAS,EAAE,uBAAuB,CAAC;IACnC,WAAW,EAAE,4BAA4B,CAAC;IAC1C,IAAI,EAAE,WAAW,CAAC;CACnB,CAAC,CAAC;AAEH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7C,OAAO,CAKT;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,uBAAuB,CAY7F;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,uBAAuB,GAAG,WAAW,CAS1F;AASD,wBAAgB,6BAA6B,CAC3C,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,GACf,qBAAqB,CAwBvB;AAED,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,qBAAqB,GAC9B,WAAW,CAOb;AAED,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,qBAAqB,GAC9B,4BAA4B,CAU9B;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,uBAAuB,CAAC;CAClC,CAiCA"}
1
+ {"version":3,"file":"staged-directory.d.ts","sourceRoot":"","sources":["../src/staged-directory.ts"],"names":[],"mappings":"AAAA,OAAW,EAAE,KAAK,WAAW,EAAE,MAAM,SAAS,CAAC;AAE/C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EAEL,KAAK,4BAA4B,EACjC,KAAK,yBAAyB,EAE/B,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAGhE,MAAM,MAAM,uBAAuB,GAAG,iBAAiB,CAAC,WAAW,CAAC,CAAC;AAErE,MAAM,MAAM,qBAAqB,GAAG,QAAQ,CAAC;IAC3C,SAAS,EAAE,uBAAuB,CAAC;IACnC,WAAW,EAAE,4BAA4B,CAAC;IAC1C,IAAI,EAAE,WAAW,CAAC;IAClB,cAAc,CAAC,EAAE,yBAAyB,CAAC;IAC3C,kBAAkB,CAAC,IAAI,IAAI,CAAC;CAC7B,CAAC,CAAC;AAEH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAC7C,OAAO,CAKT;AAED,wBAAgB,uBAAuB,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,uBAAuB,CAY7F;AAED,wBAAgB,4BAA4B,CAAC,OAAO,EAAE,uBAAuB,GAAG,WAAW,CAS1F;AASD,wBAAgB,6BAA6B,CAC3C,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,aAAa,GACtB,qBAAqB,CA2CvB;AAED,wBAAgB,kCAAkC,CAChD,QAAQ,EAAE,qBAAqB,GAC9B,WAAW,CAeb;AAED,wBAAgB,uCAAuC,CACrD,QAAQ,EAAE,qBAAqB,GAC9B,4BAA4B,CAoB9B;AAED,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,gBAAgB,GAAG;IACzE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,uBAAuB,CAAC;CAClC,CAiCA"}
@@ -2,6 +2,7 @@ import fs, {} from "node:fs";
2
2
  import path from "node:path";
3
3
  import { FsSafeError } from "./errors.js";
4
4
  import { inspectDirectoryIdentitySync } from "./directory-guard.js";
5
+ import { nativePolicyDirectoryObserver, NativePolicyDirectoryMismatch } from "./native-policy-directory-observation.js";
5
6
  import { checkedMutationDirectory, } from "./pinned-mutation-observation.js";
6
7
  import { realpathSync } from "./realpath.js";
7
8
  import { resolvePathPreservingWindowsRoot } from "./windows-path-alias.js";
@@ -38,12 +39,31 @@ function sameDirectoryMetadata(left, right) {
38
39
  }
39
40
  // The policy hot path is Node/POSIX-only. It deliberately uses native
40
41
  // canonicalization so a successful capture contains no JS realpath work.
41
- export function describePolicyStagedDirectory(fd, pathname) {
42
+ export function describePolicyStagedDirectory(fd, pathname, binding) {
42
43
  const resolved = path.resolve(pathname);
43
44
  const descriptor = fs.fstatSync(fd, { bigint: true });
44
45
  if (!descriptor.isDirectory()) {
45
46
  throw new FsSafeError("not-file", "staging parent must be a directory");
46
47
  }
48
+ const observeCurrent = nativePolicyDirectoryObserver(binding, fd, resolved);
49
+ const observed = observeCurrent?.();
50
+ if (observed) {
51
+ if (!sameDirectoryMetadata(descriptor, observed.identity)) {
52
+ throw new NativePolicyDirectoryMismatch();
53
+ }
54
+ const directory = Object.freeze({
55
+ path: resolved,
56
+ realPath: observed.canonicalPath,
57
+ identity: Object.freeze({ dev: descriptor.dev, ino: descriptor.ino }),
58
+ });
59
+ return Object.freeze({
60
+ directory,
61
+ observation: checkedMutationDirectory(resolved, observed.canonicalPath, observed.identity, observeCurrent),
62
+ stat: descriptor,
63
+ observeCurrent,
64
+ disposeObservation: observeCurrent.dispose,
65
+ });
66
+ }
47
67
  const before = inspectDirectoryIdentitySync(resolved);
48
68
  const canonicalPath = path.resolve(realpathSync.native(resolved));
49
69
  const after = inspectDirectoryIdentitySync(resolved, descriptor);
@@ -64,6 +84,14 @@ export function describePolicyStagedDirectory(fd, pathname) {
64
84
  });
65
85
  }
66
86
  export function assertPolicyStagedDirectoryCurrent(captured) {
87
+ const observed = captured.observeCurrent?.();
88
+ if (observed) {
89
+ if (!sameDirectoryMetadata(captured.stat, observed.identity) ||
90
+ observed.canonicalPath !== captured.directory.realPath) {
91
+ throw new FsSafeError("path-mismatch", "staging directory pathname changed");
92
+ }
93
+ return captured.stat;
94
+ }
67
95
  const current = inspectDirectoryIdentitySync(captured.directory.path, captured.directory.identity);
68
96
  if (!sameDirectoryMetadata(captured.stat, current) ||
69
97
  path.resolve(realpathSync.native(captured.directory.path)) !== captured.directory.realPath) {
@@ -72,6 +100,14 @@ export function assertPolicyStagedDirectoryCurrent(captured) {
72
100
  return current;
73
101
  }
74
102
  export function refreshPolicyStagedDirectoryObservation(captured) {
103
+ const observed = captured.observeCurrent?.();
104
+ if (observed) {
105
+ if (!exactIdentityMatches(captured.directory.identity, observed.identity) ||
106
+ observed.canonicalPath !== captured.directory.realPath) {
107
+ throw new FsSafeError("path-mismatch", "staging directory pathname changed");
108
+ }
109
+ return checkedMutationDirectory(captured.directory.path, observed.canonicalPath, observed.identity, captured.observeCurrent);
110
+ }
75
111
  const current = inspectDirectoryIdentitySync(captured.directory.path, captured.directory.identity);
76
112
  const canonicalPath = path.resolve(realpathSync.native(captured.directory.path));
77
113
  if (canonicalPath !== captured.directory.realPath) {
@@ -1 +1 @@
1
- {"version":3,"file":"temp-workspace-admission.d.ts","sourceRoot":"","sources":["../src/temp-workspace-admission.ts"],"names":[],"mappings":"AA6BA,KAAK,aAAa,GAAG,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAiB5D,MAAM,MAAM,0BAA0B,GAAG;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClD,aAAa,IAAI,IAAI,CAAC;IACtB,cAAc,IAAI,IAAI,CAAC;IACvB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/C,CAAC;AA0UF,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAM1F;AAED,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAyBjG;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAsBtF"}
1
+ {"version":3,"file":"temp-workspace-admission.d.ts","sourceRoot":"","sources":["../src/temp-workspace-admission.ts"],"names":[],"mappings":"AA6BA,KAAK,aAAa,GAAG,QAAQ,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAiB5D,MAAM,MAAM,0BAA0B,GAAG;IACvC,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,aAAa,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,oBAAoB,CAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClD,aAAa,IAAI,IAAI,CAAC;IACtB,cAAc,IAAI,IAAI,CAAC;IACvB,gBAAgB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/C,CAAC;AAsWF,wBAAgB,8BAA8B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAM1F;AAED,wBAAsB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAyBjG;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,GAAG,0BAA0B,CAsBtF"}
@@ -116,6 +116,30 @@ function assertSnapshot(entry, uid) {
116
116
  assertTrustedTempWorkspaceDirectory(stat, uid);
117
117
  assertCanonicalRoot(entry);
118
118
  }
119
+ function snapshotMissingRootComponent(parent, dir, ownerUid) {
120
+ if (parent.dir !== parent.realPath) {
121
+ assertSnapshot(parent, ownerUid);
122
+ return snapshot(dir, ownerUid);
123
+ }
124
+ const current = inspectSnapshotIdentity(parent);
125
+ assertTrustedTempWorkspaceDirectory(current, ownerUid);
126
+ let realPath;
127
+ try {
128
+ realPath = canonicalTempWorkspacePath(dir);
129
+ }
130
+ catch (error) {
131
+ // Keep parent resolution failures ahead of a missing or unreadable child.
132
+ assertCanonicalRoot(parent);
133
+ throw error;
134
+ }
135
+ if (path.dirname(realPath) !== parent.realPath) {
136
+ assertCanonicalRoot(parent);
137
+ return snapshot(dir, ownerUid);
138
+ }
139
+ // The child's canonical parent confirms the same parent name at this phase.
140
+ // Its exact non-symlink/owner observation still precedes mode initialization.
141
+ return snapshot(dir, ownerUid, realPath);
142
+ }
119
143
  function assertChain(chain, uid) {
120
144
  for (const entry of chain) {
121
145
  const current = inspectSnapshotIdentity(entry);
@@ -321,6 +345,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
321
345
  return admission;
322
346
  const guardedChain = chain;
323
347
  for (const segment of missing) {
348
+ const parentEntry = guardedChain[guardedChain.length - 1];
324
349
  const parent = rootAdmission(guardedChain, ownerUid);
325
350
  const dir = path.join(parent.dir, segment);
326
351
  assertNoWindowsPathAlias(dir, "filesystem");
@@ -334,8 +359,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
334
359
  if (error.code !== "EEXIST")
335
360
  throw error;
336
361
  }
337
- parent.assertCurrent();
338
- const observed = snapshot(dir, ownerUid);
362
+ const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
339
363
  if (created) {
340
364
  const modeInitialization = admitTempWorkspaceChild(dir, observed.stat, parent, 0o700);
341
365
  if (modeInitialization)
@@ -351,6 +375,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
351
375
  return admission;
352
376
  const guardedChain = chain;
353
377
  for (const segment of missing) {
378
+ const parentEntry = guardedChain[guardedChain.length - 1];
354
379
  const parent = rootAdmission(guardedChain, ownerUid);
355
380
  const dir = path.join(parent.dir, segment);
356
381
  assertNoWindowsPathAlias(dir, "filesystem");
@@ -364,8 +389,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
364
389
  if (error.code !== "EEXIST")
365
390
  throw error;
366
391
  }
367
- parent.assertCurrent();
368
- const observed = snapshot(dir, ownerUid);
392
+ const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
369
393
  if (created)
370
394
  admitTempWorkspaceChildSync(dir, observed.stat, parent, 0o700);
371
395
  guardedChain.push(observed.entry);
package/docs/guest.md CHANGED
@@ -80,7 +80,7 @@ disables. Do not omit required fields.
80
80
  | Rename | `rename srcRoot srcParent srcBasename dstRoot dstParent dstBasename mkdir` | Rename with cross-device copy/delete fallback. |
81
81
  | Remove | `remove root parent basename recursive force` | Removes the leaf, or recursively removes its tree. |
82
82
  | Make directories | `mkdirp root directory` | Creates missing relative directory components. |
83
- | List directory | `readdir root directory` | JSON array of `{ name, isDirectory }`; no sorting guarantee. |
83
+ | List directory | `readdir root directory` | JSON array of `{ name, isDirectory, isFile }`; both kind fields are false for symlinks and special entries; no sorting guarantee. |
84
84
 
85
85
  The complete program rejects empty, `.`, `..`, slash-containing, and NUL
86
86
  basenames before opening roots or creating parents. Both leaf operands of copy
@@ -43,6 +43,19 @@ when admitting a temp workspace. Containment and identity checks stay intact.
43
43
 
44
44
  Configure the mode once during startup. Loading is lazy and cached; changing from `auto` to `require` after a failed load changes failure policy but does not repeatedly probe the binary.
45
45
 
46
+ Native-created descriptors retain their originating native close operation through
47
+ normal and error cleanup, including later mode changes. Node-created roots and
48
+ directory handles keep Node's close operation, and borrowed handles keep their
49
+ caller-owned lifetime. This preserves Node worker-thread descriptor tracking
50
+ without unmanaged-descriptor warnings. A helper missing native close support is
51
+ unavailable before descriptor allocation.
52
+
53
+ Close retained native resources and let in-flight operations finish before
54
+ forcibly terminating a worker. Native-created descriptors are not registered
55
+ with Node's automatic worker-exit cleanup; `Worker.terminate()` can leave them
56
+ open until process exit. The native close operation handles explicit cleanup,
57
+ not forced worker termination.
58
+
46
59
  [`tempWorkspace()` and its scoped/sync variants](temp.md#private-temp-workspaces)
47
60
  remain available in every mode. Their default compatible cleanup uses guarded
48
61
  JavaScript quarantine when owned native tree removal is unavailable.
@@ -84,7 +97,7 @@ normalization, and the decision to fall back.
84
97
 
85
98
  - Linux uses `openat2` with `RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS`, `renameat`, and `renameat2(RENAME_NOREPLACE)`. Owned-tree cleanup enumerates and unlinks through retained directory descriptors and rejects device crossings.
86
99
  - macOS 15.4 and newer prefer `O_RESOLVE_BENEATH`; older kernels resolve components with `O_NOFOLLOW` and restart in-root symlinks from the pinned root descriptor. Both routes use an `F_GETPATH` post-open escape detector and report `best-effort` because directory rename races are not atomic with that check. Publication uses `renameat` for replacement and `renameatx_np(RENAME_EXCL)` for no-replace; owned-tree cleanup uses descriptor-relative `openat`/`unlinkat`.
87
- - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table.
100
+ - Windows uses handle-relative `NtCreateFile`, rejects reparse points during root-bounded traversal, uses `FileRenameInfoEx` with replacement selected explicitly by the TypeScript policy layer, and deletes owned trees through exact opened handles with `FileDispositionInfoEx`; symlink/reparse entries in owned trees are removed as leaves and never traversed. Descriptors crossing N-API are converted only by the host executable's paired `uv_get_osfhandle` and `uv_open_osfhandle` exports. A runtime without both exports is unsupported for these native operations; the binding never guesses a raw HANDLE or uses a foreign CRT descriptor table. Descriptor-producing operations also require that same host's synchronous libuv close and request-management APIs before exporting an owned descriptor.
88
101
 
89
102
  Native primitives back create-only and replacing pinned writes, no-clobber
90
103
  `Root.move()`, async sidecar creation, guarded publication, archive acceleration,
package/docs/native.md CHANGED
@@ -206,6 +206,14 @@ that fallback does not apply to POSIX no-read modes.
206
206
 
207
207
  ## JavaScript fallback guarantees and delta
208
208
 
209
+ Policy-bound parent creation can refresh exact directory facts through the
210
+ retained POSIX descriptor. The optional native observer compares descriptor and
211
+ no-follow pathname metadata and verifies the descriptor's canonical path before
212
+ and after the observation. Ordinary paths can share that observation within one
213
+ synchronous admission phase; callbacks, mutations, and later phases require fresh
214
+ evidence. Unsupported helpers retain the guarded pathname checks. Policy and
215
+ denied-path decisions remain in TypeScript.
216
+
209
217
  Public policy does not change with the selected mechanism: traversal and link
210
218
  rejection, archive filters/limits/modes, exclusive target creation, source and
211
219
  target identity fencing, publication cleanup receipts, and secret/lock policy
package/docs/writing.md CHANGED
@@ -537,6 +537,12 @@ Windows `Root.write()` and `Root.writeJson()` honor this policy both as a Root d
537
537
 
538
538
  The Windows buffered compatibility path resolves permitted in-root aliases before choosing its lock and binds publication to that effective destination. With the existing lock protocol, effective path components beneath the Root must contain only lower-case ASCII letters, digits, `.`, `_`, or `-`, with no trailing `.`. Unsupported spellings, including missing upper-case or non-ASCII names, fail with `path-alias` before mutation; no filesystem case-sensitivity or Unicode-folding behavior is guessed. This restriction does not apply to strict writes. Opaque Windows pathname identities still use strict verification against the retained original descriptor: they never, by themselves, authorize content-based acceptance of a replacement.
539
539
 
540
+ The library's own lock-destination check permits the writer to reuse parent
541
+ admission within that operation. The lock key and destination checks remain
542
+ unchanged, and evidence is revoked when the locked operation finishes. Supplying
543
+ an `assertBeforeMutation` callback retains full parent admission because caller
544
+ code can change the filesystem before dispatch.
545
+
540
546
  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. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-guarded-remove-if-unchanged).
541
547
 
542
548
  ## See also
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -167,13 +167,13 @@
167
167
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@openclaw/fs-safe-darwin-arm64": "0.13.0",
171
- "@openclaw/fs-safe-darwin-x64": "0.13.0",
172
- "@openclaw/fs-safe-linux-arm64-gnu": "0.13.0",
173
- "@openclaw/fs-safe-linux-arm64-musl": "0.13.0",
174
- "@openclaw/fs-safe-linux-x64-gnu": "0.13.0",
175
- "@openclaw/fs-safe-linux-x64-musl": "0.13.0",
176
- "@openclaw/fs-safe-win32-x64-msvc": "0.13.0",
170
+ "@openclaw/fs-safe-darwin-arm64": "0.14.0",
171
+ "@openclaw/fs-safe-darwin-x64": "0.14.0",
172
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.14.0",
173
+ "@openclaw/fs-safe-linux-arm64-musl": "0.14.0",
174
+ "@openclaw/fs-safe-linux-x64-gnu": "0.14.0",
175
+ "@openclaw/fs-safe-linux-x64-musl": "0.14.0",
176
+ "@openclaw/fs-safe-win32-x64-msvc": "0.14.0",
177
177
  "jszip": "^3.10.2"
178
178
  },
179
179
  "devDependencies": {