@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.
- package/CHANGELOG.md +24 -0
- package/dist/archive-zip-directory.js +4 -0
- package/dist/archive-zip-loader.js +3 -1
- package/dist/archive-zip-manifest.js +3 -1
- package/dist/copy-publication.d.ts +1 -3
- package/dist/copy-publication.js +2 -6
- package/dist/deny-mutation-match.d.ts +2 -0
- package/dist/deny-mutation-match.js +71 -0
- package/dist/deny-mutations.js +4 -3
- package/dist/file-identity.js +10 -2
- package/dist/file-lock-sync-admission.js +5 -1
- package/dist/file-lock-sync-root-io.d.ts +2 -2
- package/dist/file-lock-sync-root-io.js +1 -1
- package/dist/file-lock-sync.js +2 -2
- package/dist/file-store-prune.js +4 -4
- package/dist/mutation-authority.js +5 -0
- package/dist/native-binding.d.ts +11 -0
- package/dist/native-pinned-write.js +8 -2
- package/dist/pinned-mutation-admission.js +4 -2
- package/dist/replace-file-copy-fallback.js +4 -4
- package/dist/replace-file-destination.d.ts +4 -0
- package/dist/replace-file-destination.js +67 -5
- package/dist/replace-file.js +2 -1
- package/dist/retained-file-types.d.ts +3 -14
- package/dist/root-directory-list.d.ts +2 -0
- package/dist/root-directory-list.js +46 -20
- package/dist/root-move-noreplace.js +3 -3
- package/dist/sidecar-lock-acquire.js +3 -3
- package/dist/sidecar-lock-reclaim.d.ts +2 -2
- package/dist/sidecar-lock-reclaim.js +6 -6
- package/dist/sidecar-lock.js +4 -4
- package/dist/staged-symlink-types.d.ts +4 -14
- package/dist/test-hooks.d.ts +1 -0
- package/dist/watch-alias.js +11 -3
- package/dist/watch-hints.d.ts +2 -1
- package/dist/watch-hints.js +17 -1
- package/dist/watch-native.d.ts +4 -0
- package/dist/watch-native.js +18 -1
- package/dist/watch-scan.d.ts +5 -1
- package/dist/watch-scan.js +37 -6
- package/dist/watch-stream.d.ts +8 -0
- package/dist/watch-stream.js +32 -0
- package/dist/watch-types.d.ts +2 -0
- package/dist/watch.js +35 -7
- package/docs/archive.md +6 -0
- package/docs/atomic.md +23 -2
- package/docs/contributing.md +69 -6
- package/docs/install.md +2 -0
- package/docs/native.md +24 -6
- package/docs/public-api.md +23 -2
- package/docs/retained-file.md +4 -2
- package/docs/root.md +19 -7
- package/docs/sidecar-lock.md +1 -1
- package/docs/staged-symlink.md +2 -1
- package/docs/testing.md +132 -10
- package/docs/watch.md +77 -10
- 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;
|
package/dist/copy-publication.js
CHANGED
|
@@ -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
|
-
|
|
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,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
|
+
}
|
package/dist/deny-mutations.js
CHANGED
|
@@ -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
|
|
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 ((
|
|
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
|
}
|
package/dist/file-identity.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
}
|
package/dist/file-lock-sync.js
CHANGED
|
@@ -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.
|
package/dist/file-store-prune.js
CHANGED
|
@@ -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)
|
package/dist/native-binding.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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?.
|
|
180
|
+
await writeAtomicDestination(handle, original, destination?.beforeRestore, destination?.assertBeforeMutation, destination?.writing);
|
|
181
181
|
if (destination)
|
|
182
|
-
await destination.
|
|
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?.
|
|
210
|
-
destination?.
|
|
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
|
|
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
|
}
|
package/dist/replace-file.js
CHANGED
|
@@ -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;
|