@indigoai-us/hq-cloud 6.14.7 → 6.14.9

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 (104) hide show
  1. package/dist/backup-prune.d.ts +68 -0
  2. package/dist/backup-prune.d.ts.map +1 -0
  3. package/dist/backup-prune.js +196 -0
  4. package/dist/backup-prune.js.map +1 -0
  5. package/dist/backup-prune.test.d.ts +2 -0
  6. package/dist/backup-prune.test.d.ts.map +1 -0
  7. package/dist/backup-prune.test.js +89 -0
  8. package/dist/backup-prune.test.js.map +1 -0
  9. package/dist/bin/backup-prune-runner.d.ts +3 -0
  10. package/dist/bin/backup-prune-runner.d.ts.map +1 -0
  11. package/dist/bin/backup-prune-runner.js +50 -0
  12. package/dist/bin/backup-prune-runner.js.map +1 -0
  13. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-company.js +8 -0
  15. package/dist/bin/sync-runner-company.js.map +1 -1
  16. package/dist/bin/sync-runner-planning.d.ts +1 -1
  17. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-planning.js +15 -1
  19. package/dist/bin/sync-runner-planning.js.map +1 -1
  20. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  21. package/dist/bin/sync-runner-watch-loop.js +28 -0
  22. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  23. package/dist/bin/sync-runner.d.ts +14 -0
  24. package/dist/bin/sync-runner.d.ts.map +1 -1
  25. package/dist/bin/sync-runner.js +26 -0
  26. package/dist/bin/sync-runner.js.map +1 -1
  27. package/dist/bin/sync-runner.test.js +68 -1
  28. package/dist/bin/sync-runner.test.js.map +1 -1
  29. package/dist/cli/rescue-classify-ordering.test.js +105 -0
  30. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  31. package/dist/cli/rescue-core.d.ts +1 -0
  32. package/dist/cli/rescue-core.d.ts.map +1 -1
  33. package/dist/cli/rescue-core.js +478 -300
  34. package/dist/cli/rescue-core.js.map +1 -1
  35. package/dist/cli/rescue-snapshot.d.ts +14 -0
  36. package/dist/cli/rescue-snapshot.d.ts.map +1 -0
  37. package/dist/cli/rescue-snapshot.js +39 -0
  38. package/dist/cli/rescue-snapshot.js.map +1 -0
  39. package/dist/cli/rescue-snapshot.test.d.ts +2 -0
  40. package/dist/cli/rescue-snapshot.test.d.ts.map +1 -0
  41. package/dist/cli/rescue-snapshot.test.js +46 -0
  42. package/dist/cli/rescue-snapshot.test.js.map +1 -0
  43. package/dist/cli/sync-scope.test.js +3 -1
  44. package/dist/cli/sync-scope.test.js.map +1 -1
  45. package/dist/cli/sync.d.ts +6 -6
  46. package/dist/cli/sync.d.ts.map +1 -1
  47. package/dist/cli/sync.js +51 -41
  48. package/dist/cli/sync.js.map +1 -1
  49. package/dist/cli/sync.test.js +102 -2
  50. package/dist/cli/sync.test.js.map +1 -1
  51. package/dist/manifest-reconcile.d.ts +27 -0
  52. package/dist/manifest-reconcile.d.ts.map +1 -0
  53. package/dist/manifest-reconcile.js +120 -0
  54. package/dist/manifest-reconcile.js.map +1 -0
  55. package/dist/manifest-reconcile.test.d.ts +2 -0
  56. package/dist/manifest-reconcile.test.d.ts.map +1 -0
  57. package/dist/manifest-reconcile.test.js +97 -0
  58. package/dist/manifest-reconcile.test.js.map +1 -0
  59. package/dist/object-io.d.ts +19 -2
  60. package/dist/object-io.d.ts.map +1 -1
  61. package/dist/object-io.js +168 -32
  62. package/dist/object-io.js.map +1 -1
  63. package/dist/object-io.test.js +157 -1
  64. package/dist/object-io.test.js.map +1 -1
  65. package/dist/s3.d.ts +12 -1
  66. package/dist/s3.d.ts.map +1 -1
  67. package/dist/s3.js +51 -18
  68. package/dist/s3.js.map +1 -1
  69. package/dist/s3.test.js +12 -1
  70. package/dist/s3.test.js.map +1 -1
  71. package/dist/signals/get.test.js +21 -1
  72. package/dist/signals/get.test.js.map +1 -1
  73. package/dist/signals/list.test.js +21 -1
  74. package/dist/signals/list.test.js.map +1 -1
  75. package/dist/sources/get.test.js +21 -1
  76. package/dist/sources/get.test.js.map +1 -1
  77. package/dist/sources/list.test.js +21 -1
  78. package/dist/sources/list.test.js.map +1 -1
  79. package/package.json +3 -2
  80. package/src/backup-prune.test.ts +98 -0
  81. package/src/backup-prune.ts +182 -0
  82. package/src/bin/backup-prune-runner.ts +33 -0
  83. package/src/bin/sync-runner-company.ts +7 -0
  84. package/src/bin/sync-runner-planning.ts +16 -2
  85. package/src/bin/sync-runner-watch-loop.ts +18 -0
  86. package/src/bin/sync-runner.test.ts +82 -1
  87. package/src/bin/sync-runner.ts +45 -0
  88. package/src/cli/rescue-classify-ordering.test.ts +121 -0
  89. package/src/cli/rescue-core.ts +261 -86
  90. package/src/cli/rescue-snapshot.test.ts +57 -0
  91. package/src/cli/rescue-snapshot.ts +51 -0
  92. package/src/cli/sync-scope.test.ts +3 -1
  93. package/src/cli/sync.test.ts +121 -2
  94. package/src/cli/sync.ts +71 -53
  95. package/src/manifest-reconcile.test.ts +107 -0
  96. package/src/manifest-reconcile.ts +160 -0
  97. package/src/object-io.test.ts +175 -0
  98. package/src/object-io.ts +213 -32
  99. package/src/s3.test.ts +18 -0
  100. package/src/s3.ts +64 -30
  101. package/src/signals/get.test.ts +26 -2
  102. package/src/signals/list.test.ts +26 -2
  103. package/src/sources/get.test.ts +26 -2
  104. package/src/sources/list.test.ts +26 -2
