@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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUtf8 } from "node:buffer";
|
|
1
2
|
import fsSync from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
@@ -11,8 +12,25 @@ import { errorCauseOptions, rootPathChangedError } from "./root-errors.js";
|
|
|
11
12
|
import { admitPathInsideRoot } from "./root-boundary.js";
|
|
12
13
|
import { createSuppressedError } from "./suppressed-error.js";
|
|
13
14
|
import { realpathSync } from "./realpath.js";
|
|
15
|
+
import { inspectStatObservationSync } from "./stat-observation.js";
|
|
14
16
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
15
17
|
const METADATA_BATCH_SIZE = 32;
|
|
18
|
+
function directoryEntryName(bytes) {
|
|
19
|
+
if (!isUtf8(bytes)) {
|
|
20
|
+
throw new FsSafeError("invalid-path", "directory entry name is not valid UTF-8");
|
|
21
|
+
}
|
|
22
|
+
return bytes.toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
async function readDirectoryEntryName(handle) {
|
|
25
|
+
const entry = await handle.read();
|
|
26
|
+
// Bun returns the raw Buffer directly; Node returns a Dirent whose name is a Buffer.
|
|
27
|
+
// Node's Dir types still declare only string names.
|
|
28
|
+
return entry === null ? undefined
|
|
29
|
+
: directoryEntryName(Buffer.isBuffer(entry) ? entry : entry.name);
|
|
30
|
+
}
|
|
31
|
+
function openDirectoryNames(directory) {
|
|
32
|
+
return fs.opendir(directory, { bufferSize: 1, encoding: "buffer" });
|
|
33
|
+
}
|
|
16
34
|
export function pathStatFromStats(stat) {
|
|
17
35
|
const mtimeMs = typeof stat.mtimeMs === "bigint"
|
|
18
36
|
? "mtimeNs" in stat && typeof stat.mtimeNs === "bigint"
|
|
@@ -173,14 +191,7 @@ function normalizeInitialDirectoryError(error) {
|
|
|
173
191
|
export async function listDirectoryPath(root, directory, withFileTypes, receipt) {
|
|
174
192
|
let guard;
|
|
175
193
|
if (receipt) {
|
|
176
|
-
|
|
177
|
-
receipt.directoryGuard.dir !== directory ||
|
|
178
|
-
receipt.target !== receipt.directoryGuard ||
|
|
179
|
-
(!isNativeDirectoryObservationGuard(receipt.directoryGuard) &&
|
|
180
|
-
!receipt.directoryGuard.stat.isDirectory())) {
|
|
181
|
-
throw new FsSafeError("path-mismatch", "directory observation receipt does not match target");
|
|
182
|
-
}
|
|
183
|
-
guard = receipt.directoryGuard;
|
|
194
|
+
guard = directoryGuardFromReceipt(directory, receipt);
|
|
184
195
|
}
|
|
185
196
|
else {
|
|
186
197
|
try {
|
|
@@ -192,13 +203,23 @@ export async function listDirectoryPath(root, directory, withFileTypes, receipt)
|
|
|
192
203
|
}
|
|
193
204
|
return await listGuardedDirectoryPath(root, guard, withFileTypes, receipt);
|
|
194
205
|
}
|
|
206
|
+
function directoryGuardFromReceipt(directory, receipt) {
|
|
207
|
+
if (receipt.kind !== "directory" || receipt.targetPath !== directory ||
|
|
208
|
+
receipt.directoryGuard.dir !== directory ||
|
|
209
|
+
receipt.target !== receipt.directoryGuard ||
|
|
210
|
+
(!isNativeDirectoryObservationGuard(receipt.directoryGuard) &&
|
|
211
|
+
!receipt.directoryGuard.stat.isDirectory())) {
|
|
212
|
+
throw new FsSafeError("path-mismatch", "directory observation receipt does not match target");
|
|
213
|
+
}
|
|
214
|
+
return receipt.directoryGuard;
|
|
215
|
+
}
|
|
195
216
|
async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
|
|
196
217
|
let entries;
|
|
197
218
|
try {
|
|
198
219
|
const beforeObservation = getFsSafeTestHooks()?.beforeRootListObservation;
|
|
199
220
|
if (beforeObservation)
|
|
200
221
|
await beforeObservation(guard.realPath, withFileTypes);
|
|
201
|
-
const names = (await fs.readdir(guard.realPath)).sort();
|
|
222
|
+
const names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName).sort();
|
|
202
223
|
entries = withFileTypes
|
|
203
224
|
? names.map(name => ({
|
|
204
225
|
name,
|
|
@@ -221,19 +242,30 @@ async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
|
|
|
221
242
|
await assertRootDirectoryObservationGuard(root, guard);
|
|
222
243
|
return entries;
|
|
223
244
|
}
|
|
224
|
-
export async function openRootDirectoryListing(root, directory, options) {
|
|
225
|
-
|
|
226
|
-
throw
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
245
|
+
export async function openRootDirectoryListing(root, directory, options, receipt) {
|
|
246
|
+
if (options.exactIdentity && options.order !== "filesystem")
|
|
247
|
+
throw new TypeError("exact entry identities require filesystem order");
|
|
248
|
+
let guard;
|
|
249
|
+
if (receipt) {
|
|
250
|
+
guard = directoryGuardFromReceipt(directory, receipt);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
const admitted = await createRootDirectoryObservationGuard(root, directory).catch((error) => {
|
|
254
|
+
throw normalizeDirectoryError(error);
|
|
255
|
+
});
|
|
256
|
+
// Retain exact admission identities; metadata rechecks can use numeric Stats
|
|
257
|
+
// only when every identity component is losslessly representable.
|
|
258
|
+
guard = extendDirectoryObservationGuard({
|
|
259
|
+
stat: admitted.stat,
|
|
260
|
+
identity: { dev: admitted.stat.dev, ino: admitted.stat.ino },
|
|
261
|
+
}, admitted.dir, admitted.realPath);
|
|
262
|
+
}
|
|
234
263
|
const assertCurrent = async () => {
|
|
235
264
|
options.signal?.throwIfAborted();
|
|
236
|
-
|
|
265
|
+
if (receipt)
|
|
266
|
+
assertRootPathObservationReceiptCurrent(root, receipt);
|
|
267
|
+
else
|
|
268
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
237
269
|
options.signal?.throwIfAborted();
|
|
238
270
|
};
|
|
239
271
|
let handle;
|
|
@@ -248,23 +280,29 @@ export async function openRootDirectoryListing(root, directory, options) {
|
|
|
248
280
|
const close = async () => {
|
|
249
281
|
const owned = handle;
|
|
250
282
|
handle = undefined;
|
|
251
|
-
|
|
283
|
+
try {
|
|
284
|
+
await owned?.close();
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
options.onCleanupFailure?.(error);
|
|
288
|
+
throw error;
|
|
289
|
+
}
|
|
252
290
|
};
|
|
253
291
|
try {
|
|
254
292
|
await assertCurrent();
|
|
255
293
|
if (options.order === "filesystem") {
|
|
256
294
|
// A one-entry buffer keeps the truncation lookahead independent of width.
|
|
257
|
-
handle = await
|
|
295
|
+
handle = await openDirectoryNames(guard.realPath);
|
|
258
296
|
}
|
|
259
297
|
else if (options.snapshot) {
|
|
260
|
-
snapshot = await listGuardedDirectoryPath(root, guard, true);
|
|
298
|
+
snapshot = await listGuardedDirectoryPath(root, guard, true, receipt);
|
|
261
299
|
}
|
|
262
300
|
else if (options.maxNames !== undefined) {
|
|
263
301
|
names = [];
|
|
264
|
-
handle = await
|
|
302
|
+
handle = await openDirectoryNames(guard.realPath);
|
|
265
303
|
while (true) {
|
|
266
304
|
await assertCurrent();
|
|
267
|
-
const name =
|
|
305
|
+
const name = await readDirectoryEntryName(handle);
|
|
268
306
|
await assertCurrent();
|
|
269
307
|
if (name === undefined)
|
|
270
308
|
break;
|
|
@@ -276,7 +314,7 @@ export async function openRootDirectoryListing(root, directory, options) {
|
|
|
276
314
|
await close();
|
|
277
315
|
}
|
|
278
316
|
else {
|
|
279
|
-
names = await fs.readdir(guard.realPath);
|
|
317
|
+
names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName);
|
|
280
318
|
}
|
|
281
319
|
await assertCurrent();
|
|
282
320
|
names?.sort();
|
|
@@ -361,17 +399,30 @@ export async function openRootDirectoryListing(root, directory, options) {
|
|
|
361
399
|
return { kind: "limit", name: pendingLimit };
|
|
362
400
|
return;
|
|
363
401
|
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
402
|
+
for (;;) {
|
|
403
|
+
await assertCurrent();
|
|
404
|
+
const name = await readDirectoryEntryName(handle);
|
|
405
|
+
await assertCurrent();
|
|
406
|
+
if (name === undefined)
|
|
407
|
+
return;
|
|
408
|
+
if (!options.admitEntry())
|
|
409
|
+
return { kind: "limit", name };
|
|
410
|
+
// The stream's post-read fence is also the pre-stat fence in this owned operation.
|
|
411
|
+
const pathname = path.join(guard.realPath, name);
|
|
412
|
+
let observed;
|
|
413
|
+
try {
|
|
414
|
+
observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
|
|
415
|
+
? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : { stat: fsSync.lstatSync(pathname) };
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
await assertCurrent();
|
|
419
|
+
if (options.skipVanished && isNotFoundPathError(error))
|
|
420
|
+
continue;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
await assertCurrent();
|
|
424
|
+
return { kind: "entry", entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
|
|
425
|
+
}
|
|
375
426
|
}
|
|
376
427
|
catch (error) {
|
|
377
428
|
throw normalizeDirectoryError(error);
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RootContext } from "./root-context.js";
|
|
2
|
+
/** Internal registration; never reconstruct authority from public pathname fields. */
|
|
3
|
+
export declare function registerRootHandleContext(handle: object, context: RootContext): void;
|
|
4
|
+
export declare function rootHandleContext(handle: object): RootContext;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
const contexts = new WeakMap();
|
|
3
|
+
/** Internal registration; never reconstruct authority from public pathname fields. */
|
|
4
|
+
export function registerRootHandleContext(handle, context) {
|
|
5
|
+
contexts.set(handle, context);
|
|
6
|
+
}
|
|
7
|
+
export function rootHandleContext(handle) {
|
|
8
|
+
const context = contexts.get(handle);
|
|
9
|
+
if (!context)
|
|
10
|
+
throw new FsSafeError("invalid-path", "watch requires a genuine fs-safe Root");
|
|
11
|
+
return context;
|
|
12
|
+
}
|
package/dist/root-impl.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { type ReadResult } from "./read-opened-file.js";
|
|
|
5
5
|
import { type RootEntriesOptions } from "./root-entries.js";
|
|
6
6
|
import { type RootContext } from "./root-context.js";
|
|
7
7
|
import type { DirEntry, PathStat } from "./types.js";
|
|
8
|
-
import { type RootWalkEntry, type RootWalkOptions } from "./root-walk.js";
|
|
8
|
+
import { type RootWalkEntry, type RootWalkOptions, type RootWalkSymlinkPolicy } from "./root-walk.js";
|
|
9
9
|
import { type RootAppendOptions, type RootCopyOptions, type RootCopySource, type RootCreateJsonOptions, type RootCreateOptions, type RootCreateStreamOptions, type RootDefaults, type RootMkdirOptions, type RootMoveOptions, type RootOpenOptions, type RootOpenWritableOptions, type RootReadOptions, type RootRemoveOptions, type RootWriteJsonOptions, type RootWriteOptions } from "./root-options.js";
|
|
10
10
|
export { DEFAULT_ROOT_MAX_BYTES } from "./root-options.js";
|
|
11
11
|
export { resolveOpenedFileRealPathForHandle } from "./opened-realpath.js";
|
|
@@ -54,7 +54,7 @@ export interface Root {
|
|
|
54
54
|
}): Promise<DirEntry[]>;
|
|
55
55
|
entries(relativePath: string, options?: RootEntriesOptions): AsyncIterableIterator<DirEntry>;
|
|
56
56
|
move(fromRelative: string, toRelative: string, options?: RootMoveOptions): Promise<void>;
|
|
57
|
-
walk(relativePath: string, options: RootWalkOptions): AsyncIterableIterator<RootWalkEntry
|
|
57
|
+
walk<Policy extends RootWalkSymlinkPolicy>(relativePath: string, options: RootWalkOptions<Policy>): AsyncIterableIterator<RootWalkEntry<Policy>>;
|
|
58
58
|
}
|
|
59
59
|
export declare class RootHandle implements Root {
|
|
60
60
|
private readonly context;
|
|
@@ -97,7 +97,7 @@ export declare class RootHandle implements Root {
|
|
|
97
97
|
}): Promise<DirEntry[]>;
|
|
98
98
|
move(fromRelative: string, toRelative: string, options?: RootMoveOptions): Promise<void>;
|
|
99
99
|
entries(relativePath: string, options?: RootEntriesOptions): AsyncIterableIterator<DirEntry>;
|
|
100
|
-
walk(relativePath: string, options: RootWalkOptions): AsyncIterableIterator<RootWalkEntry
|
|
100
|
+
walk<Policy extends RootWalkSymlinkPolicy>(relativePath: string, options: RootWalkOptions<Policy>): AsyncIterableIterator<RootWalkEntry<Policy>>;
|
|
101
101
|
}
|
|
102
102
|
export declare function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;
|
|
103
103
|
export declare function rootFromDirectoryGuard(guard: {
|
package/dist/root-impl.js
CHANGED
|
@@ -51,6 +51,7 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
51
51
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
52
52
|
});
|
|
53
53
|
import { randomUUID } from "node:crypto";
|
|
54
|
+
import { registerRootHandleContext } from "./root-handle-context.js";
|
|
54
55
|
import fsSync, { constants as fsConstants } from "node:fs";
|
|
55
56
|
import fs from "node:fs/promises";
|
|
56
57
|
import path from "node:path";
|
|
@@ -85,7 +86,7 @@ import { listDirectoryPath, openRootDirectoryListing } from "./root-directory-li
|
|
|
85
86
|
import { statResolvedPathInRoot } from "./root-path-stat.js";
|
|
86
87
|
import { entriesInRoot } from "./root-entries.js";
|
|
87
88
|
import { assertMoveMutationAllowed } from "./root-move-preflight.js";
|
|
88
|
-
import { assertRootIdentityCurrent, assertRootIdentityCurrentSync, assertValidRootDestinationPath, assertValidRootRelativePath, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, rootRelativeReadPath, } from "./root-context.js";
|
|
89
|
+
import { assertRootIdentityCurrent, assertRootIdentityCurrentSync, assertValidRootDestinationPath, assertValidRootRelativePath, createRootObservationGuard, ensureTrailingSep, expandRelativePathWithHome, resolvePathInRoot, resolveRootContext, rootRelativeReadPath, } from "./root-context.js";
|
|
89
90
|
import { errorCauseOptions, fileNotFoundError, hardlinkedPathNotAllowedError, isAlreadyExistsError, normalizePinnedPathError, normalizePinnedWriteError, outsideWorkspaceError, } from "./root-errors.js";
|
|
90
91
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
91
92
|
import { stringifyJsonDocument } from "./json-stringify.js";
|
|
@@ -199,6 +200,7 @@ export class RootHandle {
|
|
|
199
200
|
this.rootWithSep = context.rootWithSep;
|
|
200
201
|
this.defaults = defaults;
|
|
201
202
|
registerFileLockSyncRootAdapter(this, context, defaults);
|
|
203
|
+
registerRootHandleContext(this, context);
|
|
202
204
|
}
|
|
203
205
|
mutationOptions(options) {
|
|
204
206
|
return {
|
|
@@ -409,9 +411,13 @@ export class RootHandle {
|
|
|
409
411
|
assertValidRootRelativePath(relativePath);
|
|
410
412
|
return walkRoot({
|
|
411
413
|
rootReal: this.context.rootReal,
|
|
414
|
+
observeRoot: () => createRootObservationGuard(this.context),
|
|
412
415
|
stat: relative => this.stat(relative),
|
|
413
|
-
list: async (relative, listingOptions) => {
|
|
416
|
+
list: async (relative, listingOptions, receipt) => {
|
|
414
417
|
validatePinnedRelativePath(relative);
|
|
418
|
+
if (receipt) {
|
|
419
|
+
return await openRootDirectoryListing(this.context, receipt.targetPath, listingOptions, receipt);
|
|
420
|
+
}
|
|
415
421
|
const resolved = await resolvePinnedPathInRoot(this.context, { relativePath: relative, allowRoot: true });
|
|
416
422
|
return await openRootDirectoryListing(this.context, resolved.resolved, listingOptions);
|
|
417
423
|
},
|
|
@@ -4,7 +4,7 @@ import { assertAsyncDirectoryGuard, assertSyncDirectoryGuard } from "./directory
|
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
5
|
import { assertMutationNotDenied } from "./deny-mutations.js";
|
|
6
6
|
import { openNativeParentAdmission, openNativeRootAdmission, } from "./native-parent-admission.js";
|
|
7
|
-
import {
|
|
7
|
+
import { requireNativeBinding } from "./native.js";
|
|
8
8
|
import { isNotFoundPathError } from "./path.js";
|
|
9
9
|
import { assertRootIdentityCurrent, assertRootIdentityCurrentSync } from "./root-context.js";
|
|
10
10
|
import { resolveRootPathSync } from "./root-path.js";
|
|
@@ -78,8 +78,8 @@ export async function movePathNoReplaceNative(root, params, paths) {
|
|
|
78
78
|
throw error;
|
|
79
79
|
// Advisory fast rejection only. renameNoReplace owns the collision decision.
|
|
80
80
|
}
|
|
81
|
-
const binding =
|
|
82
|
-
if (
|
|
81
|
+
const binding = requireNativeBinding();
|
|
82
|
+
if (typeof binding.renameNoReplace !== "function") {
|
|
83
83
|
throw new FsSafeError("helper-unavailable", "native no-replace move is unavailable");
|
|
84
84
|
}
|
|
85
85
|
const rootAdmission = await openNativeRootAdmission(binding, {
|
package/dist/root-walk.d.ts
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import type { DirectoryObservationGuard } from "./directory-guard.js";
|
|
2
|
+
import { type RootPathObservationReceipt } from "./root-path.js";
|
|
1
3
|
import type { RootDirectoryListing, RootDirectoryListingOptions } from "./root-directory-list.js";
|
|
2
4
|
import type { PathStat } from "./types.js";
|
|
3
|
-
export type RootWalkSymlinkPolicy = "skip" | "follow-within-root";
|
|
5
|
+
export type RootWalkSymlinkPolicy = "skip" | "follow-within-root" | "include";
|
|
6
|
+
type LegacyRootWalkSymlinkPolicy = Exclude<RootWalkSymlinkPolicy, "include">;
|
|
4
7
|
export type RootWalkLimitBehavior = "truncate" | "throw";
|
|
5
8
|
export type RootWalkDirectoryErrorBehavior = "throw" | "skip-and-report";
|
|
6
9
|
export type RootWalkEntryFilterResult = "include" | "skip" | "skip-subtree";
|
|
7
|
-
export type RootWalkDataEntryKind = "file" | "directory" | "other";
|
|
8
|
-
export type RootWalkEntryKind = RootWalkDataEntryKind | "directory-error" | "truncated";
|
|
9
|
-
|
|
10
|
+
export type RootWalkDataEntryKind<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = "file" | "directory" | "other" | ("include" extends Policy ? "symlink" : never);
|
|
11
|
+
export type RootWalkEntryKind<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = RootWalkDataEntryKind<Policy> | "directory-error" | "truncated";
|
|
12
|
+
type WalkEntryOfKind<Kind extends RootWalkDataEntryKind<RootWalkSymlinkPolicy>> = {
|
|
10
13
|
relativePath: string;
|
|
11
|
-
kind:
|
|
14
|
+
kind: Kind;
|
|
12
15
|
size: number;
|
|
13
16
|
};
|
|
14
|
-
export type
|
|
17
|
+
export type RootWalkDataEntry<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = WalkEntryOfKind<RootWalkDataEntryKind> | ("include" extends Policy ? WalkEntryOfKind<"symlink"> : never);
|
|
18
|
+
export type RootWalkEntry<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = RootWalkDataEntry<Policy> | {
|
|
15
19
|
relativePath: string;
|
|
16
20
|
kind: "truncated";
|
|
17
21
|
size: 0;
|
|
@@ -21,21 +25,24 @@ export type RootWalkEntry = RootWalkDataEntry | {
|
|
|
21
25
|
size: 0;
|
|
22
26
|
error: unknown;
|
|
23
27
|
};
|
|
24
|
-
export type RootWalkEntryFilter = (entry: RootWalkDataEntry) => RootWalkEntryFilterResult | Promise<RootWalkEntryFilterResult>;
|
|
25
|
-
export type RootWalkOptions = {
|
|
28
|
+
export type RootWalkEntryFilter<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = (entry: RootWalkDataEntry<Policy>) => RootWalkEntryFilterResult | Promise<RootWalkEntryFilterResult>;
|
|
29
|
+
export type RootWalkOptions<Policy extends RootWalkSymlinkPolicy = LegacyRootWalkSymlinkPolicy> = {
|
|
26
30
|
maxDepth?: number;
|
|
27
31
|
maxEntries?: number;
|
|
28
32
|
order?: "sorted" | "filesystem";
|
|
29
|
-
symlinkPolicy:
|
|
33
|
+
symlinkPolicy: Policy;
|
|
30
34
|
signal?: AbortSignal;
|
|
31
35
|
limitBehavior?: RootWalkLimitBehavior;
|
|
32
|
-
entryFilter?: RootWalkEntryFilter
|
|
36
|
+
entryFilter?: RootWalkEntryFilter<Policy>;
|
|
33
37
|
onDirectoryError?: RootWalkDirectoryErrorBehavior;
|
|
34
38
|
};
|
|
39
|
+
type AnyRootWalkOptions = RootWalkOptions | RootWalkOptions<"include"> | RootWalkOptions<RootWalkSymlinkPolicy>;
|
|
40
|
+
type AnyRootWalkEntry = RootWalkEntry<RootWalkSymlinkPolicy>;
|
|
35
41
|
type RootWalkCapability = {
|
|
36
42
|
rootReal: string;
|
|
43
|
+
observeRoot(): Promise<DirectoryObservationGuard | undefined>;
|
|
37
44
|
stat(relativePath: string): Promise<PathStat>;
|
|
38
|
-
list(relativePath: string, options: RootDirectoryListingOptions): Promise<RootDirectoryListing>;
|
|
45
|
+
list(relativePath: string, options: RootDirectoryListingOptions, receipt?: RootPathObservationReceipt): Promise<RootDirectoryListing>;
|
|
39
46
|
};
|
|
40
|
-
export declare function walkRoot(root: RootWalkCapability, relativePath: string, options:
|
|
47
|
+
export declare function walkRoot(root: RootWalkCapability, relativePath: string, options: AnyRootWalkOptions): AsyncGenerator<AnyRootWalkEntry>;
|
|
41
48
|
export {};
|
package/dist/root-walk.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { FsSafeError } from "./errors.js";
|
|
3
3
|
import { expandRelativePathWithHome } from "./root-context.js";
|
|
4
|
-
import { resolveRootPath, ROOT_PATH_ALIAS_POLICIES } from "./root-path.js";
|
|
4
|
+
import { resolveRootPath, resolveRootPathWithObservation, ROOT_PATH_ALIAS_POLICIES, RootPathObservationError, } from "./root-path.js";
|
|
5
5
|
import { createSuppressedError } from "./suppressed-error.js";
|
|
6
|
+
function filterEntry(options, entry) {
|
|
7
|
+
if (entry.kind === "symlink") {
|
|
8
|
+
return options.symlinkPolicy === "include" ? options.entryFilter?.(entry) ?? "include" : "skip";
|
|
9
|
+
}
|
|
10
|
+
return options.entryFilter?.(entry) ?? "include";
|
|
11
|
+
}
|
|
6
12
|
function validateBudget(name, value) {
|
|
7
13
|
if (value === undefined)
|
|
8
14
|
return Number.POSITIVE_INFINITY;
|
|
@@ -24,7 +30,7 @@ function limitEntry(relativePath) {
|
|
|
24
30
|
return { relativePath, kind: "truncated", size: 0 };
|
|
25
31
|
}
|
|
26
32
|
export async function* walkRoot(root, relativePath, options) {
|
|
27
|
-
if (!["skip", "follow-within-root"].includes(options.symlinkPolicy)) {
|
|
33
|
+
if (!["skip", "follow-within-root", "include"].includes(options.symlinkPolicy)) {
|
|
28
34
|
throw new TypeError(`invalid root walk symlink policy: ${String(options.symlinkPolicy)}`);
|
|
29
35
|
}
|
|
30
36
|
if (options.order !== undefined && !["sorted", "filesystem"].includes(options.order)) {
|
|
@@ -62,24 +68,50 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
62
68
|
throw error;
|
|
63
69
|
return { relativePath: directory, kind: "directory-error", size: 0, error };
|
|
64
70
|
};
|
|
65
|
-
async function* visit(directory, depth) {
|
|
71
|
+
async function* visit(directory, depth, admittedChildPath) {
|
|
66
72
|
options.signal?.throwIfAborted();
|
|
67
73
|
let listing;
|
|
74
|
+
let canonicalDirectory;
|
|
68
75
|
try {
|
|
69
76
|
const expandedDirectory = depth === 0 ? await expandRelativePathWithHome(directory) : directory;
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
77
|
+
const noFollowChildSymlinks = depth > 0 && options.symlinkPolicy !== "follow-within-root";
|
|
78
|
+
const includeChild = depth > 0 && options.symlinkPolicy === "include";
|
|
79
|
+
const resolution = {
|
|
80
|
+
absolutePath: admittedChildPath ?? path.resolve(root.rootReal, expandedDirectory),
|
|
73
81
|
rootPath: root.rootReal,
|
|
74
82
|
rootCanonicalPath: root.rootReal,
|
|
75
83
|
boundaryLabel: "root walk",
|
|
76
|
-
policy:
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
policy: noFollowChildSymlinks ? ROOT_PATH_ALIAS_POLICIES.unlinkTarget : undefined,
|
|
85
|
+
};
|
|
86
|
+
let receipt;
|
|
87
|
+
let resolvedDirectory;
|
|
88
|
+
if (includeChild) {
|
|
89
|
+
const rootGuard = await root.observeRoot();
|
|
90
|
+
if (!rootGuard) {
|
|
91
|
+
throw new FsSafeError("helper-unavailable", "root walk requires an exact directory observation");
|
|
92
|
+
}
|
|
93
|
+
const observed = await resolveRootPathWithObservation({
|
|
94
|
+
...resolution,
|
|
95
|
+
rootIdentity: rootGuard.identity,
|
|
96
|
+
}, { kind: "directory", rootGuard });
|
|
97
|
+
resolvedDirectory = observed.resolved;
|
|
98
|
+
receipt = observed.receipt;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
resolvedDirectory = await resolveRootPath(resolution);
|
|
102
|
+
}
|
|
103
|
+
if (noFollowChildSymlinks && resolvedDirectory.kind === "symlink") {
|
|
104
|
+
if (options.symlinkPolicy === "skip")
|
|
105
|
+
return;
|
|
106
|
+
throw new FsSafeError("path-mismatch", `root walk directory became a symlink: ${directory}`);
|
|
107
|
+
}
|
|
80
108
|
if (!resolvedDirectory.exists || resolvedDirectory.kind !== "directory") {
|
|
81
109
|
throw new FsSafeError("not-file", `root walk path is not a directory: ${directory || "."}`);
|
|
82
110
|
}
|
|
111
|
+
if (includeChild && (!receipt || receipt.directoryGuard.realPath !== resolvedDirectory.canonicalPath)) {
|
|
112
|
+
throw new FsSafeError("path-mismatch", "root walk directory observation was not retained");
|
|
113
|
+
}
|
|
114
|
+
canonicalDirectory = resolvedDirectory.canonicalPath;
|
|
83
115
|
if (visitedDirectories.has(resolvedDirectory.canonicalPath)) {
|
|
84
116
|
return;
|
|
85
117
|
}
|
|
@@ -97,10 +129,10 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
97
129
|
signal: options.signal,
|
|
98
130
|
snapshot: maxEntries === Number.POSITIVE_INFINITY,
|
|
99
131
|
admitEntry,
|
|
100
|
-
});
|
|
132
|
+
}, receipt);
|
|
101
133
|
}
|
|
102
134
|
catch (error) {
|
|
103
|
-
yield onDirectoryError(directory, error);
|
|
135
|
+
yield onDirectoryError(directory, error instanceof RootPathObservationError ? error.error : error);
|
|
104
136
|
return;
|
|
105
137
|
}
|
|
106
138
|
// A thrown undefined still needs to be retained if closing also fails.
|
|
@@ -131,10 +163,9 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
131
163
|
const entry = next.entry;
|
|
132
164
|
let kind = entryKind(entry);
|
|
133
165
|
let size = entry.size;
|
|
134
|
-
if (kind === "symlink")
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
166
|
+
if (kind === "symlink" && options.symlinkPolicy === "skip")
|
|
167
|
+
continue;
|
|
168
|
+
if (kind === "symlink" && options.symlinkPolicy === "follow-within-root") {
|
|
138
169
|
const resolved = await resolveRootPath({
|
|
139
170
|
absolutePath: path.resolve(root.rootReal, child),
|
|
140
171
|
rootPath: root.rootReal,
|
|
@@ -149,7 +180,7 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
149
180
|
size = target.size;
|
|
150
181
|
}
|
|
151
182
|
const walkEntry = { relativePath: child, kind, size };
|
|
152
|
-
let filterResult = options
|
|
183
|
+
let filterResult = filterEntry(options, walkEntry);
|
|
153
184
|
if (typeof filterResult !== "string") {
|
|
154
185
|
filterResult = (await filterResult) ?? "include";
|
|
155
186
|
options.signal?.throwIfAborted();
|
|
@@ -177,7 +208,7 @@ export async function* walkRoot(root, relativePath, options) {
|
|
|
177
208
|
yield onLimit(child);
|
|
178
209
|
return;
|
|
179
210
|
}
|
|
180
|
-
yield* visit(child, depth + 1);
|
|
211
|
+
yield* visit(child, depth + 1, options.symlinkPolicy === "include" ? path.join(canonicalDirectory, name) : undefined);
|
|
181
212
|
if (truncated)
|
|
182
213
|
return;
|
|
183
214
|
}
|
|
@@ -290,9 +290,9 @@ export async function acquireSidecarLock(options, context) {
|
|
|
290
290
|
}
|
|
291
291
|
await handle.writeFile(raw, "utf8");
|
|
292
292
|
}
|
|
293
|
-
const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd), ownershipToken };
|
|
293
|
+
const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd, { bigint: true }), ownershipToken };
|
|
294
294
|
createdSnapshot = snapshot;
|
|
295
|
-
if (snapshot.stat.nlink ===
|
|
295
|
+
if (snapshot.stat.nlink === 0n) {
|
|
296
296
|
await handle.close();
|
|
297
297
|
handle = null;
|
|
298
298
|
await waitForRetry();
|
|
@@ -361,7 +361,7 @@ export async function acquireSidecarLock(options, context) {
|
|
|
361
361
|
if (handle) {
|
|
362
362
|
failedSnapshot ??= { payload: null };
|
|
363
363
|
try {
|
|
364
|
-
failedSnapshot.stat = fsSync.fstatSync(handle.fd);
|
|
364
|
+
failedSnapshot.stat = fsSync.fstatSync(handle.fd, { bigint: true });
|
|
365
365
|
}
|
|
366
366
|
catch {
|
|
367
367
|
// Best-effort cleanup of a failed exclusive create.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Stats } from "node:fs";
|
|
1
|
+
import { type BigIntStats, type Stats } from "node:fs";
|
|
2
2
|
import type { Root } from "./root-impl.js";
|
|
3
3
|
export type SidecarLockStaleSnapshot = {
|
|
4
4
|
lockPath: string;
|
|
@@ -9,7 +9,7 @@ export type SidecarLockStaleSnapshot = {
|
|
|
9
9
|
export type SidecarLockSnapshot = {
|
|
10
10
|
raw?: string;
|
|
11
11
|
payload: unknown;
|
|
12
|
-
stat?: Stats;
|
|
12
|
+
stat?: Stats | BigIntStats;
|
|
13
13
|
ownershipToken?: string;
|
|
14
14
|
};
|
|
15
15
|
type SidecarLockRawSnapshot = Omit<SidecarLockSnapshot, "payload"> & {
|
|
@@ -90,7 +90,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
90
90
|
const raw = (await readFileHandleBounded(opened.handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
|
|
91
91
|
return {
|
|
92
92
|
raw,
|
|
93
|
-
stat: opened.
|
|
93
|
+
stat: fsSync.fstatSync(opened.handle.fd, { bigint: true }),
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
finally {
|
|
@@ -99,7 +99,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
99
99
|
}
|
|
100
100
|
let before;
|
|
101
101
|
try {
|
|
102
|
-
before = fsSync.lstatSync(lockPath);
|
|
102
|
+
before = fsSync.lstatSync(lockPath, { bigint: true });
|
|
103
103
|
}
|
|
104
104
|
catch (error) {
|
|
105
105
|
before = missingSnapshotPath(error);
|
|
@@ -132,7 +132,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
132
132
|
options.onOpenFailure?.(error);
|
|
133
133
|
throw error;
|
|
134
134
|
}
|
|
135
|
-
const opened = fsSync.fstatSync(handle.fd);
|
|
135
|
+
const opened = fsSync.fstatSync(handle.fd, { bigint: true });
|
|
136
136
|
if (!opened.isFile()) {
|
|
137
137
|
if (options.rejectNonFile) {
|
|
138
138
|
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
|
@@ -144,7 +144,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
144
144
|
const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
|
|
145
145
|
let after;
|
|
146
146
|
try {
|
|
147
|
-
after = fsSync.lstatSync(lockPath);
|
|
147
|
+
after = fsSync.lstatSync(lockPath, { bigint: true });
|
|
148
148
|
}
|
|
149
149
|
catch (error) {
|
|
150
150
|
after = missingSnapshotPath(error);
|
|
@@ -159,7 +159,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
function lstatSidecarLockSync(lockPath) {
|
|
161
161
|
try {
|
|
162
|
-
return fsSync.lstatSync(lockPath);
|
|
162
|
+
return fsSync.lstatSync(lockPath, { bigint: true });
|
|
163
163
|
}
|
|
164
164
|
catch (error) {
|
|
165
165
|
return missingSnapshotPath(error);
|
|
@@ -189,7 +189,7 @@ export function readSidecarLockRawSnapshotSync(lockPath, options = {}) {
|
|
|
189
189
|
options.onOpenFailure?.(error);
|
|
190
190
|
throw error;
|
|
191
191
|
}
|
|
192
|
-
const opened = fsSync.fstatSync(fd);
|
|
192
|
+
const opened = fsSync.fstatSync(fd, { bigint: true });
|
|
193
193
|
if (!opened.isFile()) {
|
|
194
194
|
if (options.rejectNonFile) {
|
|
195
195
|
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -50,7 +50,7 @@ function resolveManagerState(key) {
|
|
|
50
50
|
function snapshotMatchesSync(lockPath, observed) {
|
|
51
51
|
let fd;
|
|
52
52
|
try {
|
|
53
|
-
const beforeStat = fsSync.lstatSync(lockPath);
|
|
53
|
+
const beforeStat = fsSync.lstatSync(lockPath, { bigint: true });
|
|
54
54
|
if (!beforeStat.isFile()) {
|
|
55
55
|
return false;
|
|
56
56
|
}
|
|
@@ -60,17 +60,17 @@ function snapshotMatchesSync(lockPath, observed) {
|
|
|
60
60
|
: 0) |
|
|
61
61
|
(typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
|
|
62
62
|
fd = fsSync.openSync(lockPath, openFlags);
|
|
63
|
-
const openedStat = fsSync.fstatSync(fd);
|
|
63
|
+
const openedStat = fsSync.fstatSync(fd, { bigint: true });
|
|
64
64
|
// Token-owned files can have different descriptor/path identities on VirtioFS.
|
|
65
65
|
// Require a known descriptor identity without rejecting that supported drift.
|
|
66
66
|
if (!openedStat.isFile() || !sameFileIdentityForCleanup(openedStat, openedStat)) {
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
-
if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
|
|
69
|
+
if (observed.raw !== undefined && openedStat.size !== BigInt(Buffer.byteLength(observed.raw))) {
|
|
70
70
|
return false;
|
|
71
71
|
}
|
|
72
72
|
const raw = fsSync.readFileSync(fd, "utf8");
|
|
73
|
-
const afterStat = fsSync.lstatSync(lockPath);
|
|
73
|
+
const afterStat = fsSync.lstatSync(lockPath, { bigint: true });
|
|
74
74
|
if (!afterStat.isFile() || !sameFileIdentityForCleanup(beforeStat, afterStat)) {
|
|
75
75
|
return false;
|
|
76
76
|
}
|