@poe-platform/safe-fs 0.1.799 → 0.1.801

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 (34) hide show
  1. package/README.md +16 -7
  2. package/dist/safe-fs/contracts/filesystem.d.ts +2 -0
  3. package/dist/safe-fs/contracts/io.js +26 -36
  4. package/dist/safe-fs/contracts/path.d.ts +2 -7
  5. package/dist/safe-fs/contracts/path.js +1 -7
  6. package/dist/safe-fs/contracts/virtual-path.d.ts +8 -3
  7. package/dist/safe-fs/contracts/virtual-path.js +22 -5
  8. package/dist/safe-fs/core.d.ts +1 -0
  9. package/dist/safe-fs/core.js +1 -0
  10. package/dist/safe-fs/fs/devices/path.js +18 -17
  11. package/dist/safe-fs/fs/memory/index.js +57 -43
  12. package/dist/safe-fs/fs/memory/limits.d.ts +5 -1
  13. package/dist/safe-fs/fs/memory/limits.js +7 -11
  14. package/dist/safe-fs/fs/memory/missing-target.d.ts +2 -1
  15. package/dist/safe-fs/fs/memory/missing-target.js +8 -1
  16. package/dist/safe-fs/fs/overlay/index.d.ts +4 -1
  17. package/dist/safe-fs/fs/overlay/index.js +57 -11
  18. package/dist/safe-fs/fs/overlay/memory-publication.d.ts +11 -4
  19. package/dist/safe-fs/fs/overlay/memory-publication.js +240 -14
  20. package/dist/safe-fs/fs/s3/http/request-fetch.d.ts +4 -0
  21. package/dist/safe-fs/fs/s3/http/request-fetch.js +74 -0
  22. package/dist/safe-fs/fs/s3/http/request-node.d.ts +4 -0
  23. package/dist/safe-fs/fs/s3/http/request-node.js +111 -0
  24. package/dist/safe-fs/fs/s3/http/request.d.ts +5 -4
  25. package/dist/safe-fs/fs/s3/http/request.js +1 -109
  26. package/dist/safe-fs/fs/s3/http/signature.js +7 -5
  27. package/dist/safe-fs/fs/s3/http/transport.js +6 -3
  28. package/dist/safe-fs/fs/s3/namespace.js +1 -1
  29. package/dist/safe-fs/fs/scoped.js +2 -2
  30. package/dist/safe-fs/fs/webdav/webdav.d.ts +3 -0
  31. package/dist/safe-fs/fs/webdav/webdav.js +5 -4
  32. package/dist/xml-ast/index.d.ts +2 -0
  33. package/dist/xml-ast/index.js +68 -31
  34. package/package.json +9 -2
@@ -1,8 +1,6 @@
1
1
  import { concatBytes } from "./bytes.js";
2
- import { once } from "node:events";
3
- import { request as httpRequest } from "node:http";
4
- import { request as httpsRequest } from "node:https";
5
2
  import { S3ServiceError } from "../transport.js";
3
+ export { sendRequest } from "#safe-fs-s3-request";
6
4
  export function scopeFor(signal, timeout) {
7
5
  const controller = new AbortController();
8
6
  const abort = () => controller.abort(signal?.reason);
@@ -31,112 +29,6 @@ export async function abortable(pending, signal) {
31
29
  signal.removeEventListener("abort", abort);
32
30
  }
33
31
  }