@@ -9,12 +9,17 @@
9
9
  */
10
10
 
11
11
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
12
+ import * as http from "node:http";
13
+ import { once } from "node:events";
14
+ import { spawn } from "node:child_process";
12
15
  import {
13
16
  PresignObjectIO,
14
17
  S3SdkObjectIO,
15
18
  setObjectIOFactory,
19
+ setPresignedGetTransportForTesting,
16
20
  resolveObjectIO,
17
21
  presignObjectIOFactory,
22
+ type PresignedGetTransport,
18
23
  type PresignTransportClient,
19
24
  } from "./object-io.js";
20
25
  import type { EntityContext } from "./types.js";
@@ -23,6 +28,55 @@ import { isAccessDenied } from "./sync-core.js";
23
28
 
24
29
  const COMPANY = "cmp_test";
25
30
 
31
+ function fetchBackedPresignedGetTransport(): PresignedGetTransport {
32
+ return {
33
+ async get(url, headers) {
34
+ const response = await fetch(url, { method: "GET", headers });
35
+ const body = response.body;
36
+ return {
37
+ status: response.status,
38
+ headers: response.headers,
39
+ body: body
40
+ ? (async function* (): AsyncIterable<Uint8Array> {
41
+ const reader = body.getReader();
42
+ let exhausted = false;
43
+ try {
44
+ while (true) {
45
+ const { done, value } = await reader.read();
46
+ if (done) {
47
+ exhausted = true;
48
+ return;
49
+ }
50
+ if (value.byteLength > 0) yield value;
51
+ }
52
+ } finally {
53
+ if (!exhausted) {
54
+ try {
55
+ await reader.cancel();
56
+ } catch {
57
+ // The response may already have been cancelled by destroy.
58
+ }
59
+ }
60
+ reader.releaseLock();
61
+ }
62
+ })()
63
+ : undefined,
64
+ destroy: () => {
65
+ void body?.cancel().catch(() => undefined);
66
+ },
67
+ };
68
+ },
69
+ };
70
+ }
71
+
72
+ beforeEach(() => {
73
+ setPresignedGetTransportForTesting(fetchBackedPresignedGetTransport());
74
+ });
75
+
76
+ afterEach(() => {
77
+ setPresignedGetTransportForTesting(null);
78
+ });
79
+
26
80
  function ctx(): EntityContext {
27
81
  return {
28
82
  uid: COMPANY,
@@ -506,6 +560,27 @@ describe("PresignObjectIO.getObject", () => {
506
560
  });
507
561
  });
508
562
 
