@poe-platform/safe-fs 0.1.799 → 0.1.800

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.
@@ -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");
@@ -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);
@@ -296,7 +305,7 @@ export function* parseXmlSteps(input, limits = {}) {
296
305
  }
297
306
  if (text.indexOf("]]>") >= 0)
298
307
  invalid("CDATA terminator in text");
299
- const resolved = stack.length ? (text.indexOf("&") < 0 ? (pendingWork += text.length, text) : yield* entities(text)) : text;
308
+ const resolved = stack.length ? (text.indexOf("&") < 0 ? (pendingWork += text.length, text) : yield* entities(text, limits.recover)) : text;
300
309
  while (pendingWork >= 512) {
301
310
  yield 512;
302
311
  pendingWork -= 512;
@@ -412,8 +421,11 @@ export function* parseXmlSteps(input, limits = {}) {
412
421
  yield 512;
413
422
  pendingWork -= 512;
414
423
  }
415
- if (source[offset++] !== ">" || stack.pop()?.name !== name)
416
- invalid("mismatched closing tag");
424
+ if (source[offset++] !== ">" || stack.pop()?.name !== name) {
425
+ if (!limits.recover)
426
+ invalid("mismatched closing tag");
427
+ limits.recover("mismatched closing tag");
428
+ }
417
429
  }
418
430
  else {
419
431
  offset++;
@@ -441,7 +453,7 @@ export function* parseXmlSteps(input, limits = {}) {
441
453
  pendingWork -= 512;
442
454
  }
443
455
  const ch = source.charCodeAt(offset);
444
- if (ch === 47 || ch === 62)
456
+ if (ch === 47 || ch === 62 || limits.recover && offset === source.length)
445
457
  break;
446
458
  if (ws === 0)
447
459
  invalid("attributes require whitespace");
@@ -482,7 +494,7 @@ export function* parseXmlSteps(input, limits = {}) {
482
494
  if (raw.indexOf("<") >= 0)
483
495
  invalid("less-than in attribute");
484
496
  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);
497
+ const value = normalized.indexOf("&") < 0 ? (pendingWork += normalized.length, normalized) : yield* entities(normalized, limits.recover);
486
498
  while (pendingWork >= 512) {
487
499
  yield 512;
488
500
  pendingWork -= 512;
@@ -571,8 +583,11 @@ export function* parseXmlSteps(input, limits = {}) {
571
583
  const empty = source[offset] === "/";
572
584
  if (empty)
573
585
  offset++;
574
- if (source[offset++] !== ">")
575
- invalid("unterminated start tag");
586
+ if (source[offset++] !== ">") {
587
+ if (!limits.recover)
588
+ invalid("unterminated start tag");
589
+ limits.recover("unterminated start tag");
590
+ }
576
591
  // Only cache compact, attribute-free spellings. Namespace resolution and
577
592
  // every admission counter still run for each distinct physical element.
578
593
  if (empty && !attributes && name.length <= 512 && source.slice(offset - name.length - 3, offset) === `<${name}/>`)
@@ -585,8 +600,13 @@ export function* parseXmlSteps(input, limits = {}) {
585
600
  yield pendingWork;
586
601
  pendingWork = 0;
587
602
  }
588
- if (stack.length || !root)
603
+ if (!root)
589
604
  invalid("incomplete document");
605
+ if (stack.length) {
606
+ if (!limits.recover)
607
+ invalid("incomplete document");
608
+ limits.recover("incomplete document");
609
+ }
590
610
  return root;
591
611
  }
592
612
  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.800",
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",