34
- export async function sendRequest(options, body, scope, factory) {
35
- const signal = scope.signal;
36
- signal.throwIfAborted();
37
- return new Promise((resolve, reject) => {
38
- let request;
39
- let response;
40
- let headersReceived = false;
41
- let finished = false;
42
- const cleanup = () => {
43
- signal.removeEventListener("abort", abort);
44
- scope.finish();
45
- };
46
- const close = () => {
47
- if (finished)
48
- return;
49
- finished = true;
50
- cleanup();
51
- response?.destroy();
52
- request?.destroy();
53
- };
54
- const fail = (error) => {
55
- reject(signal.aborted ? signal.reason : error);
56
- close();
57
- };
58
- const abort = () => fail(signal.reason);
59
- signal.addEventListener("abort", abort, { once: true });
60
- try {
61
- const create = factory ?? (options.protocol === "https:" ? httpsRequest : httpRequest);
62
- request = create(options, message => {
63
- response = message;
64
- message.on("error", () => { });
65
- if (finished || signal.aborted) {
66
- close();
67
- message.destroy();
68
- return;
69
- }
70
- headersReceived = true;
71
- message.once("close", cleanup);
72
- let claimed = false;
73
- const source = {
74
- [Symbol.asyncIterator]() {
75
- if (claimed)
76
- throw new S3ServiceError("InvalidResponse", 502, "response body is single-use");
77
- claimed = true;
78
- const iterator = message[Symbol.asyncIterator]();
79
- let ended = false;
80
- return {
81
- async next() {
82
- try {
83
- signal.throwIfAborted();
84
- if (ended)
85
- return { done: true, value: undefined };
86
- const result = await abortable(iterator.next(), signal);
87
- signal.throwIfAborted();
88
- if (result.done) {
89
- ended = true;
90
- if (!message.complete)
91
- throw new S3ServiceError("InvalidResponse", 502, "incomplete HTTP response");
92
- close();
93
- return { done: true, value: undefined };
94
- }
95
- if (!(result.value instanceof Uint8Array))
96
- throw new S3ServiceError("InvalidResponse", 502, "nonbinary HTTP body");
97
- return { done: false, value: new Uint8Array(result.value) };
98
- }
99
- catch (error) {
100
- ended = true;
101
- close();
102
- throw signal.aborted ? signal.reason : error;
103
- }
104
- },
105
- async return() {
106
- ended = true;
107
- close();
108
- return { done: true, value: undefined };
109
- },
110
- };
111
- },
112
- };
113
- resolve({ message, body: source, close });
114
- });
115
- request.on("error", fail);
116
- request.once("close", () => {
117
- if (!headersReceived)
118
- fail(new S3ServiceError("InvalidResponse", 502, "connection closed before response headers"));
119
- });
120
- if (signal.aborted || finished) {
121
- abort();
122
- request.destroy();
123
- return;
124
- }
125
- void (async () => {
126
- for (let offset = 0; offset < body.length; offset += 64 * 1024) {
127
- signal.throwIfAborted();
128
- if (!request.write(body.subarray(offset, offset + 64 * 1024)))
129
- await once(request, "drain", { signal });
130
- }
131
- signal.throwIfAborted();
132
- request.end();
133
- })().catch(fail);
134
- }
135
- catch (error) {
136
- fail(error);
137
- }
138
- });
139
- }
140
32
  export function limitedBody(response, maximum, expected) {
141
33
  if (expected !== undefined && expected > maximum) {
142
34
  response.close();
@@ -1,4 +1,6 @@
1
- import { createHash, createHmac } from "node:crypto";
1
+ import { sha256 as hash } from "@noble/hashes/sha2.js";
2
+ import { hmac } from "@noble/hashes/hmac.js";
3
+ import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils.js";
2
4
  import { S3ServiceError } from "../transport.js";
3
5
  export function invalid(message) {
4
6
  throw new S3ServiceError("InvalidArgument", 400, message);
@@ -20,7 +22,7 @@ export function canonicalQuery(entries) {
20
22
  .map(([name, value]) => `${name}=${value}`).join("&");
21
23
  }
22
24
  export function sha256(bytes) {
23
- return createHash("sha256").update(bytes).digest("hex");
25
+ return bytesToHex(hash(typeof bytes === "string" ? utf8ToBytes(bytes) : bytes));
24
26
  }
25
27
  export function headerValue(value) {
26
28
  if (typeof value !== "string" || /[^\x20-\x7e\t]/.test(value))
@@ -63,10 +65,10 @@ export function signRequest(input) {
63
65
  names.map(name => `${name}:${headers[name]}\n`).join(""), signedHeaders, payloadHash].join("\n");
64
66
  const scope = `${date}/${input.region}/s3/aws4_request`;
65
67
  const stringToSign = ["AWS4-HMAC-SHA256", timestamp, scope, sha256(canonicalRequest)].join("\n");
66
- let key = createHmac("sha256", `AWS4${credentials.secretAccessKey}`).update(date).digest();
68
+ let key = hmac(hash, utf8ToBytes(`AWS4${credentials.secretAccessKey}`), utf8ToBytes(date));
67
69
  for (const part of [input.region, "s3", "aws4_request"])
68
- key = createHmac("sha256", key).update(part).digest();
69
- const signature = createHmac("sha256", key).update(stringToSign).digest("hex");
70
+ key = hmac(hash, key, utf8ToBytes(part));
71
+ const signature = bytesToHex(hmac(hash, key, utf8ToBytes(stringToSign)));
70
72
  headers.authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
71
73
  if (Object.entries(headers).reduce((size, [name, value]) => size + name.length + value.length + 4, 0) > 16 * 1024)
72
74
  invalid("request headers exceed 16 KiB");
@@ -1,5 +1,4 @@
1
1
  import { concatBytes } from "./bytes.js";
2
- import { isIP } from "node:net";
3
2
  import { S3ServiceError } from "../transport.js";
4
3
  import { abortable, collect, limitedBody, scopeFor, sendRequest } from "./request.js";
5
4
  import { canonicalQuery, headerValue, invalid, signRequest, uriEncode } from "./signature.js";
@@ -35,9 +34,13 @@ function boolean(value, fallback, name) {
35
34
  invalid(`invalid ${name}`);
36
35
  return value ?? fallback;
37
36
  }
37
+ function isIPv4(value) {
38
+ const parts = value.split(".");
39
+ return parts.length === 4 && parts.every(part => String(Number(part)) === part && Number(part) >= 0 && Number(part) <= 255);
40
+ }
38
41
  function bucketName(bucket) {
39
42
  if (typeof bucket !== "string" || !/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket)
40
- || bucket.includes("..") || bucket.includes(".-") || bucket.includes("-.") || isIP(bucket))
43
+ || bucket.includes("..") || bucket.includes(".-") || bucket.includes("-.") || isIPv4(bucket))
41
44
  invalid("expected a standard S3 bucket name");
42
45
  }
43
46
  function objectKey(key) {
@@ -179,7 +182,7 @@ export function createS3HttpTransport(options) {
179
182
  const listEncoding = options.listUrlEncoding ?? "percent";
180
183
  if (listEncoding !== "percent" && listEncoding !== "form")
181
184
  invalid("unsupported LIST URL encoding");
182
- if (addressing === "virtual-hosted" && (isIP(endpoint.hostname.replace(/^\[|\]$/g, "")) || endpoint.hostname === "localhost"))
185
+ if (addressing === "virtual-hosted" && (endpoint.hostname.includes(":") || isIPv4(endpoint.hostname) || endpoint.hostname === "localhost"))
183
186
  invalid("virtual-hosted addressing requires a DNS endpoint");
184
187
  const maxPut = limit(options.maxPutBytes, Infinity, "maxPutBytes");
185
188
  const maxGet = limit(options.maxGetBytes, Infinity, "maxGetBytes");
@@ -18,7 +18,7 @@ export async function createS3NamespaceFileSystem(options) {
18
18
  throw new TypeError('An explicit S3 bucket and manifest key are required');
19
19
  const maxBytes = limit(options.maxBytes);
20
20
  const maxEntries = limit(options.maxEntries);
21
- const maxManifestBytes = limit(options.maxManifestBytes ?? 4 * 1024 * 1024);
21
+ const maxManifestBytes = options.maxManifestBytes === Infinity ? Infinity : limit(options.maxManifestBytes);
22
22
  const maxAttempts = limit(options.maxAttempts);
23
23
  const descriptorOptions = options;
24
24
  for (const key of ['chunkBytes', 'maxStagedBytes', 'maxStagedPages', 'maxFileBytes', 'maxOpenFiles']) {
@@ -537,8 +537,8 @@ export function scopeFileSystem(filesystem, charge, signal, cleanupCharge = char
537
537
  }
538
538
  export function retainFileSystemCleanup(filesystem, cleanupCallback, options) {
539
539
  const maximum = options?.maxOperations ?? Infinity;
540
- if (options?.maxOperations !== undefined && (!Number.isSafeInteger(maximum) || maximum < 0))
541
- throw new RangeError("cleanup maxOperations must be a nonnegative safe integer");
540
+ if (maximum !== Infinity && (!Number.isSafeInteger(maximum) || maximum < 0))
541
+ throw new RangeError("cleanup maxOperations must be a nonnegative safe integer or Infinity");
542
542
  if (typeof cleanupCallback !== "function")
543
543
  throw new TypeError("cleanup callback must be a function");
544
544
  const scope = originals.get(filesystem);
@@ -29,6 +29,8 @@ export interface WebDavFileSystemOptions {
29
29
  readonly maxResponseBytes?: number;
30
30
  /** Metadata response ceiling before decoding/parsing; unlimited unless configured. */
31
31
  readonly maxXmlBytes?: number;
32
+ /** Timestamp property UTF-8 byte ceiling; unlimited unless configured. */
33
+ readonly maxTimestampPropertyBytes?: number;
32
34
  readonly maxEntries?: number;
33
35
  /** XML structure ceilings; unlimited unless configured. */
34
36
  readonly xmlLimits?: Pick<XmlLimits, "maxNodes" | "maxContentNodes" | "maxDepth" | "maxAttributes">;
@@ -80,6 +82,7 @@ export declare class WebDavFileSystem implements FileSystem {
80
82
  private readonly headers;
81
83
  private readonly maxResponseBytes;
82
84
  private readonly maxXmlBytes;
85
+ private readonly maxTimestampPropertyBytes;
83
86
  private readonly maxEntries;
84
87
  private readonly xmlLimits;
85
88
  private readonly timeoutMs;
@@ -6,16 +6,15 @@ 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
9
  /** Admit only five scalar members before native JSON parsing can allocate a graph. */
11
- function preflightTimestamps(property) {
10
+ function preflightTimestamps(property, maxTimestampPropertyBytes) {
12
11
  // Check the untrimmed text first; counting UTF-8 bytes does not allocate an encoded copy.
13
12
  let bytes = 0;
14
13
  for (const point of property.text) {
15
14
  const code = point.codePointAt(0);
16
15
  bytes += code <= 0x7f ? 1 : code <= 0x7ff ? 2 : code <= 0xffff ? 3 : 4;
17
16
  if (bytes > maxTimestampPropertyBytes)
18
- throw new Error("timestamp property exceeds 4KiB limit");
17
+ throw new Error("timestamp property exceeds byte limit");
19
18
  }
20
19
  const text = scalar(property);
21
20
  let offset = 0;
@@ -233,6 +232,7 @@ export class WebDavFileSystem {
233
232
  headers;
234
233
  maxResponseBytes;
235
234
  maxXmlBytes;
235
+ maxTimestampPropertyBytes;
236
236
  maxEntries;
237
237
  xmlLimits;
238
238
  timeoutMs;
@@ -290,6 +290,7 @@ export class WebDavFileSystem {
290
290
  streamingAppend: this.requestStreamSupport !== false,
291
291
  });
292
292
  this.maxResponseBytes = options.maxResponseBytes === undefined ? Infinity : positive(options.maxResponseBytes, "maxResponseBytes", false, true);
293
+ this.maxTimestampPropertyBytes = positive(options.maxTimestampPropertyBytes ?? Infinity, "maxTimestampPropertyBytes", true, true);
293
294
  this.maxXmlBytes = options.maxXmlBytes === undefined ? Infinity : positive(options.maxXmlBytes, "maxXmlBytes", false, true);
294
295
  this.xmlLimits = Object.freeze({ ...options.xmlLimits });
295
296
  for (const [name, value] of Object.entries(this.xmlLimits))
@@ -714,7 +715,7 @@ export class WebDavFileSystem {
714
715
  let mtimeMs = date("getlastmodified") ?? 0;
715
716
  let atimeMs = 0;
716
717
  if (timestampProperty) {
717
- const timestamps = JSON.parse(preflightTimestamps(timestampProperty));
718
+ const timestamps = JSON.parse(preflightTimestamps(timestampProperty, this.maxTimestampPropertyBytes));
718
719
  if (typeof timestamps !== "object" || timestamps === null
719
720
  || !("version" in timestamps) || timestamps.version !== 1
720
721
  || !("etag" in timestamps) || typeof timestamps.etag !== "string" || !/^"[\x21\x23-\x7e\x80-\xff]*"$/.test(timestamps.etag)
@@ -26,6 +26,8 @@ export interface XmlElement extends XmlName {
26
26
  readonly epilog?: readonly XmlContent[];
27
27
  }
28
28
  export interface XmlLimits {
29
+ /** Opt-in best-effort parsing; each repaired syntax error is reported. DTDs remain forbidden. */
30
+ readonly recover?: (message: string) => void;
29
31
  readonly expectedEncoding?: "UTF-8" | "UTF-16" | "UTF-16LE" | "UTF-16BE";
30
32
  readonly retainContent?: boolean;
31
33
  readonly maxDepth?: number;
@@ -74,7 +74,7 @@ function qualifiedNameSync(name, cache) {
74
74
  }
75
75
  return parts;
76
76
  }
77
- function* entities(text) {
77
+ function* entities(text, recover) {
78
78
  let result = "";
79
79
  let offset = 0;
80
80
  while (offset < text.length) {
@@ -83,8 +83,12 @@ function* entities(text) {
83
83
  return result + text.slice(offset);
84
84
  result += text.slice(offset, start);
85
85
  const end = yield* find(text, ";", start + 1);
86
- if (end < 0)
87
- invalid("unterminated entity");
86
+ if (end < 0) {
87
+ if (!recover)
88
+ invalid("unterminated entity");
89
+ recover("unterminated entity");
90
+ return result + text.slice(start + 1);
91
+ }
88
92
  const entity = text.slice(start + 1, end);
89
93
  const predefined = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
90
94
  if (Object.hasOwn(predefined, entity))
@@ -92,8 +96,13 @@ function* entities(text) {
92
96
  else {
93
97
  const hexadecimal = entity.startsWith("#x");
94
98
  const digits = entity.slice(hexadecimal ? 2 : 1);
95
- if (!entity.startsWith("#") || !digits.length)
96
- invalid("undeclared entity");
99
+ if (!entity.startsWith("#") || !digits.length) {
100
+ if (!recover)
101
+ invalid("undeclared entity");
102
+ recover("undeclared entity");
103
+ offset = end + 1;
104
+ continue;
105
+ }
97
106
  let point = 0;
98
107
  for (let index = 0; index < digits.length; index++) {
99
108
  const code = digits.charCodeAt(index);
@@ -255,27 +264,42 @@ export function* parseXmlSteps(input, limits = {}) {
255
264
  throw new XmlLimitError("maxContentNodes", "XML content node limit exceeded");
256
265
  };
257
266
  let pendingWork = 0;
258
- const skipWhitespace = () => {
267
+ const skipWhitespace = function* () {
259
268
  const start = offset;
260
269
  while (offset < source.length) {
261
270
  const c = source.charCodeAt(offset);
262
271
  if (c !== 32 && c !== 9 && c !== 10 && c !== 13)
263
272
  break;
264
273
  offset++;
274
+ pendingWork++;
275
+ if (pendingWork >= 512) {
276
+ yield 512;
277
+ pendingWork -= 512;
278
+ }
265
279
  }
266
280
  return offset - start;
267
281
  };
268
- const scanName = () => {
282
+ const scanName = function* () {
269
283
  const start = offset;
270
284
  while (offset < source.length) {
271
285
  const c = source.charCodeAt(offset);
272
286
  if (c === 32 || c === 9 || c === 13 || c === 10 || c === 47 || c === 61 || c === 62 || c === 63)
273
287
  break;
274
288
  offset++;
289
+ pendingWork++;
290
+ if (pendingWork >= 512) {
291
+ yield 512;
292
+ pendingWork -= 512;
293
+ }
275
294
  }
276
295
  const name = source.slice(start, offset);
296
+ pendingWork += offset - start;
297
+ while (pendingWork >= 512) {
298
+ yield 512;
299
+ pendingWork -= 512;
300
+ }
277
301
  const [prefix, localName] = qualifiedNameSync(name, qualifiedNames);
278
- return [name, prefix, localName, (offset - start) * 2];
302
+ return [name, prefix, localName];
279
303
  };
280
304
  while (offset < source.length) {
281
305
  pendingWork += 1;
@@ -296,7 +320,7 @@ export function* parseXmlSteps(input, limits = {}) {
296
320
  }
297
321
  if (text.indexOf("]]>") >= 0)
298
322
  invalid("CDATA terminator in text");
299
- const resolved = stack.length ? (text.indexOf("&") < 0 ? (pendingWork += text.length, text) : yield* entities(text)) : text;
323
+ const resolved = stack.length ? (text.indexOf("&") < 0 ? (pendingWork += text.length, text) : yield* entities(text, limits.recover)) : text;
300
324
  while (pendingWork >= 512) {
301
325
  yield 512;
302
326
  pendingWork -= 512;
@@ -313,11 +337,15 @@ export function* parseXmlSteps(input, limits = {}) {
313
337
  }
314
338
  else {
315
339
  for (let index = 0; index < resolved.length; index++) {
340
+ pendingWork++;
341
+ if (pendingWork >= 512) {
342
+ yield 512;
343
+ pendingWork -= 512;
344
+ }
316
345
  const c = resolved.charCodeAt(index);
317
346
  if (c !== 32 && c !== 9 && c !== 13 && c !== 10)
318
347
  invalid("text outside the root");
319
348
  }
320
- pendingWork += resolved.length;
321
349
  while (pendingWork >= 512) {
322
350
  yield 512;
323
351
  pendingWork -= 512;
@@ -362,8 +390,7 @@ export function* parseXmlSteps(input, limits = {}) {
362
390
  else if (source.startsWith("<?", offset)) {
363
391
  const start = offset;
364
392
  offset += 2;
365
- const [target, , , wName] = scanName();
366
- pendingWork += wName;
393
+ const [target] = yield* scanName();
367
394
  while (pendingWork >= 512) {
368
395
  yield 512;
369
396
  pendingWork -= 512;
@@ -406,25 +433,29 @@ export function* parseXmlSteps(input, limits = {}) {
406
433
  }
407
434
  else if (source.charCodeAt(offset + 1) === 47) {
408
435
  offset += 2;
409
- const [name, , , wName] = scanName();
410
- pendingWork += wName + skipWhitespace();
436
+ const [name] = yield* scanName();
437
+ yield* skipWhitespace();
411
438
  while (pendingWork >= 512) {
412
439
  yield 512;
413
440
  pendingWork -= 512;
414
441
  }
415
- if (source[offset++] !== ">" || stack.pop()?.name !== name)
416
- invalid("mismatched closing tag");
442
+ if (source[offset++] !== ">" || stack.pop()?.name !== name) {
443
+ if (!limits.recover)
444
+ invalid("mismatched closing tag");
445
+ limits.recover("mismatched closing tag");
446
+ }
417
447
  }
418
448
  else {
419
449
  offset++;
420
450
  const repeated = previousEmpty && source.startsWith(previousEmpty.suffix, offset) ? previousEmpty : undefined;
421
- const [name, prefix, localName, wName] = repeated
422
- ? [repeated.name, repeated.prefix, repeated.localName, repeated.name.length * 2]
423
- : scanName();
451
+ const [name, prefix, localName] = repeated
452
+ ? [repeated.name, repeated.prefix, repeated.localName]
453
+ : (yield* scanName());
424
454
  if (repeated)
425
455
  offset += name.length;
426
456
  // Preserve scan/validation work while reusing the admitted spelling.
427
- pendingWork += wName;
457
+ if (repeated)
458
+ pendingWork += name.length * 2;
428
459
  while (pendingWork >= 512) {
429
460
  yield 512;
430
461
  pendingWork -= 512;
@@ -434,19 +465,17 @@ export function* parseXmlSteps(input, limits = {}) {
434
465
  let namespaces = stack.at(-1)?.namespaces ?? new Map([["xml", xmlNamespace]]);
435
466
  let ownsNamespaces = stack.length === 0;
436
467
  while (!repeated) {
437
- const ws = skipWhitespace();
438
- pendingWork += ws;
468
+ const ws = (yield* skipWhitespace());
439
469
  while (pendingWork >= 512) {
440
470
  yield 512;
441
471
  pendingWork -= 512;
442
472
  }
443
473
  const ch = source.charCodeAt(offset);
444
- if (ch === 47 || ch === 62)
474
+ if (ch === 47 || ch === 62 || limits.recover && offset === source.length)
445
475
  break;
446
476
  if (ws === 0)
447
477
  invalid("attributes require whitespace");
448
- const [attribute, attrPrefix, attrLocal, wAttr] = scanName();
449
- pendingWork += wAttr;
478
+ const [attribute, attrPrefix, attrLocal] = yield* scanName();
450
479
  while (pendingWork >= 512) {
451
480
  yield 512;
452
481
  pendingWork -= 512;
@@ -457,10 +486,10 @@ export function* parseXmlSteps(input, limits = {}) {
457
486
  throw new XmlLimitError("maxAttributes", "XML attribute limit exceeded");
458
487
  if ((attributes?.size ?? 0) >= maxAttributesPerElement)
459
488
  throw new XmlLimitError("maxAttributesPerElement", "XML attribute limit exceeded");
460
- pendingWork += skipWhitespace();
489
+ yield* skipWhitespace();
461
490
  if (source[offset++] !== "=")
462
491
  invalid("missing attribute equals");
463
- pendingWork += skipWhitespace();
492
+ yield* skipWhitespace();
464
493
  const quote = source[offset++];
465
494
  if (quote !== '"' && quote !== "'")
466
495
  invalid("unquoted attribute");
@@ -482,7 +511,7 @@ export function* parseXmlSteps(input, limits = {}) {
482
511
  if (raw.indexOf("<") >= 0)
483
512
  invalid("less-than in attribute");
484
513
  const normalized = /[\t\n\r]/.test(raw) ? raw.replace(/[\t\n\r]/g, " ") : raw;
485
- const value = normalized.indexOf("&") < 0 ? (pendingWork += normalized.length, normalized) : yield* entities(normalized);
514
+ const value = normalized.indexOf("&") < 0 ? (pendingWork += normalized.length, normalized) : yield* entities(normalized, limits.recover);
486
515
  while (pendingWork >= 512) {
487
516
  yield 512;
488
517
  pendingWork -= 512;
@@ -571,8 +600,11 @@ export function* parseXmlSteps(input, limits = {}) {
571
600
  const empty = source[offset] === "/";
572
601
  if (empty)
573
602
  offset++;
574
- if (source[offset++] !== ">")
575
- invalid("unterminated start tag");
603
+ if (source[offset++] !== ">") {
604
+ if (!limits.recover)
605
+ invalid("unterminated start tag");
606
+ limits.recover("unterminated start tag");
607
+ }
576
608
  // Only cache compact, attribute-free spellings. Namespace resolution and
577
609
  // every admission counter still run for each distinct physical element.
578
610
  if (empty && !attributes && name.length <= 512 && source.slice(offset - name.length - 3, offset) === `<${name}/>`)
@@ -585,8 +617,13 @@ export function* parseXmlSteps(input, limits = {}) {
585
617
  yield pendingWork;
586
618
  pendingWork = 0;
587
619
  }
588
- if (stack.length || !root)
620
+ if (!root)
589
621
  invalid("incomplete document");
622
+ if (stack.length) {
623
+ if (!limits.recover)
624
+ invalid("incomplete document");
625
+ limits.recover("incomplete document");
626
+ }
590
627
  return root;
591
628
  }
592
629
  export function parseXml(input, limits = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poe-platform/safe-fs",
3
- "version": "0.1.799",
3
+ "version": "0.1.801",
4
4
  "description": "Composable filesystem with a portable core and explicit Node adapters",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -133,9 +133,16 @@
133
133
  "access": "public"
134
134
  },
135
135
  "dependencies": {
136
- "@types/node": "^25.2.2"
136
+ "@types/node": "^25.2.2",
137
+ "@noble/hashes": "2.4.0"
137
138
  },
138
139
  "imports": {
140
+ "#safe-fs-s3-request": {
141
+ "types": "./dist/safe-fs/fs/s3/http/request-node.d.ts",
142
+ "workerd": "./dist/safe-fs/fs/s3/http/request-fetch.js",
143
+ "browser": "./dist/safe-fs/fs/s3/http/request-fetch.js",
144
+ "default": "./dist/safe-fs/fs/s3/http/request-node.js"
145
+ },
139
146
  "#safe-fs-platform": {
140
147
  "types": {
141
148
  "workerd": "./dist/safe-fs/platform/browser.d.ts",