@poe-platform/safe-fs 0.1.727 → 0.1.728
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/dist/safe-fs/contracts/errors.js +8 -1
- package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
- package/dist/safe-fs/contracts/io.js +183 -27
- package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
- package/dist/safe-fs/contracts/virtual-path.js +20 -1
- package/dist/safe-fs/core.d.ts +1 -0
- package/dist/safe-fs/core.js +1 -0
- package/dist/safe-fs/fs/capabilities.d.ts +1 -1
- package/dist/safe-fs/fs/capabilities.js +59 -21
- package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
- package/dist/safe-fs/fs/conditional-chmod.js +19 -0
- package/dist/safe-fs/fs/descriptor.js +51 -25
- package/dist/safe-fs/fs/devices/index.d.ts +7 -2
- package/dist/safe-fs/fs/devices/index.js +210 -29
- package/dist/safe-fs/fs/devices/path.d.ts +3 -1
- package/dist/safe-fs/fs/devices/path.js +70 -12
- package/dist/safe-fs/fs/memory/index.d.ts +34 -48
- package/dist/safe-fs/fs/memory/index.js +1150 -235
- package/dist/safe-fs/fs/memory/ledger.d.ts +2 -0
- package/dist/safe-fs/fs/memory/ledger.js +15 -3
- package/dist/safe-fs/fs/memory/missing-target.js +21 -1
- package/dist/safe-fs/fs/mount/index.d.ts +11 -3
- package/dist/safe-fs/fs/mount/index.js +326 -15
- package/dist/safe-fs/fs/object-publication/index.js +79 -3
- package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
- package/dist/safe-fs/fs/overlay/index.js +22 -2
- package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
- package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
- package/dist/safe-fs/fs/quota/index.js +1 -1
- package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
- package/dist/safe-fs/fs/readonly/index.js +13 -0
- package/dist/safe-fs/fs/real/index.d.ts +6 -2
- package/dist/safe-fs/fs/real/index.js +78 -8
- package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
- package/dist/safe-fs/fs/s3/filesystem.js +48 -16
- package/dist/safe-fs/fs/s3/http/transport.js +20 -3
- package/dist/safe-fs/fs/s3/mock.js +19 -2
- package/dist/safe-fs/fs/s3/namespace.js +8 -2
- package/dist/safe-fs/fs/s3/registry.js +2 -0
- package/dist/safe-fs/fs/scoped.d.ts +1 -0
- package/dist/safe-fs/fs/scoped.js +261 -74
- package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
- package/dist/safe-fs/fs/staging-ancestry.js +115 -0
- package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
- package/dist/safe-fs/fs/staging-cleanup.js +63 -0
- package/dist/safe-fs/platform/browser.d.ts +5 -0
- package/dist/safe-fs/platform/browser.js +17 -0
- package/dist/safe-fs/platform/node.d.ts +5 -0
- package/dist/safe-fs/platform/node.js +28 -0
- package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
- package/dist/safe-fs/platform/transport-budget.js +4 -0
- package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
- package/dist/safe-fs/testing/object-io-metrics.js +29 -0
- package/dist/safe-fs/testing/object-publication.d.ts +3 -1
- package/dist/safe-fs/testing/object-publication.js +2 -2
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { FsError, isFsError } from "../../contracts/errors.js";
|
|
|
3
3
|
import { composeAbortSignals } from "../../contracts/abort.js";
|
|
4
4
|
import { collectBytes, readBytes } from "../../contracts/io.js";
|
|
5
5
|
import { compareEntries, registerEntryAuthority } from "../mount/comparison.js";
|
|
6
|
+
import { chargeScopedTransportCall } from "#safe-fs-platform";
|
|
6
7
|
import { compareOwnedS3Entries, queryS3Head, recordS3Stat, registerS3EntryOwner } from "./authority.js";
|
|
7
8
|
import { encodeCopySource } from "./transport.js";
|
|
8
9
|
import { admitDirectoryEntries, directoryEntryLimit } from "../directory-admission.js";
|
|
@@ -63,6 +64,26 @@ function validateLimit(value, name, minimum, maximum = Number.MAX_SAFE_INTEGER)
|
|
|
63
64
|
}
|
|
64
65
|
return value;
|
|
65
66
|
}
|
|
67
|
+
function utf8ByteLength(value) {
|
|
68
|
+
let bytes = 0;
|
|
69
|
+
for (let i = 0; i < value.length; i++) {
|
|
70
|
+
const code = value.charCodeAt(i);
|
|
71
|
+
if (code < 0x80)
|
|
72
|
+
bytes += 1;
|
|
73
|
+
else if (code < 0x800)
|
|
74
|
+
bytes += 2;
|
|
75
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
|
|
76
|
+
bytes += 4;
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
bytes += 3;
|
|
81
|
+
}
|
|
82
|
+
return bytes;
|
|
83
|
+
}
|
|
84
|
+
function exceedsComponentByteLimit(value) {
|
|
85
|
+
return value.length > 85 && (value.length > 255 || utf8ByteLength(value) > 255);
|
|
86
|
+
}
|
|
66
87
|
function isWellFormed(value) {
|
|
67
88
|
return !/[\uD800-\uDFFF]/u.test(value);
|
|
68
89
|
}
|
|
@@ -82,6 +103,8 @@ export class S3FileSystem {
|
|
|
82
103
|
maxReadBytes;
|
|
83
104
|
maxStreamBytes;
|
|
84
105
|
maxListEntries;
|
|
106
|
+
maxRequests;
|
|
107
|
+
requests = 0;
|
|
85
108
|
removalLimits;
|
|
86
109
|
removalBudgets = new WeakMap();
|
|
87
110
|
constructor(options) {
|
|
@@ -101,7 +124,7 @@ export class S3FileSystem {
|
|
|
101
124
|
throw new FsError("EINVAL", { message: "prefix must be a canonical relative object-key prefix" });
|
|
102
125
|
}
|
|
103
126
|
this.prefix = prefix === "" || prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
104
|
-
if (
|
|
127
|
+
if (utf8ByteLength(this.prefix) > 1024)
|
|
105
128
|
throw new FsError("ENAMETOOLONG", { message: "S3 prefix exceeds 1024 UTF-8 bytes" });
|
|
106
129
|
this.transport = options.transport;
|
|
107
130
|
this.bucket = options.bucket;
|
|
@@ -110,6 +133,7 @@ export class S3FileSystem {
|
|
|
110
133
|
this.maxReadBytes = options.maxReadBytes === undefined ? Infinity : validateLimit(options.maxReadBytes, "maxReadBytes", 0);
|
|
111
134
|
this.maxStreamBytes = options.maxStreamBytes === undefined ? Infinity : validateLimit(options.maxStreamBytes, "maxStreamBytes", 0);
|
|
112
135
|
this.maxListEntries = options.maxListEntries === undefined ? Infinity : validateLimit(options.maxListEntries, "maxListEntries", 1);
|
|
136
|
+
this.maxRequests = options.maxRequests === undefined ? Infinity : validateLimit(options.maxRequests, "maxRequests", 1);
|
|
113
137
|
this.removalLimits = {
|
|
114
138
|
maxRequests: validateLimit(options.removalLimits?.maxRequests ?? 32, "removalLimits.maxRequests", 1),
|
|
115
139
|
maxListEntries: validateLimit(options.removalLimits?.maxListEntries ?? 32, "removalLimits.maxListEntries", 1),
|
|
@@ -167,7 +191,7 @@ export class S3FileSystem {
|
|
|
167
191
|
components.push(part);
|
|
168
192
|
}
|
|
169
193
|
const path = `/${components.join("/")}`;
|
|
170
|
-
if (
|
|
194
|
+
if (utf8ByteLength(this.key(path)) > 1024)
|
|
171
195
|
fail("ENAMETOOLONG", "resolve", input);
|
|
172
196
|
return path;
|
|
173
197
|
}
|
|
@@ -176,7 +200,7 @@ export class S3FileSystem {
|
|
|
176
200
|
}
|
|
177
201
|
directoryKey(path) {
|
|
178
202
|
const key = path === "/" ? this.prefix : `${this.key(path)}/`;
|
|
179
|
-
if (
|
|
203
|
+
if (utf8ByteLength(key) > 1024)
|
|
180
204
|
fail("ENAMETOOLONG", "resolve", path);
|
|
181
205
|
return key;
|
|
182
206
|
}
|
|
@@ -188,11 +212,17 @@ export class S3FileSystem {
|
|
|
188
212
|
return options.signal === undefined ? {} : { abortSignal: options.signal };
|
|
189
213
|
}
|
|
190
214
|
async call(syscall, path, options, action, precondition) {
|
|
215
|
+
this.checkAbort(options, syscall, path);
|
|
216
|
+
chargeScopedTransportCall(options);
|
|
191
217
|
this.checkAbort(options, syscall, path);
|
|
192
218
|
const budget = this.removalBudgets.get(options);
|
|
193
219
|
if (budget && ++budget.requests > this.removalLimits.maxRequests) {
|
|
194
220
|
fail("EFBIG", "rm", path, "S3 removal request budget exceeded");
|
|
195
221
|
}
|
|
222
|
+
if (this.requests >= this.maxRequests) {
|
|
223
|
+
fail("EFBIG", syscall, path, "S3 transport request budget exceeded");
|
|
224
|
+
}
|
|
225
|
+
this.requests++;
|
|
196
226
|
let onAbort;
|
|
197
227
|
try {
|
|
198
228
|
const pending = action();
|
|
@@ -297,7 +327,7 @@ export class S3FileSystem {
|
|
|
297
327
|
if (!key.startsWith(this.prefix))
|
|
298
328
|
fail("EIO", "listObjectsV2", path, "key escapes the configured prefix");
|
|
299
329
|
const relative = key.slice(this.prefix.length).replace(/\/$/, "");
|
|
300
|
-
if (!isWellFormed(key) || key.includes("\0") ||
|
|
330
|
+
if (!isWellFormed(key) || key.includes("\0") || utf8ByteLength(key) > 1024
|
|
301
331
|
|| (relative !== "" && relative.split("/").some((part) => !part || part === "." || part === ".."))) {
|
|
302
332
|
fail("ENOTSUP", "listObjectsV2", path, "object key cannot be represented as a canonical filesystem path");
|
|
303
333
|
}
|
|
@@ -338,22 +368,22 @@ export class S3FileSystem {
|
|
|
338
368
|
return { stat: this.makeStat("directory", marker), ...(marker ? { metadata: marker } : {}) };
|
|
339
369
|
}
|
|
340
370
|
const file = await this.head(this.key(path), path, options);
|
|
341
|
-
if (
|
|
371
|
+
if (utf8ByteLength(this.key(path)) === 1024) {
|
|
342
372
|
return file ? { stat: this.makeStat("file", file), metadata: file } : undefined;
|
|
343
373
|
}
|
|
344
374
|
const directoryKey = this.directoryKey(path);
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
directory = true;
|
|
353
|
-
break;
|
|
354
|
-
}
|
|
375
|
+
let directory = false;
|
|
376
|
+
let hasMarker = false;
|
|
377
|
+
for await (const children of this.pages(directoryKey, path, options, undefined, 1)) {
|
|
378
|
+
if ((children.Contents?.length ?? 0) > 0) {
|
|
379
|
+
directory = true;
|
|
380
|
+
hasMarker = children.Contents.some(object => object.Key === directoryKey);
|
|
381
|
+
break;
|
|
355
382
|
}
|
|
356
383
|
}
|
|
384
|
+
const marker = hasMarker ? await this.head(directoryKey, path, options) : undefined;
|
|
385
|
+
if (marker && marker.ContentLength !== 0)
|
|
386
|
+
this.unsupported("nonempty directory markers", path);
|
|
357
387
|
if (file && directory)
|
|
358
388
|
this.unsupported("file/prefix collisions", path);
|
|
359
389
|
if (file)
|
|
@@ -370,6 +400,8 @@ export class S3FileSystem {
|
|
|
370
400
|
break;
|
|
371
401
|
}
|
|
372
402
|
for (const parent of ancestors) {
|
|
403
|
+
if (parent === "/" && this.prefix === "")
|
|
404
|
+
continue;
|
|
373
405
|
const ancestor = await this.inspect(parent, options);
|
|
374
406
|
if (!ancestor)
|
|
375
407
|
fail("ENOENT", "stat", parent);
|
|
@@ -776,7 +808,7 @@ export class S3FileSystem {
|
|
|
776
808
|
if (!conditionalCopy && object.Size > (streamFallback ? this.maxStreamBytes : this.maxReadBytes)) {
|
|
777
809
|
fail("EFBIG", "rename", source, "conditional PUT fallback exceeds its configured transfer or buffered read limit");
|
|
778
810
|
}
|
|
779
|
-
if (
|
|
811
|
+
if (utf8ByteLength(this.key(destination) + object.Key.slice(this.key(source).length)) > 1024)
|
|
780
812
|
fail("ENAMETOOLONG", "rename", destination);
|
|
781
813
|
}
|
|
782
814
|
const copied = [];
|
|
@@ -4,6 +4,23 @@ import { abortable, collect, limitedBody, scopeFor, sendRequest } from "./reques
|
|
|
4
4
|
import { canonicalQuery, headerValue, invalid, signRequest, uriEncode } from "./signature.js";
|
|
5
5
|
import { children, integer, malformed, parseXml, text, timestamp } from "./xml.js";
|
|
6
6
|
const empty = new Uint8Array();
|
|
7
|
+
function utf8ByteLength(value) {
|
|
8
|
+
let bytes = 0;
|
|
9
|
+
for (let i = 0; i < value.length; i++) {
|
|
10
|
+
const code = value.charCodeAt(i);
|
|
11
|
+
if (code < 0x80)
|
|
12
|
+
bytes += 1;
|
|
13
|
+
else if (code < 0x800)
|
|
14
|
+
bytes += 2;
|
|
15
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
|
|
16
|
+
bytes += 4;
|
|
17
|
+
i++;
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
bytes += 3;
|
|
21
|
+
}
|
|
22
|
+
return bytes;
|
|
23
|
+
}
|
|
7
24
|
function limit(value, fallback, name, maximum = Number.MAX_SAFE_INTEGER) {
|
|
8
25
|
if (value === undefined)
|
|
9
26
|
return fallback;
|
|
@@ -23,7 +40,7 @@ function bucketName(bucket) {
|
|
|
23
40
|
invalid("expected a standard S3 bucket name");
|
|
24
41
|
}
|
|
25
42
|
function objectKey(key) {
|
|
26
|
-
if (typeof key !== "string" || !key ||
|
|
43
|
+
if (typeof key !== "string" || !key || utf8ByteLength(key) > 1024)
|
|
27
44
|
invalid("object keys must contain 1..1024 UTF-8 bytes");
|
|
28
45
|
uriEncode(key);
|
|
29
46
|
}
|
|
@@ -57,7 +74,7 @@ function metadataHeaders(metadata) {
|
|
|
57
74
|
if (!/^[a-z0-9][a-z0-9_-]*$/.test(lower) || `x-amz-meta-${lower}` in headers)
|
|
58
75
|
invalid("invalid or duplicate metadata key");
|
|
59
76
|
const normalized = headerValue(value);
|
|
60
|
-
size +=
|
|
77
|
+
size += utf8ByteLength(lower) + utf8ByteLength(normalized);
|
|
61
78
|
if (size > 2048)
|
|
62
79
|
invalid("metadata exceeds 2 KiB");
|
|
63
80
|
headers[`x-amz-meta-${lower}`] = normalized;
|
|
@@ -413,7 +430,7 @@ export function createS3HttpTransport(options) {
|
|
|
413
430
|
const query = [["list-type", "2"], ["encoding-type", "url"]];
|
|
414
431
|
for (const [name, value] of [["prefix", input.Prefix], ["delimiter", input.Delimiter], ["continuation-token", input.ContinuationToken]]) {
|
|
415
432
|
if (value !== undefined) {
|
|
416
|
-
if (typeof value !== "string" ||
|
|
433
|
+
if (typeof value !== "string" || utf8ByteLength(value) > 8192)
|
|
417
434
|
invalid(`invalid ${name}`);
|
|
418
435
|
query.push([name, value]);
|
|
419
436
|
}
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
function utf8ByteLength(value) {
|
|
2
|
+
let bytes = 0;
|
|
3
|
+
for (let i = 0; i < value.length; i++) {
|
|
4
|
+
const code = value.charCodeAt(i);
|
|
5
|
+
if (code < 0x80)
|
|
6
|
+
bytes += 1;
|
|
7
|
+
else if (code < 0x800)
|
|
8
|
+
bytes += 2;
|
|
9
|
+
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < value.length && value.charCodeAt(i + 1) >= 0xdc00 && value.charCodeAt(i + 1) <= 0xdfff) {
|
|
10
|
+
bytes += 4;
|
|
11
|
+
i++;
|
|
12
|
+
}
|
|
13
|
+
else
|
|
14
|
+
bytes += 3;
|
|
15
|
+
}
|
|
16
|
+
return bytes;
|
|
17
|
+
}
|
|
1
18
|
import { createHash } from "node:crypto";
|
|
2
19
|
import { collectBytes } from "../../contracts/io.js";
|
|
3
20
|
import { recordMockS3Head } from "./authority.js";
|
|
@@ -37,7 +54,7 @@ export class MockS3Client {
|
|
|
37
54
|
throw new S3ServiceError("AbortError", 499);
|
|
38
55
|
if (!this.buckets.has(input.Bucket))
|
|
39
56
|
throw new S3ServiceError("NoSuchBucket", 404);
|
|
40
|
-
if ("Key" in input && (input.Key.length === 0 ||
|
|
57
|
+
if ("Key" in input && (input.Key.length === 0 || utf8ByteLength(input.Key) > 1024)) {
|
|
41
58
|
throw new S3ServiceError("InvalidArgument", 400);
|
|
42
59
|
}
|
|
43
60
|
}
|
|
@@ -62,7 +79,7 @@ export class MockS3Client {
|
|
|
62
79
|
};
|
|
63
80
|
}
|
|
64
81
|
store(body, metadata = {}) {
|
|
65
|
-
const metadataBytes = Object.entries(metadata).reduce((total, [key, value]) => total +
|
|
82
|
+
const metadataBytes = Object.entries(metadata).reduce((total, [key, value]) => total + utf8ByteLength(key) + utf8ByteLength(value), 0);
|
|
66
83
|
if (metadataBytes > 2048)
|
|
67
84
|
throw new S3ServiceError("MetadataTooLarge", 400);
|
|
68
85
|
return {
|
|
@@ -250,7 +250,7 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
250
250
|
const filesystem = {
|
|
251
251
|
capabilities: Object.freeze({ read: true, write: true, append: true, stat: true, readdir: true, realpath: true, access: true,
|
|
252
252
|
mkdir: true, recursiveMkdir: true, explicitDirectories: true, implicitDirectories: false, exclusiveCreate: true,
|
|
253
|
-
remove: true, removeDirectory: true, recursiveRemove: true, atomicTreeRemoval: true, atomicEntryRemoval: true,
|
|
253
|
+
remove: true, removeDirectory: true, recursiveRemove: true, atomicTreeRemoval: true, atomicEntryRemoval: true, atomicEntryRemovalReceipt: false,
|
|
254
254
|
rename: true, atomicRename: true, atomicRenameNoReplace: true, copy: true, exclusiveCopy: true,
|
|
255
255
|
readOnly: false, symlinks: false, hardlinks: false, permissions: false, timestamps: false }),
|
|
256
256
|
stat: observe,
|
|
@@ -313,7 +313,13 @@ export async function createS3NamespaceFileSystem(options) {
|
|
|
313
313
|
async unlink(input, forwarded = {}) { entryPath(input); await mutate(forwarded, value => remove(value, resolveName(value, input), false, 'file')); },
|
|
314
314
|
async rmdir(input, forwarded = {}) { entryPath(input); await mutate(forwarded, value => remove(value, resolveName(value, input), false, 'directory')); },
|
|
315
315
|
async removeTreeConditional(input, forwarded) { entryPath(input); return conditionalRemove(input, forwarded, true); },
|
|
316
|
-
async removeEntryConditional(input, forwarded) {
|
|
316
|
+
async removeEntryConditional(input, forwarded) {
|
|
317
|
+
forwarded.signal?.throwIfAborted();
|
|
318
|
+
if (forwarded.returnRemainingStat === true)
|
|
319
|
+
throw new FsError('ENOTSUP');
|
|
320
|
+
entryPath(input);
|
|
321
|
+
return conditionalRemove(input, forwarded, false);
|
|
322
|
+
},
|
|
317
323
|
async rename(source, destination, forwarded = {}) {
|
|
318
324
|
entryPath(source);
|
|
319
325
|
entryPath(destination);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { FsError } from "../../contracts/errors.js";
|
|
2
2
|
import { assertCallbackAuthorityAllowed } from "../mount/comparison.js";
|
|
3
|
+
import { enableS3TransportBudget } from "../../platform/transport-budget.js";
|
|
3
4
|
const observedStats = new WeakMap();
|
|
4
5
|
const entries = new WeakMap();
|
|
5
6
|
const comparisons = new WeakMap();
|
|
@@ -8,6 +9,7 @@ export function recordS3Observation(filesystem, path, stat, entry) {
|
|
|
8
9
|
observedStats.set(stat, { filesystem, path, entry });
|
|
9
10
|
}
|
|
10
11
|
export function registerS3EntryOwner(filesystem, normalize, intact, baseComparison, comparison) {
|
|
12
|
+
enableS3TransportBudget();
|
|
11
13
|
comparisons.set(filesystem, baseComparison);
|
|
12
14
|
if (comparison)
|
|
13
15
|
configuredComparisons.set(filesystem, comparison);
|
|
@@ -2,6 +2,7 @@ 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
4
|
readonly creationMask?: number;
|
|
5
|
+
readonly maxPathComponents?: number;
|
|
5
6
|
}): FileSystem;
|
|
6
7
|
export interface RetainedFileSystemCleanupView {
|
|
7
8
|
readonly removeFileConditional?: NonNullable<FileSystem["removeFileConditional"]>;
|