@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.
- package/CHANGELOG.md +47 -0
- package/README.md +9 -1
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -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/atomic.d.ts +1 -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 +33 -0
- package/dist/native-pinned-write.js +8 -2
- package/dist/pinned-mutation-admission.js +4 -2
- package/dist/replace-file-buffer.d.ts +4 -0
- package/dist/replace-file-buffer.js +36 -0
- package/dist/replace-file-copy-fallback.d.ts +2 -0
- package/dist/replace-file-copy-fallback.js +66 -38
- package/dist/replace-file-descriptor.d.ts +4 -0
- package/dist/replace-file-descriptor.js +9 -1
- package/dist/replace-file-destination.d.ts +21 -0
- package/dist/replace-file-destination.js +123 -0
- package/dist/replace-file-mutation.d.ts +26 -0
- package/dist/replace-file-mutation.js +47 -0
- package/dist/replace-file-temp-owner.d.ts +2 -2
- package/dist/replace-file-temp-owner.js +16 -4
- package/dist/replace-file-types.d.ts +55 -0
- package/dist/replace-file-types.js +1 -0
- package/dist/replace-file.d.ts +3 -55
- package/dist/replace-file.js +31 -11
- package/dist/retained-file-types.d.ts +50 -0
- package/dist/retained-file-types.js +1 -0
- package/dist/retained-file.d.ts +3 -0
- package/dist/retained-file.js +121 -0
- package/dist/root-directory-entry.d.ts +9 -0
- package/dist/root-directory-entry.js +28 -0
- package/dist/root-directory-list.d.ts +9 -1
- package/dist/root-directory-list.js +88 -37
- package/dist/root-handle-context.d.ts +4 -0
- package/dist/root-handle-context.js +12 -0
- package/dist/root-impl.d.ts +3 -3
- package/dist/root-impl.js +8 -2
- package/dist/root-move-noreplace.js +3 -3
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- 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/temp-target.js +3 -2
- package/dist/temp-workspace-admission.js +22 -21
- package/dist/temp-workspace-child-admission.d.ts +1 -1
- package/dist/temp-workspace-child-admission.js +14 -9
- package/dist/temp-workspace-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +4 -0
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +88 -0
- package/dist/watch-hints.d.ts +9 -0
- package/dist/watch-hints.js +93 -0
- package/dist/watch-native.d.ts +36 -0
- package/dist/watch-native.js +73 -0
- package/dist/watch-scan.d.ts +28 -0
- package/dist/watch-scan.js +300 -0
- package/dist/watch-stream.d.ts +8 -0
- package/dist/watch-stream.js +32 -0
- package/dist/watch-types.d.ts +60 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +530 -0
- package/docs/advanced.md +1 -0
- package/docs/archive.md +6 -0
- package/docs/atomic.md +82 -0
- package/docs/contributing.md +74 -6
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/install.md +2 -0
- package/docs/native-helper.md +9 -0
- package/docs/native.md +24 -6
- package/docs/public-api.md +23 -2
- package/docs/retained-file.md +115 -0
- package/docs/root.md +25 -8
- package/docs/sidecar-lock.md +1 -1
- package/docs/staged-symlink.md +2 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +186 -4
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +251 -0
- 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,
|
|
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
|
package/dist/advanced.d.ts
CHANGED
|
@@ -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;
|
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
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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;
|