@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
|
@@ -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,10 @@ 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
|
+
/** Internal owner receives cleanup failures, including acquisition rollback. */
|
|
41
|
+
onCleanupFailure?: (error: unknown) => void;
|
|
36
42
|
admitEntry(): boolean;
|
|
37
43
|
};
|
|
38
|
-
export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions): Promise<RootDirectoryListing>;
|
|
44
|
+
export declare function openRootDirectoryListing(root: RootContext, directory: string, options: RootDirectoryListingOptions, receipt?: RootPathObservationReceipt): Promise<RootDirectoryListing>;
|
|
@@ -11,6 +11,7 @@ import { errorCauseOptions, rootPathChangedError } from "./root-errors.js";
|
|
|
11
11
|
import { admitPathInsideRoot } from "./root-boundary.js";
|
|
12
12
|
import { createSuppressedError } from "./suppressed-error.js";
|
|
13
13
|
import { realpathSync } from "./realpath.js";
|
|
14
|
+
import { inspectStatObservationSync } from "./stat-observation.js";
|
|
14
15
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
15
16
|
const METADATA_BATCH_SIZE = 32;
|
|
16
17
|
export function pathStatFromStats(stat) {
|
|
@@ -173,14 +174,7 @@ function normalizeInitialDirectoryError(error) {
|
|
|
173
174
|
export async function listDirectoryPath(root, directory, withFileTypes, receipt) {
|
|
174
175
|
let guard;
|
|
175
176
|
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;
|
|
177
|
+
guard = directoryGuardFromReceipt(directory, receipt);
|
|
184
178
|
}
|
|
185
179
|
else {
|
|
186
180
|
try {
|
|
@@ -192,6 +186,16 @@ export async function listDirectoryPath(root, directory, withFileTypes, receipt)
|
|
|
192
186
|
}
|
|
193
187
|
return await listGuardedDirectoryPath(root, guard, withFileTypes, receipt);
|
|
194
188
|
}
|
|
189
|
+
function directoryGuardFromReceipt(directory, receipt) {
|
|
190
|
+
if (receipt.kind !== "directory" || receipt.targetPath !== directory ||
|
|
191
|
+
receipt.directoryGuard.dir !== directory ||
|
|
192
|
+
receipt.target !== receipt.directoryGuard ||
|
|
193
|
+
(!isNativeDirectoryObservationGuard(receipt.directoryGuard) &&
|
|
194
|
+
!receipt.directoryGuard.stat.isDirectory())) {
|
|
195
|
+
throw new FsSafeError("path-mismatch", "directory observation receipt does not match target");
|
|
196
|
+
}
|
|
197
|
+
return receipt.directoryGuard;
|
|
198
|
+
}
|
|
195
199
|
async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
|
|
196
200
|
let entries;
|
|
197
201
|
try {
|
|
@@ -221,19 +225,30 @@ async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
|
|
|
221
225
|
await assertRootDirectoryObservationGuard(root, guard);
|
|
222
226
|
return entries;
|
|
223
227
|
}
|
|
224
|
-
export async function openRootDirectoryListing(root, directory, options) {
|
|
225
|
-
|
|
226
|
-
throw
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
228
|
+
export async function openRootDirectoryListing(root, directory, options, receipt) {
|
|
229
|
+
if (options.exactIdentity && options.order !== "filesystem")
|
|
230
|
+
throw new TypeError("exact entry identities require filesystem order");
|
|
231
|
+
let guard;
|
|
232
|
+
if (receipt) {
|
|
233
|
+
guard = directoryGuardFromReceipt(directory, receipt);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const admitted = await createRootDirectoryObservationGuard(root, directory).catch((error) => {
|
|
237
|
+
throw normalizeDirectoryError(error);
|
|
238
|
+
});
|
|
239
|
+
// Retain exact admission identities; metadata rechecks can use numeric Stats
|
|
240
|
+
// only when every identity component is losslessly representable.
|
|
241
|
+
guard = extendDirectoryObservationGuard({
|
|
242
|
+
stat: admitted.stat,
|
|
243
|
+
identity: { dev: admitted.stat.dev, ino: admitted.stat.ino },
|
|
244
|
+
}, admitted.dir, admitted.realPath);
|
|
245
|
+
}
|
|
234
246
|
const assertCurrent = async () => {
|
|
235
247
|
options.signal?.throwIfAborted();
|
|
236
|
-
|
|
248
|
+
if (receipt)
|
|
249
|
+
assertRootPathObservationReceiptCurrent(root, receipt);
|
|
250
|
+
else
|
|
251
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
237
252
|
options.signal?.throwIfAborted();
|
|
238
253
|
};
|
|
239
254
|
let handle;
|
|
@@ -248,7 +263,13 @@ export async function openRootDirectoryListing(root, directory, options) {
|
|
|
248
263
|
const close = async () => {
|
|
249
264
|
const owned = handle;
|
|
250
265
|
handle = undefined;
|
|
251
|
-
|
|
266
|
+
try {
|
|
267
|
+
await owned?.close();
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
options.onCleanupFailure?.(error);
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
252
273
|
};
|
|
253
274
|
try {
|
|
254
275
|
await assertCurrent();
|
|
@@ -257,7 +278,7 @@ export async function openRootDirectoryListing(root, directory, options) {
|
|
|
257
278
|
handle = await fs.opendir(guard.realPath, { bufferSize: 1 });
|
|
258
279
|
}
|
|
259
280
|
else if (options.snapshot) {
|
|
260
|
-
snapshot = await listGuardedDirectoryPath(root, guard, true);
|
|
281
|
+
snapshot = await listGuardedDirectoryPath(root, guard, true, receipt);
|
|
261
282
|
}
|
|
262
283
|
else if (options.maxNames !== undefined) {
|
|
263
284
|
names = [];
|
|
@@ -369,9 +390,13 @@ export async function openRootDirectoryListing(root, directory, options) {
|
|
|
369
390
|
if (!options.admitEntry())
|
|
370
391
|
return { kind: "limit", name };
|
|
371
392
|
// The stream's post-read fence is also the pre-stat fence in this owned operation.
|
|
372
|
-
const
|
|
393
|
+
const pathname = path.join(guard.realPath, name);
|
|
394
|
+
const observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
|
|
395
|
+
? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : undefined;
|
|
396
|
+
const stat = observed?.stat ?? fsSync.lstatSync(pathname);
|
|
373
397
|
await assertCurrent();
|
|
374
|
-
|
|
398
|
+
const entry = { name, ...pathStatFromStats(stat) };
|
|
399
|
+
return observed ? { kind: "entry", entry, identity: observed.identity } : { kind: "entry", entry };
|
|
375
400
|
}
|
|
376
401
|
catch (error) {
|
|
377
402
|
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
|
},
|
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
|
}
|
package/dist/temp-target.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { suffixWindowsReservedDeviceName } from "./filename.js";
|
|
6
6
|
import { sameFileIdentityForCleanup } from "./file-identity.js";
|
|
7
|
-
import { assertSafePathSegment,
|
|
7
|
+
import { assertSafePathSegment, isSafePathSegment, normalizeSafePathSegment, trimHyphenEdges, } from "./safe-path-segment.js";
|
|
8
8
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
9
9
|
import { hasNodeErrorCode } from "./path.js";
|
|
10
10
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
@@ -62,7 +62,8 @@ function sanitizeExtension(extension) {
|
|
|
62
62
|
export function sanitizeTempFileName(fileName) {
|
|
63
63
|
// Suffix reserved stems before admission so CON.txt stays CON_.txt.
|
|
64
64
|
const suffixed = suffixWindowsReservedDeviceName(normalizeSafePathSegment(path.basename(fileName)));
|
|
65
|
-
|
|
65
|
+
// The suffix only inserts "_", so this value is already normalized.
|
|
66
|
+
return isSafePathSegment(suffixed, { allowDotPrefix: true }) ? suffixed : "download.bin";
|
|
66
67
|
}
|
|
67
68
|
export function buildRandomTempFilePath(params) {
|
|
68
69
|
const rootDir = resolveTempRoot(params.rootDir);
|
|
@@ -36,20 +36,21 @@ function copyExactDirectoryObservation(stat) {
|
|
|
36
36
|
dev: stat.dev,
|
|
37
37
|
ino: stat.ino,
|
|
38
38
|
uid: stat.uid,
|
|
39
|
+
gid: stat.gid,
|
|
39
40
|
mode: stat.mode,
|
|
40
41
|
directory: stat.isDirectory(),
|
|
41
42
|
symbolicLink: stat.isSymbolicLink(),
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
|
-
function snapshotFromExactObservation(dir, uid, realPath, stat) {
|
|
45
|
+
function snapshotFromExactObservation(dir, uid, realPath, stat, privateDirectory = false) {
|
|
45
46
|
if (stat.symbolicLink || !stat.directory) {
|
|
46
47
|
throw new FsSafeError("not-file", "temp workspace root component must be a real directory");
|
|
47
48
|
}
|
|
48
|
-
assertTrustedTempWorkspaceDirectory(stat, uid);
|
|
49
|
+
assertTrustedTempWorkspaceDirectory(stat, uid, privateDirectory);
|
|
49
50
|
const identity = Object.freeze({ dev: stat.dev, ino: stat.ino });
|
|
50
51
|
// Retain copied immutable scalars, never a mutable Stats object supplied by
|
|
51
52
|
// an observation hook. Every later permission check uses a fresh snapshot.
|
|
52
|
-
return Object.freeze({ dir, identity, numericIdentity: safeNumericIdentity(stat), realPath });
|
|
53
|
+
return Object.freeze({ dir, identity, numericIdentity: safeNumericIdentity(stat), realPath, privateDirectory });
|
|
53
54
|
}
|
|
54
55
|
function canonicalTempWorkspacePath(dir) {
|
|
55
56
|
assertNoWindowsPathAlias(dir, "filesystem");
|
|
@@ -57,10 +58,10 @@ function canonicalTempWorkspacePath(dir) {
|
|
|
57
58
|
assertNoWindowsPathAlias(canonical, "filesystem");
|
|
58
59
|
return canonical;
|
|
59
60
|
}
|
|
60
|
-
function snapshot(dir, uid, realPath = canonicalTempWorkspacePath(dir)) {
|
|
61
|
+
function snapshot(dir, uid, realPath = canonicalTempWorkspacePath(dir), privateDirectory = false) {
|
|
61
62
|
const stat = inspectDirectoryIdentitySync(dir);
|
|
62
63
|
const observation = copyExactDirectoryObservation(stat);
|
|
63
|
-
return { entry: snapshotFromExactObservation(dir, uid, realPath, observation), stat };
|
|
64
|
+
return { entry: snapshotFromExactObservation(dir, uid, realPath, observation, privateDirectory), stat };
|
|
64
65
|
}
|
|
65
66
|
function hasCompleteExactIdentity(stat) {
|
|
66
67
|
return typeof stat.dev === "bigint" && typeof stat.ino === "bigint" &&
|
|
@@ -105,16 +106,16 @@ function inspectSnapshotIdentity(entry) {
|
|
|
105
106
|
}
|
|
106
107
|
function assertSnapshot(entry, uid) {
|
|
107
108
|
const stat = inspectSnapshotIdentity(entry);
|
|
108
|
-
assertTrustedTempWorkspaceDirectory(stat, uid);
|
|
109
|
+
assertTrustedTempWorkspaceDirectory(stat, uid, entry.privateDirectory);
|
|
109
110
|
assertCanonicalRoot(entry);
|
|
110
111
|
}
|
|
111
|
-
function snapshotMissingRootComponent(parent, dir, ownerUid) {
|
|
112
|
+
function snapshotMissingRootComponent(parent, dir, ownerUid, privateDirectory) {
|
|
112
113
|
if (parent.dir !== parent.realPath) {
|
|
113
114
|
assertSnapshot(parent, ownerUid);
|
|
114
|
-
return snapshot(dir, ownerUid);
|
|
115
|
+
return snapshot(dir, ownerUid, undefined, privateDirectory);
|
|
115
116
|
}
|
|
116
117
|
const current = inspectSnapshotIdentity(parent);
|
|
117
|
-
assertTrustedTempWorkspaceDirectory(current, ownerUid);
|
|
118
|
+
assertTrustedTempWorkspaceDirectory(current, ownerUid, parent.privateDirectory);
|
|
118
119
|
let realPath;
|
|
119
120
|
try {
|
|
120
121
|
realPath = canonicalTempWorkspacePath(dir);
|
|
@@ -126,16 +127,16 @@ function snapshotMissingRootComponent(parent, dir, ownerUid) {
|
|
|
126
127
|
}
|
|
127
128
|
if (path.dirname(realPath) !== parent.realPath) {
|
|
128
129
|
assertCanonicalRoot(parent);
|
|
129
|
-
return snapshot(dir, ownerUid);
|
|
130
|
+
return snapshot(dir, ownerUid, undefined, privateDirectory);
|
|
130
131
|
}
|
|
131
132
|
// The child's canonical parent confirms the same parent name at this phase.
|
|
132
133
|
// Its exact non-symlink/owner observation still precedes mode initialization.
|
|
133
|
-
return snapshot(dir, ownerUid, realPath);
|
|
134
|
+
return snapshot(dir, ownerUid, realPath, privateDirectory);
|
|
134
135
|
}
|
|
135
136
|
function assertChain(chain, uid) {
|
|
136
137
|
for (const entry of chain) {
|
|
137
138
|
const current = inspectSnapshotIdentity(entry);
|
|
138
|
-
assertTrustedTempWorkspaceDirectory(current, uid);
|
|
139
|
+
assertTrustedTempWorkspaceDirectory(current, uid, entry.privateDirectory);
|
|
139
140
|
}
|
|
140
141
|
const last = chain[chain.length - 1];
|
|
141
142
|
assertCanonicalRoot(last);
|
|
@@ -158,7 +159,7 @@ function associateTempWorkspaceRoot(entry, ownerUid, descriptorFd) {
|
|
|
158
159
|
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
159
160
|
throw new FsSafeError("not-file", "temp workspace cleanup parent must be a real directory");
|
|
160
161
|
}
|
|
161
|
-
assertTrustedTempWorkspaceDirectory(stat, ownerUid);
|
|
162
|
+
assertTrustedTempWorkspaceDirectory(stat, ownerUid, entry.privateDirectory);
|
|
162
163
|
}
|
|
163
164
|
function discoverMissingTempWorkspaceAncestor(ancestor, missing, missingError) {
|
|
164
165
|
// The caller already classified the first ENOENT. Keep this handoff out of
|
|
@@ -219,14 +220,14 @@ function rootPlan(rootDir) {
|
|
|
219
220
|
const existingCanonicalRoot = missing.length === 0 && observedAncestor === ancestor &&
|
|
220
221
|
!initial.symbolicLink && initial.directory && hasCompleteExactIdentity(initial);
|
|
221
222
|
if (existingCanonicalRoot) {
|
|
222
|
-
const discovery = snapshotFromExactObservation(ancestor, ownerUid, ancestor, initial);
|
|
223
|
+
const discovery = snapshotFromExactObservation(ancestor, ownerUid, ancestor, initial, true);
|
|
223
224
|
return {
|
|
224
225
|
admission: canonicalRootAdmission(discovery, ownerUid),
|
|
225
226
|
missing,
|
|
226
227
|
ownerUid,
|
|
227
228
|
};
|
|
228
229
|
}
|
|
229
|
-
const chain = canonicalAncestry(ancestor).map((dir) => snapshot(dir, ownerUid, dir).entry);
|
|
230
|
+
const chain = canonicalAncestry(ancestor).map((dir) => snapshot(dir, ownerUid, dir, missing.length === 0 && dir === ancestor).entry);
|
|
230
231
|
// Missing-component creation needs a replay before its first mutation. When
|
|
231
232
|
// the complete root already exists through an alias, retain the historical
|
|
232
233
|
// guarded route because discovery and mutation use different path spellings.
|
|
@@ -286,10 +287,10 @@ function canonicalRootAdmission(discovery, ownerUid) {
|
|
|
286
287
|
const ancestry = canonicalAncestry(discovery.dir);
|
|
287
288
|
const candidate = ancestry.map((dir, index) => {
|
|
288
289
|
if (!TEMP_WORKSPACE_NUMERIC_IDENTITY_REPLAY || index !== ancestry.length - 1) {
|
|
289
|
-
return snapshot(dir, ownerUid, dir).entry;
|
|
290
|
+
return snapshot(dir, ownerUid, dir, index === ancestry.length - 1 && discovery.privateDirectory).entry;
|
|
290
291
|
}
|
|
291
292
|
const current = inspectSnapshotIdentity(discovery);
|
|
292
|
-
assertTrustedTempWorkspaceDirectory(current, ownerUid);
|
|
293
|
+
assertTrustedTempWorkspaceDirectory(current, ownerUid, discovery.privateDirectory);
|
|
293
294
|
return discovery;
|
|
294
295
|
});
|
|
295
296
|
const current = candidate[candidate.length - 1];
|
|
@@ -332,7 +333,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
|
|
|
332
333
|
if (admission)
|
|
333
334
|
return admission;
|
|
334
335
|
const guardedChain = chain;
|
|
335
|
-
for (const segment of missing) {
|
|
336
|
+
for (const [index, segment] of missing.entries()) {
|
|
336
337
|
const parentEntry = guardedChain[guardedChain.length - 1];
|
|
337
338
|
const parent = rootAdmission(guardedChain, ownerUid);
|
|
338
339
|
const dir = path.join(parent.dir, segment);
|
|
@@ -347,7 +348,7 @@ export async function admitTempWorkspaceRoot(rootDir) {
|
|
|
347
348
|
if (error.code !== "EEXIST")
|
|
348
349
|
throw error;
|
|
349
350
|
}
|
|
350
|
-
const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
|
|
351
|
+
const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid, index === missing.length - 1);
|
|
351
352
|
if (created) {
|
|
352
353
|
const modeInitialization = admitTempWorkspaceChild(dir, observed.stat, parent, 0o700);
|
|
353
354
|
if (modeInitialization)
|
|
@@ -362,7 +363,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
|
|
|
362
363
|
if (admission)
|
|
363
364
|
return admission;
|
|
364
365
|
const guardedChain = chain;
|
|
365
|
-
for (const segment of missing) {
|
|
366
|
+
for (const [index, segment] of missing.entries()) {
|
|
366
367
|
const parentEntry = guardedChain[guardedChain.length - 1];
|
|
367
368
|
const parent = rootAdmission(guardedChain, ownerUid);
|
|
368
369
|
const dir = path.join(parent.dir, segment);
|
|
@@ -377,7 +378,7 @@ export function admitTempWorkspaceRootSync(rootDir) {
|
|
|
377
378
|
if (error.code !== "EEXIST")
|
|
378
379
|
throw error;
|
|
379
380
|
}
|
|
380
|
-
const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid);
|
|
381
|
+
const observed = snapshotMissingRootComponent(parentEntry, dir, ownerUid, index === missing.length - 1);
|
|
381
382
|
if (created)
|
|
382
383
|
admitTempWorkspaceChildSync(dir, observed.stat, parent, 0o700);
|
|
383
384
|
guardedChain.push(observed.entry);
|
|
@@ -14,7 +14,7 @@ export declare function projectTempWorkspaceNumericIdentity(identity: TempWorksp
|
|
|
14
14
|
export declare function inspectTempWorkspaceDescriptorIdentitySync(fd: number, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
|
|
15
15
|
export declare function inspectTempWorkspaceDirectoryIdentitySync(dir: string, expected: TempWorkspaceIdentity, numeric: TempWorkspaceNumericIdentity | undefined): TempWorkspaceIdentityStat;
|
|
16
16
|
export declare function validateTempWorkspaceDirMode(mode: number): void;
|
|
17
|
-
export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "mode"> | Pick<Stats, "uid" | "mode">, uid: number | undefined,
|
|
17
|
+
export declare function assertTrustedTempWorkspaceDirectory(stat: Pick<BigIntStats, "uid" | "gid" | "mode"> | Pick<Stats, "uid" | "gid" | "mode">, uid: number | undefined, privateDirectory?: boolean): void;
|
|
18
18
|
export declare function assertTempWorkspaceChildState(stat: BigIntStats | Stats, ownerUid: number | undefined): void;
|
|
19
19
|
export declare function validateInitialTempWorkspaceChild(stat: BigIntStats, ownerUid: number | undefined, mode: number): boolean;
|
|
20
20
|
export declare function childHasRequestedMode(stat: BigIntStats | Stats, mode: number): boolean;
|