563
+ it("drains the injected transport body instead of returning empty content", async () => {
564
+ const { vault, setPresign } = makeVault();
565
+ setPresign([{ key: "shared/a.md", op: "get", url: "https://s3/get" }]);
566
+ const transportGet = vi.fn(async () => ({
567
+ status: 200,
568
+ headers: new Headers({ "x-amz-meta-source": "transport" }),
569
+ body: (async function* (): AsyncIterable<Uint8Array> {
570
+ yield Buffer.from("transport-bytes");
571
+ })(),
572
+ destroy: () => undefined,
573
+ }));
574
+ setPresignedGetTransportForTesting({ get: transportGet });
575
+
576
+ const io = new PresignObjectIO(vault, COMPANY);
577
+ const res = await io.getObject("shared/a.md");
578
+
579
+ expect(res.body.toString("utf-8")).toBe("transport-bytes");
580
+ expect(res.metadata).toEqual({ source: "transport" });
581
+ expect(transportGet).toHaveBeenCalledWith("https://s3/get", undefined);
582
+ });
583
+
509
584
  it("throws a NotFound-named error on 404 (so s3.ts catch sites match)", async () => {
510
585
  const { vault, setPresign } = makeVault();
511
586
  setPresign([{ key: "gone", op: "get", url: "https://s3/get" }]);
@@ -1044,3 +1119,103 @@ describe("presignObjectIOFactory — personal vaults route to S3 SDK", () => {
1044
1119
  expect(personal).toBeInstanceOf(S3SdkObjectIO);
1045
1120
  });
1046
1121
  });
1122
+
1123
+ describe("PresignObjectIO — core HTTP presigned GET", () => {
1124
+ function streamingVault(baseUrl: string): PresignTransportClient {
1125
+ return {
1126
+ presign: async (input) => ({
1127
+ results: input.keys.map((key) => ({
1128
+ key: key.key,
1129
+ op: "get" as const,
1130
+ url: `${baseUrl}/${key.key}`,
1131
+ })),
1132
+ expiresAt: "2099-01-01T00:00:00.000Z",
1133
+ }),
1134
+ listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
1135
+ };
1136
+ }
1137
+
1138
+ it("destroys an early-exit response and rejects a FIN-short body", async () => {
1139
+ setPresignedGetTransportForTesting(null);
1140
+ let responseClosed: Promise<unknown> | undefined;
1141
+ const server = http.createServer((req, res) => {
1142
+ if (req.url === "/short") {
1143
+ res.writeHead(200, { "content-length": "10" });
1144
+ res.end("short");
1145
+ return;
1146
+ }
1147
+
1148
+ res.writeHead(200, { "content-length": "131072" });
1149
+ res.write(Buffer.alloc(65_536, 1));
1150
+ responseClosed = once(res, "close");
1151
+ const finish = setTimeout(() => res.end(Buffer.alloc(65_536, 2)), 1_000);
1152
+ res.once("close", () => clearTimeout(finish));
1153
+ });
1154
+ server.listen(0, "127.0.0.1");
1155
+ await once(server, "listening");
1156
+ const address = server.address();
1157
+ if (!address || typeof address === "string") {
1158
+ throw new Error("test server did not bind a TCP port");
1159
+ }
1160
+
1161
+ try {
1162
+ const io = new PresignObjectIO(
1163
+ streamingVault(`http://127.0.0.1:${address.port}`),
1164
+ COMPANY,
1165
+ );
1166
+ const streamed = await io.getObjectStream("large");
1167
+ const iterator = streamed.body[Symbol.asyncIterator]();
1168
+ expect((await iterator.next()).value).toBeInstanceOf(Uint8Array);
1169
+ await iterator.return?.();
1170
+ if (!responseClosed) throw new Error("response did not start streaming");
1171
+ await expect(responseClosed).resolves.toBeDefined();
1172
+
1173
+ await expect(io.getObject("short")).rejects.toThrow(
1174
+ /content-length mismatch/,
1175
+ );
1176
+ } finally {
1177
+ server.close();
1178
+ await once(server, "close");
1179
+ }
1180
+ });
1181
+
1182
+ it.runIf(
1183
+ process.platform === "win32" && /^(22|24)\./.test(process.versions.node),
1184
+ )("keeps a child FIN safe after the consumer has applied backpressure", async () => {
1185
+ setPresignedGetTransportForTesting(null);
1186
+ const child = spawn(process.execPath, [
1187
+ "-e",
1188
+ [
1189
+ "const http = require('node:http');",
1190
+ "const body = Buffer.alloc(524288, 7);",
1191
+ "const server = http.createServer((_req, res) => {",
1192
+ " res.writeHead(200, { 'content-length': String(body.length) });",
1193
+ " for (let offset = 0; offset < body.length; offset += 16384) res.write(body.subarray(offset, offset + 16384));",
1194
+ " res.end();",
1195
+ "});",
1196
+ "server.listen(0, '127.0.0.1', () => process.stdout.write(String(server.address().port) + '\\n'));",
1197
+ "process.on('SIGTERM', () => server.close(() => process.exit(0)));",
1198
+ ].join("\n"),
1199
+ ], { stdio: ["ignore", "pipe", "inherit"] });
1200
+
1201
+ const port = await new Promise<number>((resolve, reject) => {
1202
+ child.once("error", reject);
1203
+ child.stdout?.once("data", (data: Buffer) => resolve(Number(data.toString())));
1204
+ child.once("exit", (code) => reject(new Error(`child server exited (${code})`)));
1205
+ });
1206
+
1207
+ try {
1208
+ const io = new PresignObjectIO(
1209
+ streamingVault(`http://127.0.0.1:${port}`),
1210
+ COMPANY,
1211
+ );
1212
+ const streamed = await io.getObjectStream("bulk");
1213
+ await new Promise((resolve) => setTimeout(resolve, 40));
1214
+ let bytes = 0;
1215
+ for await (const chunk of streamed.body) bytes += chunk.byteLength;
1216
+ expect(bytes).toBe(524_288);
1217
+ } finally {
1218
+ child.kill("SIGTERM");
1219
+ }
1220
+ });
1221
+ });
package/src/object-io.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * - `PresignObjectIO` — the presigned-URL path. The vault-service decides
14
14
  * access as a runtime DDB check (no IAM policy ceiling) and hands back
