@poe-platform/safe-fs 0.1.808 → 0.1.810
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 +9 -1
- package/dist/safe-fs/contracts/filesystem.d.ts +15 -1
- package/dist/safe-fs/core.d.ts +1 -1
- package/dist/safe-fs/core.js +1 -1
- package/dist/safe-fs/fs/capabilities.d.ts +1 -1
- package/dist/safe-fs/fs/capabilities.js +7 -3
- package/dist/safe-fs/fs/devices/index.d.ts +2 -2
- package/dist/safe-fs/fs/devices/index.js +13 -7
- package/dist/safe-fs/fs/memory/index.d.ts +2 -2
- package/dist/safe-fs/fs/memory/index.js +45 -4
- package/dist/safe-fs/fs/mount/index.d.ts +2 -2
- package/dist/safe-fs/fs/mount/index.js +16 -8
- package/dist/safe-fs/fs/overlay/index.d.ts +1 -0
- package/dist/safe-fs/fs/overlay/index.js +10 -0
- package/dist/safe-fs/fs/overlay/memory-publication.js +20 -0
- package/dist/safe-fs/fs/real/index.js +2 -0
- package/dist/safe-fs/fs/scoped.js +3 -1
- package/dist/safe-fs/storage.d.ts +9 -1
- package/dist/safe-fs/storage.js +101 -2
- package/dist/xml-ast/errors.d.ts +4 -0
- package/dist/xml-ast/errors.js +7 -0
- package/dist/xml-ast/index.d.ts +2 -4
- package/dist/xml-ast/index.js +3 -7
- package/dist/xml-ast/stream.d.ts +31 -0
- package/dist/xml-ast/stream.js +179 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,6 +10,10 @@ working data. Supply `{fs, cwd, env, signal}` and an optional page-cache count
|
|
|
10
10
|
`env.TMPDIR` or `cwd`, with conditional unlink and cleanup through `close()`.
|
|
11
11
|
Providers must support those capabilities; there is no host fallback. Use an
|
|
12
12
|
external backend for large data because memory filesystems retain spilled bytes.
|
|
13
|
+
`IntegerTable.entries()` replays records in unsigned key order with a fixed
|
|
14
|
+
16-level node stack and batches of at most 128 records. It accepts zero-filled
|
|
15
|
+
caller storage with `allocate`, `read` and `write`; mutation during traversal
|
|
16
|
+
is rejected. Branch pointers use a separate fixed 128-entry cache.
|
|
13
17
|
|
|
14
18
|
## Quickstart
|
|
15
19
|
|
|
@@ -133,7 +137,11 @@ For temporary staging that must be cleaned up after its parent moves, adapters
|
|
|
133
137
|
advertising `retainedStagingCleanup` accept `createStagedFile(..., { parent,
|
|
134
138
|
retainCleanup: true })`. Use the returned `staging.cleanup.remove()` and always
|
|
135
139
|
call `staging.cleanup.close()` in `finally`. Memory and its supported wrappers
|
|
136
|
-
retain only the owned staging entries; replacement entries remain protected.
|
|
140
|
+
retain only the owned staging entries; replacement entries remain protected. When
|
|
141
|
+
`atomicStagedFileMutation` is available, `publishStagedFile(..., { preserveIdentity: true,
|
|
142
|
+
parent, destination })` updates an existing file without breaking hardlink aliases.
|
|
143
|
+
Memory and its supported wrappers adopt staged bytes and timestamps while keeping
|
|
144
|
+
the destination identity and mode; unsupported adapters reject this option.
|
|
137
145
|
Overlays backed by stock Memory filesystems support conditional file mutations,
|
|
138
146
|
directory preparation, and confined staging. Publication may omit `ancestors`
|
|
139
147
|
when the destination shares the staging parent; the overlay checks the ancestry
|
|
@@ -76,6 +76,8 @@ export interface FileSystemCapabilities {
|
|
|
76
76
|
/** Owned staging serialized within a trusted host; requires external tree isolation. */
|
|
77
77
|
readonly trustedOwnedStaging?: boolean;
|
|
78
78
|
readonly atomicFileStaging?: boolean;
|
|
79
|
+
/** Staged payload publication can atomically preserve an existing inode. */
|
|
80
|
+
readonly atomicStagedFileMutation?: boolean;
|
|
79
81
|
/** Creates a cleanup handle bound to owned staging entries, independent of ancestor paths. */
|
|
80
82
|
readonly retainedStagingCleanup?: boolean;
|
|
81
83
|
readonly retainedStagingWrite?: boolean;
|
|
@@ -85,6 +87,8 @@ export interface FileSystemCapabilities {
|
|
|
85
87
|
readonly synchronousDirectoryValidation?: boolean;
|
|
86
88
|
/** Captures a followed destination path with a synchronous commit validator. */
|
|
87
89
|
readonly synchronousStagingResolution?: boolean;
|
|
90
|
+
/** Also binds the final symlink and its target in the synchronous validator. */
|
|
91
|
+
readonly synchronousFollowedStagingResolution?: boolean;
|
|
88
92
|
/** Invokes commitGuard in the same synchronous section as staged replacement. */
|
|
89
93
|
readonly guardedStagingPublication?: boolean;
|
|
90
94
|
readonly atomicFilePublication?: boolean;
|
|
@@ -127,6 +131,7 @@ export interface CapabilityQueryOptions extends OpenReadFileOptions {
|
|
|
127
131
|
* Omission retains the ordinary per-target query and its acquisition intent. */
|
|
128
132
|
readonly stagingAncestry?: boolean;
|
|
129
133
|
readonly stagingResolution?: boolean;
|
|
134
|
+
readonly followFinalSymlink?: boolean;
|
|
130
135
|
readonly create?: boolean;
|
|
131
136
|
readonly creation?: OpenFileOptions["creation"];
|
|
132
137
|
}
|
|
@@ -231,6 +236,10 @@ export interface FileStagingEntry {
|
|
|
231
236
|
export interface FileResolutionStep extends FileStagingEntry {
|
|
232
237
|
readonly linkTarget?: string;
|
|
233
238
|
}
|
|
239
|
+
export interface PrepareStagingResolutionOptions extends FsOptions {
|
|
240
|
+
/** Requires synchronousFollowedStagingResolution; omission preserves final-entry resolution. */
|
|
241
|
+
readonly followFinalSymlink?: boolean;
|
|
242
|
+
}
|
|
234
243
|
export interface FileStagingResolution {
|
|
235
244
|
readonly path: string;
|
|
236
245
|
readonly parent: FileStat;
|
|
@@ -277,6 +286,11 @@ export interface CreateStagedFileOptions extends FsOptions {
|
|
|
277
286
|
readonly mtimeMs?: number;
|
|
278
287
|
}
|
|
279
288
|
export interface PublishStagedFileOptions extends FsOptions {
|
|
289
|
+
/** Requires atomicStagedFileMutation and an existing regular destination.
|
|
290
|
+
* Atomically adopts staged bytes and timestamps while preserving destination
|
|
291
|
+
* identity, links and mode. Consumes the staged pathname as ordinary publication.
|
|
292
|
+
* Unsupported hosts must reject before effects, never silently rename. */
|
|
293
|
+
readonly preserveIdentity?: boolean;
|
|
280
294
|
readonly ancestors?: readonly FileStagingEntry[];
|
|
281
295
|
/** Trusted, mutation-free validation. Must return literal true synchronously;
|
|
282
296
|
* a promise is not acceptance. Requires guardedStagingPublication. */
|
|
@@ -337,7 +351,7 @@ export interface FileSystem {
|
|
|
337
351
|
* The returned guard checks current identities/search access synchronously;
|
|
338
352
|
* preparation is not validation, and the guard's result is not an async lease. */
|
|
339
353
|
prepareDirectoryAncestry?(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
|
|
340
|
-
prepareStagingResolution?(path: string, options?:
|
|
354
|
+
prepareStagingResolution?(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
|
|
341
355
|
removeStagedFile?(staging: FileStaging, options?: FsOptions): Promise<void>;
|
|
342
356
|
openReadFile?(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
|
|
343
357
|
openResizeFile?(path: string, options?: OpenResizeFileOptions): Promise<FileResizeHandle>;
|
package/dist/safe-fs/core.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./fs/object-publication/index.js";
|
|
|
8
8
|
export * from "./fs/webdav/index.js";
|
|
9
9
|
export * from "./bridge/index.js";
|
|
10
10
|
export * from "./python/index.js";
|
|
11
|
-
export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
|
|
11
|
+
export { parseXml, parseXmlSteps, parseXmlStream, XmlLimitError } from "./xml.js";
|
|
12
12
|
export type { XmlName, XmlAttribute, XmlContent, XmlElement, XmlLimits } from "./xml.js";
|
|
13
13
|
export * from "./contracts/object.js";
|
|
14
14
|
export { ObjectAuthority } from "./fs/object-authority.js";
|
package/dist/safe-fs/core.js
CHANGED
|
@@ -8,7 +8,7 @@ export * from "./fs/object-publication/index.js";
|
|
|
8
8
|
export * from "./fs/webdav/index.js";
|
|
9
9
|
export * from "./bridge/index.js";
|
|
10
10
|
export * from "./python/index.js";
|
|
11
|
-
export { parseXml, parseXmlSteps, XmlLimitError } from "./xml.js";
|
|
11
|
+
export { parseXml, parseXmlSteps, parseXmlStream, XmlLimitError } from "./xml.js";
|
|
12
12
|
export * from "./contracts/object.js";
|
|
13
13
|
export { ObjectAuthority } from "./fs/object-authority.js";
|
|
14
14
|
export * from "./fs/s3/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: "retainedStagingCleanup" | "atomicFilePublication" | "atomicEntryRemoval" | "atomicEntryRemovalReceipt" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "guardedStagingPublication" | "atomicStagingAncestry" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
|
|
10
|
+
export declare function requireOwnedMutation(filesystem: FileSystem, path: string, capability: "retainedStagingCleanup" | "atomicFilePublication" | "atomicEntryRemoval" | "atomicEntryRemovalReceipt" | "atomicTreeRemoval" | "atomicFileMutation" | "atomicFileStaging" | "atomicStagedFileMutation" | "guardedStagingPublication" | "atomicStagingAncestry" | "atomicDirectoryMetadata", options: FsOptions, create?: boolean): Promise<void>;
|
|
@@ -132,11 +132,11 @@ export function readOnlyCapabilities(capabilities) {
|
|
|
132
132
|
].filter(name => capabilities[name] !== undefined).map(name => [name, capabilities[name]]));
|
|
133
133
|
return Object.freeze({
|
|
134
134
|
...inspection, retainedStagingCleanup: false, retainedStagingWrite: false, readOnly: true, write: false, append: false, exclusiveCreate: false,
|
|
135
|
-
synchronousStagingResolution: false,
|
|
135
|
+
synchronousStagingResolution: false, synchronousFollowedStagingResolution: false,
|
|
136
136
|
mkdir: false, recursiveMkdir: false, remove: false, removeDirectory: false, recursiveRemove: false,
|
|
137
137
|
rename: false, copy: false, exclusiveCopy: false, truncate: false, streamingAppend: false,
|
|
138
138
|
randomAccessWrite: false, hardlinks: false, permissions: false, conditionalChmod: false, timestamps: false,
|
|
139
|
-
descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
139
|
+
descriptorWriteStream: false, atomicResize: false, retainedResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
140
140
|
atomicFilePublication: false, atomicRename: false, atomicRenameNoReplace: false, streamingWrite: false,
|
|
141
141
|
});
|
|
142
142
|
}
|
|
@@ -144,7 +144,7 @@ export function quotaCapabilities(capabilities) {
|
|
|
144
144
|
const streamingWrite = requireCapabilities(capabilities.write, capabilities.append, capabilities.streamingAppend, !capabilities.readOnly);
|
|
145
145
|
const streamingAppend = requireCapabilities(capabilities.append, capabilities.streamingAppend, !capabilities.readOnly);
|
|
146
146
|
const { streamingWrite: ignoredWrite, streamingAppend: ignoredAppend, ...rest } = capabilities;
|
|
147
|
-
return Object.freeze({ ...rest, synchronousStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: 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,
|
|
147
|
+
return Object.freeze({ ...rest, synchronousStagingResolution: false, synchronousFollowedStagingResolution: false, retainedStagingCleanup: false, retainedStagingWrite: false, atomicStagingAncestry: false, synchronousDirectoryValidation: false, guardedStagingPublication: false, atomicFilePublication: false, descriptorWriteStream: false, atomicResize: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
148
148
|
...(streamingWrite === undefined ? {} : { streamingWrite }),
|
|
149
149
|
...(streamingAppend === undefined ? {} : { streamingAppend }),
|
|
150
150
|
});
|
|
@@ -167,6 +167,8 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
|
|
|
167
167
|
if (capabilities.atomicFileStaging === true && (capabilities.readOnly === true
|
|
168
168
|
|| typeof filesystem.createStagedFile !== "function" || typeof filesystem.publishStagedFile !== "function" || typeof filesystem.removeStagedFile !== "function"))
|
|
169
169
|
(unavailable ??= {}).atomicFileStaging = false;
|
|
170
|
+
if (capabilities.atomicStagedFileMutation === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
|
|
171
|
+
(unavailable ??= {}).atomicStagedFileMutation = false;
|
|
170
172
|
if (capabilities.retainedStagingCleanup === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
|
|
171
173
|
(unavailable ??= {}).retainedStagingCleanup = false;
|
|
172
174
|
if (capabilities.retainedStagingWrite === true && (capabilities.retainedStagingCleanup !== true || unavailable?.retainedStagingCleanup === false))
|
|
@@ -177,6 +179,8 @@ export function ownedMutationCapabilities(filesystem, capabilities = filesystem.
|
|
|
177
179
|
(unavailable ??= {}).synchronousDirectoryValidation = false;
|
|
178
180
|
if (capabilities.synchronousStagingResolution === true && (capabilities.readOnly === true || typeof filesystem.prepareStagingResolution !== "function"))
|
|
179
181
|
(unavailable ??= {}).synchronousStagingResolution = false;
|
|
182
|
+
if (capabilities.synchronousFollowedStagingResolution === true && (capabilities.synchronousStagingResolution !== true || unavailable?.synchronousStagingResolution === false))
|
|
183
|
+
(unavailable ??= {}).synchronousFollowedStagingResolution = false;
|
|
180
184
|
if (capabilities.guardedStagingPublication === true && (capabilities.atomicFileStaging !== true || unavailable?.atomicFileStaging === false))
|
|
181
185
|
(unavailable ??= {}).guardedStagingPublication = false;
|
|
182
186
|
if (capabilities.atomicDirectoryMetadata === true && (capabilities.readOnly === true || typeof filesystem.prepareDirectory !== "function"))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
2
|
-
import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
2
|
+
import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
3
3
|
import type { ByteSource } from "../../contracts/io.js";
|
|
4
4
|
import { type EntryLocation } from "../mount/comparison.js";
|
|
5
5
|
import { pathNamespace } from "../path-namespace.js";
|
|
@@ -43,7 +43,7 @@ export declare class DeviceFileSystem implements FileSystem {
|
|
|
43
43
|
publishStagedFile(staging: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
|
|
44
44
|
publishStagedFileSet(receipt: FileStaging, destination: string, supplied: PublishStagedFileSetOptions): Promise<FileStat>;
|
|
45
45
|
prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
|
|
46
|
-
prepareStagingResolution(path: string, options?:
|
|
46
|
+
prepareStagingResolution(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
|
|
47
47
|
removeStagedFile(staging: FileStaging, options?: FsOptions): Promise<void>;
|
|
48
48
|
prepareDirectory(path: string, options: PrepareDirectoryOptions): Promise<FileStat>;
|
|
49
49
|
rename(source: string, destination: string, options?: RenameOptions): Promise<void>;
|
|
@@ -13,14 +13,14 @@ import { openFileDescriptor } from "../descriptor.js";
|
|
|
13
13
|
import { normalizePath } from "../../contracts/virtual-path.js";
|
|
14
14
|
const views = new WeakMap();
|
|
15
15
|
const deviceCapabilities = Object.freeze({
|
|
16
|
-
synchronousStagingResolution: false,
|
|
16
|
+
synchronousStagingResolution: false, synchronousFollowedStagingResolution: false,
|
|
17
17
|
readOnly: false, read: true, stat: true, realpath: true, access: true, readdir: false,
|
|
18
18
|
write: true, append: true, exclusiveCreate: true, streamingRead: true, retainedRead: true,
|
|
19
19
|
streamingWrite: true, streamingAppend: true, independentWriteStreams: true, copy: true, exclusiveCopy: true,
|
|
20
20
|
remove: false, removeDirectory: false, recursiveRemove: false, rename: false,
|
|
21
21
|
mkdir: false, recursiveMkdir: false, symlinks: false, hardlinks: false, readlink: false,
|
|
22
22
|
permissions: false, timestamps: false, truncate: false, randomAccessWrite: false,
|
|
23
|
-
open: true, retainedStagingCleanup: false, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
23
|
+
open: true, retainedStagingCleanup: false, atomicFilePublication: false, atomicFileMutation: false, atomicEntryRemoval: false, atomicEntryRemovalReceipt: false, atomicTreeRemoval: false, atomicStagedFileMutation: false, atomicFileStaging: false, atomicDirectoryMetadata: false, trustedOwnedStaging: false,
|
|
24
24
|
atomicRename: false, atomicRenameNoReplace: false, descriptorWriteStream: true, retainedResize: true, atomicResize: false,
|
|
25
25
|
});
|
|
26
26
|
function globalCapabilities(filesystem) {
|
|
@@ -31,6 +31,7 @@ function globalCapabilities(filesystem) {
|
|
|
31
31
|
synchronousDirectoryValidation: ["prepareDirectoryAncestry"], guardedStagingPublication: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
|
|
32
32
|
synchronousStagingResolution: ["prepareStagingResolution"],
|
|
33
33
|
trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
|
|
34
|
+
atomicStagedFileMutation: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
|
|
34
35
|
atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicEntryRemovalReceipt: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
|
|
35
36
|
streamingRead: ["readStream"], streamingWrite: ["writeStream"], retainedRead: ["openReadFile"],
|
|
36
37
|
streamingAppend: ["writeStream"], descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
|
|
@@ -178,7 +179,7 @@ export class DeviceFileSystem {
|
|
|
178
179
|
let selected;
|
|
179
180
|
const traversal = options.stagingResolution === true ? { virtual: false } : undefined;
|
|
180
181
|
try {
|
|
181
|
-
resolved = await this.#resolve(path, options, options.stagingResolution
|
|
182
|
+
resolved = await this.#resolve(path, options, options.stagingResolution === true ? options.followFinalSymlink === true : options.create !== undefined || options.creation !== "exclusive", options.create, traversal);
|
|
182
183
|
}
|
|
183
184
|
catch (error) {
|
|
184
185
|
options.signal?.throwIfAborted();
|
|
@@ -209,7 +210,7 @@ export class DeviceFileSystem {
|
|
|
209
210
|
// This intent cannot bind virtual traversal. Do not infer other path
|
|
210
211
|
// capabilities by resolving the shadowed entry in the backing filesystem.
|
|
211
212
|
if (traversal?.virtual)
|
|
212
|
-
return Object.freeze({ synchronousStagingResolution: false });
|
|
213
|
+
return Object.freeze({ synchronousStagingResolution: false, synchronousFollowedStagingResolution: false });
|
|
213
214
|
if (selected === undefined) {
|
|
214
215
|
const query = this.#filesystem.capabilitiesFor;
|
|
215
216
|
options.signal?.throwIfAborted();
|
|
@@ -228,6 +229,7 @@ export class DeviceFileSystem {
|
|
|
228
229
|
throw new FsError("EAGAIN", { path });
|
|
229
230
|
if ([resolution.path, ...resolution.traversed.map(entry => entry.path)].some(entry => entry === deviceDirectory || entry.startsWith(`${deviceDirectory}/`))) {
|
|
230
231
|
(unavailable ??= {}).synchronousStagingResolution = false;
|
|
232
|
+
unavailable.synchronousFollowedStagingResolution = false;
|
|
231
233
|
}
|
|
232
234
|
runStagingGuard(resolution.validate);
|
|
233
235
|
}
|
|
@@ -236,6 +238,7 @@ export class DeviceFileSystem {
|
|
|
236
238
|
if (!isFsError(error, "ENOTSUP"))
|
|
237
239
|
throw error;
|
|
238
240
|
(unavailable ??= {}).synchronousStagingResolution = false;
|
|
241
|
+
unavailable.synchronousFollowedStagingResolution = false;
|
|
239
242
|
}
|
|
240
243
|
}
|
|
241
244
|
if (resolved.startsWith(`${deviceDirectory}/`)) {
|
|
@@ -243,6 +246,7 @@ export class DeviceFileSystem {
|
|
|
243
246
|
u.atomicStagingAncestry = false;
|
|
244
247
|
u.synchronousDirectoryValidation = false;
|
|
245
248
|
u.synchronousStagingResolution = false;
|
|
249
|
+
u.synchronousFollowedStagingResolution = false;
|
|
246
250
|
}
|
|
247
251
|
if (typeof this.#filesystem.open !== "function" && capabilities.open !== false)
|
|
248
252
|
(unavailable ??= {}).open = false;
|
|
@@ -667,6 +671,8 @@ export class DeviceFileSystem {
|
|
|
667
671
|
await this.#mutable(path, options, false);
|
|
668
672
|
await requireOwnedMutation(this.#filesystem, staging.directory.path, "atomicFileStaging", options);
|
|
669
673
|
await requireOwnedMutation(this.#filesystem, destination, "atomicFileStaging", options, options.destination === null);
|
|
674
|
+
if (options.preserveIdentity)
|
|
675
|
+
await requireOwnedMutation(this.#filesystem, destination, "atomicStagedFileMutation", options);
|
|
670
676
|
if (!this.#filesystem.publishStagedFile)
|
|
671
677
|
throw new FsError("ENOTSUP", { path: destination });
|
|
672
678
|
if (options.commitGuard !== undefined)
|
|
@@ -750,19 +756,19 @@ export class DeviceFileSystem {
|
|
|
750
756
|
}
|
|
751
757
|
}
|
|
752
758
|
async prepareStagingResolution(path, options = {}) {
|
|
753
|
-
const controls = options.signal === undefined ? {} : { signal: options.signal };
|
|
759
|
+
const controls = { ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.followFinalSymlink === true ? { followFinalSymlink: true } : {}) };
|
|
754
760
|
controls.signal?.throwIfAborted();
|
|
755
761
|
if (normalizePath(path) !== path || !path.startsWith("/"))
|
|
756
762
|
throw new FsError("EINVAL", { path });
|
|
757
763
|
try {
|
|
758
764
|
const traversal = { virtual: false };
|
|
759
|
-
const resolved = await this.#resolve(path, controls,
|
|
765
|
+
const resolved = await this.#resolve(path, controls, controls.followFinalSymlink === true, undefined, traversal);
|
|
760
766
|
controls.signal?.throwIfAborted();
|
|
761
767
|
if (traversal.virtual)
|
|
762
768
|
throw new FsError("ENOTSUP", { path });
|
|
763
769
|
const declared = ownedMutationCapabilities(this.#filesystem, await this.#filesystem.capabilitiesFor?.(path, { ...controls, stagingResolution: true }) ?? this.#filesystem.capabilities);
|
|
764
770
|
controls.signal?.throwIfAborted();
|
|
765
|
-
if (declared.synchronousStagingResolution !== true || !this.#filesystem.prepareStagingResolution)
|
|
771
|
+
if (declared.synchronousStagingResolution !== true || controls.followFinalSymlink === true && declared.synchronousFollowedStagingResolution !== true || !this.#filesystem.prepareStagingResolution)
|
|
766
772
|
throw new FsError("ENOTSUP", { path });
|
|
767
773
|
const receipt = snapshotStagingResolution(await this.#filesystem.prepareStagingResolution(path, controls));
|
|
768
774
|
controls.signal?.throwIfAborted();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
1
|
+
import type { AppendFileOptions, CopyFileOptions, DirectoryEntry, EntryComparison, FileReadHandle, FileResizeHandle, FileStat, FileSystem, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, FileDescriptor, OpenFileOptions, OpenReadFileOptions, OpenResizeFileOptions, WriteFileOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
2
2
|
import type { ByteSource } from "../../contracts/io.js";
|
|
3
3
|
import { type MemoryAtomicView } from "./atomic-view.js";
|
|
4
4
|
import { MemoryAllocation, MemoryLedger } from "./ledger.js";
|
|
@@ -116,7 +116,7 @@ export declare class MemoryFileSystem implements FileSystem {
|
|
|
116
116
|
createStagedFile(directoryPath: string, name: string, content: StagedFileContent, options: CreateStagedFileOptions): Promise<FileStaging>;
|
|
117
117
|
private retainStagingCleanup;
|
|
118
118
|
prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
|
|
119
|
-
prepareStagingResolution(path: string, options?:
|
|
119
|
+
prepareStagingResolution(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
|
|
120
120
|
private verifyDirectoryAncestry;
|
|
121
121
|
publishStagedFile(receipt: FileStaging, destination: string, options: PublishStagedFileOptions): Promise<void>;
|
|
122
122
|
publishStagedFileSet(receipt: FileStaging, destination: string, options: PublishStagedFileSetOptions): Promise<FileStat>;
|
|
@@ -584,8 +584,8 @@ const SHARED_STOCK_MEMORY_CAPABILITIES = Object.freeze({
|
|
|
584
584
|
permissions: true,
|
|
585
585
|
timestamps: true,
|
|
586
586
|
atomicRename: true,
|
|
587
|
-
atomicFileStaging: true, retainedStagingCleanup: true, retainedStagingWrite: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicEntryRemovalReceipt: true, atomicTreeRemoval: true,
|
|
588
|
-
synchronousDirectoryValidation: true, synchronousStagingResolution: true, guardedStagingPublication: true,
|
|
587
|
+
atomicStagedFileMutation: true, atomicFileStaging: true, retainedStagingCleanup: true, retainedStagingWrite: true, atomicStagingAncestry: true, atomicFileMutation: true, atomicEntryRemoval: true, atomicEntryRemovalReceipt: true, atomicTreeRemoval: true,
|
|
588
|
+
synchronousDirectoryValidation: true, synchronousStagingResolution: true, synchronousFollowedStagingResolution: true, guardedStagingPublication: true,
|
|
589
589
|
atomicDirectoryMetadata: true,
|
|
590
590
|
streamingRead: true,
|
|
591
591
|
retainedRead: true,
|
|
@@ -2186,13 +2186,14 @@ export class MemoryFileSystem {
|
|
|
2186
2186
|
return () => this.verifyDirectoryAncestry(entries, controls);
|
|
2187
2187
|
}
|
|
2188
2188
|
async prepareStagingResolution(path, options = {}) {
|
|
2189
|
+
const followFinal = options.followFinalSymlink === true;
|
|
2189
2190
|
const controls = options.signal === undefined ? {} : { signal: options.signal };
|
|
2190
2191
|
controls.signal?.throwIfAborted();
|
|
2191
2192
|
this.validatePath(path, "prepareStagingResolution");
|
|
2192
2193
|
if (!path.startsWith("/") || normalizePath(path) !== path)
|
|
2193
2194
|
this.fail("EINVAL", "prepareStagingResolution", path);
|
|
2194
2195
|
const traversed = [];
|
|
2195
|
-
const location = this.resolve(path, "prepareStagingResolution", { followFinal
|
|
2196
|
+
const location = this.resolve(path, "prepareStagingResolution", { followFinal, allowMissing: true, resolutionSteps: traversed });
|
|
2196
2197
|
if (location.node && location.node.type !== "file")
|
|
2197
2198
|
this.fail("ENOTSUP", "prepareStagingResolution", path);
|
|
2198
2199
|
const parentPath = location.path.slice(0, location.path.lastIndexOf("/")) || "/";
|
|
@@ -2207,7 +2208,7 @@ export class MemoryFileSystem {
|
|
|
2207
2208
|
const currentSteps = [];
|
|
2208
2209
|
let current;
|
|
2209
2210
|
try {
|
|
2210
|
-
current = this.resolve(path, "verifyStagingResolution", { followFinal
|
|
2211
|
+
current = this.resolve(path, "verifyStagingResolution", { followFinal, allowMissing: true, resolutionSteps: currentSteps });
|
|
2211
2212
|
}
|
|
2212
2213
|
catch (error) {
|
|
2213
2214
|
if (error instanceof FsError && ["ENOENT", "ENOTDIR", "ELOOP"].includes(error.code))
|
|
@@ -2245,6 +2246,7 @@ export class MemoryFileSystem {
|
|
|
2245
2246
|
}
|
|
2246
2247
|
async publishStagedFile(receipt, destination, options) {
|
|
2247
2248
|
const signal = options.signal;
|
|
2249
|
+
const preserveIdentity = options.preserveIdentity === true;
|
|
2248
2250
|
const guard = options.commitGuard;
|
|
2249
2251
|
const ancestors = options.ancestors === undefined ? undefined : snapshotDirectoryAncestry(options.ancestors);
|
|
2250
2252
|
const snapshot = (entry) => ({ path: entry.path, stat: { ...entry.stat } });
|
|
@@ -2272,9 +2274,48 @@ export class MemoryFileSystem {
|
|
|
2272
2274
|
this.fail("EINVAL", "publishStagedFile", destination);
|
|
2273
2275
|
if (target.node && target.node.type !== "file")
|
|
2274
2276
|
this.fail("EAGAIN", "publishStagedFile", destination);
|
|
2277
|
+
if (preserveIdentity) {
|
|
2278
|
+
if (this.capabilities.atomicStagedFileMutation !== true)
|
|
2279
|
+
this.fail("ENOTSUP", "publishStagedFile", destination);
|
|
2280
|
+
if (file.node?.type !== "file" || target.node?.type !== "file" || expected === null)
|
|
2281
|
+
this.fail("EINVAL", "publishStagedFile", destination);
|
|
2282
|
+
this.permission(file.parent, 3, "publishStagedFile", staging.file.path);
|
|
2283
|
+
this.permission(target.parent, 3, "publishStagedFile", destination);
|
|
2284
|
+
this.permission(target.node, 2, "publishStagedFile", destination);
|
|
2285
|
+
// Provider-owned storage is copied directly, without assembling an archive
|
|
2286
|
+
// in caller memory. Preserve retained staging readers and independent writes.
|
|
2287
|
+
this.admitSize(target.node, file.node.byteLength, "publishStagedFile", destination);
|
|
2288
|
+
const allocation = this.allocate(file.node.byteLength, "publishStagedFile", destination);
|
|
2289
|
+
try {
|
|
2290
|
+
for (let offset = 0; offset < file.node.byteLength; offset += 65536) {
|
|
2291
|
+
allocation.data.set(file.node.data.subarray(offset, offset + 65536), offset);
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
catch (error) {
|
|
2295
|
+
allocation.release();
|
|
2296
|
+
throw error;
|
|
2297
|
+
}
|
|
2298
|
+
// No awaits or overridable operations occur between admission and commit.
|
|
2299
|
+
const now = Date.now();
|
|
2300
|
+
this.replaceData(target.node, allocation);
|
|
2301
|
+
this.changed(target.node, now);
|
|
2302
|
+
target.node.atimeMs = file.node.atimeMs;
|
|
2303
|
+
target.node.mtimeMs = file.node.mtimeMs;
|
|
2304
|
+
file.parent.entries.delete(file.name);
|
|
2305
|
+
this.ledger.release(file.name.length * 2, 1);
|
|
2306
|
+
file.node.nlink--;
|
|
2307
|
+
file.node.ctimeMs = now;
|
|
2308
|
+
file.node.revision = Math.min(Number.MAX_SAFE_INTEGER + 1, file.node.revision + 1);
|
|
2309
|
+
this.releaseNode(file.node);
|
|
2310
|
+
this.changed(file.parent, now);
|
|
2311
|
+
memoryCaches.get(this.ledger).clearWrites();
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2275
2314
|
return MemoryFileSystem.prototype.rename.call(this, staging.file.path, destination, { ...controls, noReplace: expected === null });
|
|
2276
2315
|
}
|
|
2277
2316
|
async publishStagedFileSet(receipt, destination, options) {
|
|
2317
|
+
if (options.preserveIdentity)
|
|
2318
|
+
this.fail("ENOTSUP", "publishStagedFileSet", destination);
|
|
2278
2319
|
const signal = options.signal;
|
|
2279
2320
|
const guard = options.commitGuard;
|
|
2280
2321
|
const ancestors = options.ancestors === undefined ? undefined : snapshotDirectoryAncestry(options.ancestors);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
1
|
+
import type { AppendFileOptions, CapabilityQueryOptions, CopyFileOptions, DirectoryEntry, FileReadHandle, FileResizeHandle, FileResizeOperation, FileResizeOptions, FileStat, FileSystem, OpenReadFileOptions, OpenResizeFileOptions, FileSystemCapabilities, FsOptions, ChmodOptions, RenameOptions, MkdirOptions, ReadDirectoryOptions, ReadFileOptions, ReadStreamOptions, RemoveOptions, WriteFileOptions, ConditionalFilePublicationOptions, ConditionalWriteFileOptions, ConditionalRemoveFileOptions, ConditionalRemoveEntryOptions, ConditionalRemoveEntryReceiptOptions, CreateStagedFileOptions, FileStaging, FileStagingEntry, FileStagingResolution, PrepareStagingResolutionOptions, PublishStagedFileOptions, PublishStagedFileSetOptions, PrepareDirectoryOptions, StagedFileContent } from "../../contracts/filesystem.js";
|
|
2
2
|
import type { ByteSource } from "../../contracts/io.js";
|
|
3
3
|
import type { FileDescriptor, OpenFileOptions } from "../../contracts/descriptor.js";
|
|
4
4
|
export interface MountFileSystemOptions {
|
|
@@ -18,7 +18,7 @@ export declare class MountFileSystem implements FileSystem {
|
|
|
18
18
|
private supportsStagingAncestry;
|
|
19
19
|
private supportsDirectoryValidation;
|
|
20
20
|
prepareDirectoryAncestry(ancestors: readonly FileStagingEntry[], options?: FsOptions): Promise<() => true>;
|
|
21
|
-
prepareStagingResolution(path: string, options?:
|
|
21
|
+
prepareStagingResolution(path: string, options?: PrepareStagingResolutionOptions): Promise<FileStagingResolution>;
|
|
22
22
|
private prepareAncestry;
|
|
23
23
|
private protected;
|
|
24
24
|
openReadFile(path: string, options?: OpenReadFileOptions): Promise<FileReadHandle>;
|
|
@@ -109,6 +109,7 @@ export class MountFileSystem {
|
|
|
109
109
|
retainedStagingCleanup: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
|
|
110
110
|
synchronousDirectoryValidation: ["prepareDirectoryAncestry"], guardedStagingPublication: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
|
|
111
111
|
trustedOwnedStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile", "writeFileConditional", "removeFileConditional", "prepareDirectory"],
|
|
112
|
+
atomicStagedFileMutation: ["createStagedFile", "publishStagedFile", "removeStagedFile"],
|
|
112
113
|
atomicFilePublication: ["publishFileConditional"], atomicEntryRemoval: ["removeEntryConditional"], atomicEntryRemovalReceipt: ["removeEntryConditional"], atomicTreeRemoval: ["removeTreeConditional"], atomicFileMutation: ["writeFileConditional", "removeFileConditional"], atomicFileStaging: ["createStagedFile", "publishStagedFile", "removeStagedFile"], atomicDirectoryMetadata: ["prepareDirectory"],
|
|
113
114
|
symlinks: ["symlink", "readlink"], hardlinks: ["link"], permissions: ["chmod"], timestamps: ["utimes"], readlink: ["readlink"],
|
|
114
115
|
descriptorWriteStream: ["writeStream"], retainedResize: ["openResizeFile"], atomicResize: ["resizeFile"],
|
|
@@ -128,7 +129,7 @@ export class MountFileSystem {
|
|
|
128
129
|
return values.every(value => value === true) ? true : values.every(value => value === false) ? false : undefined;
|
|
129
130
|
};
|
|
130
131
|
const semantics = Object.fromEntries([
|
|
131
|
-
"guardedStagingPublication", "retainedStagingCleanup", "atomicEntryRemovalReceipt",
|
|
132
|
+
"guardedStagingPublication", "retainedStagingCleanup", "atomicEntryRemovalReceipt", "atomicStagedFileMutation",
|
|
132
133
|
"trustedOwnedStaging", "atomicFilePublication", "atomicEntryRemoval", "atomicTreeRemoval", "atomicFileMutation", "atomicFileStaging", "atomicDirectoryMetadata", "read", "stat", "readdir", "realpath", "access", "open", "versionedDescriptors",
|
|
133
134
|
"write", "append", "exclusiveCreate", "explicitDirectories", "implicitDirectories", "mkdir", "recursiveMkdir",
|
|
134
135
|
"remove", "removeDirectory", "recursiveRemove", "rename", "atomicRenameNoReplace", "copy", "exclusiveCopy", "readlink", "truncate",
|
|
@@ -190,7 +191,8 @@ export class MountFileSystem {
|
|
|
190
191
|
fail("EINVAL");
|
|
191
192
|
const location = await this.resolve(path, options, {
|
|
192
193
|
allowMissing: options.create ?? true,
|
|
193
|
-
|
|
194
|
+
retainedResolution: options.stagingResolution === true && options.followFinalSymlink === true,
|
|
195
|
+
followFinal: options.stagingResolution === true ? options.followFinalSymlink === true : options.create !== undefined || options.creation !== "exclusive",
|
|
194
196
|
...(options.create === undefined ? {} : { resizeCreate: options.create }),
|
|
195
197
|
});
|
|
196
198
|
const observed = ownedMutationCapabilities(location.mount.backend, await location.mount.backend.capabilitiesFor?.(location.local, options)
|
|
@@ -211,15 +213,15 @@ export class MountFileSystem {
|
|
|
211
213
|
const input = location.mount.path === "/" ? path : path.slice(location.mount.path.length) || "/";
|
|
212
214
|
const declared = input === location.local ? resize : ownedMutationCapabilities(location.mount.backend, await location.mount.backend.capabilitiesFor?.(input, options) ?? location.mount.backend.capabilities);
|
|
213
215
|
options.signal?.throwIfAborted();
|
|
214
|
-
resolution = declared.synchronousStagingResolution === true && await this.supportsStagingAncestry(location, resize, options);
|
|
216
|
+
resolution = declared.synchronousStagingResolution === true && (options.followFinalSymlink !== true || declared.synchronousFollowedStagingResolution === true) && await this.supportsStagingAncestry(location, resize, options);
|
|
215
217
|
}
|
|
216
218
|
}
|
|
217
219
|
const conditionalChmod = options.conditionalChmod === true
|
|
218
220
|
? resize.conditionalChmod === true && typeof location.mount.backend.chmod === "function"
|
|
219
221
|
&& await this.supportsDirectoryValidation(location.path.slice(0, location.path.lastIndexOf("/")) || "/", options, true)
|
|
220
222
|
: this.capabilities.conditionalChmod;
|
|
221
|
-
const { synchronousDirectoryValidation: ignoredValidation, synchronousStagingResolution: ignoredResolution, ...ordinary } = resize;
|
|
222
|
-
const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
|
|
223
|
+
const { synchronousDirectoryValidation: ignoredValidation, synchronousStagingResolution: ignoredResolution, synchronousFollowedStagingResolution: ignoredFollowedResolution, ...ordinary } = resize;
|
|
224
|
+
const withOpen = { ...ordinary, ...(conditionalChmod === undefined ? {} : { conditionalChmod }), ...(resolution === undefined ? {} : { synchronousStagingResolution: resolution, ...(options.followFinalSymlink === true ? { synchronousFollowedStagingResolution: resolution } : {}) }), ...(validation === undefined ? {} : { synchronousDirectoryValidation: validation }), atomicStagingAncestry: ancestry, ...(typeof location.mount.backend.open === "function" ? {} : { open: false }) };
|
|
223
225
|
const capabilities = location.synthetic ? { ...withOpen, open: false, retainedRead: false }
|
|
224
226
|
: retainedResizeCapabilities(location.mount.backend, retainedReadCapabilities(location.mount.backend, withOpen));
|
|
225
227
|
if (location.synthetic)
|
|
@@ -263,19 +265,19 @@ export class MountFileSystem {
|
|
|
263
265
|
return this.operation("prepareDirectoryAncestry", ancestors.at(-1)?.path ?? "/", options, () => this.prepareAncestry(ancestors, options));
|
|
264
266
|
}
|
|
265
267
|
prepareStagingResolution(path, options = {}) {
|
|
266
|
-
const controls = options.signal === undefined ? {} : { signal: options.signal };
|
|
268
|
+
const controls = { ...(options.signal === undefined ? {} : { signal: options.signal }), ...(options.followFinalSymlink === true ? { followFinalSymlink: true } : {}) };
|
|
267
269
|
return this.operation("prepareStagingResolution", path, controls, async () => {
|
|
268
270
|
if (normalizePath(globalPath(path)) !== path)
|
|
269
271
|
fail("EINVAL");
|
|
270
272
|
const observed = [];
|
|
271
|
-
const location = await this.resolve(path, controls, { followFinal:
|
|
273
|
+
const location = await this.resolve(path, controls, { followFinal: controls.followFinalSymlink === true, retainedResolution: controls.followFinalSymlink === true, allowMissing: true, resolutionSteps: observed });
|
|
272
274
|
if (location.synthetic || !within(location.mount.path, path))
|
|
273
275
|
fail("ENOTSUP");
|
|
274
276
|
const backend = location.mount.backend;
|
|
275
277
|
const input = location.mount.path === "/" ? path : path.slice(location.mount.path.length) || "/";
|
|
276
278
|
const declared = ownedMutationCapabilities(backend, await backend.capabilitiesFor?.(input, { ...controls, stagingResolution: true }) ?? backend.capabilities);
|
|
277
279
|
controls.signal?.throwIfAborted();
|
|
278
|
-
if (declared.synchronousStagingResolution !== true || !backend.prepareStagingResolution)
|
|
280
|
+
if (declared.synchronousStagingResolution !== true || controls.followFinalSymlink === true && declared.synchronousFollowedStagingResolution !== true || !backend.prepareStagingResolution)
|
|
279
281
|
fail("ENOTSUP");
|
|
280
282
|
const prefix = directoryAncestryPaths(location.mount.path).map(ancestor => {
|
|
281
283
|
const entry = observed.find(step => step.path === ancestor);
|
|
@@ -545,6 +547,10 @@ export class MountFileSystem {
|
|
|
545
547
|
let followedFinalLink = false;
|
|
546
548
|
const verified = async (location) => {
|
|
547
549
|
let inspection = !location.stat && settings.resizeCreate === true ? creationVerification : verification;
|
|
550
|
+
// A guarded resolution binds the original link chain and missing target
|
|
551
|
+
// through the authoritative backend receipt; realpath cannot name absence.
|
|
552
|
+
if (settings.retainedResolution && !location.stat && followedFinalLink)
|
|
553
|
+
inspection = undefined;
|
|
548
554
|
// A missing entry has no realpath. Verify its existing parent while
|
|
549
555
|
// leaving dangling final symlinks on the ordinary following path.
|
|
550
556
|
if (inspection && !location.stat && settings.allowMissing && !followedFinalLink && inspection.finalName === undefined) {
|
|
@@ -1096,6 +1102,8 @@ export class MountFileSystem {
|
|
|
1096
1102
|
await requireOwnedMutation(local.mount.backend, target.local, "atomicFileStaging", options, options.destination === null);
|
|
1097
1103
|
if (!local.mount.backend.publishStagedFile)
|
|
1098
1104
|
fail("ENOTSUP");
|
|
1105
|
+
if (options.preserveIdentity)
|
|
1106
|
+
await requireOwnedMutation(local.mount.backend, target.local, "atomicStagedFileMutation", options);
|
|
1099
1107
|
if (ancestors || callerGuard !== undefined)
|
|
1100
1108
|
await requireOwnedMutation(local.mount.backend, target.local, "guardedStagingPublication", options, options.destination === null);
|
|
1101
1109
|
if (!ancestors) {
|
|
@@ -554,7 +554,12 @@ export class OverlayFileSystem {
|
|
|
554
554
|
async cleanup(options = {}) {
|
|
555
555
|
return this.run(options, () => this.cleanGarbage(true));
|
|
556
556
|
}
|
|
557
|
+
// Receipts describe overlay paths and identities, never backing-layer staging paths.
|
|
558
|
+
stagingOptions({ signal }) {
|
|
559
|
+
return signal === undefined ? {} : { signal };
|
|
560
|
+
}
|
|
557
561
|
async staged(options, operation) {
|
|
562
|
+
options = this.stagingOptions(options);
|
|
558
563
|
const root = `/.virtual-bash-overlay-${platform.randomUUID()}`;
|
|
559
564
|
if (await this.maybeStat(this.#upper, root, options) || await this.maybeStat(this.#lower, root, options))
|
|
560
565
|
fail("EEXIST", root);
|
|
@@ -570,6 +575,7 @@ export class OverlayFileSystem {
|
|
|
570
575
|
}
|
|
571
576
|
}
|
|
572
577
|
async preserve(path, stat, options) {
|
|
578
|
+
options = this.stagingOptions(options);
|
|
573
579
|
if (this.#upper.chmod)
|
|
574
580
|
await this.#upper.chmod(path, stat.mode & 0o7777, options);
|
|
575
581
|
else if (((await this.#upper.lstat(path, options)).mode & 0o7777) !== (stat.mode & 0o7777))
|
|
@@ -579,6 +585,7 @@ export class OverlayFileSystem {
|
|
|
579
585
|
await this.#upper.utimes(path, stat.atimeMs, stat.mtimeMs, options);
|
|
580
586
|
}
|
|
581
587
|
async clone(entry, destination, options) {
|
|
588
|
+
options = this.stagingOptions(options);
|
|
582
589
|
if (entry.stat.type !== "directory" && ((entry.stat.nlink ?? 1) > 1
|
|
583
590
|
|| (entry.backend.capabilities.hardlinks === true && entry.stat.nlink === undefined))) {
|
|
584
591
|
fail("ENOTSUP", entry.path, "copy-up cannot preserve hardlink identity");
|
|
@@ -608,6 +615,7 @@ export class OverlayFileSystem {
|
|
|
608
615
|
}
|
|
609
616
|
}
|
|
610
617
|
async ensureDirectory(path, options) {
|
|
618
|
+
options = this.stagingOptions(options);
|
|
611
619
|
const entry = await this.lookup(path, options);
|
|
612
620
|
if (!entry)
|
|
613
621
|
fail("ENOENT", path);
|
|
@@ -618,6 +626,7 @@ export class OverlayFileSystem {
|
|
|
618
626
|
await this.copyUp(entry, options);
|
|
619
627
|
}
|
|
620
628
|
async copyUp(entry, options) {
|
|
629
|
+
options = this.stagingOptions(options);
|
|
621
630
|
if (entry.backend === this.#upper)
|
|
622
631
|
return;
|
|
623
632
|
if (entry.path === "/")
|
|
@@ -640,6 +649,7 @@ export class OverlayFileSystem {
|
|
|
640
649
|
});
|
|
641
650
|
}
|
|
642
651
|
async parent(path, options) {
|
|
652
|
+
options = this.stagingOptions(options);
|
|
643
653
|
const parent = await this.lookup(dirname(path), options);
|
|
644
654
|
if (!parent)
|
|
645
655
|
fail("ENOENT", dirname(path));
|
|
@@ -376,6 +376,24 @@ export class OverlayMemoryPublication {
|
|
|
376
376
|
const owned = await this.upper.createStagedFile(directory, name, content, { ...options, parent });
|
|
377
377
|
const staging = Object.freeze({
|
|
378
378
|
parent: owned.parent, directory: owned.directory, file: owned.file,
|
|
379
|
+
...(owned.writer ? { writer: Object.freeze({
|
|
380
|
+
write: async (bytes, controls = {}) => {
|
|
381
|
+
controls.signal?.throwIfAborted();
|
|
382
|
+
this.stores();
|
|
383
|
+
guard?.();
|
|
384
|
+
this.check(ancestors);
|
|
385
|
+
await owned.writer.write(bytes, controls);
|
|
386
|
+
},
|
|
387
|
+
finish: async (controls = {}) => {
|
|
388
|
+
controls.signal?.throwIfAborted();
|
|
389
|
+
this.stores();
|
|
390
|
+
guard?.();
|
|
391
|
+
this.check(ancestors);
|
|
392
|
+
const stat = await owned.writer.finish(controls);
|
|
393
|
+
this.stages.set(staging.directory, { ...owned, file: { ...owned.file, stat } });
|
|
394
|
+
return stat;
|
|
395
|
+
},
|
|
396
|
+
}) } : {}),
|
|
379
397
|
...(owned.cleanup ? { cleanup: createStagingCleanup(directory, controls => this.cleanup(staging, controls, true), () => owned.cleanup.close()) } : {}),
|
|
380
398
|
});
|
|
381
399
|
this.stages.set(staging.directory, owned);
|
|
@@ -384,6 +402,8 @@ export class OverlayMemoryPublication {
|
|
|
384
402
|
return staging;
|
|
385
403
|
}
|
|
386
404
|
async publish(staging, path, options) {
|
|
405
|
+
if (options.preserveIdentity)
|
|
406
|
+
throw new FsError("ENOTSUP", { path });
|
|
387
407
|
options.signal?.throwIfAborted();
|
|
388
408
|
this.path(path);
|
|
389
409
|
const owned = this.stages.get(staging.directory);
|
|
@@ -413,6 +413,8 @@ export class RealFileSystem {
|
|
|
413
413
|
throw new FsError("EAGAIN");
|
|
414
414
|
}
|
|
415
415
|
async publishStagedFile(staging, destination, options) {
|
|
416
|
+
if (options.preserveIdentity)
|
|
417
|
+
throw new FsError("ENOTSUP", { path: destination });
|
|
416
418
|
return this.operation("publishStagedFile", staging.file.path, options, async () => {
|
|
417
419
|
options.signal?.throwIfAborted();
|
|
418
420
|
if (options.ancestors !== undefined || options.commitGuard !== undefined)
|
|
@@ -343,6 +343,8 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
343
343
|
if (property === "publishStagedFile") {
|
|
344
344
|
const publishOptions = (options ?? {});
|
|
345
345
|
await requireOwnedMutation(original, args[1], "atomicFileStaging", publishOptions, publishOptions.destination === null);
|
|
346
|
+
if (publishOptions.preserveIdentity)
|
|
347
|
+
await requireOwnedMutation(original, args[1], "atomicStagedFileMutation", publishOptions);
|
|
346
348
|
const query = options.destination === null ? { ...options, create: true } : options;
|
|
347
349
|
const declared = ownedMutationCapabilities(original, await original.capabilitiesFor?.(args[1], query) ?? original.capabilities);
|
|
348
350
|
assertReady();
|
|
@@ -457,7 +459,7 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
457
459
|
try {
|
|
458
460
|
const declared = ownedMutationCapabilities(original, await original.capabilitiesFor?.(path, { ...controls, stagingResolution: true }) ?? original.capabilities);
|
|
459
461
|
controls.signal?.throwIfAborted();
|
|
460
|
-
if (declared.synchronousStagingResolution !== true)
|
|
462
|
+
if (declared.synchronousStagingResolution !== true || controls.followFinalSymlink === true && declared.synchronousFollowedStagingResolution !== true)
|
|
461
463
|
throw new FsError("ENOTSUP", { path });
|
|
462
464
|
const resolution = snapshotStagingResolution(await Reflect.apply(method, original, [path, controls]));
|
|
463
465
|
controls.signal?.throwIfAborted();
|
|
@@ -39,11 +39,19 @@ export declare class IntegerTable {
|
|
|
39
39
|
private readonly storage;
|
|
40
40
|
private readonly maxEntries;
|
|
41
41
|
private root;
|
|
42
|
+
private revision;
|
|
43
|
+
private readonly branches;
|
|
42
44
|
private readonly cache;
|
|
43
|
-
constructor(storage: PagedStorage, maxEntries?: number);
|
|
45
|
+
constructor(storage: Pick<PagedStorage, "allocate" | "read" | "write">, maxEntries?: number);
|
|
44
46
|
private retain;
|
|
45
47
|
get(key: bigint): Promise<bigint | undefined>;
|
|
46
48
|
set(key: bigint, value: bigint): Promise<void>;
|
|
49
|
+
/** Ordered replay with a fixed 16-level radix stack (at most 2 KiB of
|
|
50
|
+
* owned node bytes) and 128 output records. Batches avoid alternating sparse
|
|
51
|
+
* index reads with consumer writes on every record. Mutation is not permitted. */
|
|
52
|
+
entries(start?: bigint, end?: bigint): AsyncGenerator<readonly [bigint, bigint]>;
|
|
53
|
+
/** Hot radix paths must not evict dirty leaf pages in a one-page backend. */
|
|
54
|
+
private branch;
|
|
47
55
|
private lookup;
|
|
48
56
|
private persist;
|
|
49
57
|
}
|
package/dist/safe-fs/storage.js
CHANGED
|
@@ -103,14 +103,20 @@ export class PagedStorage {
|
|
|
103
103
|
this.pages.set(number, cached);
|
|
104
104
|
return cached;
|
|
105
105
|
}
|
|
106
|
+
let reusable;
|
|
106
107
|
if (this.pages.size === this.maxPages) {
|
|
107
108
|
if (!this.descriptor)
|
|
108
109
|
await this.spill();
|
|
109
110
|
const [oldNumber, oldPage] = this.pages.entries().next().value;
|
|
110
111
|
await this.flush(oldNumber, oldPage);
|
|
111
112
|
this.pages.delete(oldNumber);
|
|
113
|
+
reusable = oldPage;
|
|
112
114
|
}
|
|
113
|
-
|
|
115
|
+
// Flush owns the bytes until its awaited write completes. Reuse only then;
|
|
116
|
+
// zero new/unwritten ranges so the previous page cannot leak into them.
|
|
117
|
+
const page = reusable ?? { bytes: new Uint8Array(pageBytes), dirty: false };
|
|
118
|
+
if (reusable)
|
|
119
|
+
page.bytes.fill(0);
|
|
114
120
|
if (number * pageBytes < this.diskLength) {
|
|
115
121
|
let offset = 0;
|
|
116
122
|
while (offset < pageBytes) {
|
|
@@ -192,6 +198,8 @@ export class IntegerTable {
|
|
|
192
198
|
storage;
|
|
193
199
|
maxEntries;
|
|
194
200
|
root = 0;
|
|
201
|
+
revision = 0;
|
|
202
|
+
branches = new Map();
|
|
195
203
|
cache = new Map();
|
|
196
204
|
constructor(storage, maxEntries = 4096) {
|
|
197
205
|
this.storage = storage;
|
|
@@ -202,8 +210,14 @@ export class IntegerTable {
|
|
|
202
210
|
async retain(key, value, dirty) {
|
|
203
211
|
if (this.cache.size === this.maxEntries && !this.cache.has(key)) {
|
|
204
212
|
const [oldKey, old] = this.cache.entries().next().value;
|
|
213
|
+
// Flush the bounded dirty window together, before the caller touches other
|
|
214
|
+
// stored data. One dirty leaf per insertion otherwise thrashes a page cache.
|
|
205
215
|
if (old.dirty)
|
|
206
|
-
|
|
216
|
+
for (const [key, entry] of this.cache)
|
|
217
|
+
if (entry.dirty) {
|
|
218
|
+
await this.persist(key, entry.value);
|
|
219
|
+
entry.dirty = false;
|
|
220
|
+
}
|
|
207
221
|
this.cache.delete(oldKey);
|
|
208
222
|
}
|
|
209
223
|
this.cache.delete(key);
|
|
@@ -224,19 +238,98 @@ export class IntegerTable {
|
|
|
224
238
|
async set(key, value) {
|
|
225
239
|
if (key < 0n || key > 0xffffffffffffffffn || value < 0n || value >= 0xffffffffffffffffn)
|
|
226
240
|
throw new RangeError("Invalid storage integer table value");
|
|
241
|
+
this.revision++;
|
|
227
242
|
await this.retain(key, value, true);
|
|
228
243
|
}
|
|
244
|
+
/** Ordered replay with a fixed 16-level radix stack (at most 2 KiB of
|
|
245
|
+
* owned node bytes) and 128 output records. Batches avoid alternating sparse
|
|
246
|
+
* index reads with consumer writes on every record. Mutation is not permitted. */
|
|
247
|
+
async *entries(start = 0n, end = 0x10000000000000000n) {
|
|
248
|
+
if (start < 0n || start > end || end > 0x10000000000000000n)
|
|
249
|
+
throw new RangeError("Invalid integer table range");
|
|
250
|
+
if (start === end)
|
|
251
|
+
return;
|
|
252
|
+
const revision = this.revision;
|
|
253
|
+
const check = () => { if (revision !== this.revision)
|
|
254
|
+
throw new Error("Integer table changed during iteration"); };
|
|
255
|
+
for (const [key, entry] of this.cache)
|
|
256
|
+
if (entry.dirty) {
|
|
257
|
+
check();
|
|
258
|
+
await this.persist(key, entry.value);
|
|
259
|
+
check();
|
|
260
|
+
entry.dirty = false;
|
|
261
|
+
}
|
|
262
|
+
const storage = this.storage;
|
|
263
|
+
async function* visit(position, shift, prefix) {
|
|
264
|
+
check();
|
|
265
|
+
if (!position)
|
|
266
|
+
return;
|
|
267
|
+
const bytes = new Uint8Array(await storage.read(position, 128));
|
|
268
|
+
check();
|
|
269
|
+
if (bytes.length !== 128)
|
|
270
|
+
throw new RangeError("Truncated integer table node");
|
|
271
|
+
const node = new DataView(bytes.buffer);
|
|
272
|
+
for (let slot = 0; slot < 16; slot++) {
|
|
273
|
+
check();
|
|
274
|
+
const value = node.getBigUint64(slot * 8, true);
|
|
275
|
+
if (!value)
|
|
276
|
+
continue;
|
|
277
|
+
const key = prefix | BigInt(slot) << shift;
|
|
278
|
+
if (key >= end || key + (1n << shift) <= start)
|
|
279
|
+
continue;
|
|
280
|
+
if (shift === 0n)
|
|
281
|
+
yield [key, value - 1n];
|
|
282
|
+
else
|
|
283
|
+
yield* visit(Number(value), shift - 4n, key);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const batch = [];
|
|
287
|
+
for await (const entry of visit(this.root, 60n, 0n)) {
|
|
288
|
+
batch.push(entry);
|
|
289
|
+
if (batch.length === 128) {
|
|
290
|
+
for (const entry of batch) {
|
|
291
|
+
check();
|
|
292
|
+
yield entry;
|
|
293
|
+
}
|
|
294
|
+
batch.length = 0;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const entry of batch) {
|
|
298
|
+
check();
|
|
299
|
+
yield entry;
|
|
300
|
+
}
|
|
301
|
+
check();
|
|
302
|
+
}
|
|
303
|
+
/** Hot radix paths must not evict dirty leaf pages in a one-page backend. */
|
|
304
|
+
branch(key, shift, position) {
|
|
305
|
+
const prefix = key >> shift | 1n << (64n - shift);
|
|
306
|
+
const value = position ?? this.branches.get(prefix);
|
|
307
|
+
if (value !== undefined) {
|
|
308
|
+
this.branches.delete(prefix);
|
|
309
|
+
this.branches.set(prefix, value);
|
|
310
|
+
if (this.branches.size > 128)
|
|
311
|
+
this.branches.delete(this.branches.keys().next().value);
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
229
315
|
async lookup(key) {
|
|
230
316
|
let node = this.root;
|
|
231
317
|
for (let shift = 60n; shift >= 0n; shift -= 4n) {
|
|
232
318
|
if (!node)
|
|
233
319
|
return undefined;
|
|
320
|
+
const cached = shift === 0n ? undefined : this.branch(key, shift);
|
|
321
|
+
if (cached !== undefined) {
|
|
322
|
+
node = cached;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
234
325
|
const slot = node + Number((key >> shift) & 15n) * 8;
|
|
235
326
|
const bytes = await this.storage.read(slot, 8);
|
|
236
327
|
const value = new DataView(bytes.buffer, bytes.byteOffset, 8).getBigUint64(0, true);
|
|
237
328
|
if (shift === 0n)
|
|
238
329
|
return value === 0n ? undefined : value - 1n;
|
|
239
330
|
node = Number(value);
|
|
331
|
+
if (node)
|
|
332
|
+
this.branch(key, shift, node);
|
|
240
333
|
}
|
|
241
334
|
return undefined;
|
|
242
335
|
}
|
|
@@ -245,6 +338,11 @@ export class IntegerTable {
|
|
|
245
338
|
this.root = this.storage.allocate(128);
|
|
246
339
|
let node = this.root;
|
|
247
340
|
for (let shift = 60n; shift >= 0n; shift -= 4n) {
|
|
341
|
+
const cached = shift === 0n ? undefined : this.branch(key, shift);
|
|
342
|
+
if (cached !== undefined) {
|
|
343
|
+
node = cached;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
248
346
|
const slot = node + Number((key >> shift) & 15n) * 8;
|
|
249
347
|
const bytes = await this.storage.read(slot, 8);
|
|
250
348
|
const view = new DataView(bytes.buffer, bytes.byteOffset, 8);
|
|
@@ -260,6 +358,7 @@ export class IntegerTable {
|
|
|
260
358
|
await this.storage.write(slot, bytes);
|
|
261
359
|
}
|
|
262
360
|
node = child;
|
|
361
|
+
this.branch(key, shift, child);
|
|
263
362
|
}
|
|
264
363
|
}
|
|
265
364
|
}
|
package/dist/xml-ast/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { XmlLimitError } from "./errors.js";
|
|
1
2
|
export interface XmlName {
|
|
2
3
|
readonly name: string;
|
|
3
4
|
readonly namespace: string;
|
|
@@ -39,9 +40,6 @@ export interface XmlLimits {
|
|
|
39
40
|
readonly maxTextLength?: number;
|
|
40
41
|
readonly onElement?: (element: XmlName, parent: XmlName | undefined, depth: number) => void;
|
|
41
42
|
}
|
|
42
|
-
export declare class XmlLimitError extends SyntaxError {
|
|
43
|
-
readonly limit: string;
|
|
44
|
-
constructor(limit: string, message: string);
|
|
45
|
-
}
|
|
46
43
|
export declare function parseXmlSteps(input: string, limits?: XmlLimits): Generator<number, XmlElement, void>;
|
|
47
44
|
export declare function parseXml(input: string, limits?: XmlLimits): XmlElement;
|
|
45
|
+
export { parseXmlStream, type XmlStreamLimits, type XmlStreamEvent } from "./stream.js";
|
package/dist/xml-ast/index.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
constructor(limit, message) {
|
|
4
|
-
super(message);
|
|
5
|
-
this.limit = limit;
|
|
6
|
-
}
|
|
7
|
-
}
|
|
1
|
+
import { XmlLimitError } from "./errors.js";
|
|
2
|
+
export { XmlLimitError } from "./errors.js";
|
|
8
3
|
function* find(source, needle, start) {
|
|
9
4
|
const found = source.indexOf(needle, start);
|
|
10
5
|
const end = found < 0 ? source.length : found;
|
|
@@ -633,3 +628,4 @@ export function parseXml(input, limits = {}) {
|
|
|
633
628
|
result = parser.next();
|
|
634
629
|
return result.value;
|
|
635
630
|
}
|
|
631
|
+
export { parseXmlStream } from "./stream.js";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { XmlContent, XmlElement, XmlLimits, XmlName } from "./index.js";
|
|
2
|
+
export type XmlStreamEvent = {
|
|
3
|
+
readonly type: "open" | "close";
|
|
4
|
+
readonly element: XmlElement;
|
|
5
|
+
readonly parent: XmlElement | undefined;
|
|
6
|
+
} | {
|
|
7
|
+
readonly type: "content";
|
|
8
|
+
readonly content: Exclude<XmlContent, XmlElement>;
|
|
9
|
+
readonly parent: XmlElement | undefined;
|
|
10
|
+
};
|
|
11
|
+
export interface XmlStreamLimits extends XmlLimits {
|
|
12
|
+
/** Validate the full document but return only its root name, without a retained tree. */
|
|
13
|
+
readonly retainTree?: boolean;
|
|
14
|
+
/** Ordered node events, awaited after each parser window. Requires retainTree:
|
|
15
|
+
* false; elements carry metadata without child/content arrays. Individual XML
|
|
16
|
+
* tokens remain resident until the underlying parser completes them. */
|
|
17
|
+
readonly events?: (event: XmlStreamEvent) => void | Promise<void>;
|
|
18
|
+
/** Detach selected non-root subtrees and await their consumer before parsing
|
|
19
|
+
* the next 512-unit window. By default nested matches belong to the selected ancestor. */
|
|
20
|
+
readonly streamElements?: {
|
|
21
|
+
/** Also detach preceding sibling content when selecting a subtree, before
|
|
22
|
+
* the parser advances. The final trailing content remains in the parent. */
|
|
23
|
+
readonly captureBefore?: boolean;
|
|
24
|
+
/** Detach nested matches too, consuming descendants before their ancestors. */
|
|
25
|
+
readonly includeNested?: boolean;
|
|
26
|
+
readonly matches: (element: XmlName, parent: XmlName | undefined, depth: number) => boolean;
|
|
27
|
+
readonly consume: (element: XmlElement, parent: XmlElement, before?: readonly XmlContent[]) => void | Promise<void>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Incremental source consumption. Individual XML tokens and optional trees remain resident. */
|
|
31
|
+
export declare function parseXmlStream(source: AsyncIterable<string> | Iterable<string>, limits?: XmlStreamLimits, checkpoint?: (units: number) => void | Promise<void>): Promise<XmlElement>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { SaxesParser } from "saxes/saxes.js";
|
|
2
|
+
import { XmlLimitError } from "./errors.js";
|
|
3
|
+
/** Incremental source consumption. Individual XML tokens and optional trees remain resident. */
|
|
4
|
+
export async function parseXmlStream(source, limits = {}, checkpoint) {
|
|
5
|
+
if (limits.recover)
|
|
6
|
+
throw new TypeError("XML stream recovery is unsupported; use parseXmlSteps");
|
|
7
|
+
for (const key of ["maxDepth", "maxNodes", "maxAttributes", "maxAttributesPerElement", "maxNamespaces", "maxContentNodes", "maxTextLength"]) {
|
|
8
|
+
const value = limits[key];
|
|
9
|
+
if (value !== undefined && value !== Infinity && (!Number.isSafeInteger(value) || value < 1))
|
|
10
|
+
throw new RangeError("XML limits must be positive integers");
|
|
11
|
+
}
|
|
12
|
+
const counters = { maxNodes: 0, maxAttributes: 0, maxContentNodes: 0, maxTextLength: 0 };
|
|
13
|
+
function bound(key, value) {
|
|
14
|
+
if (value > (limits[key] ?? Infinity))
|
|
15
|
+
throw new XmlLimitError(key, "XML resource limit exceeded");
|
|
16
|
+
}
|
|
17
|
+
function charge(key, value = 1) { counters[key] += value; bound(key, counters[key]); }
|
|
18
|
+
const retainContent = limits.retainContent !== false, tree = limits.retainTree !== false, retain = tree && retainContent;
|
|
19
|
+
const events = limits.events;
|
|
20
|
+
if (events && tree)
|
|
21
|
+
throw new TypeError("XML events require retainTree: false");
|
|
22
|
+
const describe = retain || !!events;
|
|
23
|
+
const queued = [];
|
|
24
|
+
const streaming = limits.streamElements;
|
|
25
|
+
if (streaming && !retain)
|
|
26
|
+
throw new TypeError("XML subtree streaming requires retained content and tree mode");
|
|
27
|
+
let selectedCount = 0;
|
|
28
|
+
const completed = [];
|
|
29
|
+
const stack = [];
|
|
30
|
+
const prolog = [], epilog = [];
|
|
31
|
+
const emptyNamespaces = new Map();
|
|
32
|
+
let root, declaration;
|
|
33
|
+
const parser = new SaxesParser({ xmlns: true, defaultXMLVersion: "1.0", forceXMLVersion: true });
|
|
34
|
+
parser.on("error", error => { throw new SyntaxError(`Invalid XML: ${error.message}`); });
|
|
35
|
+
parser.on("doctype", () => { throw new SyntaxError("Invalid XML: DTD and entity declarations are forbidden"); });
|
|
36
|
+
let prefix = "", capturing = describe;
|
|
37
|
+
parser.on("xmldecl", value => {
|
|
38
|
+
const encoding = value.encoding?.toUpperCase();
|
|
39
|
+
if (value.version !== "1.0" || encoding !== undefined &&
|
|
40
|
+
(!["UTF-8", "UTF-16", "UTF-16LE", "UTF-16BE"].includes(encoding) ||
|
|
41
|
+
limits.expectedEncoding !== undefined && encoding !== limits.expectedEncoding &&
|
|
42
|
+
!(encoding === "UTF-16" && ["UTF-16LE", "UTF-16BE"].includes(limits.expectedEncoding))))
|
|
43
|
+
throw new SyntaxError("Invalid XML: unsupported XML declaration");
|
|
44
|
+
if (describe)
|
|
45
|
+
declaration = prefix.slice(0, prefix.indexOf("?>") + 2);
|
|
46
|
+
prefix = "";
|
|
47
|
+
capturing = false;
|
|
48
|
+
});
|
|
49
|
+
parser.on("opentag", tag => {
|
|
50
|
+
charge("maxNodes");
|
|
51
|
+
bound("maxDepth", stack.length + 1);
|
|
52
|
+
const attrs = Object.values(tag.attributes);
|
|
53
|
+
charge("maxAttributes", attrs.length);
|
|
54
|
+
bound("maxAttributesPerElement", attrs.length);
|
|
55
|
+
const parent = stack.at(-1);
|
|
56
|
+
let namespaces = parent?.namespaces ?? new Map([["xml", "http://www.w3.org/XML/1998/namespace"]]);
|
|
57
|
+
const bindings = Object.entries(tag.ns);
|
|
58
|
+
if (bindings.length) {
|
|
59
|
+
namespaces = new Map(namespaces);
|
|
60
|
+
for (const [name, uri] of bindings)
|
|
61
|
+
namespaces.set(name, uri);
|
|
62
|
+
bound("maxNamespaces", namespaces.size);
|
|
63
|
+
}
|
|
64
|
+
for (const attr of attrs)
|
|
65
|
+
charge("maxTextLength", attr.value.length);
|
|
66
|
+
const name = { name: tag.name, namespace: tag.uri, localName: tag.local };
|
|
67
|
+
limits.onElement?.(name, parent?.element, stack.length + 1);
|
|
68
|
+
const selected = (!selectedCount || streaming?.includeNested === true) && !!streaming?.matches(name, parent?.element, stack.length + 1);
|
|
69
|
+
if (selected && !parent)
|
|
70
|
+
throw new TypeError("XML subtree streaming cannot select the root");
|
|
71
|
+
let before = [];
|
|
72
|
+
if (selected) {
|
|
73
|
+
selectedCount++;
|
|
74
|
+
if (streaming?.captureBefore) {
|
|
75
|
+
before = parent.element.content.splice(0);
|
|
76
|
+
parent.element.children.length = 0;
|
|
77
|
+
parent.element.text = '';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
charge("maxContentNodes", 1 + (retainContent || events ? attrs.length : 0));
|
|
81
|
+
const element = { kind: "element", ...name, children: [], content: [], text: "",
|
|
82
|
+
attributes: describe ? attrs.map(attr => ({ name: attr.name, localName: attr.local, namespace: attr.uri, value: attr.value })) : [],
|
|
83
|
+
namespaces: describe ? namespaces : emptyNamespaces,
|
|
84
|
+
...(!root && describe ? { prolog, epilog, ...(declaration === undefined ? {} : { declaration }) } : {}) };
|
|
85
|
+
if (parent && tree && !selected) {
|
|
86
|
+
parent.element.children.push(element);
|
|
87
|
+
if (retain)
|
|
88
|
+
parent.element.content.push(element);
|
|
89
|
+
}
|
|
90
|
+
else if (!parent)
|
|
91
|
+
root = element;
|
|
92
|
+
stack.push({ element, namespaces, selected, before });
|
|
93
|
+
if (events)
|
|
94
|
+
queued.push({ type: "open", element, parent: parent?.element });
|
|
95
|
+
});
|
|
96
|
+
parser.on("closetag", () => {
|
|
97
|
+
const frame = stack.pop();
|
|
98
|
+
if (events)
|
|
99
|
+
queued.push({ type: "close", element: frame.element, parent: stack.at(-1)?.element });
|
|
100
|
+
if (frame.selected) {
|
|
101
|
+
completed.push({ element: frame.element, parent: stack.at(-1).element, before: frame.before });
|
|
102
|
+
selectedCount--;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
async function drain() {
|
|
106
|
+
for (const entry of completed) {
|
|
107
|
+
await checkpoint?.(0);
|
|
108
|
+
await streaming.consume(entry.element, entry.parent, entry.before);
|
|
109
|
+
}
|
|
110
|
+
completed.length = 0;
|
|
111
|
+
for (const event of queued) {
|
|
112
|
+
await checkpoint?.(0);
|
|
113
|
+
await events(event);
|
|
114
|
+
}
|
|
115
|
+
queued.length = 0;
|
|
116
|
+
}
|
|
117
|
+
function append(content) {
|
|
118
|
+
charge("maxTextLength", content.text.length);
|
|
119
|
+
charge("maxContentNodes");
|
|
120
|
+
const parent = stack.at(-1)?.element;
|
|
121
|
+
if (events)
|
|
122
|
+
queued.push({ type: "content", content, parent });
|
|
123
|
+
if (tree && parent && (content.kind === "text" || content.kind === "cdata"))
|
|
124
|
+
parent.text += content.text;
|
|
125
|
+
if (retain)
|
|
126
|
+
(parent ? parent.content : root ? epilog : prolog).push(content);
|
|
127
|
+
}
|
|
128
|
+
parser.on("text", text => { if (text.length)
|
|
129
|
+
append({ kind: "text", text }); });
|
|
130
|
+
parser.on("cdata", text => append({ kind: "cdata", text }));
|
|
131
|
+
parser.on("comment", text => append({ kind: "comment", text }));
|
|
132
|
+
parser.on("processinginstruction", value => append({ kind: "processing-instruction", target: value.target, text: value.body }));
|
|
133
|
+
let cr = false, first = true;
|
|
134
|
+
function write(text) {
|
|
135
|
+
if (!text)
|
|
136
|
+
return;
|
|
137
|
+
if (first) {
|
|
138
|
+
first = false;
|
|
139
|
+
if (text.charCodeAt(0) === 0xfeff) {
|
|
140
|
+
parser.write(text[0]);
|
|
141
|
+
text = text.slice(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (cr) {
|
|
145
|
+
if (text.startsWith("\n"))
|
|
146
|
+
text = text.slice(1);
|
|
147
|
+
cr = false;
|
|
148
|
+
}
|
|
149
|
+
cr = text.endsWith("\r");
|
|
150
|
+
text = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
|
|
151
|
+
if (capturing) {
|
|
152
|
+
prefix += text;
|
|
153
|
+
if (prefix.length >= 6 && !(prefix.startsWith("<?xml") && " \t\n".includes(prefix[5]))) {
|
|
154
|
+
prefix = "";
|
|
155
|
+
capturing = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
parser.write(text);
|
|
159
|
+
}
|
|
160
|
+
await checkpoint?.(0);
|
|
161
|
+
for await (const text of source) {
|
|
162
|
+
if (typeof text !== "string")
|
|
163
|
+
throw new TypeError("XML producer must yield strings");
|
|
164
|
+
for (let i = 0; i < text.length; i += 512) {
|
|
165
|
+
await checkpoint?.(0);
|
|
166
|
+
write(text.slice(i, i + 512));
|
|
167
|
+
if (completed.length || queued.length)
|
|
168
|
+
await drain();
|
|
169
|
+
await checkpoint?.(Math.min(512, text.length - i));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await checkpoint?.(0);
|
|
173
|
+
parser.close();
|
|
174
|
+
if (completed.length || queued.length)
|
|
175
|
+
await drain();
|
|
176
|
+
if (!root)
|
|
177
|
+
throw new SyntaxError("Invalid XML: incomplete document");
|
|
178
|
+
return root;
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poe-platform/safe-fs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.810",
|
|
4
4
|
"description": "Composable filesystem with a portable core and explicit Node adapters",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -148,6 +148,7 @@
|
|
|
148
148
|
"access": "public"
|
|
149
149
|
},
|
|
150
150
|
"dependencies": {
|
|
151
|
+
"saxes": "^6.0.0",
|
|
151
152
|
"@types/node": "^25.2.2",
|
|
152
153
|
"@noble/hashes": "2.4.0"
|
|
153
154
|
},
|