@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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type syncFs from "node:fs";
|
|
2
|
+
import type fs from "node:fs/promises";
|
|
3
|
+
import type { RenameIdentityPolicy } from "./pinned-write-types.js";
|
|
4
|
+
import type { AtomicMutationOptions } from "./replace-file-mutation.js";
|
|
5
|
+
import type { ReplaceFileCopyFallbackRestorePolicy, ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
|
|
6
|
+
export type ReplaceFileAtomicFileSystem = {
|
|
7
|
+
promises: Pick<typeof fs, "mkdir" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat"> & {
|
|
8
|
+
/** @deprecated Accepted for adapter compatibility but never called. */
|
|
9
|
+
chmod?: typeof fs.chmod;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync"> & {
|
|
13
|
+
/** @deprecated Accepted for adapter compatibility but never called. */
|
|
14
|
+
chmodSync?: typeof syncFs.chmodSync;
|
|
15
|
+
fchmodSync?: typeof syncFs.fchmodSync;
|
|
16
|
+
};
|
|
17
|
+
export type ReplaceFileAtomicBaseOptions = AtomicMutationOptions & {
|
|
18
|
+
filePath: string;
|
|
19
|
+
content: string | Uint8Array;
|
|
20
|
+
dirMode?: number;
|
|
21
|
+
mode?: number;
|
|
22
|
+
/** Inherit only rwx bits from an existing non-symlink regular file. */
|
|
23
|
+
preserveExistingMode?: boolean;
|
|
24
|
+
tempPrefix?: string;
|
|
25
|
+
renameMaxRetries?: number;
|
|
26
|
+
renameRetryBaseDelayMs?: number;
|
|
27
|
+
copyFallbackOnPermissionError?: boolean;
|
|
28
|
+
copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
|
|
29
|
+
maxRestoreBytes?: number;
|
|
30
|
+
destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
|
|
31
|
+
/** Strict by default; locked content verification is an explicit FUSE compatibility policy. */
|
|
32
|
+
renameIdentity?: RenameIdentityPolicy;
|
|
33
|
+
syncTempFile?: boolean;
|
|
34
|
+
syncParentDir?: boolean;
|
|
35
|
+
throwOnCleanupError?: boolean;
|
|
36
|
+
};
|
|
37
|
+
export type ReplaceFileAtomicOptions = ReplaceFileAtomicBaseOptions & {
|
|
38
|
+
fileSystem?: ReplaceFileAtomicFileSystem;
|
|
39
|
+
/** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
|
|
40
|
+
beforeRename?: (params: {
|
|
41
|
+
filePath: string;
|
|
42
|
+
tempPath: string;
|
|
43
|
+
}) => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
export type ReplaceFileAtomicSyncOptions = ReplaceFileAtomicBaseOptions & {
|
|
46
|
+
fileSystem?: ReplaceFileAtomicSyncFileSystem;
|
|
47
|
+
/** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
|
|
48
|
+
beforeRename?: (params: {
|
|
49
|
+
filePath: string;
|
|
50
|
+
tempPath: string;
|
|
51
|
+
}) => void;
|
|
52
|
+
};
|
|
53
|
+
export type ReplaceFileAtomicResult = {
|
|
54
|
+
method: "rename" | "copy-fallback";
|
|
55
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/replace-file.d.ts
CHANGED
|
@@ -1,58 +1,6 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { type ReplaceFileCopyFallbackRestorePolicy, type ReplaceFileDestinationHardlinkPolicy } from "./replace-file-copy-fallback.js";
|
|
5
|
-
export type ReplaceFileAtomicFileSystem = {
|
|
6
|
-
promises: Pick<typeof fs, "mkdir" | "writeFile" | "rename" | "copyFile" | "unlink" | "rm" | "open" | "stat" | "lstat"> & {
|
|
7
|
-
/** @deprecated Accepted for adapter compatibility but never called. */
|
|
8
|
-
chmod?: typeof fs.chmod;
|
|
9
|
-
};
|
|
10
|
-
};
|
|
11
|
-
export type ReplaceFileAtomicSyncFileSystem = Pick<typeof syncFs, "mkdirSync" | "readFileSync" | "writeFileSync" | "renameSync" | "copyFileSync" | "unlinkSync" | "rmSync" | "openSync" | "fsyncSync" | "closeSync" | "fstatSync" | "statSync" | "lstatSync" | "ftruncateSync" | "readSync" | "writeSync"> & {
|
|
12
|
-
/** @deprecated Accepted for adapter compatibility but never called. */
|
|
13
|
-
chmodSync?: typeof syncFs.chmodSync;
|
|
14
|
-
fchmodSync?: typeof syncFs.fchmodSync;
|
|
15
|
-
};
|
|
16
|
-
type ReplaceFileAtomicBaseOptions = {
|
|
17
|
-
filePath: string;
|
|
18
|
-
content: string | Uint8Array;
|
|
19
|
-
dirMode?: number;
|
|
20
|
-
mode?: number;
|
|
21
|
-
/** Inherit only rwx bits from an existing non-symlink regular file. */
|
|
22
|
-
preserveExistingMode?: boolean;
|
|
23
|
-
tempPrefix?: string;
|
|
24
|
-
renameMaxRetries?: number;
|
|
25
|
-
renameRetryBaseDelayMs?: number;
|
|
26
|
-
copyFallbackOnPermissionError?: boolean;
|
|
27
|
-
copyFallbackRestore?: ReplaceFileCopyFallbackRestorePolicy;
|
|
28
|
-
maxRestoreBytes?: number;
|
|
29
|
-
destinationHardlinks?: ReplaceFileDestinationHardlinkPolicy;
|
|
30
|
-
/** Strict by default; locked content verification is an explicit FUSE compatibility policy. */
|
|
31
|
-
renameIdentity?: RenameIdentityPolicy;
|
|
32
|
-
syncTempFile?: boolean;
|
|
33
|
-
syncParentDir?: boolean;
|
|
34
|
-
throwOnCleanupError?: boolean;
|
|
35
|
-
};
|
|
36
|
-
export type ReplaceFileAtomicOptions = ReplaceFileAtomicBaseOptions & {
|
|
37
|
-
fileSystem?: ReplaceFileAtomicFileSystem;
|
|
38
|
-
/** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
|
|
39
|
-
beforeRename?: (params: {
|
|
40
|
-
filePath: string;
|
|
41
|
-
tempPath: string;
|
|
42
|
-
}) => Promise<void>;
|
|
43
|
-
};
|
|
44
|
-
export type ReplaceFileAtomicSyncOptions = ReplaceFileAtomicBaseOptions & {
|
|
45
|
-
fileSystem?: ReplaceFileAtomicSyncFileSystem;
|
|
46
|
-
/** Runs while the exact staged file is retained; replacing or hardlinking it is rejected. */
|
|
47
|
-
beforeRename?: (params: {
|
|
48
|
-
filePath: string;
|
|
49
|
-
tempPath: string;
|
|
50
|
-
}) => void;
|
|
51
|
-
};
|
|
52
|
-
export type ReplaceFileAtomicResult = {
|
|
53
|
-
method: "rename" | "copy-fallback";
|
|
54
|
-
};
|
|
1
|
+
import type { ReplaceFileAtomicOptions, ReplaceFileAtomicSyncOptions, ReplaceFileAtomicResult } from "./replace-file-types.js";
|
|
2
|
+
export type { ReplaceFileAtomicDestinationState } from "./replace-file-mutation.js";
|
|
3
|
+
export type { ReplaceFileAtomicFileSystem, ReplaceFileAtomicSyncFileSystem, ReplaceFileAtomicOptions, ReplaceFileAtomicSyncOptions, ReplaceFileAtomicResult, } from "./replace-file-types.js";
|
|
55
4
|
export declare function replaceFileAtomic(options: ReplaceFileAtomicOptions): Promise<ReplaceFileAtomicResult>;
|
|
56
5
|
export declare function replaceFileAtomicWithDirectorySync(options: ReplaceFileAtomicOptions, syncParent?: (directoryPath: string) => Promise<unknown>): Promise<ReplaceFileAtomicResult>;
|
|
57
6
|
export declare function replaceFileAtomicSync(options: ReplaceFileAtomicSyncOptions): ReplaceFileAtomicResult;
|
|
58
|
-
export {};
|
package/dist/replace-file.js
CHANGED
|
@@ -13,15 +13,19 @@ import { admitStandalonePublicationPath } from "./windows-path-alias.js";
|
|
|
13
13
|
import { sleep, sleepSync } from "./timing.js";
|
|
14
14
|
import { serializePathWrite } from "./write-queue.js";
|
|
15
15
|
import { hasErrorCode, readErrorCode } from "./file-cleanup.js";
|
|
16
|
+
import { AtomicMutation } from "./replace-file-mutation.js";
|
|
17
|
+
import { assertSynchronousCallbackResult } from "./mutation-authority.js";
|
|
16
18
|
async function renameWithRetry(params) {
|
|
17
19
|
for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
|
|
18
20
|
if (attempt > 0)
|
|
19
21
|
await params.assertSourceCurrent();
|
|
20
22
|
try {
|
|
23
|
+
params.mutation.assert();
|
|
21
24
|
await params.fsModule.rename(params.src, params.dest);
|
|
22
25
|
return { method: "rename" };
|
|
23
26
|
}
|
|
24
27
|
catch (error) {
|
|
28
|
+
params.mutation.rethrowRefusal();
|
|
25
29
|
const code = readErrorCode(error);
|
|
26
30
|
if (code === "EBUSY" && attempt < params.maxRetries) {
|
|
27
31
|
await sleep(params.baseDelayMs * 2 ** attempt);
|
|
@@ -37,6 +41,7 @@ async function renameWithRetry(params) {
|
|
|
37
41
|
maxRestoreBytes: params.maxRestoreBytes,
|
|
38
42
|
expectedSourceIdentity: params.sourceIdentity,
|
|
39
43
|
sync: params.syncFallback,
|
|
44
|
+
mutation: params.mutation,
|
|
40
45
|
});
|
|
41
46
|
return { method: "copy-fallback" };
|
|
42
47
|
}
|
|
@@ -50,10 +55,12 @@ function renameWithRetrySync(params) {
|
|
|
50
55
|
if (attempt > 0)
|
|
51
56
|
params.assertSourceCurrent();
|
|
52
57
|
try {
|
|
58
|
+
params.mutation.assert();
|
|
53
59
|
params.fsModule.renameSync(params.src, params.dest);
|
|
54
60
|
return { method: "rename" };
|
|
55
61
|
}
|
|
56
62
|
catch (error) {
|
|
63
|
+
params.mutation.rethrowRefusal();
|
|
57
64
|
const code = readErrorCode(error);
|
|
58
65
|
if (code === "EBUSY" && attempt < params.maxRetries) {
|
|
59
66
|
sleepSync(params.baseDelayMs * 2 ** attempt);
|
|
@@ -70,6 +77,7 @@ function renameWithRetrySync(params) {
|
|
|
70
77
|
expectedSourceIdentity: params.sourceIdentity,
|
|
71
78
|
fchmodSync: params.fchmodSync,
|
|
72
79
|
sync: params.syncFallback,
|
|
80
|
+
mutation: params.mutation,
|
|
73
81
|
});
|
|
74
82
|
return { method: "copy-fallback" };
|
|
75
83
|
}
|
|
@@ -142,24 +150,27 @@ export async function replaceFileAtomic(options) {
|
|
|
142
150
|
}
|
|
143
151
|
// Internal owner hook: keep directory durability inside publication verification and serialization.
|
|
144
152
|
export async function replaceFileAtomicWithDirectorySync(options, syncParent) {
|
|
153
|
+
const mutation = new AtomicMutation(options);
|
|
145
154
|
const filePath = validateReplaceFilePath(options.filePath);
|
|
146
155
|
validateRestoreOptions(options);
|
|
147
156
|
const renameIdentity = options.renameIdentity;
|
|
148
157
|
validateRenameIdentity(renameIdentity);
|
|
149
158
|
return await serializePathWrite(path.resolve(filePath), async () => {
|
|
150
159
|
if (renameIdentity !== "verify-content-with-lock") {
|
|
151
|
-
return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent);
|
|
160
|
+
return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent);
|
|
152
161
|
}
|
|
153
162
|
const fsModule = options.fileSystem?.promises ?? fs;
|
|
154
163
|
const dir = path.dirname(filePath);
|
|
164
|
+
mutation.assert();
|
|
155
165
|
await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, {
|
|
156
166
|
recursive: true,
|
|
157
167
|
mode: options.dirMode ?? 0o700,
|
|
158
168
|
});
|
|
159
|
-
|
|
169
|
+
mutation.assert();
|
|
170
|
+
return await withAtomicRenameIdentityLock(filePath, async () => await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent));
|
|
160
171
|
});
|
|
161
172
|
}
|
|
162
|
-
async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent) {
|
|
173
|
+
async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent) {
|
|
163
174
|
const fsModule = options.fileSystem?.promises ?? fs;
|
|
164
175
|
const dir = path.dirname(filePath);
|
|
165
176
|
const dirMode = options.dirMode ?? 0o700;
|
|
@@ -169,8 +180,9 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
169
180
|
const tempOwner = new AsyncAtomicTempOwner(tempPath);
|
|
170
181
|
let originalFailure;
|
|
171
182
|
try {
|
|
183
|
+
mutation.assert();
|
|
172
184
|
await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
|
|
173
|
-
await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode });
|
|
185
|
+
await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode, mutation });
|
|
174
186
|
tempOwner.start();
|
|
175
187
|
tempOwner.adopt(await writeTempFile({
|
|
176
188
|
fsModule,
|
|
@@ -179,6 +191,7 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
179
191
|
mode,
|
|
180
192
|
sync: options.syncTempFile === true,
|
|
181
193
|
onIdentity: tempOwner.onIdentity,
|
|
194
|
+
mutation,
|
|
182
195
|
}));
|
|
183
196
|
await tempOwner.assertCurrent(fsModule);
|
|
184
197
|
if (options.beforeRename) {
|
|
@@ -202,10 +215,11 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
202
215
|
sourceIdentity: tempOwner.identity,
|
|
203
216
|
assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
|
|
204
217
|
syncFallback: options.syncTempFile === true,
|
|
218
|
+
mutation,
|
|
205
219
|
});
|
|
206
220
|
if (result.method === "rename") {
|
|
207
221
|
tempOwner.markRenamed();
|
|
208
|
-
await tempOwner.assertPublished(fsModule, filePath, expectedHash);
|
|
222
|
+
await tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
|
|
209
223
|
}
|
|
210
224
|
else {
|
|
211
225
|
await tempOwner.assertCurrent(fsModule);
|
|
@@ -237,22 +251,25 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
237
251
|
}
|
|
238
252
|
}
|
|
239
253
|
export function replaceFileAtomicSync(options) {
|
|
254
|
+
const mutation = new AtomicMutation(options);
|
|
240
255
|
const filePath = validateReplaceFilePath(options.filePath);
|
|
241
256
|
validateRestoreOptions(options);
|
|
242
257
|
const renameIdentity = options.renameIdentity;
|
|
243
258
|
validateRenameIdentity(renameIdentity);
|
|
244
259
|
if (renameIdentity !== "verify-content-with-lock") {
|
|
245
|
-
return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity);
|
|
260
|
+
return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation);
|
|
246
261
|
}
|
|
247
262
|
const fsModule = options.fileSystem ?? syncFs;
|
|
248
263
|
const dir = path.dirname(filePath);
|
|
264
|
+
mutation.assert();
|
|
249
265
|
fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, {
|
|
250
266
|
recursive: true,
|
|
251
267
|
mode: options.dirMode ?? 0o700,
|
|
252
268
|
});
|
|
253
|
-
|
|
269
|
+
mutation.assert();
|
|
270
|
+
return withAtomicRenameIdentityLockSync(filePath, () => replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation));
|
|
254
271
|
}
|
|
255
|
-
function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
272
|
+
function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation) {
|
|
256
273
|
const fsModule = options.fileSystem ?? syncFs;
|
|
257
274
|
const dir = path.dirname(filePath);
|
|
258
275
|
const dirMode = options.dirMode ?? 0o700;
|
|
@@ -269,8 +286,9 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
|
269
286
|
const tempOwner = new SyncAtomicTempOwner(tempPath);
|
|
270
287
|
let originalFailure;
|
|
271
288
|
try {
|
|
289
|
+
mutation.assert();
|
|
272
290
|
fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
|
|
273
|
-
applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync });
|
|
291
|
+
applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync, mutation });
|
|
274
292
|
tempOwner.start();
|
|
275
293
|
tempOwner.adopt(writeTempFileSync({
|
|
276
294
|
fsModule,
|
|
@@ -280,10 +298,11 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
|
280
298
|
fchmodSync,
|
|
281
299
|
sync: options.syncTempFile === true,
|
|
282
300
|
onIdentity: tempOwner.onIdentity,
|
|
301
|
+
mutation,
|
|
283
302
|
}));
|
|
284
303
|
tempOwner.assertCurrent(fsModule);
|
|
285
304
|
if (options.beforeRename) {
|
|
286
|
-
options.beforeRename({ filePath, tempPath });
|
|
305
|
+
assertSynchronousCallbackResult(options.beforeRename({ filePath, tempPath }), "beforeRename");
|
|
287
306
|
tempOwner.assertCurrent(fsModule);
|
|
288
307
|
}
|
|
289
308
|
if (options.destinationHardlinks === "reject") {
|
|
@@ -304,10 +323,11 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
|
304
323
|
assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
|
|
305
324
|
fchmodSync,
|
|
306
325
|
syncFallback: options.syncTempFile === true,
|
|
326
|
+
mutation,
|
|
307
327
|
});
|
|
308
328
|
if (result.method === "rename") {
|
|
309
329
|
tempOwner.markRenamed();
|
|
310
|
-
tempOwner.assertPublished(fsModule, filePath, expectedHash);
|
|
330
|
+
tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
|
|
311
331
|
}
|
|
312
332
|
else {
|
|
313
333
|
tempOwner.assertCurrent(fsModule);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { StagedFileReceipt } from "./staged-file-types.js";
|
|
2
|
+
/** Exact producer observations: never supply rounded Number identities. */
|
|
3
|
+
export type RetainedFileExpected = Readonly<Pick<StagedFileReceipt["identity"], "dev" | "ino" | "size" | "mtimeNs" | "ctimeNs"> & {
|
|
4
|
+
/** Lowercase SHA-256 of the producer's expected bytes. */
|
|
5
|
+
sha256: string;
|
|
6
|
+
}>;
|
|
7
|
+
export type RetainedFileIssue = Readonly<{
|
|
8
|
+
phase: string;
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
}>;
|
|
13
|
+
/** Descriptive facts, not a deletion capability or a durable transaction receipt. */
|
|
14
|
+
export type RetainedFileResult = Readonly<{
|
|
15
|
+
status: "unsupported" | "not-attempted" | "preserved-mismatch" | "disposition-accepted" | "name-absent-after-settlement" | "failed" | "indeterminate";
|
|
16
|
+
phase: string;
|
|
17
|
+
/** Full native 64-bit volume serial and 128-bit file ID, when observed. */
|
|
18
|
+
identity?: string;
|
|
19
|
+
disposition: "not-attempted" | "accepted" | "rejected" | "indeterminate";
|
|
20
|
+
namespace: "not-observed" | "absent" | "original" | "foreign" | "unknown";
|
|
21
|
+
resources: "closed" | "close-failed";
|
|
22
|
+
persistence: "not-proven";
|
|
23
|
+
errors: readonly RetainedFileIssue[];
|
|
24
|
+
}>;
|
|
25
|
+
export type RetainedFileReceipt = Readonly<{
|
|
26
|
+
directory: string;
|
|
27
|
+
parent: Readonly<{
|
|
28
|
+
dev: bigint;
|
|
29
|
+
ino: bigint;
|
|
30
|
+
}>;
|
|
31
|
+
basename: string;
|
|
32
|
+
expected: RetainedFileExpected;
|
|
33
|
+
identity: string;
|
|
34
|
+
}>;
|
|
35
|
+
export interface RetainedFile extends Disposable {
|
|
36
|
+
readonly receipt: RetainedFileReceipt;
|
|
37
|
+
/** One-shot, synchronous authority admission; always settles owned resources. */
|
|
38
|
+
remove(): RetainedFileResult;
|
|
39
|
+
/** Closes only. Never requests deletion, including after an exception. */
|
|
40
|
+
dispose(): RetainedFileResult;
|
|
41
|
+
}
|
|
42
|
+
export type RetainedFileAdmission = Readonly<{
|
|
43
|
+
status: "retained";
|
|
44
|
+
file: RetainedFile;
|
|
45
|
+
}> | RetainedFileResult;
|
|
46
|
+
export type RetainFileInDirectoryOptions = Readonly<Pick<RetainedFileReceipt, "directory" | "parent" | "basename" | "expected"> & {
|
|
47
|
+
assertBeforeMutation: () => void;
|
|
48
|
+
/** Synchronous verification budget, default 16 MiB; maximum 64 MiB. */
|
|
49
|
+
maxBytes?: number;
|
|
50
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { RetainedFileAdmission, RetainFileInDirectoryOptions } from "./retained-file-types.js";
|
|
2
|
+
/** Retain one existing regular file on supported local Windows NTFS. No fallback. */
|
|
3
|
+
export declare function retainFileInDirectory(options: RetainFileInDirectoryOptions): RetainedFileAdmission;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
import { getNativeBinding } from "./native.js";
|
|
3
|
+
import { assertSynchronousCallbackResult } from "./mutation-authority.js";
|
|
4
|
+
function freeze(result) {
|
|
5
|
+
return Object.freeze({ ...result, errors: Object.freeze(result.errors.map((error) => Object.freeze({ ...error }))) });
|
|
6
|
+
}
|
|
7
|
+
function unsupported(cause) {
|
|
8
|
+
return freeze({ status: "unsupported", phase: "admission", disposition: "not-attempted",
|
|
9
|
+
namespace: "not-observed", resources: "closed", persistence: "not-proven",
|
|
10
|
+
errors: [{ phase: "admission", code: "helper-unavailable",
|
|
11
|
+
message: "existing-file retention requires the maintained Windows native capability", cause }] });
|
|
12
|
+
}
|
|
13
|
+
function exact(value, name, zero = false) {
|
|
14
|
+
if (typeof value !== "bigint" || value < (zero ? 0n : 1n) || value > 0xffffffffffffffffn) {
|
|
15
|
+
throw new TypeError(`${name} must be an exact ${zero ? "nonnegative" : "positive"} unsigned 64-bit bigint`);
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
class Owner {
|
|
20
|
+
receipt;
|
|
21
|
+
#native;
|
|
22
|
+
#assert;
|
|
23
|
+
#result;
|
|
24
|
+
#running = false;
|
|
25
|
+
#reentered = false;
|
|
26
|
+
constructor(native, receipt, assertion) {
|
|
27
|
+
this.#native = native;
|
|
28
|
+
this.receipt = receipt;
|
|
29
|
+
this.#assert = assertion;
|
|
30
|
+
Object.freeze(this);
|
|
31
|
+
}
|
|
32
|
+
#settle(remove) {
|
|
33
|
+
if (this.#result)
|
|
34
|
+
return this.#result;
|
|
35
|
+
if (this.#running) {
|
|
36
|
+
this.#reentered = true;
|
|
37
|
+
throw new TypeError("retained-file settlement cannot be reentered");
|
|
38
|
+
}
|
|
39
|
+
this.#running = true;
|
|
40
|
+
let rejection;
|
|
41
|
+
if (remove) {
|
|
42
|
+
try {
|
|
43
|
+
assertSynchronousCallbackResult(this.#assert(), "assertBeforeMutation");
|
|
44
|
+
if (this.#reentered)
|
|
45
|
+
throw new TypeError("retained-file authority reentered settlement");
|
|
46
|
+
}
|
|
47
|
+
catch (cause) {
|
|
48
|
+
rejection = { cause };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const result = this.#native.settle(remove && !rejection);
|
|
53
|
+
this.#result = freeze(rejection ? { ...result, phase: "authority", errors: [
|
|
54
|
+
{ phase: "authority", code: "denied-path", message: "current mutation authority rejected removal", cause: rejection.cause },
|
|
55
|
+
...result.errors,
|
|
56
|
+
] } : result);
|
|
57
|
+
}
|
|
58
|
+
catch (cause) {
|
|
59
|
+
// An unexpected binding exception cannot establish native effect or close.
|
|
60
|
+
// Do not retry or release a dependency on an unknown native outcome.
|
|
61
|
+
this.#result = freeze({ status: "indeterminate", phase: "binding", identity: this.receipt.identity,
|
|
62
|
+
disposition: remove && !rejection ? "indeterminate" : "not-attempted", namespace: "unknown",
|
|
63
|
+
resources: "close-failed", persistence: "not-proven", errors: [
|
|
64
|
+
...(rejection ? [{ phase: "authority", code: "denied-path", message: "current mutation authority rejected removal", cause: rejection.cause }] : []),
|
|
65
|
+
{ phase: "binding", code: "helper-failed", message: "native settlement did not return a result", cause },
|
|
66
|
+
] });
|
|
67
|
+
}
|
|
68
|
+
return this.#result;
|
|
69
|
+
}
|
|
70
|
+
remove() { return this.#settle(true); }
|
|
71
|
+
dispose() { return this.#settle(false); }
|
|
72
|
+
[Symbol.dispose]() {
|
|
73
|
+
const result = this.dispose();
|
|
74
|
+
if (result.resources !== "closed") {
|
|
75
|
+
throw new FsSafeError("helper-failed", "retained-file resource settlement is uncertain", { details: { result } });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** Retain one existing regular file on supported local Windows NTFS. No fallback. */
|
|
80
|
+
export function retainFileInDirectory(options) {
|
|
81
|
+
// Snapshot once before any native resource admission or caller authority runs.
|
|
82
|
+
const directory = options.directory;
|
|
83
|
+
const basename = options.basename;
|
|
84
|
+
const sourceParent = options.parent;
|
|
85
|
+
const parent = Object.freeze({ dev: exact(sourceParent.dev, "parent.dev"), ino: exact(sourceParent.ino, "parent.ino") });
|
|
86
|
+
const source = options.expected;
|
|
87
|
+
const expected = Object.freeze({ dev: exact(source.dev, "expected.dev"), ino: exact(source.ino, "expected.ino"),
|
|
88
|
+
size: exact(source.size, "expected.size", true), mtimeNs: exact(source.mtimeNs, "expected.mtimeNs", true),
|
|
89
|
+
ctimeNs: exact(source.ctimeNs, "expected.ctimeNs", true), sha256: source.sha256 });
|
|
90
|
+
const assertion = options.assertBeforeMutation;
|
|
91
|
+
const maxBytes = options.maxBytes ?? 16 * 1024 * 1024;
|
|
92
|
+
if (typeof directory !== "string" || typeof basename !== "string" || typeof assertion !== "function"
|
|
93
|
+
|| typeof expected.sha256 !== "string" || !/^[0-9a-f]{64}$/u.test(expected.sha256)
|
|
94
|
+
|| !Number.isSafeInteger(maxBytes) || maxBytes <= 0 || maxBytes > 64 * 1024 * 1024 || expected.size > BigInt(maxBytes)) {
|
|
95
|
+
throw new TypeError("invalid retained-file directory, authority or bounded expected-byte contract");
|
|
96
|
+
}
|
|
97
|
+
if (process.platform !== "win32")
|
|
98
|
+
return unsupported();
|
|
99
|
+
let binding;
|
|
100
|
+
try {
|
|
101
|
+
binding = getNativeBinding();
|
|
102
|
+
}
|
|
103
|
+
catch (cause) {
|
|
104
|
+
return unsupported(cause);
|
|
105
|
+
}
|
|
106
|
+
if (!binding?.retainWindowsFile)
|
|
107
|
+
return unsupported();
|
|
108
|
+
let native;
|
|
109
|
+
try {
|
|
110
|
+
native = binding.retainWindowsFile(directory, basename, parent.dev, parent.ino, expected.dev, expected.ino, expected.size, expected.mtimeNs, expected.ctimeNs, expected.sha256, maxBytes);
|
|
111
|
+
}
|
|
112
|
+
catch (cause) {
|
|
113
|
+
// Factory errors have no returned owner; never infer absence or deletion.
|
|
114
|
+
return freeze({ ...unsupported(cause), status: "indeterminate", resources: "close-failed" });
|
|
115
|
+
}
|
|
116
|
+
const admission = native.admission;
|
|
117
|
+
if (admission.status !== "retained")
|
|
118
|
+
return freeze(admission);
|
|
119
|
+
const receipt = Object.freeze({ directory, parent, basename, expected, identity: admission.identity });
|
|
120
|
+
return Object.freeze({ status: "retained", file: new Owner(native, receipt, assertion) });
|
|
121
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RootContext } from "./root-context.js";
|
|
2
|
+
import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
|
|
3
|
+
import { type ExactStatIdentity } from "./stat-observation.js";
|
|
4
|
+
import type { DirEntry } from "./types.js";
|
|
5
|
+
/** One literal entry lookup under an operation-local admitted parent; never follow the leaf. */
|
|
6
|
+
export declare function lookupRootDirectoryEntry(root: RootContext, guard: RootDirectoryObservationGuard, name: string): Promise<{
|
|
7
|
+
entry: DirEntry;
|
|
8
|
+
identity: ExactStatIdentity;
|
|
9
|
+
} | undefined>;
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { assertValidRootRelativePath } from "./root-context.js";
|
|
6
|
+
import { assertRootDirectoryObservationGuard, pathStatFromStats } from "./root-directory-list.js";
|
|
7
|
+
import { inspectStatObservationSync } from "./stat-observation.js";
|
|
8
|
+
/** One literal entry lookup under an operation-local admitted parent; never follow the leaf. */
|
|
9
|
+
export async function lookupRootDirectoryEntry(root, guard, name) {
|
|
10
|
+
if (!name || name === "." || name === ".." || path.basename(name) !== name || path.isAbsolute(name)) {
|
|
11
|
+
throw new FsSafeError("invalid-path", "directory lookup requires one literal name");
|
|
12
|
+
}
|
|
13
|
+
assertValidRootRelativePath(name);
|
|
14
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
15
|
+
try {
|
|
16
|
+
const pathname = path.join(guard.realPath, name);
|
|
17
|
+
const observed = inspectStatObservationSync(bigint => bigint
|
|
18
|
+
? fs.lstatSync(pathname, { bigint: true }) : fs.lstatSync(pathname));
|
|
19
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
20
|
+
return { entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
24
|
+
if (isNotFoundPathError(error))
|
|
25
|
+
return undefined;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { BigIntStats, Stats } from "node:fs";
|
|
|
2
2
|
import { type AsyncDirectoryGuard } from "./directory-guard.js";
|
|
3
3
|
import { type RootContext } from "./root-context.js";
|
|
4
4
|
import type { RootPathDirectoryObservationGuard, RootPathObservationReceipt, RootPathParentObservationReceipt } from "./root-path.js";
|
|
5
|
+
import { type ExactStatIdentity } from "./stat-observation.js";
|
|
5
6
|
import type { DirEntry, PathStat } from "./types.js";
|
|
6
7
|
export declare function pathStatFromStats(stat: Stats | BigIntStats): PathStat;
|
|
7
8
|
export type RootDirectoryObservationGuard = AsyncDirectoryGuard<BigIntStats>;
|
|
@@ -21,6 +22,7 @@ export type RootDirectoryListing = {
|
|
|
21
22
|
next(): Promise<{
|
|
22
23
|
kind: "entry";
|
|
23
24
|
entry: DirEntry;
|
|
25
|
+
identity?: ExactStatIdentity;
|
|
24
26
|
} | {
|
|
25
27
|
kind: "limit";
|
|
26
28
|
name: string;
|
|
@@ -33,6 +35,12 @@ export type RootDirectoryListingOptions = {
|
|
|
33
35
|
snapshot: boolean;
|
|
34
36
|
maxNames?: number;
|
|
35
37
|
metadataBatchSize?: number;
|
|
38
|
+
/** Internal exact metadata lane, supported by streaming filesystem order. */
|
|
39
|
+
exactIdentity?: boolean;
|
|
40
|
+
/** Advisory scans may omit vanished leaves after revalidating their parent. */
|
|
41
|
+
skipVanished?: boolean;
|
|
42
|
+
/** Internal owner receives cleanup failures, including acquisition rollback. */
|
|
43
|
+
onCleanupFailure?: (error: unknown) => void;
|
|
36
44
|
admitEntry(): boolean;
|
|
37
45
|
};
|
|
38
|
-
export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions): Promise<RootDirectoryListing>;
|
|
46
|
+
export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions, receipt?: RootPathObservationReceipt): Promise<RootDirectoryListing>;
|