15
15
  * short-lived presigned GET/PUT/DELETE URLs + (for PUT) the exact headers
16
- * to replay. The client never holds AWS credentials — it just `fetch`es
17
- * the signed URLs.
16
+ * to replay. The client never holds AWS credentials — it requests the
17
+ * signed URLs directly.
18
18
  *
19
19
  * The seam is a per-EntityContext factory resolved INSIDE s3.ts, so every
20
20
  * existing call site (`uploadFile(ctx, …)`, `downloadFile(ctx, …)`, …) keeps
@@ -31,6 +31,9 @@ import {
31
31
  DeleteObjectCommand,
32
32
  HeadObjectCommand,
33
33
  } from "@aws-sdk/client-s3";
34
+ import * as http from "node:http";
35
+ import * as https from "node:https";
36
+ import type { IncomingMessage } from "node:http";
34
37
  import type { EntityContext } from "./types.js";
35
38
  import type {
36
39
  PresignOp,
@@ -344,6 +347,141 @@ function metaFromHeaders(headers: Headers): Record<string, string> {
344
347
  return meta;
345
348
  }
346
349
 
350
+ /**
351
+ * Response shape shared by every presigned GET consumer. Unlike the runtime
352
+ * `fetch` response, its body is backed by Node's core HTTP client, so it never
353
+ * enters the runtime-bundled undici parser path.
354
+ */
355
+ export interface PresignedGetResponse {
356
+ status: number;
357
+ headers: Headers;
358
+ body?: AsyncIterable<Uint8Array>;
359
+ destroy(): void;
360
+ }
361
+
362
+ /** Transport seam retained for deterministic wire-level regression tests. */
363
+ export interface PresignedGetTransport {
364
+ get(
365
+ url: string,
366
+ headers: Record<string, string> | undefined,
367
+ ): Promise<PresignedGetResponse>;
368
+ }
369
+
370
+ function headersFromNode(
371
+ headers: IncomingMessage["headers"],
372
+ ): Headers {
373
+ const out = new Headers();
374
+ for (const [name, value] of Object.entries(headers)) {
375
+ if (value === undefined) continue;
376
+ out.set(name, Array.isArray(value) ? value.join(", ") : value);
377
+ }
378
+ return out;
379
+ }
380
+
381
+ function expectedContentLength(headers: Headers): number | undefined {
382
+ const value = headers.get("content-length");
383
+ if (value === null) return undefined;
384
+ if (!/^\d+$/.test(value)) {
385
+ throw new Error(`invalid presigned GET content-length: ${value}`);
386
+ }
387
+ const length = Number(value);
388
+ if (!Number.isSafeInteger(length)) {
389
+ throw new Error(`invalid presigned GET content-length: ${value}`);
390
+ }
391
+ return length;
392
+ }
393
+
394
+ function contentLengthError(expected: number, actual: number): Error {
395
+ return new Error(
396
+ `presigned GET content-length mismatch: expected ${expected} bytes, received ${actual}`,
397
+ );
398
+ }
399
+
400
+ async function* checkedNodeBody(
401
+ message: IncomingMessage,
402
+ expected: number | undefined,
403
+ ): AsyncIterable<Uint8Array> {
404
+ let received = 0;
405
+ let exhausted = false;
406
+ try {
407
+ try {
408
+ for await (const chunk of message) {
409
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
410
+ received += bytes.byteLength;
411
+ if (expected !== undefined && received > expected) {
412
+ throw contentLengthError(expected, received);
413
+ }
414
+ yield bytes;
415
+ }
416
+ } catch (err) {
417
+ // A FIN before Node has received the declared bytes normally surfaces as
418
+ // an "aborted" stream error. Preserve the useful data-integrity cause.
419
+ if (expected !== undefined && received !== expected) {
420
+ throw contentLengthError(expected, received);
421
+ }
422
+ throw err;
423
+ }
424
+ exhausted = true;
425
+ if (expected !== undefined && received !== expected) {
426
+ throw contentLengthError(expected, received);
427
+ }
428
+ } finally {
429
+ // Consumers such as HEAD only inspect headers, and a caller can stop after
430
+ // a prefix. Destroying in both cases releases the socket immediately.
431
+ if (!exhausted) message.destroy();
432
+ }
433
+ }
434
+
435
+ function nodePresignedGet(
436
+ url: string,
437
+ headers: Record<string, string> | undefined,
438
+ ): Promise<PresignedGetResponse> {
439
+ const target = new URL(url);
440
+ return new Promise((resolve, reject) => {
441
+ const handleResponse = (message: IncomingMessage): void => {
442
+ const responseHeaders = headersFromNode(message.headers);
443
+ let expected: number | undefined;
444
+ try {
445
+ expected = expectedContentLength(responseHeaders);
446
+ } catch (err) {
447
+ message.destroy();
448
+ reject(err);
449
+ return;
450
+ }
451
+ resolve({
452
+ status: message.statusCode ?? 0,
453
+ headers: responseHeaders,
454
+ body: checkedNodeBody(message, expected),
455
+ destroy: () => message.destroy(),
456
+ });
457
+ };
458
+ const request = target.protocol === "https:"
459
+ ? https.request(target, { method: "GET", headers }, handleResponse)
460
+ : target.protocol === "http:"
461
+ ? http.request(target, { method: "GET", headers }, handleResponse)
462
+ : undefined;
463
+ if (!request) {
464
+ reject(new Error(`unsupported presigned GET protocol: ${target.protocol}`));
465
+ return;
466
+ }
467
+ request.once("error", reject);
468
+ request.end();
469
+ });
470
+ }
471
+
472
+ const NODE_PRESIGNED_GET_TRANSPORT: PresignedGetTransport = {
473
+ get: nodePresignedGet,
474
+ };
475
+
476
+ let presignedGetTransport: PresignedGetTransport = NODE_PRESIGNED_GET_TRANSPORT;
477
+
478
+ /** Test-only override; production always uses Node's core HTTP(S) transport. */
479
+ export function setPresignedGetTransportForTesting(
480
+ transport: PresignedGetTransport | null,
481
+ ): void {
482
+ presignedGetTransport = transport ?? NODE_PRESIGNED_GET_TRANSPORT;
483
+ }
484
+
347
485
  /**
348
486
  * Per-key presign denial codes that are EXPECTED and must be reclassified as
349
487
  * skip-with-log — throwing the `name: "Forbidden"` shape (see
@@ -825,20 +963,21 @@ export class PresignObjectIO implements ObjectIO {
825
963
 
826
964
  async getObjectStream(key: string): Promise<GetObjectStreamResult> {
827
965
  const row = await this.resolveGetUrlForBody(key);
828
- const res = await fetchWithRetry(row.url, { method: "GET" }, `presigned GET ${key}`);
966
+ const res = await presignedGetWithRetry(
967
+ row.url,
968
+ row.headers,
969
+ `presigned GET ${key}`,
970
+ );
829
971
  if (res.status === 404) {
830
- await cancelBody(res);
972
+ res.destroy();
831
973
  throw notFoundError(key);
832
974
  }
833
- if (!res.ok) {
834
- const detail = await safeText(res);
975
+ if (res.status < 200 || res.status >= 300) {
976
+ const detail = await safePresignedGetText(res);
835
977
  throw new Error(`presigned GET failed for ${key}: ${res.status} ${detail}`);
836
978
  }
837
- if (!res.body) {
838
- return { body: (async function* () {})(), metadata: metaFromHeaders(res.headers) };
839
- }
840
979
  return {
841
- body: webReadableToAsyncIterable(res.body),
980
+ body: res.body ?? (async function* () {})(),
842
981
  metadata: metaFromHeaders(res.headers),
843
982
  };
844
983
  }
@@ -933,9 +1072,13 @@ export class PresignObjectIO implements ObjectIO {
933
1072
  }
934
1073
  url = row.url;
935
1074
  }
936
- const res = await fetchWithRetry(url, { method: "GET" }, `presigned HEAD ${key}`);
1075
+ const res = await presignedGetWithRetry(
1076
+ url,
1077
+ undefined,
1078
+ `presigned HEAD ${key}`,
1079
+ );
937
1080
  if (res.status === 404) {
938
- await cancelBody(res);
1081
+ res.destroy();
939
1082
  return null;
940
1083
  }
941
1084
  if (res.status === 403) {
@@ -944,12 +1087,11 @@ export class PresignObjectIO implements ObjectIO {
944
1087
  // denial branch above. The SDK transport throws name:"Forbidden" here;
945
1088
  // mirror it so both transports agree and no caller mistakes a denial
946
1089
  // for a missing object.
947
- await cancelBody(res);
1090
+ res.destroy();
948
1091
  throw accessDeniedError(key, "presigned HEAD returned 403");
949
1092
  }
950
- if (!res.ok) {
951
- await cancelBody(res);
952
- const detail = await safeText(res);
1093
+ if (res.status < 200 || res.status >= 300) {
1094
+ const detail = await safePresignedGetText(res);
953
1095
  throw new Error(`presigned HEAD failed for ${key}: ${res.status} ${detail}`);
954
1096
  }
955
1097
  const result: HeadObjectResult = {
@@ -958,7 +1100,7 @@ export class PresignObjectIO implements ObjectIO {
958
1100
  size: Number(res.headers.get("content-length") ?? "0"),
959
1101
  metadata: metaFromHeaders(res.headers),
960
1102
  };
961
- await cancelBody(res);
1103
+ res.destroy();
962
1104
  return result;
963
1105
  }
964
1106
  }
@@ -979,21 +1121,6 @@ async function cancelBody(res: Response): Promise<void> {
979
1121
  }
980
1122
  }
981
1123
 
982
- async function* webReadableToAsyncIterable(
983
- stream: ReadableStream<Uint8Array>,
984
- ): AsyncIterable<Uint8Array> {
985
- const reader = stream.getReader();
986
- try {
987
- while (true) {
988
- const { done, value } = await reader.read();
989
- if (done) return;
990
- if (value && value.byteLength > 0) yield value;
991
- }
992
- } finally {
993
- reader.releaseLock();
994
- }
995
- }
996
-
997
1124
  function parseLastModified(value: string | null): Date {
998
1125
  if (!value) return new Date();
999
1126
  const d = new Date(value);
@@ -1023,6 +1150,60 @@ function sleep(ms: number): Promise<void> {
1023
1150
  return new Promise((resolve) => setTimeout(resolve, ms));
1024
1151
  }
1025
1152
 
1153
+ /**
1154
+ * Shared presigned-GET path for streaming reads and header-only probes.
1155
+ *
1156
+ * GET deliberately uses Node core HTTP(S), not global fetch: on affected
1157
+ * Node/Windows builds the bundled undici parser can assert after a FIN arrives
1158
+ * while the consumer is backpressured. Keep the retry budget identical to the
1159
+ * PUT/DELETE fetch path below.
1160
+ */
1161
+ async function presignedGetWithRetry(
1162
+ url: string,
1163
+ headers: Record<string, string> | undefined,
1164
+ what: string,
1165
+ ): Promise<PresignedGetResponse> {
1166
+ let lastError: unknown;
1167
+ for (let attempt = 0; attempt <= FETCH_MAX_RETRIES; attempt++) {
1168
+ if (attempt > 0) {
1169
+ const backoff = FETCH_BASE_DELAY_MS * 2 ** (attempt - 1);
1170
+ const jitter = Math.floor(Math.random() * FETCH_BASE_DELAY_MS);
1171
+ await sleep(backoff + jitter);
1172
+ }
1173
+ let res: PresignedGetResponse;
1174
+ try {
1175
+ res = await presignedGetTransport.get(url, headers);
1176
+ } catch (err) {
1177
+ lastError = err;
1178
+ continue;
1179
+ }
1180
+ if (isTransientStatus(res.status) && attempt < FETCH_MAX_RETRIES) {
1181
+ res.destroy();
1182
+ lastError = new Error(`${what}: transient ${res.status}`);
1183
+ continue;
1184
+ }
1185
+ return res;
1186
+ }
1187
+ throw lastError instanceof Error
1188
+ ? lastError
1189
+ : new Error(`${what}: failed after ${FETCH_MAX_RETRIES} retries`);
1190
+ }
1191
+
1192
+ async function safePresignedGetText(res: PresignedGetResponse): Promise<string> {
1193
+ try {
1194
+ let text = "";
1195
+ for await (const chunk of res.body ?? []) {
1196
+ text += Buffer.from(chunk).toString("utf-8");
1197
+ if (text.length >= 200) break;
1198
+ }
1199
+ return text.slice(0, 200);
1200
+ } catch {
1201
+ return "";
1202
+ } finally {
1203
+ res.destroy();
1204
+ }
1205
+ }
1206
+
1026
1207
  /**
1027
1208
  * `fetch` with bounded retry on network errors + transient 5xx. The presigned
1028
1209
  * URL is reusable until expiry and the bodies are in-memory Buffers, so a
package/src/s3.test.ts CHANGED
@@ -93,6 +93,7 @@ import {
93
93
  decodeSymlinkMetadataValue,
94
94
  FILE_MTIME_META_KEY,
95
95
  FILE_BTIME_META_KEY,
96
+ classifyVaultKey,
96
97
  validateVaultUploadKey,
97
98
  } from "./s3.js";
98
99
  import {
@@ -1450,6 +1451,23 @@ describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 202
1450
1451
  },
1451
1452
  );
1452
1453
 
1454
+ it.each([
1455
+ [
1456
+ "companies/indigo/knowledge/x.md",
1457
+ "company",
1458
+ "INVALID_KEY_COMPANIES_SCOPED",
1459
+ ],
1460
+ ["bad\x00key.md", "personal", "INVALID_KEY_CONTROL_CHARS"],
1461
+ ["docs/good.md", "personal", null],
1462
+ ] as const)(
1463
+ "classifyVaultKey reports the validator outcome without throwing: %j",
1464
+ (key, scope, code) => {
1465
+ expect(classifyVaultKey(key, scope)).toEqual(
1466
+ code === null ? null : expect.objectContaining({ code }),
1467
+ );
1468
+ },
1469
+ );
1470
+
1453
1471
  it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1454
1472
  await expect(
1455
1473
  uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
package/src/s3.ts CHANGED
@@ -561,67 +561,101 @@ export function toPosixKey(key: string): string {
561
561
  * (`cmp_*` → "company", otherwise "personal").
562
562
  *
563
563
  * Errors carry the same `code` values the server throws so telemetry and
564
- * operator messaging line up across client and server.
564
+ * operator messaging line up across client and server. Pull planning also
565
+ * uses the non-throwing classifier below to keep poison keys away from the
566
+ * presign transport.
565
567
  */
566
568
  // Matching control chars is the whole point — block NUL/0x00–0x1F/0x7F in keys
567
569
  // (they can smuggle past path checks or corrupt HTTP headers on the signed URL).
568
570
  // eslint-disable-next-line no-control-regex
569
571
  const KEY_CONTROL_CHARS = /[\x00-\x1F\x7F]/;
570
572
 
571
- export function validateVaultUploadKey(
573
+ export type VaultKeyScope = "company" | "personal";
574
+
575
+ export interface VaultKeyValidationIssue {
576
+ code: string;
577
+ message: string;
578
+ }
579
+
580
+ /**
581
+ * Classify a vault key with the same rules as {@link validateVaultUploadKey}
582
+ * without throwing. Callers that enumerate remote objects can use this before
583
+ * presigning so permanently-invalid legacy keys remain benign skips.
584
+ */
585
+ export function classifyVaultKey(
572
586
  key: string,
573
- scope: "company" | "personal",
574
- ): void {
575
- const fail = (message: string, code: string): never => {
576
- throw Object.assign(new Error(message), { code });
577
- };
587
+ scope: VaultKeyScope,
588
+ ): VaultKeyValidationIssue | null {
578
589
  if (key.length === 0) {
579
- fail("Invalid key: must not be empty", "INVALID_KEY_EMPTY");
590
+ return {
591
+ code: "INVALID_KEY_EMPTY",
592
+ message: "Invalid key: must not be empty",
593
+ };
580
594
  }
581
595
  if (key.startsWith("/")) {
582
- fail("Invalid key: leading '/' not allowed", "INVALID_KEY_LEADING_SLASH");
596
+ return {
597
+ code: "INVALID_KEY_LEADING_SLASH",
598
+ message: "Invalid key: leading '/' not allowed",
599
+ };
583
600
  }
584
601
  if (key.endsWith("/")) {
585
- fail(
586
- "Invalid key: trailing '/' (folder marker, not an object)",
587
- "INVALID_KEY_TRAILING_SLASH",
588
- );
602
+ return {
603
+ code: "INVALID_KEY_TRAILING_SLASH",
604
+ message: "Invalid key: trailing '/' (folder marker, not an object)",
605
+ };
589
606
  }
590
607
  if (scope === "company" && key.startsWith("companies/")) {
591
- fail(
592
- "Invalid key: vault keys are bucket-relative and the vault is already " +
608
+ return {
609
+ code: "INVALID_KEY_COMPANIES_SCOPED",
610
+ message:
611
+ "Invalid key: vault keys are bucket-relative and the vault is already " +
593
612
  "company-scoped — do not prefix with 'companies/<slug>/'. This " +
594
613
  "usually means a stale doubled local tree " +
595
614
  "(companies/<slug>/companies/<slug>/…); remove the inner copy.",
596
- "INVALID_KEY_COMPANIES_SCOPED",
597
- );
615
+ };
598
616
  }
599
617
  if (KEY_CONTROL_CHARS.test(key)) {
600
- fail("Invalid key: contains control characters", "INVALID_KEY_CONTROL_CHARS");
618
+ return {
619
+ code: "INVALID_KEY_CONTROL_CHARS",
620
+ message: "Invalid key: contains control characters",
621
+ };
601
622
  }
602
623
  if (key.includes("\\")) {
603
- // Unreachable after toPosixKey normalization at the upload boundary, but
604
- // kept so the validator mirrors the server rule set 1:1 for other callers.
605
- fail(
606
- "Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
607
- "INVALID_KEY_BACKSLASH",
608
- );
624
+ return {
625
+ code: "INVALID_KEY_BACKSLASH",
626
+ message:
627
+ "Invalid key: backslash separators are not allowed — vault keys are POSIX ('/')",
628
+ };
609
629
  }
610
630
  if (key.includes("//")) {
611
- fail("Invalid key: consecutive slashes not allowed", "INVALID_KEY_DOUBLE_SLASH");
631
+ return {
632
+ code: "INVALID_KEY_DOUBLE_SLASH",
633
+ message: "Invalid key: consecutive slashes not allowed",
634
+ };
612
635
  }
613
636
  for (const segment of key.split("/")) {
614
637
  if (segment === "." || segment === "..") {
615
- fail(
616
- "Invalid key: '.' and '..' path components are not allowed",
617
- "INVALID_KEY_DOT_COMPONENT",
618
- );
638
+ return {
639
+ code: "INVALID_KEY_DOT_COMPONENT",
640
+ message: "Invalid key: '.' and '..' path components are not allowed",
641
+ };
619
642
  }
620
643
  }
644
+ return null;
645
+ }
646
+
647
+ export function validateVaultUploadKey(
648
+ key: string,
649
+ scope: VaultKeyScope,
650
+ ): void {
651
+ const issue = classifyVaultKey(key, scope);
652
+ if (issue !== null) {
653
+ throw Object.assign(new Error(issue.message), { code: issue.code });
654
+ }
621
655
  }
622
656
 
623
657
  /** Scope for {@link validateVaultUploadKey}, derived from the entity uid. */
624
- function uploadScopeFor(ctx: EntityContext): "company" | "personal" {
658
+ function uploadScopeFor(ctx: EntityContext): VaultKeyScope {
625
659
  return ctx.uid.startsWith("cmp_") ? "company" : "personal";
626
660
  }
627
661