@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.
Files changed (57) hide show
  1. package/README.md +8 -1
  2. package/dist/safe-fs/contracts/errors.js +8 -1
  3. package/dist/safe-fs/contracts/filesystem.d.ts +68 -2
  4. package/dist/safe-fs/contracts/io.js +183 -27
  5. package/dist/safe-fs/contracts/virtual-path.d.ts +3 -1
  6. package/dist/safe-fs/contracts/virtual-path.js +20 -1
  7. package/dist/safe-fs/core.d.ts +1 -0
  8. package/dist/safe-fs/core.js +1 -0
  9. package/dist/safe-fs/fs/capabilities.d.ts +1 -1
  10. package/dist/safe-fs/fs/capabilities.js +59 -21
  11. package/dist/safe-fs/fs/conditional-chmod.d.ts +7 -0
  12. package/dist/safe-fs/fs/conditional-chmod.js +19 -0
  13. package/dist/safe-fs/fs/descriptor.js +51 -25
  14. package/dist/safe-fs/fs/devices/index.d.ts +7 -2
  15. package/dist/safe-fs/fs/devices/index.js +210 -29
  16. package/dist/safe-fs/fs/devices/path.d.ts +3 -1
  17. package/dist/safe-fs/fs/devices/path.js +70 -12
  18. package/dist/safe-fs/fs/memory/index.d.ts +34 -48
  19. package/dist/safe-fs/fs/memory/index.js +1150 -235
  20. package/dist/safe-fs/fs/memory/ledger.d.ts +2 -0
  21. package/dist/safe-fs/fs/memory/ledger.js +15 -3
  22. package/dist/safe-fs/fs/memory/missing-target.js +21 -1
  23. package/dist/safe-fs/fs/mount/index.d.ts +11 -3
  24. package/dist/safe-fs/fs/mount/index.js +326 -15
  25. package/dist/safe-fs/fs/object-publication/index.js +79 -3
  26. package/dist/safe-fs/fs/overlay/index.d.ts +2 -2
  27. package/dist/safe-fs/fs/overlay/index.js +22 -2
  28. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +2 -1
  29. package/dist/safe-fs/fs/overlay/memory-publication.js +39 -2
  30. package/dist/safe-fs/fs/quota/index.js +1 -1
  31. package/dist/safe-fs/fs/readonly/index.d.ts +1 -0
  32. package/dist/safe-fs/fs/readonly/index.js +13 -0
  33. package/dist/safe-fs/fs/real/index.d.ts +6 -2
  34. package/dist/safe-fs/fs/real/index.js +78 -8
  35. package/dist/safe-fs/fs/s3/filesystem.d.ts +3 -0
  36. package/dist/safe-fs/fs/s3/filesystem.js +48 -16
  37. package/dist/safe-fs/fs/s3/http/transport.js +20 -3
  38. package/dist/safe-fs/fs/s3/mock.js +19 -2
  39. package/dist/safe-fs/fs/s3/namespace.js +8 -2
  40. package/dist/safe-fs/fs/s3/registry.js +2 -0
  41. package/dist/safe-fs/fs/scoped.d.ts +1 -0
  42. package/dist/safe-fs/fs/scoped.js +261 -74
  43. package/dist/safe-fs/fs/staging-ancestry.d.ts +8 -0
  44. package/dist/safe-fs/fs/staging-ancestry.js +115 -0
  45. package/dist/safe-fs/fs/staging-cleanup.d.ts +5 -0
  46. package/dist/safe-fs/fs/staging-cleanup.js +63 -0
  47. package/dist/safe-fs/platform/browser.d.ts +5 -0
  48. package/dist/safe-fs/platform/browser.js +17 -0
  49. package/dist/safe-fs/platform/node.d.ts +5 -0
  50. package/dist/safe-fs/platform/node.js +28 -0
  51. package/dist/safe-fs/platform/transport-budget.d.ts +7 -0
  52. package/dist/safe-fs/platform/transport-budget.js +4 -0
  53. package/dist/safe-fs/testing/object-io-metrics.d.ts +3 -0
  54. package/dist/safe-fs/testing/object-io-metrics.js +29 -0
  55. package/dist/safe-fs/testing/object-publication.d.ts +3 -1
  56. package/dist/safe-fs/testing/object-publication.js +2 -2
  57. 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 (Buffer.byteLength(this.prefix) > 1024)
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 (Buffer.byteLength(this.key(path)) > 1024)
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 (Buffer.byteLength(key) > 1024)
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") || Buffer.byteLength(key) > 1024
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 (Buffer.byteLength(this.key(path)) === 1024) {
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
- const marker = await this.head(directoryKey, path, options);
346
- if (marker && marker.ContentLength !== 0)
347
- this.unsupported("nonempty directory markers", path);
348
- let directory = marker !== undefined;
349
- if (!directory) {
350
- for await (const children of this.pages(directoryKey, path, options, undefined, 1)) {
351
- if ((children.Contents?.length ?? 0) > 0) {
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 (Buffer.byteLength(this.key(destination) + object.Key.slice(this.key(source).length)) > 1024)
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 || Buffer.byteLength(key) > 1024)
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 += Buffer.byteLength(lower) + Buffer.byteLength(normalized);
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" || Buffer.byteLength(value) > 8192)
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 || Buffer.byteLength(input.Key) > 1024)) {
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 + Buffer.byteLength(key) + Buffer.byteLength(value), 0);
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) { entryPath(input); return conditionalRemove(input, forwarded, false); },
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"]>;