@openclaw/fs-safe 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +9 -1
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/atomic.d.ts +1 -1
- package/dist/native-binding.d.ts +22 -0
- 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 +17 -0
- package/dist/replace-file-destination.js +61 -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 +29 -10
- package/dist/retained-file-types.d.ts +61 -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 +7 -1
- package/dist/root-directory-list.js +48 -23
- 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-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- 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 +3 -0
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +80 -0
- package/dist/watch-hints.d.ts +8 -0
- package/dist/watch-hints.js +77 -0
- package/dist/watch-native.d.ts +32 -0
- package/dist/watch-native.js +56 -0
- package/dist/watch-scan.d.ts +24 -0
- package/dist/watch-scan.js +269 -0
- package/dist/watch-types.d.ts +58 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +502 -0
- package/docs/advanced.md +1 -0
- package/docs/atomic.md +61 -0
- package/docs/contributing.md +5 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/native-helper.md +9 -0
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +60 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/package.json +12 -8
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { assertSynchronousCallbackResult } from "./mutation-authority.js";
|
|
2
|
+
export class AtomicMutation {
|
|
3
|
+
active;
|
|
4
|
+
#assertion;
|
|
5
|
+
#observer;
|
|
6
|
+
#refusal;
|
|
7
|
+
#writingReported = false;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.#assertion = options.assertBeforeMutation;
|
|
10
|
+
this.#observer = options.onDestinationState;
|
|
11
|
+
this.active = Boolean(this.#assertion || this.#observer);
|
|
12
|
+
}
|
|
13
|
+
rethrowRefusal() {
|
|
14
|
+
if (this.#refusal)
|
|
15
|
+
throw this.#refusal.error;
|
|
16
|
+
}
|
|
17
|
+
refuse(error) {
|
|
18
|
+
this.#refusal ??= { error };
|
|
19
|
+
throw this.#refusal.error;
|
|
20
|
+
}
|
|
21
|
+
#invoke(operation, name) {
|
|
22
|
+
this.rethrowRefusal();
|
|
23
|
+
try {
|
|
24
|
+
const result = operation();
|
|
25
|
+
assertSynchronousCallbackResult(result, name);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
this.refuse(error);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
assert() {
|
|
32
|
+
this.#invoke(() => this.#assertion?.(), "assertBeforeMutation");
|
|
33
|
+
}
|
|
34
|
+
destination(state, path, identity) {
|
|
35
|
+
if (state === "writing") {
|
|
36
|
+
if (this.#writingReported)
|
|
37
|
+
return;
|
|
38
|
+
this.#writingReported = true;
|
|
39
|
+
}
|
|
40
|
+
this.#invoke(() => this.#observer?.(Object.freeze({
|
|
41
|
+
state, path, dev: identity.dev, ino: identity.ino,
|
|
42
|
+
})), "onDestinationState");
|
|
43
|
+
}
|
|
44
|
+
removed(path) {
|
|
45
|
+
this.#invoke(() => this.#observer?.(Object.freeze({ state: "removed", path })), "onDestinationState");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -26,7 +26,7 @@ export declare class AsyncAtomicTempOwner extends AtomicTempOwner<FileHandle> {
|
|
|
26
26
|
identity: BigIntStats;
|
|
27
27
|
}): void;
|
|
28
28
|
assertCurrent(fsModule: AsyncOwnerFileSystem, pathname?: string): Promise<void>;
|
|
29
|
-
assertPublished(fsModule: AsyncOwnerFileSystem, pathname: string, expectedHash?: string): Promise<void>;
|
|
29
|
+
assertPublished(fsModule: AsyncOwnerFileSystem, pathname: string, expectedHash?: string, onVerified?: (identity: BigIntStats) => void): Promise<void>;
|
|
30
30
|
finish(params: {
|
|
31
31
|
fsModule: AsyncOwnerFileSystem;
|
|
32
32
|
originalFailure?: AtomicTempFailure;
|
|
@@ -39,7 +39,7 @@ export declare class SyncAtomicTempOwner extends AtomicTempOwner<number> {
|
|
|
39
39
|
identity: BigIntStats;
|
|
40
40
|
}): void;
|
|
41
41
|
assertCurrent(fsModule: SyncOwnerFileSystem, pathname?: string): void;
|
|
42
|
-
assertPublished(fsModule: SyncOwnerFileSystem, pathname: string, expectedHash?: string): void;
|
|
42
|
+
assertPublished(fsModule: SyncOwnerFileSystem, pathname: string, expectedHash?: string, onVerified?: (identity: BigIntStats) => void): void;
|
|
43
43
|
finish(params: {
|
|
44
44
|
fsModule: SyncOwnerFileSystem;
|
|
45
45
|
originalFailure?: AtomicTempFailure;
|
|
@@ -161,16 +161,21 @@ export class AsyncAtomicTempOwner extends AtomicTempOwner {
|
|
|
161
161
|
throw error;
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
|
-
async assertPublished(fsModule, pathname, expectedHash) {
|
|
164
|
+
async assertPublished(fsModule, pathname, expectedHash, onVerified) {
|
|
165
|
+
let identityCurrent = false;
|
|
165
166
|
try {
|
|
166
167
|
await this.assertCurrent(fsModule, pathname);
|
|
167
|
-
|
|
168
|
+
identityCurrent = true;
|
|
168
169
|
}
|
|
169
170
|
catch (error) {
|
|
170
171
|
if (!(error instanceof FsSafeError) || !hasErrorCode(error, "path-mismatch") || !expectedHash) {
|
|
171
172
|
throw error;
|
|
172
173
|
}
|
|
173
174
|
}
|
|
175
|
+
if (identityCurrent) {
|
|
176
|
+
onVerified?.(this.identity);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
174
179
|
let published;
|
|
175
180
|
try {
|
|
176
181
|
try {
|
|
@@ -199,6 +204,7 @@ export class AsyncAtomicTempOwner extends AtomicTempOwner {
|
|
|
199
204
|
if (sha256Hex(await published.readFile()) !== expectedHash) {
|
|
200
205
|
throw new FsSafeError("path-mismatch", `Atomic replace published content changed: ${pathname}`);
|
|
201
206
|
}
|
|
207
|
+
onVerified?.(identity);
|
|
202
208
|
const previousHandle = this.takeResource();
|
|
203
209
|
await previousHandle?.close();
|
|
204
210
|
this.resource = published;
|
|
@@ -269,16 +275,21 @@ export class SyncAtomicTempOwner extends AtomicTempOwner {
|
|
|
269
275
|
throw error;
|
|
270
276
|
}
|
|
271
277
|
}
|
|
272
|
-
assertPublished(fsModule, pathname, expectedHash) {
|
|
278
|
+
assertPublished(fsModule, pathname, expectedHash, onVerified) {
|
|
279
|
+
let identityCurrent = false;
|
|
273
280
|
try {
|
|
274
281
|
this.assertCurrent(fsModule, pathname);
|
|
275
|
-
|
|
282
|
+
identityCurrent = true;
|
|
276
283
|
}
|
|
277
284
|
catch (error) {
|
|
278
285
|
if (!(error instanceof FsSafeError) || !hasErrorCode(error, "path-mismatch") || !expectedHash) {
|
|
279
286
|
throw error;
|
|
280
287
|
}
|
|
281
288
|
}
|
|
289
|
+
if (identityCurrent) {
|
|
290
|
+
onVerified?.(this.identity);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
282
293
|
let publishedFd;
|
|
283
294
|
try {
|
|
284
295
|
try {
|
|
@@ -305,6 +316,7 @@ export class SyncAtomicTempOwner extends AtomicTempOwner {
|
|
|
305
316
|
if (sha256Hex(fsModule.readFileSync(publishedFd)) !== expectedHash) {
|
|
306
317
|
throw new FsSafeError("path-mismatch", `Atomic replace published content changed: ${pathname}`);
|
|
307
318
|
}
|
|
319
|
+
onVerified?.(identity);
|
|
308
320
|
const previousFd = this.takeResource();
|
|
309
321
|
fsModule.closeSync(previousFd);
|
|
310
322
|
this.resource = publishedFd;
|
|
@@ -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,18 @@ 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";
|
|
16
17
|
async function renameWithRetry(params) {
|
|
17
18
|
for (let attempt = 0; attempt <= params.maxRetries; attempt++) {
|
|
18
19
|
if (attempt > 0)
|
|
19
20
|
await params.assertSourceCurrent();
|
|
20
21
|
try {
|
|
22
|
+
params.mutation.assert();
|
|
21
23
|
await params.fsModule.rename(params.src, params.dest);
|
|
22
24
|
return { method: "rename" };
|
|
23
25
|
}
|
|
24
26
|
catch (error) {
|
|
27
|
+
params.mutation.rethrowRefusal();
|
|
25
28
|
const code = readErrorCode(error);
|
|
26
29
|
if (code === "EBUSY" && attempt < params.maxRetries) {
|
|
27
30
|
await sleep(params.baseDelayMs * 2 ** attempt);
|
|
@@ -37,6 +40,7 @@ async function renameWithRetry(params) {
|
|
|
37
40
|
maxRestoreBytes: params.maxRestoreBytes,
|
|
38
41
|
expectedSourceIdentity: params.sourceIdentity,
|
|
39
42
|
sync: params.syncFallback,
|
|
43
|
+
mutation: params.mutation,
|
|
40
44
|
});
|
|
41
45
|
return { method: "copy-fallback" };
|
|
42
46
|
}
|
|
@@ -50,10 +54,12 @@ function renameWithRetrySync(params) {
|
|
|
50
54
|
if (attempt > 0)
|
|
51
55
|
params.assertSourceCurrent();
|
|
52
56
|
try {
|
|
57
|
+
params.mutation.assert();
|
|
53
58
|
params.fsModule.renameSync(params.src, params.dest);
|
|
54
59
|
return { method: "rename" };
|
|
55
60
|
}
|
|
56
61
|
catch (error) {
|
|
62
|
+
params.mutation.rethrowRefusal();
|
|
57
63
|
const code = readErrorCode(error);
|
|
58
64
|
if (code === "EBUSY" && attempt < params.maxRetries) {
|
|
59
65
|
sleepSync(params.baseDelayMs * 2 ** attempt);
|
|
@@ -70,6 +76,7 @@ function renameWithRetrySync(params) {
|
|
|
70
76
|
expectedSourceIdentity: params.sourceIdentity,
|
|
71
77
|
fchmodSync: params.fchmodSync,
|
|
72
78
|
sync: params.syncFallback,
|
|
79
|
+
mutation: params.mutation,
|
|
73
80
|
});
|
|
74
81
|
return { method: "copy-fallback" };
|
|
75
82
|
}
|
|
@@ -142,24 +149,27 @@ export async function replaceFileAtomic(options) {
|
|
|
142
149
|
}
|
|
143
150
|
// Internal owner hook: keep directory durability inside publication verification and serialization.
|
|
144
151
|
export async function replaceFileAtomicWithDirectorySync(options, syncParent) {
|
|
152
|
+
const mutation = new AtomicMutation(options);
|
|
145
153
|
const filePath = validateReplaceFilePath(options.filePath);
|
|
146
154
|
validateRestoreOptions(options);
|
|
147
155
|
const renameIdentity = options.renameIdentity;
|
|
148
156
|
validateRenameIdentity(renameIdentity);
|
|
149
157
|
return await serializePathWrite(path.resolve(filePath), async () => {
|
|
150
158
|
if (renameIdentity !== "verify-content-with-lock") {
|
|
151
|
-
return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent);
|
|
159
|
+
return await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent);
|
|
152
160
|
}
|
|
153
161
|
const fsModule = options.fileSystem?.promises ?? fs;
|
|
154
162
|
const dir = path.dirname(filePath);
|
|
163
|
+
mutation.assert();
|
|
155
164
|
await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, {
|
|
156
165
|
recursive: true,
|
|
157
166
|
mode: options.dirMode ?? 0o700,
|
|
158
167
|
});
|
|
159
|
-
|
|
168
|
+
mutation.assert();
|
|
169
|
+
return await withAtomicRenameIdentityLock(filePath, async () => await replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent));
|
|
160
170
|
});
|
|
161
171
|
}
|
|
162
|
-
async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, syncParent) {
|
|
172
|
+
async function replaceFileAtomicUnserialized(options, filePath, renameIdentity, mutation, syncParent) {
|
|
163
173
|
const fsModule = options.fileSystem?.promises ?? fs;
|
|
164
174
|
const dir = path.dirname(filePath);
|
|
165
175
|
const dirMode = options.dirMode ?? 0o700;
|
|
@@ -169,8 +179,9 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
169
179
|
const tempOwner = new AsyncAtomicTempOwner(tempPath);
|
|
170
180
|
let originalFailure;
|
|
171
181
|
try {
|
|
182
|
+
mutation.assert();
|
|
172
183
|
await fsModule.mkdir(fsModule === fs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
|
|
173
|
-
await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode });
|
|
184
|
+
await applyDirectoryMode({ fsModule, dirPath: dir, mode: dirMode, mutation });
|
|
174
185
|
tempOwner.start();
|
|
175
186
|
tempOwner.adopt(await writeTempFile({
|
|
176
187
|
fsModule,
|
|
@@ -179,6 +190,7 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
179
190
|
mode,
|
|
180
191
|
sync: options.syncTempFile === true,
|
|
181
192
|
onIdentity: tempOwner.onIdentity,
|
|
193
|
+
mutation,
|
|
182
194
|
}));
|
|
183
195
|
await tempOwner.assertCurrent(fsModule);
|
|
184
196
|
if (options.beforeRename) {
|
|
@@ -202,10 +214,11 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
202
214
|
sourceIdentity: tempOwner.identity,
|
|
203
215
|
assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
|
|
204
216
|
syncFallback: options.syncTempFile === true,
|
|
217
|
+
mutation,
|
|
205
218
|
});
|
|
206
219
|
if (result.method === "rename") {
|
|
207
220
|
tempOwner.markRenamed();
|
|
208
|
-
await tempOwner.assertPublished(fsModule, filePath, expectedHash);
|
|
221
|
+
await tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
|
|
209
222
|
}
|
|
210
223
|
else {
|
|
211
224
|
await tempOwner.assertCurrent(fsModule);
|
|
@@ -237,22 +250,25 @@ async function replaceFileAtomicUnserialized(options, filePath, renameIdentity,
|
|
|
237
250
|
}
|
|
238
251
|
}
|
|
239
252
|
export function replaceFileAtomicSync(options) {
|
|
253
|
+
const mutation = new AtomicMutation(options);
|
|
240
254
|
const filePath = validateReplaceFilePath(options.filePath);
|
|
241
255
|
validateRestoreOptions(options);
|
|
242
256
|
const renameIdentity = options.renameIdentity;
|
|
243
257
|
validateRenameIdentity(renameIdentity);
|
|
244
258
|
if (renameIdentity !== "verify-content-with-lock") {
|
|
245
|
-
return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity);
|
|
259
|
+
return replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation);
|
|
246
260
|
}
|
|
247
261
|
const fsModule = options.fileSystem ?? syncFs;
|
|
248
262
|
const dir = path.dirname(filePath);
|
|
263
|
+
mutation.assert();
|
|
249
264
|
fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, {
|
|
250
265
|
recursive: true,
|
|
251
266
|
mode: options.dirMode ?? 0o700,
|
|
252
267
|
});
|
|
253
|
-
|
|
268
|
+
mutation.assert();
|
|
269
|
+
return withAtomicRenameIdentityLockSync(filePath, () => replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation));
|
|
254
270
|
}
|
|
255
|
-
function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
271
|
+
function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity, mutation) {
|
|
256
272
|
const fsModule = options.fileSystem ?? syncFs;
|
|
257
273
|
const dir = path.dirname(filePath);
|
|
258
274
|
const dirMode = options.dirMode ?? 0o700;
|
|
@@ -269,8 +285,9 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
|
269
285
|
const tempOwner = new SyncAtomicTempOwner(tempPath);
|
|
270
286
|
let originalFailure;
|
|
271
287
|
try {
|
|
288
|
+
mutation.assert();
|
|
272
289
|
fsModule.mkdirSync(fsModule === syncFs ? recursiveMkdirPath(dir) : dir, { recursive: true, mode: dirMode });
|
|
273
|
-
applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync });
|
|
290
|
+
applyDirectoryModeSync({ fsModule, dirPath: dir, mode: dirMode, fchmodSync, mutation });
|
|
274
291
|
tempOwner.start();
|
|
275
292
|
tempOwner.adopt(writeTempFileSync({
|
|
276
293
|
fsModule,
|
|
@@ -280,6 +297,7 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
|
280
297
|
fchmodSync,
|
|
281
298
|
sync: options.syncTempFile === true,
|
|
282
299
|
onIdentity: tempOwner.onIdentity,
|
|
300
|
+
mutation,
|
|
283
301
|
}));
|
|
284
302
|
tempOwner.assertCurrent(fsModule);
|
|
285
303
|
if (options.beforeRename) {
|
|
@@ -304,10 +322,11 @@ function replaceFileAtomicSyncUnserialized(options, filePath, renameIdentity) {
|
|
|
304
322
|
assertSourceCurrent: () => tempOwner.assertCurrent(fsModule),
|
|
305
323
|
fchmodSync,
|
|
306
324
|
syncFallback: options.syncTempFile === true,
|
|
325
|
+
mutation,
|
|
307
326
|
});
|
|
308
327
|
if (result.method === "rename") {
|
|
309
328
|
tempOwner.markRenamed();
|
|
310
|
-
tempOwner.assertPublished(fsModule, filePath, expectedHash);
|
|
329
|
+
tempOwner.assertPublished(fsModule, filePath, expectedHash, identity => mutation.destination("published", filePath, identity));
|
|
311
330
|
}
|
|
312
331
|
else {
|
|
313
332
|
tempOwner.assertCurrent(fsModule);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** 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;
|
|
8
|
+
/** Lowercase SHA-256 of the producer's expected bytes. */
|
|
9
|
+
sha256: string;
|
|
10
|
+
}>;
|
|
11
|
+
export type RetainedFileIssue = Readonly<{
|
|
12
|
+
phase: string;
|
|
13
|
+
code: string;
|
|
14
|
+
message: string;
|
|
15
|
+
cause?: unknown;
|
|
16
|
+
}>;
|
|
17
|
+
/** Descriptive facts, not a deletion capability or a durable transaction receipt. */
|
|
18
|
+
export type RetainedFileResult = Readonly<{
|
|
19
|
+
status: "unsupported" | "not-attempted" | "preserved-mismatch" | "disposition-accepted" | "name-absent-after-settlement" | "failed" | "indeterminate";
|
|
20
|
+
phase: string;
|
|
21
|
+
/** Full native 64-bit volume serial and 128-bit file ID, when observed. */
|
|
22
|
+
identity?: string;
|
|
23
|
+
disposition: "not-attempted" | "accepted" | "rejected" | "indeterminate";
|
|
24
|
+
namespace: "not-observed" | "absent" | "original" | "foreign" | "unknown";
|
|
25
|
+
resources: "closed" | "close-failed";
|
|
26
|
+
persistence: "not-proven";
|
|
27
|
+
errors: readonly RetainedFileIssue[];
|
|
28
|
+
}>;
|
|
29
|
+
export type RetainedFileReceipt = Readonly<{
|
|
30
|
+
directory: string;
|
|
31
|
+
parent: Readonly<{
|
|
32
|
+
dev: bigint;
|
|
33
|
+
ino: bigint;
|
|
34
|
+
}>;
|
|
35
|
+
basename: string;
|
|
36
|
+
expected: RetainedFileExpected;
|
|
37
|
+
identity: string;
|
|
38
|
+
}>;
|
|
39
|
+
export interface RetainedFile extends Disposable {
|
|
40
|
+
readonly receipt: RetainedFileReceipt;
|
|
41
|
+
/** One-shot, synchronous authority admission; always settles owned resources. */
|
|
42
|
+
remove(): RetainedFileResult;
|
|
43
|
+
/** Closes only. Never requests deletion, including after an exception. */
|
|
44
|
+
dispose(): RetainedFileResult;
|
|
45
|
+
}
|
|
46
|
+
export type RetainedFileAdmission = Readonly<{
|
|
47
|
+
status: "retained";
|
|
48
|
+
file: RetainedFile;
|
|
49
|
+
}> | 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;
|
|
58
|
+
assertBeforeMutation: () => void;
|
|
59
|
+
/** Synchronous verification budget, default 16 MiB; maximum 64 MiB. */
|
|
60
|
+
maxBytes?: number;
|
|
61
|
+
}>;
|
|
@@ -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
|
+
}
|