@poe-platform/safe-fs 0.1.727 → 0.1.728
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/README.md +8 -1
- package/dist/safe-fs/contracts/errors.js +8 -1
- package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
- package/dist/safe-fs/contracts/io.js +183 -27
- package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
- package/dist/safe-fs/contracts/virtual-path.js +20 -1
- package/dist/safe-fs/core.d.ts +1 -0
- package/dist/safe-fs/core.js +1 -0
- package/dist/safe-fs/fs/capabilities.d.ts +1 -1
- package/dist/safe-fs/fs/capabilities.js +59 -21
- package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
- package/dist/safe-fs/fs/conditional-chmod.js +19 -0
- package/dist/safe-fs/fs/descriptor.js +51 -25
- package/dist/safe-fs/fs/devices/index.d.ts +7 -2
- package/dist/safe-fs/fs/devices/index.js +210 -29
- package/dist/safe-fs/fs/devices/path.d.ts +3 -1
- package/dist/safe-fs/fs/devices/path.js +70 -12
- package/dist/safe-fs/fs/memory/index.d.ts +34 -48
- package/dist/safe-fs/fs/memory/index.js +1150 -235
- package/dist/safe-fs/fs/memory/ledger.d.ts +2 -0
- package/dist/safe-fs/fs/memory/ledger.js +15 -3
- package/dist/safe-fs/fs/memory/missing-target.js +21 -1
- package/dist/safe-fs/fs/mount/index.d.ts +11 -3
- package/dist/safe-fs/fs/mount/index.js +326 -15
- package/dist/safe-fs/fs/object-publication/index.js +79 -3
- package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
- package/dist/safe-fs/fs/overlay/index.js +22 -2
- package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
- package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
- package/dist/safe-fs/fs/quota/index.js +1 -1
- package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
- package/dist/safe-fs/fs/readonly/index.js +13 -0
- package/dist/safe-fs/fs/real/index.d.ts +6 -2
- package/dist/safe-fs/fs/real/index.js +78 -8
- package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
- package/dist/safe-fs/fs/s3/filesystem.js +48 -16
- package/dist/safe-fs/fs/s3/http/transport.js +20 -3
- package/dist/safe-fs/fs/s3/mock.js +19 -2
- package/dist/safe-fs/fs/s3/namespace.js +8 -2
- package/dist/safe-fs/fs/s3/registry.js +2 -0
- package/dist/safe-fs/fs/scoped.d.ts +1 -0
- package/dist/safe-fs/fs/scoped.js +261 -74
- package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
- package/dist/safe-fs/fs/staging-ancestry.js +115 -0
- package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
- package/dist/safe-fs/fs/staging-cleanup.js +63 -0
- package/dist/safe-fs/platform/browser.d.ts +5 -0
- package/dist/safe-fs/platform/browser.js +17 -0
- package/dist/safe-fs/platform/node.d.ts +5 -0
- package/dist/safe-fs/platform/node.js +28 -0
- package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
- package/dist/safe-fs/platform/transport-budget.js +4 -0
- package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
- package/dist/safe-fs/testing/object-io-metrics.js +29 -0
- package/dist/safe-fs/testing/object-publication.d.ts +3 -1
- package/dist/safe-fs/testing/object-publication.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -98,6 +98,13 @@ creation and updates without inode numbers, staging directories or permission
|
|
|
98
98
|
APIs. Read the [conditional publication contract](src/contracts/conditional-publication.md)
|
|
99
99
|
before implementing the host operation; ordinary writes do not provide it.
|
|
100
100
|
|
|
101
|
+
For temporary staging that must be cleaned up after its parent moves, adapters
|
|
102
|
+
advertising `retainedStagingCleanup` accept `createStagedFile(..., { parent,
|
|
103
|
+
retainCleanup: true })`. Use the returned `staging.cleanup.remove()` and always
|
|
104
|
+
call `staging.cleanup.close()` in `finally`. Memory and its supported wrappers
|
|
105
|
+
retain only the owned staging entries; replacement entries remain protected.
|
|
106
|
+
See the [staging contract](src/contracts/filesystem.md#atomic-owned-staging).
|
|
107
|
+
|
|
101
108
|
| Backend or wrapper | Use it for |
|
|
102
109
|
| --- | --- |
|
|
103
110
|
| `createMemoryFileSystem()` | Isolated, nonpersistent storage with links, permissions, timestamps, and streams; each path resolution admits at most 65,536 cumulative UTF-16 code units across the input and followed symlink targets, rejecting excess with `ENAMETOOLONG` before component allocation |
|
|
@@ -251,7 +258,7 @@ Every raw filesystem operation accepts an optional `signal`. Additional fields a
|
|
|
251
258
|
| `copyFile` | `exclusive` (default false) |
|
|
252
259
|
| `readStream` | `start` (default 0), `endExclusive` (default end of file), `chunkSize` (built-in default 64 KiB) |
|
|
253
260
|
|
|
254
|
-
`access` takes a separate mode bitmask from `ACCESS_MODES`. `chmod` takes a mode, `utimes` takes millisecond timestamps, and `truncate` takes a byte length (default 0). Backend limits still apply. Node-shaped bridge methods translate their own options rather than accepting these raw option objects; see the [bridge signatures](src/bridge/filesystem.ts).
|
|
261
|
+
`access` takes a separate mode bitmask from `ACCESS_MODES`. `chmod` takes a mode, `utimes` takes millisecond timestamps, and `truncate` takes a byte length (default 0). For conditional chmod, check `capabilitiesFor(path, { conditionalChmod: true })` (or `capabilities`) and require `conditionalChmod: true`; supply `parent`, `expected`, and complete root-to-parent `ancestors` together. An optional mutation-free `commitGuard` must return literal `true` synchronously. Memory validates at its metadata commit; mount and supported Memory overlays preserve wrapper ancestry. Real uses its existing externally isolated host-tree boundary and does not prevent races with other processes. Backend limits still apply. Node-shaped bridge methods translate their own options rather than accepting these raw option objects; see the [bridge signatures](src/bridge/filesystem.ts).
|
|
255
262
|
|
|
256
263
|
`collectBytes(source, { maxBytes, maxMemoryBytes, signal })` snapshots streamed chunks into one growing buffer. `maxMemoryBytes` limits owned capacity, the current input's full backing buffer, and overlapping allocations during growth; exhaustion throws `EFBIG`. Browser and Worker bundles additionally share a fixed 32 MiB budget across active collectors, even when byte limits are omitted. The returned view may retain geometric spare capacity. This budget covers collection, not caller-retained results, transport buffering, archive decoding, strings, or the rest of the runtime; use streaming APIs and limit concurrent workloads for larger inputs.
|
|
257
264
|
|
|
@@ -44,7 +44,14 @@ export class FsError extends Error {
|
|
|
44
44
|
const operation = options.syscall ? `, ${options.syscall}` : "";
|
|
45
45
|
const path = options.path === undefined ? "" : ` '${options.path}'`;
|
|
46
46
|
const destination = options.dest === undefined ? "" : ` -> '${options.dest}'`;
|
|
47
|
-
|
|
47
|
+
const prevStackLimit = Error.stackTraceLimit;
|
|
48
|
+
Error.stackTraceLimit = 0;
|
|
49
|
+
try {
|
|
50
|
+
super(`${code}: ${options.message ?? descriptions[code]}${operation}${path}${destination}`, options);
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
Error.stackTraceLimit = prevStackLimit;
|
|
54
|
+
}
|
|
48
55
|
this.name = "FsError";
|
|
49
56
|
this.code = code;
|
|
50
57
|
this.errno = errno;
|
|
@@ -68,16 +68,29 @@ export interface FileSystemCapabilities {
|
|
|
68
68
|
readonly symlinks?: boolean;
|
|
69
69
|
readonly hardlinks?: boolean;
|
|
70
70
|
readonly permissions?: boolean;
|
|
71
|
+
/** Enforces chmod target/ancestry receipts and a synchronous guard at the metadata commit.
|
|
72
|
+
* Host adapters retain their documented external-filesystem isolation requirements. */
|
|
73
|
+
readonly conditionalChmod?: boolean;
|
|
71
74
|
readonly timestamps?: boolean;
|
|
72
75
|
readonly atomicRename?: boolean;
|
|
73
76
|
/** Owned staging serialized within a trusted host; requires external tree isolation. */
|
|
74
77
|
readonly trustedOwnedStaging?: boolean;
|
|
75
78
|
readonly atomicFileStaging?: boolean;
|
|
79
|
+
/** Creates a cleanup handle bound to owned staging entries, independent of ancestor paths. */
|
|
80
|
+
readonly retainedStagingCleanup?: boolean;
|
|
76
81
|
/** Atomically verifies every supplied root-to-parent directory identity at publication. */
|
|
77
82
|
readonly atomicStagingAncestry?: boolean;
|
|
83
|
+
/** Prepares directory guards that validate without yielding or mutating state. */
|
|
84
|
+
readonly synchronousDirectoryValidation?: boolean;
|
|
85
|
+
/** Captures a followed destination path with a synchronous commit validator. */
|
|
86
|
+
readonly synchronousStagingResolution?: boolean;
|
|
87
|
+
/** Invokes commitGuard in the same synchronous section as staged replacement. */
|
|
88
|
+
readonly guardedStagingPublication?: boolean;
|
|
78
89
|
readonly atomicFilePublication?: boolean;
|
|
79
90
|
readonly atomicFileMutation?: boolean;
|
|
80
91
|
readonly atomicEntryRemoval?: boolean;
|
|
92
|
+
/** Returns an atomic post-unlink inode snapshot when explicitly requested. */
|
|
93
|
+
readonly atomicEntryRemovalReceipt?: boolean;
|
|
81
94
|
readonly atomicTreeRemoval?: boolean;
|
|
82
95
|
readonly atomicDirectoryMetadata?: boolean;
|
|
83
96
|
readonly atomicRenameNoReplace?: boolean;
|
|
@@ -93,10 +106,24 @@ export interface FileSystemCapabilities {
|
|
|
93
106
|
export interface FsOptions {
|
|
94
107
|
readonly signal?: AbortSignal;
|
|
95
108
|
}
|
|
109
|
+
export interface ChmodOptions extends FsOptions {
|
|
110
|
+
/** Conditional fields require conditionalChmod and a complete parent/target/ancestry receipt. */
|
|
111
|
+
readonly parent?: FileStat;
|
|
112
|
+
readonly expected?: FileStat;
|
|
113
|
+
readonly ancestors?: readonly FileStagingEntry[];
|
|
114
|
+
/** Trusted, mutation-free validation; must return literal true synchronously. */
|
|
115
|
+
readonly commitGuard?: () => true;
|
|
116
|
+
}
|
|
96
117
|
export interface OpenReadFileOptions extends FsOptions {
|
|
97
118
|
readonly allowDirectory?: boolean;
|
|
98
119
|
}
|
|
99
120
|
export interface CapabilityQueryOptions extends OpenReadFileOptions {
|
|
121
|
+
/** Include complete wrapper ancestry when checking conditional chmod support. */
|
|
122
|
+
readonly conditionalChmod?: boolean;
|
|
123
|
+
/** Inspect complete ancestry for staged publication, including other mounts.
|
|
124
|
+
* Omission retains the ordinary per-target query and its acquisition intent. */
|
|
125
|
+
readonly stagingAncestry?: boolean;
|
|
126
|
+
readonly stagingResolution?: boolean;
|
|
100
127
|
readonly create?: boolean;
|
|
101
128
|
readonly creation?: OpenFileOptions["creation"];
|
|
102
129
|
}
|
|
@@ -182,20 +209,46 @@ export interface ConditionalWriteFileOptions extends FsOptions {
|
|
|
182
209
|
readonly expected: FileStat | null;
|
|
183
210
|
readonly append?: boolean;
|
|
184
211
|
readonly mode?: number;
|
|
212
|
+
readonly atimeMs?: number;
|
|
213
|
+
readonly mtimeMs?: number;
|
|
185
214
|
}
|
|
186
215
|
export interface ConditionalRemoveEntryOptions extends FsOptions {
|
|
187
216
|
readonly parent: FileStat;
|
|
188
217
|
readonly expected: FileStat;
|
|
189
218
|
}
|
|
219
|
+
export interface ConditionalRemoveEntryReceiptOptions extends ConditionalRemoveEntryOptions {
|
|
220
|
+
readonly returnRemainingStat?: boolean | undefined;
|
|
221
|
+
}
|
|
190
222
|
export type ConditionalRemoveFileOptions = ConditionalRemoveEntryOptions;
|
|
191
223
|
export interface FileStagingEntry {
|
|
192
224
|
readonly path: string;
|
|
193
225
|
readonly stat: FileStat;
|
|
194
226
|
}
|
|
227
|
+
export interface FileResolutionStep extends FileStagingEntry {
|
|
228
|
+
readonly linkTarget?: string;
|
|
229
|
+
}
|
|
230
|
+
export interface FileStagingResolution {
|
|
231
|
+
readonly path: string;
|
|
232
|
+
readonly parent: FileStat;
|
|
233
|
+
readonly destination: FileStat | null;
|
|
234
|
+
readonly ancestors: readonly FileStagingEntry[];
|
|
235
|
+
readonly traversed: readonly FileResolutionStep[];
|
|
236
|
+
readonly validate: () => true;
|
|
237
|
+
}
|
|
238
|
+
/** Ownership acquired with staging creation. remove is one-shot with shared
|
|
239
|
+
* completion; close drains admitted removal and releases without new mutation.
|
|
240
|
+
* Both release retained resources even after a failed removal. Call close when
|
|
241
|
+
* abandoning the staging receipt. An explicit cleanup signal is independent of
|
|
242
|
+
* the creation signal; scoped cleanup consumes the cleanup operation budget. */
|
|
243
|
+
export interface FileStagingCleanup {
|
|
244
|
+
remove(options?: FsOptions): Promise<void>;
|
|
245
|
+
close(): Promise<void>;
|
|
246
|
+
}
|
|
195
247
|
export interface FileStaging {
|
|
196
248
|
readonly parent: FileStagingEntry;
|
|
197
249
|
readonly directory: FileStagingEntry;
|
|
198
250
|
readonly file: FileStagingEntry;
|
|
251
|
+
readonly cleanup?: FileStagingCleanup;
|
|
199
252
|
}
|
|
200
253
|
export type StagedFileContent = {
|
|
201
254
|
readonly type: "file";
|
|
@@ -205,6 +258,8 @@ export type StagedFileContent = {
|
|
|
205
258
|
readonly target: string;
|
|
206
259
|
};
|
|
207
260
|
export interface CreateStagedFileOptions extends FsOptions {
|
|
261
|
+
/** Requires retainedStagingCleanup; unsupported backends reject before creation. */
|
|
262
|
+
readonly retainCleanup?: boolean;
|
|
208
263
|
readonly parent: FileStat;
|
|
209
264
|
readonly mode?: number;
|
|
210
265
|
readonly atimeMs?: number;
|
|
@@ -212,6 +267,9 @@ export interface CreateStagedFileOptions extends FsOptions {
|
|
|
212
267
|
}
|
|
213
268
|
export interface PublishStagedFileOptions extends FsOptions {
|
|
214
269
|
readonly ancestors?: readonly FileStagingEntry[];
|
|
270
|
+
/** Trusted, mutation-free validation. Must return literal true synchronously;
|
|
271
|
+
* a promise is not acceptance. Requires guardedStagingPublication. */
|
|
272
|
+
readonly commitGuard?: () => true;
|
|
215
273
|
readonly parent: FileStat;
|
|
216
274
|
readonly destination: FileStat | null;
|
|
217
275
|
}
|
|
@@ -241,7 +299,10 @@ export interface FileSystem {
|
|
|
241
299
|
* Failures before commit preserve the destination. No stat/write fallback. */
|
|
242
300
|
publishFileConditional?(path: string, source: ByteSource, options: ConditionalFilePublicationOptions): Promise<FileStat>;
|
|
243
301
|
writeFileConditional?(path: string, data: Uint8Array, options: ConditionalWriteFileOptions): Promise<FileStat>;
|
|
244
|
-
removeEntryConditional?(path: string, options: ConditionalRemoveEntryOptions
|
|
302
|
+
removeEntryConditional?(path: string, options: ConditionalRemoveEntryOptions & {
|
|
303
|
+
readonly returnRemainingStat?: false | undefined;
|
|
304
|
+
}): Promise<void>;
|
|
305
|
+
removeEntryConditional?(path: string, options: ConditionalRemoveEntryReceiptOptions): Promise<void | FileStat>;
|
|
245
306
|
removeTreeConditional?(path: string, options: ConditionalRemoveEntryOptions): Promise<void>;
|
|
246
307
|
removeFileConditional?(path: string, options: ConditionalRemoveFileOptions): Promise<void>;
|
|
247
308
|
readonly capabilities: FileSystemCapabilities;
|
|
@@ -249,6 +310,11 @@ export interface FileSystem {
|
|
|
249
310
|
prepareDirectory?(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
|
|
250
311
|
createStagedFile?(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
251
312
|
publishStagedFile?(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
|
|
313
|
+
/** Admit a complete canonical root-to-directory prefix and prepare a guard.
|
|
314
|
+
* The returned guard checks current identities/search access synchronously;
|
|
315
|
+
* preparation is not validation, and the guard's result is not an async lease. */
|
|
316
|
+
prepareDirectoryAncestry?(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
|
|
317
|
+
prepareStagingResolution?(path: string, options?: FsOptions): Promise<FileStagingResolution>;
|
|
252
318
|
removeStagedFile?(staging: FileStaging, options?: FsOptions): Promise<void>;
|
|
253
319
|
openReadFile?(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
|
|
254
320
|
openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
|
|
@@ -275,7 +341,7 @@ export interface FileSystem {
|
|
|
275
341
|
readlink?(path: string, options?: FsOptions): Promise<string>;
|
|
276
342
|
symlink?(target: string, path: string, options?: FsOptions): Promise<void>;
|
|
277
343
|
link?(existingPath: string, newPath: string, options?: FsOptions): Promise<void>;
|
|
278
|
-
chmod?(path: string, mode: number, options?:
|
|
344
|
+
chmod?(path: string, mode: number, options?: ChmodOptions): Promise<void>;
|
|
279
345
|
utimes?(path: string, atimeMs: number, mtimeMs: number, options?: FsOptions): Promise<void>;
|
|
280
346
|
truncate?(path: string, length?: number, options?: FsOptions): Promise<void>;
|
|
281
347
|
readStream?(path: string, options?: ReadStreamOptions): ByteSource;
|
|
@@ -1,15 +1,56 @@
|
|
|
1
1
|
import { FsError } from "./errors.js";
|
|
2
2
|
import { finishCleanup } from "./cleanup.js";
|
|
3
3
|
import { platform } from "#safe-fs-platform";
|
|
4
|
+
let sharedTextEncoder;
|
|
5
|
+
const EMPTY_BYTES = new Uint8Array(0);
|
|
6
|
+
const DONE_RESULT = Object.freeze({ done: true, value: undefined });
|
|
7
|
+
const RESOLVED_DONE = Promise.resolve(DONE_RESULT);
|
|
8
|
+
const readBytesSignal = Symbol.for("safe-fs.readBytesSignal");
|
|
9
|
+
const EMPTY_BYTE_ITERATOR = Object.freeze({
|
|
10
|
+
[Symbol.asyncIterator]() { return this; },
|
|
11
|
+
tryNextSync() { return DONE_RESULT; },
|
|
12
|
+
next() { return RESOLVED_DONE; },
|
|
13
|
+
return() { return RESOLVED_DONE; },
|
|
14
|
+
});
|
|
15
|
+
const EMPTY_BYTE_SOURCE = Object.freeze({
|
|
16
|
+
[Symbol.asyncIterator]() { return EMPTY_BYTE_ITERATOR; },
|
|
17
|
+
});
|
|
4
18
|
export function toByteSource(input) {
|
|
5
19
|
if (typeof input !== "string" && !(input instanceof Uint8Array)) {
|
|
6
20
|
throw new TypeError("Byte source input must be a string or Uint8Array");
|
|
7
21
|
}
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
22
|
+
if (typeof input === "string") {
|
|
23
|
+
if (input.length === 0)
|
|
24
|
+
return EMPTY_BYTE_SOURCE;
|
|
25
|
+
}
|
|
26
|
+
else if (input.byteLength === 0) {
|
|
27
|
+
return EMPTY_BYTE_SOURCE;
|
|
28
|
+
}
|
|
29
|
+
const bytes = typeof input === "string" ? (sharedTextEncoder ??= new TextEncoder()).encode(input) : new Uint8Array(input);
|
|
30
|
+
return {
|
|
31
|
+
[Symbol.asyncIterator]() {
|
|
32
|
+
let consumed = false;
|
|
33
|
+
return {
|
|
34
|
+
[Symbol.asyncIterator]() { return this; },
|
|
35
|
+
tryNextSync() {
|
|
36
|
+
if (consumed)
|
|
37
|
+
return DONE_RESULT;
|
|
38
|
+
consumed = true;
|
|
39
|
+
return { done: false, value: bytes };
|
|
40
|
+
},
|
|
41
|
+
next() {
|
|
42
|
+
if (consumed)
|
|
43
|
+
return RESOLVED_DONE;
|
|
44
|
+
consumed = true;
|
|
45
|
+
return Promise.resolve({ done: false, value: bytes });
|
|
46
|
+
},
|
|
47
|
+
return() {
|
|
48
|
+
consumed = true;
|
|
49
|
+
return RESOLVED_DONE;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
13
54
|
}
|
|
14
55
|
let activeCollectionBytes = 0;
|
|
15
56
|
export async function collectBytes(source, options) {
|
|
@@ -91,34 +132,149 @@ async function abortable(operation, signal) {
|
|
|
91
132
|
}
|
|
92
133
|
});
|
|
93
134
|
}
|
|
94
|
-
export
|
|
95
|
-
signal
|
|
96
|
-
|
|
135
|
+
export function readBytes(source, signal) {
|
|
136
|
+
if (signal !== undefined && source[readBytesSignal] === signal) {
|
|
137
|
+
return source;
|
|
138
|
+
}
|
|
139
|
+
let iterator;
|
|
140
|
+
let nativeAbort = false;
|
|
97
141
|
let finished = false;
|
|
98
|
-
let
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
142
|
+
let closing;
|
|
143
|
+
let turn;
|
|
144
|
+
let readingSync = false;
|
|
145
|
+
let syncFailure;
|
|
146
|
+
const schedule = (action) => {
|
|
147
|
+
const previous = turn;
|
|
148
|
+
let release;
|
|
149
|
+
const reserved = new Promise(resolve => { release = resolve; });
|
|
150
|
+
turn = reserved;
|
|
151
|
+
const result = previous ? previous.then(action) : readingSync ? Promise.resolve().then(action) : action();
|
|
152
|
+
const finish = () => { if (turn === reserved)
|
|
153
|
+
turn = undefined; release(); };
|
|
154
|
+
void result.then(finish, finish);
|
|
155
|
+
return result;
|
|
156
|
+
};
|
|
157
|
+
const ensureIterator = () => {
|
|
158
|
+
if (!iterator) {
|
|
159
|
+
signal?.throwIfAborted();
|
|
160
|
+
iterator = source[Symbol.asyncIterator]();
|
|
161
|
+
nativeAbort = signal !== undefined && iterator.abortSignal === signal;
|
|
162
|
+
}
|
|
163
|
+
return iterator;
|
|
164
|
+
};
|
|
165
|
+
const cleanupIterator = async (failed) => {
|
|
166
|
+
if (!finished && iterator?.return) {
|
|
167
|
+
finished = true;
|
|
168
|
+
const cleanup = Promise.resolve().then(() => iterator.return());
|
|
169
|
+
if (signal?.aborted)
|
|
170
|
+
void cleanup.catch(() => { });
|
|
171
|
+
else
|
|
172
|
+
closing = abortable(() => cleanup, signal).then(() => { });
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
finished = true;
|
|
176
|
+
}
|
|
177
|
+
if (closing) {
|
|
178
|
+
const pending = closing;
|
|
179
|
+
try {
|
|
180
|
+
await finishCleanup(() => pending, failed);
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
if (closing === pending)
|
|
184
|
+
closing = undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const tryNextSync = () => {
|
|
189
|
+
if (turn || readingSync || syncFailure)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (finished)
|
|
192
|
+
return DONE_RESULT;
|
|
193
|
+
readingSync = true;
|
|
194
|
+
try {
|
|
195
|
+
const it = ensureIterator();
|
|
196
|
+
if (typeof it.tryNextSync !== "function")
|
|
197
|
+
return undefined;
|
|
198
|
+
signal?.throwIfAborted();
|
|
199
|
+
const syncResult = it.tryNextSync();
|
|
200
|
+
signal?.throwIfAborted();
|
|
201
|
+
if (syncResult === undefined)
|
|
202
|
+
return undefined;
|
|
203
|
+
if (syncResult.done) {
|
|
204
|
+
finished = true;
|
|
205
|
+
return DONE_RESULT;
|
|
206
|
+
}
|
|
207
|
+
if (!(syncResult.value instanceof Uint8Array))
|
|
208
|
+
throw new TypeError("Byte sources must yield Uint8Array chunks");
|
|
209
|
+
return syncResult;
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
syncFailure = { reason: error };
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
readingSync = false;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const runNext = async () => {
|
|
220
|
+
if (finished)
|
|
221
|
+
return DONE_RESULT;
|
|
222
|
+
try {
|
|
223
|
+
if (syncFailure) {
|
|
224
|
+
const { reason } = syncFailure;
|
|
225
|
+
syncFailure = undefined;
|
|
226
|
+
throw reason;
|
|
227
|
+
}
|
|
228
|
+
const it = ensureIterator();
|
|
229
|
+
signal?.throwIfAborted();
|
|
230
|
+
const syncResult = typeof it.tryNextSync === "function" ? it.tryNextSync() : undefined;
|
|
231
|
+
const result = syncResult ?? (nativeAbort ? await it.next() : await abortable(() => it.next(), signal));
|
|
232
|
+
signal?.throwIfAborted();
|
|
102
233
|
if (result.done) {
|
|
103
234
|
finished = true;
|
|
104
|
-
return;
|
|
235
|
+
return DONE_RESULT;
|
|
105
236
|
}
|
|
106
237
|
if (!(result.value instanceof Uint8Array))
|
|
107
238
|
throw new TypeError("Byte sources must yield Uint8Array chunks");
|
|
108
|
-
|
|
239
|
+
return result;
|
|
109
240
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
throw error;
|
|
114
|
-
}
|
|
115
|
-
finally {
|
|
116
|
-
if (!finished && iterator.return) {
|
|
117
|
-
const cleanup = Promise.resolve().then(() => iterator.return());
|
|
118
|
-
if (signal?.aborted)
|
|
119
|
-
void cleanup.catch(() => { });
|
|
120
|
-
else
|
|
121
|
-
await finishCleanup(() => abortable(() => cleanup, signal), failed);
|
|
241
|
+
catch (error) {
|
|
242
|
+
await cleanupIterator(true);
|
|
243
|
+
throw error;
|
|
122
244
|
}
|
|
123
|
-
}
|
|
245
|
+
};
|
|
246
|
+
const gen = {
|
|
247
|
+
[readBytesSignal]: signal,
|
|
248
|
+
abortSignal: signal,
|
|
249
|
+
[Symbol.asyncIterator]() { return this; },
|
|
250
|
+
tryNextSync,
|
|
251
|
+
next() {
|
|
252
|
+
if (!turn && !readingSync && !syncFailure) {
|
|
253
|
+
if (finished && !closing)
|
|
254
|
+
return RESOLVED_DONE;
|
|
255
|
+
const syncResult = tryNextSync();
|
|
256
|
+
if (syncResult !== undefined) {
|
|
257
|
+
return syncResult.done ? RESOLVED_DONE : Promise.resolve(syncResult);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return schedule(runNext);
|
|
261
|
+
},
|
|
262
|
+
return(value) {
|
|
263
|
+
if ((finished || !iterator?.return) && !turn && !readingSync && !closing && value === undefined) {
|
|
264
|
+
finished = true;
|
|
265
|
+
return RESOLVED_DONE;
|
|
266
|
+
}
|
|
267
|
+
return schedule(async () => {
|
|
268
|
+
await cleanupIterator(false);
|
|
269
|
+
return { done: true, value: await value };
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
throw(error) {
|
|
273
|
+
return schedule(async () => {
|
|
274
|
+
await cleanupIterator(true);
|
|
275
|
+
throw error;
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
return gen;
|
|
124
280
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export declare
|
|
1
|
+
export declare const MAX_PATH_BYTES = 65536;
|
|
2
|
+
export declare const MAX_PATH_COMPONENTS = 2048;
|
|
3
|
+
export declare function validatePath(path: string, maxComponents?: number): void;
|
|
2
4
|
export declare function resolvePath(cwd: string, ...paths: string[]): string;
|
|
3
5
|
export declare function normalizePath(path: string, cwd?: string): string;
|
|
4
6
|
export declare function dirname(path: string): string;
|
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
import { FsError } from "./errors.js";
|
|
2
|
-
export
|
|
2
|
+
export const MAX_PATH_BYTES = 65_536;
|
|
3
|
+
export const MAX_PATH_COMPONENTS = 2048;
|
|
4
|
+
export function validatePath(path, maxComponents = MAX_PATH_COMPONENTS) {
|
|
3
5
|
if (typeof path !== "string" || path.includes("\0")) {
|
|
4
6
|
throw new FsError("EINVAL", { syscall: "resolve", message: "paths must be strings without NUL bytes" });
|
|
5
7
|
}
|
|
8
|
+
if (path.length > MAX_PATH_BYTES) {
|
|
9
|
+
throw new FsError("ENAMETOOLONG", { syscall: "resolve", path });
|
|
10
|
+
}
|
|
11
|
+
const limit = Math.min(maxComponents, MAX_PATH_COMPONENTS);
|
|
12
|
+
let components = 0;
|
|
13
|
+
let inComponent = false;
|
|
14
|
+
for (let i = 0; i < path.length; i++) {
|
|
15
|
+
if (path.charCodeAt(i) === 47) {
|
|
16
|
+
inComponent = false;
|
|
17
|
+
}
|
|
18
|
+
else if (!inComponent) {
|
|
19
|
+
inComponent = true;
|
|
20
|
+
if (++components > limit) {
|
|
21
|
+
throw new FsError("ENAMETOOLONG", { syscall: "resolve", path });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
6
25
|
}
|
|
7
26
|
export function resolvePath(cwd, ...paths) {
|
|
8
27
|
validatePath(cwd);
|
package/dist/safe-fs/core.d.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
|
|
|
23
23
|
export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits } from "./xml.js";
|
|
24
24
|
export * from "./contracts/object.js";
|
|
25
25
|
export { ObjectAuthority } from "./fs/object-authority.js";
|
|
26
|
+
export { bindConditionalMutation, type ConditionalMutationBinding } from "./fs/memory/index.js";
|
package/dist/safe-fs/core.js
CHANGED
|
@@ -19,3 +19,4 @@ export { compareEntries, registerEntryView } from "./fs/mount/comparison.js";
|
|
|
19
19
|
export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
|
|
20
20
|
export * from "./contracts/object.js";
|
|
21
21
|
export { ObjectAuthority } from "./fs/object-authority.js";
|
|
22
|
+
export { bindConditionalMutation } from "./fs/memory/index.js";
|
|
@@ -7,4 +7,4 @@ export declare function openRetainedResizeFile(filesystem: FileSystem, path: str
|
|
|
7
7
|
export declare function readOnlyCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
|
|
8
8
|
export declare function quotaCapabilities(capabilities: FileSystemCapabilities): FileSystemCapabilities;
|
|
9
9
|
export declare function ownedMutationCapabilities(filesystem: FileSystem, capabilities?: FileSystemCapabilities): FileSystemCapabilities;
|
|
10
|
-
export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "atomicFilePublication" | "atomicEntryRemoval" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
|
|
10
|
+
export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "retainedStagingCleanup" | "atomicFilePublication" | "atomicEntryRemoval" | "atomicEntryRemovalReceipt" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "guardedStagingPublication" | "atomicStagingAncestry" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { FsError } from "../contracts/errors.js";
|
|
1
|
+
import { FsError, toFsError } from "../contracts/errors.js";
|
|
2
2
|
import { finishCleanup } from "../contracts/cleanup.js";
|
|
3
|
+
import { inspectStagingBindings } from "./staging-ancestry.js";
|
|
3
4
|
export function retainedReadCapabilities(filesystem, capabilities = filesystem.capabilities) {
|
|
4
5
|
return typeof filesystem.openReadFile === "function" ? capabilities : { ...capabilities, retainedRead: false };
|
|
5
6
|
}
|
|
@@ -104,11 +105,12 @@ export function readOnlyCapabilities(capabilities) {
|
|
|
104
105
|
"symlinks", "streamingRead", "open", "retainedRead", "versionedDescriptors",
|
|
105
106
|
].filter(name => capabilities[name] !== undefined).map(name => [name, capabilities[name]]));
|
|
106
107
|
return Object.freeze({
|
|
107
|
-
...inspection, readOnly: true, write: false, append: false, exclusiveCreate: false,
|
|
108
|
+
...inspection, retainedStagingCleanup: false, readOnly: true, write: false, append: false, exclusiveCreate: false,
|
|
109
|
+
synchronousStagingResolution: false,
|
|
108
110
|
mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
|
|
109
111
|
rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
|
|
110
|
-
randomAccessWrite: false, hardlinks: false, permissions: false, timestamps: false,
|
|
111
|
-
descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
112
|
+
randomAccessWrite: false, hardlinks: false, permissions: false, conditionalChmod: false, timestamps: false,
|
|
113
|
+
descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
112
114
|
atomicFilePublication: false, atomicRename: false, atomicRenameNoReplace: false, streamingWrite: false,
|
|
113
115
|
});
|
|
114
116
|
}
|
|
@@ -116,35 +118,71 @@ export function quotaCapabilities(capabilities) {
|
|
|
116
118
|
const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, !capabilities.readOnly);
|
|
117
119
|
const streamingAppend = requireCapabilities(capabilities.append, !capabilities.readOnly);
|
|
118
120
|
const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
|
|
119
|
-
return Object.freeze({ ...rest, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
121
|
+
return Object.freeze({ ...rest, synchronousStagingResolution: false, retainedStagingCleanup: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
120
122
|
...(streamingWrite === undefined ? {} : { streamingWrite }),
|
|
121
123
|
...(streamingAppend === undefined ? {} : { streamingAppend }),
|
|
122
124
|
});
|
|
123
125
|
}
|
|
126
|
+
const TRUSTED_OWNED_STAGING_METHODS = [
|
|
127
|
+
"createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory",
|
|
128
|
+
];
|
|
124
129
|
export function ownedMutationCapabilities(filesystem, capabilities = filesystem.capabilities) {
|
|
125
|
-
|
|
130
|
+
let unavailable;
|
|
126
131
|
if (capabilities.atomicFilePublication === true && (capabilities.readOnly === true || typeof filesystem.publishFileConditional !== "function"))
|
|
127
|
-
unavailable.atomicFilePublication = false;
|
|
132
|
+
(unavailable ??= {}).atomicFilePublication = false;
|
|
128
133
|
if (capabilities.atomicEntryRemoval === true && (capabilities.readOnly === true || typeof filesystem.removeEntryConditional !== "function"))
|
|
129
|
-
unavailable.atomicEntryRemoval = false;
|
|
134
|
+
(unavailable ??= {}).atomicEntryRemoval = false;
|
|
135
|
+
if (capabilities.atomicEntryRemovalReceipt === true && (capabilities.atomicEntryRemoval !== true || unavailable?.atomicEntryRemoval === false))
|
|
136
|
+
(unavailable ??= {}).atomicEntryRemovalReceipt = false;
|
|
130
137
|
if (capabilities.atomicTreeRemoval === true && (capabilities.readOnly === true || typeof filesystem.removeTreeConditional !== "function"))
|
|
131
|
-
unavailable.atomicTreeRemoval = false;
|
|
138
|
+
(unavailable ??= {}).atomicTreeRemoval = false;
|
|
132
139
|
if (capabilities.atomicFileMutation === true && (capabilities.readOnly === true || typeof filesystem.writeFileConditional !== "function" || typeof filesystem.removeFileConditional !== "function"))
|
|
133
|
-
unavailable.atomicFileMutation = false;
|
|
140
|
+
(unavailable ??= {}).atomicFileMutation = false;
|
|
134
141
|
if (capabilities.atomicFileStaging === true && (capabilities.readOnly === true
|
|
135
142
|
|| typeof filesystem.createStagedFile !== "function" || typeof filesystem.publishStagedFile !== "function" || typeof filesystem.removeStagedFile !== "function"))
|
|
136
|
-
unavailable.atomicFileStaging = false;
|
|
143
|
+
(unavailable ??= {}).atomicFileStaging = false;
|
|
144
|
+
if (capabilities.retainedStagingCleanup === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
|
|
145
|
+
(unavailable ??= {}).retainedStagingCleanup = false;
|
|
146
|
+
if (capabilities.atomicStagingAncestry === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
|
|
147
|
+
(unavailable ??= {}).atomicStagingAncestry = false;
|
|
148
|
+
if (capabilities.synchronousDirectoryValidation === true && typeof filesystem.prepareDirectoryAncestry !== "function")
|
|
149
|
+
(unavailable ??= {}).synchronousDirectoryValidation = false;
|
|
150
|
+
if (capabilities.synchronousStagingResolution === true && (capabilities.readOnly === true || typeof filesystem.prepareStagingResolution !== "function"))
|
|
151
|
+
(unavailable ??= {}).synchronousStagingResolution = false;
|
|
152
|
+
if (capabilities.guardedStagingPublication === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
|
|
153
|
+
(unavailable ??= {}).guardedStagingPublication = false;
|
|
137
154
|
if (capabilities.atomicDirectoryMetadata === true && (capabilities.readOnly === true || typeof filesystem.prepareDirectory !== "function"))
|
|
138
|
-
unavailable.atomicDirectoryMetadata = false;
|
|
139
|
-
if (capabilities.trustedOwnedStaging === true
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
(unavailable ??= {}).atomicDirectoryMetadata = false;
|
|
156
|
+
if (capabilities.trustedOwnedStaging === true) {
|
|
157
|
+
let missing = capabilities.readOnly === true;
|
|
158
|
+
if (!missing) {
|
|
159
|
+
for (let i = 0; i < TRUSTED_OWNED_STAGING_METHODS.length; i++) {
|
|
160
|
+
if (typeof filesystem[TRUSTED_OWNED_STAGING_METHODS[i]] !== "function") {
|
|
161
|
+
missing = true;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (missing)
|
|
167
|
+
(unavailable ??= {}).trustedOwnedStaging = false;
|
|
168
|
+
}
|
|
169
|
+
return unavailable ? { ...capabilities, ...unavailable } : capabilities;
|
|
143
170
|
}
|
|
144
171
|
export async function requireOwnedMutation(filesystem, path, capability, options, create = false) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
172
|
+
try {
|
|
173
|
+
options.signal?.throwIfAborted();
|
|
174
|
+
const query = create ? { ...options, create: true } : options;
|
|
175
|
+
const capabilities = ownedMutationCapabilities(filesystem, await filesystem.capabilitiesFor?.(path, capability === "atomicStagingAncestry" ? { ...query, stagingAncestry: true } : query) ?? filesystem.capabilities);
|
|
176
|
+
options.signal?.throwIfAborted();
|
|
177
|
+
if (capabilities[capability] !== true && !(capabilities.trustedOwnedStaging === true && ["atomicFileStaging", "atomicFileMutation", "atomicDirectoryMetadata"].includes(capability)))
|
|
178
|
+
throw new FsError("ENOTSUP", { path, syscall: capability });
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
options.signal?.throwIfAborted();
|
|
182
|
+
if (capability === "atomicStagingAncestry" && "ancestors" in options && options.ancestors !== undefined
|
|
183
|
+
&& "destination" in options && ["ENOTSUP", "ENOENT", "ENOTDIR", "ELOOP"].includes(toFsError(error).code)) {
|
|
184
|
+
await inspectStagingBindings(entry => filesystem.lstat(entry, options), path, options);
|
|
185
|
+
}
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
150
188
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ChmodOptions, FileStagingEntry, FileStat } from "../contracts/filesystem.js";
|
|
2
|
+
export interface ConditionalChmodOptions extends ChmodOptions {
|
|
3
|
+
readonly parent: FileStat;
|
|
4
|
+
readonly expected: FileStat;
|
|
5
|
+
readonly ancestors: readonly FileStagingEntry[];
|
|
6
|
+
}
|
|
7
|
+
export declare function snapshotConditionalChmod(path: string, options: ChmodOptions): ConditionalChmodOptions | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { FsError } from "../contracts/errors.js";
|
|
2
|
+
import { dirname } from "../contracts/virtual-path.js";
|
|
3
|
+
import { snapshotDirectoryAncestry } from "./staging-ancestry.js";
|
|
4
|
+
export function snapshotConditionalChmod(path, options) {
|
|
5
|
+
const { signal, parent, expected, ancestors, commitGuard } = options;
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
if (parent === undefined && expected === undefined && ancestors === undefined && commitGuard === undefined)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (parent === undefined || expected === undefined || ancestors === undefined) {
|
|
10
|
+
throw new FsError("EINVAL", { syscall: "chmod", path, message: "complete conditional chmod receipt is required" });
|
|
11
|
+
}
|
|
12
|
+
if (commitGuard !== undefined && typeof commitGuard !== "function")
|
|
13
|
+
throw new FsError("EINVAL", { syscall: "chmod", path });
|
|
14
|
+
const entries = snapshotDirectoryAncestry(ancestors);
|
|
15
|
+
if (entries.at(-1)?.path !== dirname(path))
|
|
16
|
+
throw new FsError("EINVAL", { syscall: "chmod", path });
|
|
17
|
+
return { ...(signal === undefined ? {} : { signal }), parent: { ...parent }, expected: { ...expected }, ancestors: entries,
|
|
18
|
+
...(commitGuard === undefined ? {} : { commitGuard }) };
|
|
19
|
+
}
|