@poe-platform/safe-fs 0.1.721 → 0.1.723
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 +67 -21
- package/dist/safe-fs/bridge/filesystem.d.ts +4 -0
- package/dist/safe-fs/bridge/filesystem.js +49 -10
- package/dist/safe-fs/bridge/types.d.ts +6 -0
- package/dist/safe-fs/contracts/descriptor.d.ts +5 -0
- package/dist/safe-fs/contracts/filesystem.d.ts +15 -0
- package/dist/safe-fs/contracts/io.d.ts +3 -1
- package/dist/safe-fs/contracts/io.js +48 -16
- package/dist/safe-fs/core.d.ts +2 -1
- package/dist/safe-fs/core.js +1 -1
- package/dist/safe-fs/fs/capabilities.js +6 -3
- package/dist/safe-fs/fs/descriptor.js +9 -2
- package/dist/safe-fs/fs/devices/index.d.ts +1 -0
- package/dist/safe-fs/fs/devices/index.js +24 -6
- package/dist/safe-fs/fs/memory/index.d.ts +2 -0
- package/dist/safe-fs/fs/memory/index.js +95 -4
- package/dist/safe-fs/fs/memory/limits.js +6 -4
- package/dist/safe-fs/fs/mount/index.d.ts +4 -0
- package/dist/safe-fs/fs/mount/index.js +76 -12
- package/dist/safe-fs/fs/object-publication/index.d.ts +4 -0
- package/dist/safe-fs/fs/object-publication/index.js +9 -8
- package/dist/safe-fs/fs/overlay/index.d.ts +4 -2
- package/dist/safe-fs/fs/overlay/index.js +92 -36
- package/dist/safe-fs/fs/quota/index.js +83 -23
- package/dist/safe-fs/fs/readonly/index.js +5 -3
- package/dist/safe-fs/fs/real/index.d.ts +21 -1
- package/dist/safe-fs/fs/real/index.js +266 -22
- package/dist/safe-fs/fs/s3/filesystem.d.ts +8 -0
- package/dist/safe-fs/fs/s3/filesystem.js +59 -12
- package/dist/safe-fs/fs/s3/http/request.d.ts +1 -1
- package/dist/safe-fs/fs/s3/http/request.js +1 -1
- package/dist/safe-fs/fs/s3/http/transport.js +8 -6
- package/dist/safe-fs/fs/s3/http/xml.d.ts +4 -1
- package/dist/safe-fs/fs/s3/http/xml.js +6 -2
- package/dist/safe-fs/fs/s3/manifest-admission.d.ts +8 -0
- package/dist/safe-fs/fs/s3/manifest-admission.js +171 -0
- package/dist/safe-fs/fs/s3/namespace.js +13 -10
- package/dist/safe-fs/fs/scoped.d.ts +1 -0
- package/dist/safe-fs/fs/scoped.js +54 -35
- package/dist/safe-fs/fs/webdav/webdav.d.ts +4 -1
- package/dist/safe-fs/fs/webdav/webdav.js +101 -23
- package/dist/safe-fs/node/filesystem.d.ts +8 -1
- package/dist/safe-fs/node/filesystem.js +7 -1
- package/dist/safe-fs/node/host.d.ts +3 -0
- package/dist/safe-fs/node/host.js +78 -0
- package/dist/safe-fs/node/index.d.ts +2 -1
- package/dist/safe-fs/node/index.js +2 -1
- package/dist/safe-fs/platform/browser.d.ts +1 -0
- package/dist/safe-fs/platform/browser.js +1 -0
- package/dist/safe-fs/platform/node.d.ts +1 -0
- package/dist/safe-fs/platform/node.js +1 -0
- package/dist/safe-fs/python/emscripten.d.ts +1 -0
- package/dist/safe-fs/python/emscripten.js +5 -5
- package/dist/safe-fs/python/filesystem.js +5 -5
- package/dist/safe-fs/python/native.d.ts +1 -0
- package/dist/safe-fs/python/native.js +3 -2
- package/dist/safe-fs/xml.js +32 -21
- package/package.json +1 -1
|
@@ -35,7 +35,11 @@ function decodeEntities(text) {
|
|
|
35
35
|
}
|
|
36
36
|
return result;
|
|
37
37
|
}
|
|
38
|
-
export function parseXml(bytes) {
|
|
38
|
+
export function parseXml(bytes, limits = {}) {
|
|
39
|
+
for (const limit of [limits.maxNodes, limits.maxDepth]) {
|
|
40
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1))
|
|
41
|
+
throw new RangeError("XML limits must be positive safe integers");
|
|
42
|
+
}
|
|
39
43
|
let xml;
|
|
40
44
|
try {
|
|
41
45
|
xml = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
@@ -146,7 +150,7 @@ export function parseXml(bytes) {
|
|
|
146
150
|
position++;
|
|
147
151
|
if (xml[position++] !== ">")
|
|
148
152
|
malformed();
|
|
149
|
-
if (++nodes >
|
|
153
|
+
if (++nodes > (limits.maxNodes ?? Infinity) || stack.length >= (limits.maxDepth ?? Infinity))
|
|
150
154
|
malformed("XML structure limit exceeded");
|
|
151
155
|
const node = { name: tag, text: "", children: [] };
|
|
152
156
|
const parent = stack.at(-1);
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ByteSource } from '../../contracts/io.js';
|
|
2
|
+
/** Admit the fixed manifest grammar before JSON.parse can allocate its graph. */
|
|
3
|
+
export declare function admitManifest(source: ByteSource, options: {
|
|
4
|
+
maxManifestBytes: number;
|
|
5
|
+
maxEntries: number;
|
|
6
|
+
maxBytes: number;
|
|
7
|
+
signal?: AbortSignal | undefined;
|
|
8
|
+
}): ByteSource;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { FsError } from '../../contracts/errors.js';
|
|
2
|
+
import { readBytes } from '../../contracts/io.js';
|
|
3
|
+
/** Admit the fixed manifest grammar before JSON.parse can allocate its graph. */
|
|
4
|
+
export async function* admitManifest(source, options) {
|
|
5
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
6
|
+
const frames = [];
|
|
7
|
+
let started = false;
|
|
8
|
+
let done = false;
|
|
9
|
+
let token = '';
|
|
10
|
+
let quoted = false;
|
|
11
|
+
let escaped = false;
|
|
12
|
+
let entries = 0;
|
|
13
|
+
let bytes = 0;
|
|
14
|
+
let wireBytes = 0;
|
|
15
|
+
let allocation = 0;
|
|
16
|
+
// Conservative accounting for graph storage, array growth, and validation
|
|
17
|
+
// tables. This is an admission estimate, not a process-RSS guarantee.
|
|
18
|
+
const allocationLimit = options.maxManifestBytes * 4;
|
|
19
|
+
const invalid = () => { throw new FsError('EIO', { message: 'Invalid S3 namespace structure' }); };
|
|
20
|
+
const reserve = (amount) => {
|
|
21
|
+
if (amount > allocationLimit - allocation)
|
|
22
|
+
throw new FsError('EFBIG', { message: 'S3 namespace graph exceeds allocation budget' });
|
|
23
|
+
allocation += amount;
|
|
24
|
+
};
|
|
25
|
+
const open = (shape) => {
|
|
26
|
+
reserve(256);
|
|
27
|
+
frames.push({ shape, state: shape === 'bytes' ? 'valueOrEnd' : 'keyOrEnd', key: '', keys: new Set() });
|
|
28
|
+
};
|
|
29
|
+
const scalar = () => {
|
|
30
|
+
const frame = frames.at(-1) ?? invalid();
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(token);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
invalid();
|
|
37
|
+
}
|
|
38
|
+
token = '';
|
|
39
|
+
if (frame.state === 'key' || frame.state === 'keyOrEnd') {
|
|
40
|
+
if (typeof value !== 'string' || frame.keys.has(value))
|
|
41
|
+
invalid();
|
|
42
|
+
const key = value;
|
|
43
|
+
if (frame.shape === 'nodes') {
|
|
44
|
+
if (key.length > 4096)
|
|
45
|
+
invalid();
|
|
46
|
+
if (++entries > options.maxEntries)
|
|
47
|
+
throw new FsError('ENOSPC');
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const fields = frame.shape === 'namespace' ? ['version', 'identity', 'nextInode', 'nodes']
|
|
51
|
+
: ['ino', 'revision', 'type', 'mode', 'time', 'bytes'];
|
|
52
|
+
if (!fields.includes(key))
|
|
53
|
+
invalid();
|
|
54
|
+
}
|
|
55
|
+
reserve(64 + key.length * 2);
|
|
56
|
+
frame.keys.add(key);
|
|
57
|
+
frame.key = key;
|
|
58
|
+
frame.state = 'colon';
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (frame.state !== 'value' && frame.state !== 'valueOrEnd')
|
|
62
|
+
invalid();
|
|
63
|
+
if (frame.shape === 'bytes') {
|
|
64
|
+
if (!Number.isInteger(value) || value < 0 || value > 255)
|
|
65
|
+
invalid();
|
|
66
|
+
if (++bytes > options.maxBytes)
|
|
67
|
+
throw new FsError('ENOSPC');
|
|
68
|
+
reserve(16);
|
|
69
|
+
}
|
|
70
|
+
else if (frame.shape === 'nodes' || frame.key === 'nodes' || frame.key === 'bytes')
|
|
71
|
+
invalid();
|
|
72
|
+
else if (frame.key === 'identity' || frame.key === 'type') {
|
|
73
|
+
if (typeof value !== 'string' || (frame.key === 'identity' ? value.length < 1 || value.length > 128 : !['file', 'directory'].includes(value)))
|
|
74
|
+
invalid();
|
|
75
|
+
reserve(value.length * 2);
|
|
76
|
+
}
|
|
77
|
+
else if (typeof value !== 'number' || !Number.isFinite(value))
|
|
78
|
+
invalid();
|
|
79
|
+
frame.state = 'commaOrEnd';
|
|
80
|
+
};
|
|
81
|
+
const feed = (text) => {
|
|
82
|
+
for (const character of text) {
|
|
83
|
+
if (token) {
|
|
84
|
+
if (quoted) {
|
|
85
|
+
token += character;
|
|
86
|
+
if (token.length > 4096 * 6 + 2)
|
|
87
|
+
invalid();
|
|
88
|
+
if (escaped)
|
|
89
|
+
escaped = false;
|
|
90
|
+
else if (character === '\\')
|
|
91
|
+
escaped = true;
|
|
92
|
+
else if (character === '"') {
|
|
93
|
+
quoted = false;
|
|
94
|
+
scalar();
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (![' ', '\n', '\r', '\t', ',', '}', ']'].includes(character)) {
|
|
99
|
+
if (token.length >= 64)
|
|
100
|
+
invalid();
|
|
101
|
+
token += character;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
scalar();
|
|
105
|
+
}
|
|
106
|
+
if ([' ', '\n', '\r', '\t'].includes(character))
|
|
107
|
+
continue;
|
|
108
|
+
if (done)
|
|
109
|
+
invalid();
|
|
110
|
+
if (!started) {
|
|
111
|
+
if (character !== '{')
|
|
112
|
+
invalid();
|
|
113
|
+
started = true;
|
|
114
|
+
open('namespace');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const frame = frames.at(-1) ?? invalid();
|
|
118
|
+
if (character === '}' || character === ']') {
|
|
119
|
+
if ((character === ']') !== (frame.shape === 'bytes')
|
|
120
|
+
|| !['commaOrEnd', 'keyOrEnd', 'valueOrEnd'].includes(frame.state))
|
|
121
|
+
invalid();
|
|
122
|
+
if (frame.shape === 'namespace' && frame.keys.size !== 4 || frame.shape === 'node' && frame.keys.size !== 6)
|
|
123
|
+
invalid();
|
|
124
|
+
frames.pop();
|
|
125
|
+
if (!frames.length)
|
|
126
|
+
done = true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (frame.state === 'commaOrEnd') {
|
|
130
|
+
if (character !== ',')
|
|
131
|
+
invalid();
|
|
132
|
+
frame.state = frame.shape === 'bytes' ? 'value' : 'key';
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (frame.state === 'colon') {
|
|
136
|
+
if (character !== ':')
|
|
137
|
+
invalid();
|
|
138
|
+
frame.state = 'value';
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (frame.state === 'key' || frame.state === 'keyOrEnd') {
|
|
142
|
+
if (character !== '"')
|
|
143
|
+
invalid();
|
|
144
|
+
}
|
|
145
|
+
else if (character === '{' || character === '[') {
|
|
146
|
+
const shape = frame.shape === 'namespace' && frame.key === 'nodes' ? 'nodes'
|
|
147
|
+
: frame.shape === 'nodes' ? 'node' : frame.shape === 'node' && frame.key === 'bytes' ? 'bytes' : invalid();
|
|
148
|
+
if ((character === '[') !== (shape === 'bytes'))
|
|
149
|
+
invalid();
|
|
150
|
+
frame.state = 'commaOrEnd';
|
|
151
|
+
open(shape);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
else if (frame.shape === 'nodes' || frame.key === 'nodes' || frame.key === 'bytes')
|
|
155
|
+
invalid();
|
|
156
|
+
quoted = character === '"';
|
|
157
|
+
escaped = false;
|
|
158
|
+
token = character;
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
for await (const chunk of readBytes(source, options.signal)) {
|
|
162
|
+
if (chunk.byteLength > options.maxManifestBytes - wireBytes)
|
|
163
|
+
throw new FsError('EFBIG');
|
|
164
|
+
wireBytes += chunk.byteLength;
|
|
165
|
+
feed(decoder.decode(chunk, { stream: true }));
|
|
166
|
+
yield chunk;
|
|
167
|
+
}
|
|
168
|
+
feed(decoder.decode());
|
|
169
|
+
if (!done || token || frames.length)
|
|
170
|
+
invalid();
|
|
171
|
+
}
|
|
@@ -2,8 +2,11 @@ import { FsError } from '../../contracts/errors.js';
|
|
|
2
2
|
import { collectBytes } from '../../contracts/io.js';
|
|
3
3
|
import { dirname, normalizePath, validatePath } from '../../contracts/virtual-path.js';
|
|
4
4
|
import { withObjectFileDescriptors } from '../object-publication/index.js';
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { admitManifest } from './manifest-admission.js';
|
|
6
|
+
function limit(value) {
|
|
7
|
+
if (value === undefined)
|
|
8
|
+
return Infinity;
|
|
9
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
7
10
|
throw new RangeError('Invalid S3 namespace limit');
|
|
8
11
|
return value;
|
|
9
12
|
}
|
|
@@ -13,14 +16,14 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
13
16
|
throw new FsError('ENOTSUP', { message: 'S3 namespaces require verified conditional PUT and bounded streaming reads' });
|
|
14
17
|
if (![options.bucket, options.key].every(value => typeof value === 'string' && value.length > 0 && !value.includes('\0')))
|
|
15
18
|
throw new TypeError('An explicit S3 bucket and manifest key are required');
|
|
16
|
-
const maxBytes = limit(options.maxBytes
|
|
17
|
-
const maxEntries = limit(options.maxEntries
|
|
18
|
-
const maxManifestBytes = limit(options.maxManifestBytes ??
|
|
19
|
-
const maxAttempts = limit(options.maxAttempts
|
|
20
|
-
const descriptorOptions =
|
|
19
|
+
const maxBytes = limit(options.maxBytes);
|
|
20
|
+
const maxEntries = limit(options.maxEntries);
|
|
21
|
+
const maxManifestBytes = limit(options.maxManifestBytes ?? 4 * 1024 * 1024);
|
|
22
|
+
const maxAttempts = limit(options.maxAttempts);
|
|
23
|
+
const descriptorOptions = options;
|
|
21
24
|
for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
|
|
22
25
|
if (descriptorOptions[key] !== undefined)
|
|
23
|
-
limit(descriptorOptions[key]
|
|
26
|
+
limit(descriptorOptions[key]);
|
|
24
27
|
}
|
|
25
28
|
const object = Object.freeze({ Bucket: options.bucket, Key: options.key });
|
|
26
29
|
const scope = Object.freeze({});
|
|
@@ -76,7 +79,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
76
79
|
forwarded.signal?.throwIfAborted();
|
|
77
80
|
try {
|
|
78
81
|
const response = await client.getObjectStream(object, forwarded.signal ? { abortSignal: forwarded.signal } : {});
|
|
79
|
-
const body = await collectBytes(response.Body, { ...forwarded, maxBytes: maxManifestBytes });
|
|
82
|
+
const body = await collectBytes(admitManifest(response.Body, { maxManifestBytes, maxEntries, maxBytes, ...(forwarded.signal ? { signal: forwarded.signal } : {}) }), { ...forwarded, maxBytes: maxManifestBytes });
|
|
80
83
|
if (typeof response.ETag !== 'string' || !response.ETag || response.ETag.startsWith('W/'))
|
|
81
84
|
throw new FsError('EIO', { message: 'S3 namespace requires a strong object validator' });
|
|
82
85
|
let decoded;
|
|
@@ -378,7 +381,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
378
381
|
return version(node);
|
|
379
382
|
},
|
|
380
383
|
async publish(input, expectedRevision, source, forwarded) {
|
|
381
|
-
const bytes = await collectBytes(source, { ...forwarded, maxBytes });
|
|
384
|
+
const bytes = await collectBytes(source, { ...forwarded, ...(maxBytes === Infinity ? {} : { maxBytes: maxBytes }) });
|
|
382
385
|
if (bytes.length !== forwarded.size)
|
|
383
386
|
throw new FsError('EIO');
|
|
384
387
|
const published = await mutate(forwarded, value => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
|
|
2
2
|
export declare function scopeFileSystem(filesystem: FileSystem, charge: () => void, signal: AbortSignal, cleanupCharge?: () => void, options?: {
|
|
3
3
|
readonly preserveDescriptorWriteReceipt?: boolean;
|
|
4
|
+
readonly creationMask?: number;
|
|
4
5
|
}): FileSystem;
|
|
5
6
|
export interface RetainedFileSystemCleanupView {
|
|
6
7
|
readonly removeFileConditional?: NonNullable<FileSystem["removeFileConditional"]>;
|
|
@@ -5,13 +5,26 @@ import { openRetainedResizeFile, retainedResizeCapabilities, ownedMutationCapabi
|
|
|
5
5
|
const originals = new WeakMap();
|
|
6
6
|
const operations = new Set([
|
|
7
7
|
"publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory",
|
|
8
|
-
"access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
|
|
8
|
+
"confineExtraction", "access", "appendFile", "canonicalizeMissingTarget", "capabilitiesFor", "chmod", "compareEntry",
|
|
9
9
|
"copyFile", "link", "lstat", "mkdir", "openReadFile", "openResizeFile", "readFile", "readStream", "readdir",
|
|
10
10
|
"readlink", "realpath", "rename", "resizeFile", "rm", "rmdir", "unlink", "stat", "symlink", "truncate", "utimes",
|
|
11
11
|
"writeFile", "writeStream",
|
|
12
12
|
]);
|
|
13
13
|
export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = charge, options = {}) {
|
|
14
14
|
const original = originals.get(filesystem)?.filesystem ?? filesystem;
|
|
15
|
+
const creationMask = options.creationMask ?? originals.get(filesystem)?.creationMask;
|
|
16
|
+
if (creationMask !== undefined && (!Number.isInteger(creationMask) || creationMask < 0 || creationMask > 0o777))
|
|
17
|
+
throw new RangeError("creationMask must be a permission mask between 0 and 0777");
|
|
18
|
+
const creationOptions = (settings, directory = false) => {
|
|
19
|
+
// Explicit directory modes (mkdir -m) are independent of the shell mask.
|
|
20
|
+
// Modes remain advisory on adapters that do not enforce permissions.
|
|
21
|
+
if (creationMask === undefined || directory && settings.mode !== undefined)
|
|
22
|
+
return settings;
|
|
23
|
+
const mode = settings.mode ?? (directory ? 0o777 : 0o666);
|
|
24
|
+
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0o7777)
|
|
25
|
+
throw new FsError("EINVAL");
|
|
26
|
+
return { ...settings, mode: mode & ~creationMask };
|
|
27
|
+
};
|
|
15
28
|
const methods = new Map();
|
|
16
29
|
const assertOpen = (options) => {
|
|
17
30
|
signal.throwIfAborted();
|
|
@@ -199,6 +212,10 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
199
212
|
const options = args.at(-1);
|
|
200
213
|
admit(options && typeof options === "object" && "signal" in options ? options : undefined);
|
|
201
214
|
}
|
|
215
|
+
if (["writeFile", "appendFile", "writeStream", "mkdir"].includes(String(property))) {
|
|
216
|
+
const index = property === "mkdir" ? 1 : 2;
|
|
217
|
+
args[index] = creationOptions((args[index] ?? {}), property === "mkdir");
|
|
218
|
+
}
|
|
202
219
|
if (["publishFileConditional", "removeEntryConditional", "removeTreeConditional", "writeFileConditional", "removeFileConditional", "createStagedFile", "publishStagedFile", "removeStagedFile", "prepareDirectory"].includes(String(property)))
|
|
203
220
|
return (async () => {
|
|
204
221
|
const path = typeof args[0] === "string" ? args[0] : args[0].directory.path;
|
|
@@ -229,43 +246,45 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
229
246
|
})();
|
|
230
247
|
return Reflect.apply(method, original, args);
|
|
231
248
|
};
|
|
232
|
-
const scoped = property === "
|
|
233
|
-
? async (
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
let descriptor;
|
|
237
|
-
try {
|
|
238
|
-
descriptor = await Reflect.apply(method, original, [path, selected]);
|
|
239
|
-
assertOpen(options);
|
|
240
|
-
return wrapDescriptor(descriptor);
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
if (descriptor)
|
|
244
|
-
await finishCleanup(() => descriptor.close(), true);
|
|
245
|
-
assertOpen(options);
|
|
246
|
-
throw error;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
: property === "openResizeFile"
|
|
250
|
-
? async (path, options = {}) => {
|
|
249
|
+
const scoped = property === "confineExtraction"
|
|
250
|
+
? async (...args) => scopeFileSystem(await dispatch(...args), charge, signal, cleanupCharge, options)
|
|
251
|
+
: property === "open"
|
|
252
|
+
? async (path, options) => {
|
|
251
253
|
admit(options);
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
const selected = resizeOptions(creationOptions(options));
|
|
255
|
+
let descriptor;
|
|
256
|
+
try {
|
|
257
|
+
descriptor = await Reflect.apply(method, original, [path, selected]);
|
|
258
|
+
assertOpen(options);
|
|
259
|
+
return wrapDescriptor(descriptor);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (descriptor)
|
|
263
|
+
await finishCleanup(() => descriptor.close(), true);
|
|
264
|
+
assertOpen(options);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
254
267
|
}
|
|
255
|
-
: property === "
|
|
256
|
-
? async (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
268
|
+
: property === "openResizeFile"
|
|
269
|
+
? async (path, options = {}) => {
|
|
270
|
+
admit(options);
|
|
271
|
+
assertOpen(options);
|
|
272
|
+
return wrapResizeHandle(await openRetainedResizeFile(original, path, resizeOptions(creationOptions(options))));
|
|
273
|
+
}
|
|
274
|
+
: property === "capabilitiesFor"
|
|
275
|
+
? async (...args) => ownedMutationCapabilities(original, retainedResizeCapabilities(original, await dispatch(...args)))
|
|
276
|
+
: property === "openReadFile"
|
|
277
|
+
? async (...args) => wrapHandle(await dispatch(...args))
|
|
278
|
+
: property === "readStream"
|
|
279
|
+
? (...args) => wrapStream(dispatch(...args), args[1])
|
|
280
|
+
: operations.has(property) && property !== "canonicalizeMissingTarget"
|
|
281
|
+
? async (...args) => dispatch(...args)
|
|
282
|
+
: dispatch;
|
|
264
283
|
methods.set(property, { original: method, scoped });
|
|
265
284
|
return scoped;
|
|
266
285
|
},
|
|
267
286
|
});
|
|
268
|
-
originals.set(view, { filesystem: original, signal, cleanupCharge });
|
|
287
|
+
originals.set(view, { filesystem: original, signal, cleanupCharge, creationMask });
|
|
269
288
|
registerEntryView(view, async (path, options) => {
|
|
270
289
|
assertOpen(options);
|
|
271
290
|
return { filesystem: original, path };
|
|
@@ -273,9 +292,9 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
|
|
|
273
292
|
return view;
|
|
274
293
|
}
|
|
275
294
|
export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
|
|
276
|
-
const maximum = options?.maxOperations
|
|
277
|
-
if (!Number.isSafeInteger(maximum) || maximum < 0
|
|
278
|
-
throw new RangeError("cleanup maxOperations must be
|
|
295
|
+
const maximum = options?.maxOperations ?? Infinity;
|
|
296
|
+
if (options?.maxOperations !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
|
|
297
|
+
throw new RangeError("cleanup maxOperations must be a nonnegative safe integer");
|
|
279
298
|
if (typeof cleanupCallback !== "function")
|
|
280
299
|
throw new TypeError("cleanup callback must be a function");
|
|
281
300
|
const scope = originals.get(filesystem);
|
|
@@ -24,10 +24,12 @@ export interface WebDavFileSystemOptions {
|
|
|
24
24
|
readonly fetch: WebDavFetch;
|
|
25
25
|
readonly requestStreamSupport?: "native" | boolean;
|
|
26
26
|
readonly headers?: Readonly<Record<string, string>>;
|
|
27
|
+
/** Response ceiling; defaults to 16 MiB. Hosts must budget for concurrent reads. */
|
|
27
28
|
readonly maxResponseBytes?: number;
|
|
29
|
+
/** Metadata response ceiling; defaults to 1 MiB before decoding/parsing. */
|
|
28
30
|
readonly maxXmlBytes?: number;
|
|
29
31
|
readonly maxEntries?: number;
|
|
30
|
-
/** Per-request and aggregate stat/write-preflight walk timeout;
|
|
32
|
+
/** Per-request and aggregate stat/write-preflight walk timeout; unlimited unless configured. */
|
|
31
33
|
readonly timeoutMs?: number;
|
|
32
34
|
readonly overwritePolicy?: "lock" | "etag";
|
|
33
35
|
readonly atomicEmptyDirectory?: WebDavAtomicEmptyDirectoryBinding;
|
|
@@ -91,6 +93,7 @@ export declare class WebDavFileSystem implements FileSystem {
|
|
|
91
93
|
private request;
|
|
92
94
|
private requestStream;
|
|
93
95
|
private bytes;
|
|
96
|
+
private responseLength;
|
|
94
97
|
private bodyChunks;
|
|
95
98
|
private xml;
|
|
96
99
|
private multistatus;
|
|
@@ -6,6 +6,76 @@ import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admissi
|
|
|
6
6
|
import { assertCallbackAuthorityAllowed, compareEntries, registerEntryAuthority } from "../mount/comparison.js";
|
|
7
7
|
import { compareWebDavResources, ownedResponseIdentifier, recordOwnedResourceStat, registerResourceQuery, resourceIdentifier } from "./resource-id.js";
|
|
8
8
|
const timestampNamespace = "urn:virtual-bash:metadata";
|
|
9
|
+
const maxTimestampPropertyBytes = 4096;
|
|
10
|
+
/** Admit only five scalar members before native JSON parsing can allocate a graph. */
|
|
11
|
+
function preflightTimestamps(property) {
|
|
12
|
+
// Check the untrimmed text first; counting UTF-8 bytes does not allocate an encoded copy.
|
|
13
|
+
let bytes = 0;
|
|
14
|
+
for (const point of property.text) {
|
|
15
|
+
const code = point.codePointAt(0);
|
|
16
|
+
bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
|
|
17
|
+
if (bytes > maxTimestampPropertyBytes)
|
|
18
|
+
throw new Error("timestamp property exceeds 4KiB limit");
|
|
19
|
+
}
|
|
20
|
+
const text = scalar(property);
|
|
21
|
+
let offset = 0;
|
|
22
|
+
const invalid = () => { throw new Error("invalid timestamp property structure"); };
|
|
23
|
+
const whitespace = () => {
|
|
24
|
+
while (" \t\r\n".includes(text[offset] ?? "\0"))
|
|
25
|
+
offset++;
|
|
26
|
+
};
|
|
27
|
+
const stringEnd = () => {
|
|
28
|
+
if (text[offset++] !== '"')
|
|
29
|
+
invalid();
|
|
30
|
+
while (offset < text.length) {
|
|
31
|
+
const char = text[offset++];
|
|
32
|
+
if (char === '"')
|
|
33
|
+
return;
|
|
34
|
+
if (char === "\\")
|
|
35
|
+
offset++;
|
|
36
|
+
}
|
|
37
|
+
invalid();
|
|
38
|
+
};
|
|
39
|
+
if (text[offset++] !== "{")
|
|
40
|
+
invalid();
|
|
41
|
+
const fields = new Set();
|
|
42
|
+
while (true) {
|
|
43
|
+
whitespace();
|
|
44
|
+
const start = offset;
|
|
45
|
+
stringEnd();
|
|
46
|
+
const field = JSON.parse(text.slice(start, offset));
|
|
47
|
+
if (typeof field !== "string" || !["version", "etag", "type", "atimeMs", "mtimeMs"].includes(field)
|
|
48
|
+
|| fields.has(field) || fields.size >= 5)
|
|
49
|
+
return invalid();
|
|
50
|
+
fields.add(field);
|
|
51
|
+
whitespace();
|
|
52
|
+
if (text[offset++] !== ":")
|
|
53
|
+
invalid();
|
|
54
|
+
whitespace();
|
|
55
|
+
if (text[offset] === '"')
|
|
56
|
+
stringEnd();
|
|
57
|
+
else {
|
|
58
|
+
const start = offset;
|
|
59
|
+
while (offset < text.length && text[offset] !== "," && text[offset] !== "}") {
|
|
60
|
+
if ('{[:"\\]'.includes(text[offset]))
|
|
61
|
+
invalid();
|
|
62
|
+
offset++;
|
|
63
|
+
}
|
|
64
|
+
if (offset === start)
|
|
65
|
+
invalid();
|
|
66
|
+
}
|
|
67
|
+
whitespace();
|
|
68
|
+
const delimiter = text[offset++];
|
|
69
|
+
if (delimiter === "}")
|
|
70
|
+
break;
|
|
71
|
+
if (delimiter !== ",")
|
|
72
|
+
invalid();
|
|
73
|
+
}
|
|
74
|
+
whitespace();
|
|
75
|
+
if (offset !== text.length || fields.size !== 5)
|
|
76
|
+
invalid();
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
9
79
|
const propfindBody = '<?xml version="1.0" encoding="utf-8"?>'
|
|
10
80
|
+ '<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/><d:getcontentlength/>'
|
|
11
81
|
+ `<d:getlastmodified/><d:creationdate/><d:getetag/><v:timestamps xmlns:v="${timestampNamespace}"/></d:prop></d:propfind>`;
|
|
@@ -24,7 +94,7 @@ function positive(value, name, zero = false) {
|
|
|
24
94
|
}
|
|
25
95
|
function createRequestTimeout(timeoutMs) {
|
|
26
96
|
const controller = new AbortController();
|
|
27
|
-
const timer = setTimeout(() => {
|
|
97
|
+
const timer = timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
28
98
|
controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError"));
|
|
29
99
|
}, timeoutMs);
|
|
30
100
|
if (typeof timer === "object" && timer !== null) {
|
|
@@ -216,15 +286,15 @@ export class WebDavFileSystem {
|
|
|
216
286
|
removeDirectory: this.atomicEmptyDirectory !== undefined,
|
|
217
287
|
streamingAppend: this.requestStreamSupport !== false,
|
|
218
288
|
});
|
|
219
|
-
this.maxResponseBytes =
|
|
220
|
-
this.maxXmlBytes =
|
|
221
|
-
this.maxEntries = positive(options.maxEntries
|
|
222
|
-
this.timeoutMs = positive(options.timeoutMs
|
|
289
|
+
this.maxResponseBytes = options.maxResponseBytes === undefined ? 16 * 1024 * 1024 : positive(options.maxResponseBytes, "maxResponseBytes");
|
|
290
|
+
this.maxXmlBytes = options.maxXmlBytes === undefined ? 1024 * 1024 : positive(options.maxXmlBytes, "maxXmlBytes");
|
|
291
|
+
this.maxEntries = options.maxEntries === undefined ? Infinity : positive(options.maxEntries, "maxEntries");
|
|
292
|
+
this.timeoutMs = options.timeoutMs === undefined ? undefined : positive(options.timeoutMs, "timeoutMs");
|
|
223
293
|
this.overwritePolicy = options.overwritePolicy ?? "lock";
|
|
224
294
|
this.configuredComparison = options.compareEntry !== undefined;
|
|
225
295
|
if (!["lock", "etag"].includes(this.overwritePolicy))
|
|
226
296
|
throw new FsError("EINVAL", { message: "invalid overwritePolicy" });
|
|
227
|
-
if (this.timeoutMs > 2_147_483_647)
|
|
297
|
+
if (this.timeoutMs !== undefined && this.timeoutMs > 2_147_483_647)
|
|
228
298
|
throw new FsError("EINVAL", { message: "timeoutMs exceeds timer range" });
|
|
229
299
|
registerResourceQuery(this, (path, settings) => this.resourceId(path, settings), originalWebDavComparison, options.compareEntry);
|
|
230
300
|
registerEntryAuthority(this, compareWebDavResources);
|
|
@@ -424,29 +494,37 @@ export class WebDavFileSystem {
|
|
|
424
494
|
}
|
|
425
495
|
}
|
|
426
496
|
async bytes(response, limit, signal) {
|
|
427
|
-
const
|
|
497
|
+
const expected = this.responseLength(response, limit);
|
|
498
|
+
let data = new Uint8Array(expected ?? 0);
|
|
428
499
|
let size = 0;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
size
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
500
|
+
// Consume borrowed chunks immediately; only streaming callers need owned copies.
|
|
501
|
+
for await (const chunk of this.bodyChunks(response, limit, signal, false)) {
|
|
502
|
+
const nextSize = size + chunk.byteLength;
|
|
503
|
+
if (nextSize > data.byteLength) {
|
|
504
|
+
const grown = new Uint8Array(Math.min(limit, Math.max(nextSize, data.byteLength * 2)));
|
|
505
|
+
grown.set(data);
|
|
506
|
+
data = grown;
|
|
507
|
+
}
|
|
508
|
+
data.set(chunk, size);
|
|
509
|
+
size = nextSize;
|
|
438
510
|
}
|
|
439
|
-
|
|
511
|
+
// A view avoids a second full allocation when the length was unknown.
|
|
512
|
+
return data.subarray(0, size);
|
|
440
513
|
}
|
|
441
|
-
|
|
514
|
+
responseLength(response, limit) {
|
|
442
515
|
const length = response.headers.get("content-length");
|
|
443
|
-
if (length !== null && (
|
|
516
|
+
if (length !== null && (!length.length || [...length].some(char => char < "0" || char > "9")
|
|
517
|
+
|| !Number.isSafeInteger(Number(length)))) {
|
|
444
518
|
fail("EIO", "webdav", "", "invalid response Content-Length");
|
|
445
519
|
}
|
|
446
520
|
const encoding = response.headers.get("content-encoding");
|
|
447
521
|
const expected = length !== null && (!encoding || encoding.toLowerCase() === "identity") ? Number(length) : undefined;
|
|
448
522
|
if (expected !== undefined && expected > limit)
|
|
449
523
|
fail("EFBIG", "webdav", "", "response exceeds byte limit");
|
|
524
|
+
return expected;
|
|
525
|
+
}
|
|
526
|
+
async *bodyChunks(response, limit, signal, copy = true) {
|
|
527
|
+
const expected = this.responseLength(response, limit);
|
|
450
528
|
if (!response.body) {
|
|
451
529
|
if (expected !== undefined && expected !== 0)
|
|
452
530
|
fail("EIO", "webdav", "", "response body length differs from Content-Length");
|
|
@@ -468,7 +546,7 @@ export class WebDavFileSystem {
|
|
|
468
546
|
fail("EFBIG", "webdav", "", "response exceeds byte limit");
|
|
469
547
|
if (expected !== undefined && size > expected)
|
|
470
548
|
fail("EIO", "webdav", "", "response body length differs from Content-Length");
|
|
471
|
-
yield new Uint8Array(result.value);
|
|
549
|
+
yield copy ? new Uint8Array(result.value) : result.value;
|
|
472
550
|
}
|
|
473
551
|
if (expected !== undefined && size !== expected)
|
|
474
552
|
fail("EIO", "webdav", "", "response body length differs from Content-Length");
|
|
@@ -492,7 +570,7 @@ export class WebDavFileSystem {
|
|
|
492
570
|
}
|
|
493
571
|
let root;
|
|
494
572
|
try {
|
|
495
|
-
root = await this.xml(response, signal, this.maxEntries);
|
|
573
|
+
root = await this.xml(response, signal, this.maxEntries === Infinity ? undefined : this.maxEntries);
|
|
496
574
|
}
|
|
497
575
|
catch (error) {
|
|
498
576
|
if (error instanceof XmlResponseLimitError)
|
|
@@ -604,7 +682,7 @@ export class WebDavFileSystem {
|
|
|
604
682
|
let mtimeMs = date("getlastmodified") ?? 0;
|
|
605
683
|
let atimeMs = 0;
|
|
606
684
|
if (timestampProperty) {
|
|
607
|
-
const timestamps = JSON.parse(
|
|
685
|
+
const timestamps = JSON.parse(preflightTimestamps(timestampProperty));
|
|
608
686
|
if (typeof timestamps !== "object" || timestamps === null
|
|
609
687
|
|| !("version" in timestamps) || timestamps.version !== 1
|
|
610
688
|
|| !("etag" in timestamps) || typeof timestamps.etag !== "string" || !/^"[\x21\x23-\x7e\x80-\xff]*"$/.test(timestamps.etag)
|
|
@@ -762,7 +840,7 @@ export class WebDavFileSystem {
|
|
|
762
840
|
}
|
|
763
841
|
async readFile(path, options = {}) {
|
|
764
842
|
const normalized = normalize(path);
|
|
765
|
-
const limit = Math.min(this.maxResponseBytes,
|
|
843
|
+
const limit = Math.min(this.maxResponseBytes, options.maxBytes === undefined ? this.maxResponseBytes : positive(options.maxBytes, "maxBytes", true));
|
|
766
844
|
if ((await this.stat(path, options)).type === "directory")
|
|
767
845
|
fail("EISDIR", "readFile", path);
|
|
768
846
|
return this.request("GET", normalized, options, { headers: { "Accept-Encoding": "identity" } }, async (response, signal) => {
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import type * as FsPromises from "node:fs/promises";
|
|
2
2
|
import type { FileSystem, FsOptions } from "../contracts/filesystem.js";
|
|
3
|
-
export type NodeFsImplementation = Pick<typeof FsPromises, "access" | "appendFile" | "chmod" | "copyFile" | "cp" | "link" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "rename" | "rm" | "rmdir" | "stat" | "symlink" | "truncate" | "utimes" | "writeFile">;
|
|
3
|
+
export type NodeFsImplementation = Pick<typeof FsPromises, "access" | "appendFile" | "chmod" | "copyFile" | "cp" | "link" | "lstat" | "mkdir" | "mkdtemp" | "readFile" | "readdir" | "readlink" | "realpath" | "unlink" | "rename" | "rm" | "rmdir" | "stat" | "symlink" | "truncate" | "utimes" | "writeFile">;
|
|
4
4
|
export interface NodeFsBridgeOptions {
|
|
5
5
|
readonly cwd?: string;
|
|
6
|
+
/** Optional confinement boundary; defaults to cwd. */
|
|
7
|
+
readonly root?: string;
|
|
6
8
|
readonly signal?: AbortSignal;
|
|
9
|
+
/** Trusted per-read backend byte cap, enforced before copying or decoding. */
|
|
10
|
+
readonly readFileMaxBytes?: number;
|
|
11
|
+
/** Reserve host resources; release after decoding and actual backend settlement, including cancellation. */
|
|
12
|
+
readonly reserveReadFile?: () => () => void;
|
|
7
13
|
}
|
|
8
14
|
export interface NodeFsBridgeFileSystem extends FileSystem {
|
|
9
15
|
rmdir?(path: string, options?: FsOptions): Promise<void>;
|
|
10
16
|
}
|
|
17
|
+
export declare function getNodeFsBridgeProvider(bridge: object): FileSystem | undefined;
|
|
11
18
|
export declare function createNodeFsBridge(fs: FileSystem, options?: NodeFsBridgeOptions): NodeFsImplementation;
|