@indigoai-us/hq-cloud 6.11.10 → 6.11.12

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 (173) hide show
  1. package/dist/bin/sync-runner.d.ts +2 -0
  2. package/dist/bin/sync-runner.d.ts.map +1 -1
  3. package/dist/bin/sync-runner.js +231 -52
  4. package/dist/bin/sync-runner.js.map +1 -1
  5. package/dist/bin/sync-runner.test.js +330 -11
  6. package/dist/bin/sync-runner.test.js.map +1 -1
  7. package/dist/cli/reindex.d.ts.map +1 -1
  8. package/dist/cli/reindex.js +16 -1
  9. package/dist/cli/reindex.js.map +1 -1
  10. package/dist/cli/reindex.test.js +39 -1
  11. package/dist/cli/reindex.test.js.map +1 -1
  12. package/dist/cli/rescue-classify-ordering.test.js +58 -0
  13. package/dist/cli/rescue-classify-ordering.test.js.map +1 -1
  14. package/dist/cli/rescue-core.js +229 -15
  15. package/dist/cli/rescue-core.js.map +1 -1
  16. package/dist/cli/rescue-exec-bit-preserve.test.d.ts +2 -0
  17. package/dist/cli/rescue-exec-bit-preserve.test.d.ts.map +1 -0
  18. package/dist/cli/rescue-exec-bit-preserve.test.js +169 -0
  19. package/dist/cli/rescue-exec-bit-preserve.test.js.map +1 -0
  20. package/dist/cli/share.d.ts +2 -1
  21. package/dist/cli/share.d.ts.map +1 -1
  22. package/dist/cli/share.js +100 -32
  23. package/dist/cli/share.js.map +1 -1
  24. package/dist/cli/share.test.js +30 -0
  25. package/dist/cli/share.test.js.map +1 -1
  26. package/dist/cli/sync.d.ts +28 -1
  27. package/dist/cli/sync.d.ts.map +1 -1
  28. package/dist/cli/sync.js +188 -59
  29. package/dist/cli/sync.js.map +1 -1
  30. package/dist/cli/sync.test.js +487 -1
  31. package/dist/cli/sync.test.js.map +1 -1
  32. package/dist/cognito-auth.d.ts.map +1 -1
  33. package/dist/cognito-auth.js +55 -10
  34. package/dist/cognito-auth.js.map +1 -1
  35. package/dist/cognito-auth.test.js +61 -0
  36. package/dist/cognito-auth.test.js.map +1 -1
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/journal.d.ts.map +1 -1
  42. package/dist/journal.js +93 -6
  43. package/dist/journal.js.map +1 -1
  44. package/dist/journal.test.js +59 -0
  45. package/dist/journal.test.js.map +1 -1
  46. package/dist/machine-auth.test.js +60 -2
  47. package/dist/machine-auth.test.js.map +1 -1
  48. package/dist/object-io.d.ts +37 -1
  49. package/dist/object-io.d.ts.map +1 -1
  50. package/dist/object-io.js +148 -29
  51. package/dist/object-io.js.map +1 -1
  52. package/dist/object-io.test.js +121 -0
  53. package/dist/object-io.test.js.map +1 -1
  54. package/dist/operation-lock.d.ts +8 -8
  55. package/dist/operation-lock.d.ts.map +1 -1
  56. package/dist/operation-lock.js +99 -32
  57. package/dist/operation-lock.js.map +1 -1
  58. package/dist/operation-lock.test.js +51 -4
  59. package/dist/operation-lock.test.js.map +1 -1
  60. package/dist/personal-vault.d.ts +8 -0
  61. package/dist/personal-vault.d.ts.map +1 -1
  62. package/dist/personal-vault.js +17 -3
  63. package/dist/personal-vault.js.map +1 -1
  64. package/dist/personal-vault.test.js +34 -0
  65. package/dist/personal-vault.test.js.map +1 -1
  66. package/dist/prefix-coalesce.d.ts +20 -9
  67. package/dist/prefix-coalesce.d.ts.map +1 -1
  68. package/dist/prefix-coalesce.js +124 -28
  69. package/dist/prefix-coalesce.js.map +1 -1
  70. package/dist/prefix-coalesce.test.js +57 -2
  71. package/dist/prefix-coalesce.test.js.map +1 -1
  72. package/dist/remote-pull.d.ts +6 -1
  73. package/dist/remote-pull.d.ts.map +1 -1
  74. package/dist/remote-pull.js +62 -13
  75. package/dist/remote-pull.js.map +1 -1
  76. package/dist/remote-pull.test.js +189 -0
  77. package/dist/remote-pull.test.js.map +1 -1
  78. package/dist/s3.d.ts +2 -0
  79. package/dist/s3.d.ts.map +1 -1
  80. package/dist/s3.js +197 -116
  81. package/dist/s3.js.map +1 -1
  82. package/dist/s3.test.js +109 -0
  83. package/dist/s3.test.js.map +1 -1
  84. package/dist/scope-shrink.d.ts +3 -2
  85. package/dist/scope-shrink.d.ts.map +1 -1
  86. package/dist/scope-shrink.js +1 -1
  87. package/dist/scope-shrink.js.map +1 -1
  88. package/dist/skill-telemetry.d.ts +1 -1
  89. package/dist/skill-telemetry.d.ts.map +1 -1
  90. package/dist/skill-telemetry.js +69 -9
  91. package/dist/skill-telemetry.js.map +1 -1
  92. package/dist/skill-telemetry.test.js +86 -0
  93. package/dist/skill-telemetry.test.js.map +1 -1
  94. package/dist/sync/event-sync.d.ts +6 -0
  95. package/dist/sync/event-sync.d.ts.map +1 -1
  96. package/dist/sync/event-sync.js +34 -1
  97. package/dist/sync/event-sync.js.map +1 -1
  98. package/dist/sync/event-sync.test.js +73 -0
  99. package/dist/sync/event-sync.test.js.map +1 -1
  100. package/dist/sync/metrics.d.ts +17 -1
  101. package/dist/sync/metrics.d.ts.map +1 -1
  102. package/dist/sync/metrics.js +32 -1
  103. package/dist/sync/metrics.js.map +1 -1
  104. package/dist/sync/metrics.test.js +74 -1
  105. package/dist/sync/metrics.test.js.map +1 -1
  106. package/dist/sync/pull-scope.d.ts.map +1 -1
  107. package/dist/sync/pull-scope.js +15 -7
  108. package/dist/sync/pull-scope.js.map +1 -1
  109. package/dist/sync/push-receiver.d.ts +6 -5
  110. package/dist/sync/push-receiver.d.ts.map +1 -1
  111. package/dist/sync/push-receiver.js +13 -15
  112. package/dist/sync/push-receiver.js.map +1 -1
  113. package/dist/sync/push-receiver.test.js +36 -1
  114. package/dist/sync/push-receiver.test.js.map +1 -1
  115. package/dist/telemetry.d.ts +1 -1
  116. package/dist/telemetry.d.ts.map +1 -1
  117. package/dist/telemetry.js +59 -6
  118. package/dist/telemetry.js.map +1 -1
  119. package/dist/telemetry.test.js +74 -0
  120. package/dist/telemetry.test.js.map +1 -1
  121. package/dist/types.d.ts +8 -0
  122. package/dist/types.d.ts.map +1 -1
  123. package/dist/watcher.d.ts +36 -0
  124. package/dist/watcher.d.ts.map +1 -1
  125. package/dist/watcher.js +152 -30
  126. package/dist/watcher.js.map +1 -1
  127. package/dist/watcher.test.js +103 -0
  128. package/dist/watcher.test.js.map +1 -1
  129. package/package.json +1 -1
  130. package/src/bin/sync-runner.test.ts +396 -11
  131. package/src/bin/sync-runner.ts +254 -52
  132. package/src/cli/reindex.test.ts +47 -1
  133. package/src/cli/reindex.ts +17 -1
  134. package/src/cli/rescue-classify-ordering.test.ts +61 -0
  135. package/src/cli/rescue-core.ts +261 -15
  136. package/src/cli/rescue-exec-bit-preserve.test.ts +187 -0
  137. package/src/cli/share.test.ts +38 -0
  138. package/src/cli/share.ts +103 -34
  139. package/src/cli/sync.test.ts +594 -1
  140. package/src/cli/sync.ts +229 -65
  141. package/src/cognito-auth.test.ts +77 -0
  142. package/src/cognito-auth.ts +73 -11
  143. package/src/index.ts +8 -0
  144. package/src/journal.test.ts +72 -0
  145. package/src/journal.ts +95 -8
  146. package/src/machine-auth.test.ts +64 -2
  147. package/src/object-io.test.ts +142 -0
  148. package/src/object-io.ts +182 -30
  149. package/src/operation-lock.test.ts +63 -4
  150. package/src/operation-lock.ts +99 -31
  151. package/src/personal-vault.test.ts +42 -0
  152. package/src/personal-vault.ts +18 -3
  153. package/src/prefix-coalesce.test.ts +71 -1
  154. package/src/prefix-coalesce.ts +155 -30
  155. package/src/remote-pull.test.ts +205 -0
  156. package/src/remote-pull.ts +77 -14
  157. package/src/s3.test.ts +126 -0
  158. package/src/s3.ts +237 -122
  159. package/src/scope-shrink.ts +6 -3
  160. package/src/skill-telemetry.test.ts +109 -0
  161. package/src/skill-telemetry.ts +82 -14
  162. package/src/sync/event-sync.test.ts +75 -0
  163. package/src/sync/event-sync.ts +54 -1
  164. package/src/sync/metrics.test.ts +81 -0
  165. package/src/sync/metrics.ts +59 -4
  166. package/src/sync/pull-scope.ts +23 -7
  167. package/src/sync/push-receiver.test.ts +38 -1
  168. package/src/sync/push-receiver.ts +15 -18
  169. package/src/telemetry.test.ts +85 -0
  170. package/src/telemetry.ts +69 -6
  171. package/src/types.ts +8 -0
  172. package/src/watcher.test.ts +117 -0
  173. package/src/watcher.ts +209 -33
@@ -29,7 +29,13 @@ import type {
29
29
  ExplicitGrant,
30
30
  MembershipSyncConfig,
31
31
  } from "./vault-client.js";
32
- import { coalescePrefixes } from "./prefix-coalesce.js";
32
+ import {
33
+ coalescePrefixes,
34
+ isCoveredByAny,
35
+ pathToScopePrefix,
36
+ toScopePrefixEntries,
37
+ type ScopePrefixInput,
38
+ } from "./prefix-coalesce.js";
33
39
  import {
34
40
  applyScopeShrink,
35
41
  buildScopeShrinkPlan,
@@ -73,12 +79,18 @@ export interface DecideRemotePullsInput {
73
79
  * conflict resolution can't be silently overwritten.
74
80
  */
75
81
  conflictKeys: Set<string>;
82
+ /**
83
+ * Journal keys intentionally retained by scope-shrink authorship guards
84
+ * even though they are outside the current remote listing scope.
85
+ */
86
+ protectedMissingKeys?: Set<string>;
76
87
  }
77
88
 
78
89
  export function decideRemotePulls({
79
90
  remoteFiles,
80
91
  journal,
81
92
  conflictKeys,
93
+ protectedMissingKeys = new Set<string>(),
82
94
  }: DecideRemotePullsInput): RemotePullDecision {
83
95
  const download: RemoteFile[] = [];
84
96
  const skip: SkippedKey[] = [];
@@ -111,6 +123,11 @@ export function decideRemotePulls({
111
123
  // Tombstone pass: anything in the journal that's no longer remote.
112
124
  for (const relativePath of Object.keys(journal.files)) {
113
125
  if (seenRemote.has(relativePath)) continue;
126
+ if (journal.files[relativePath]?.removedAt) continue;
127
+ if (protectedMissingKeys.has(relativePath)) {
128
+ skip.push({ key: relativePath });
129
+ continue;
130
+ }
114
131
  if (conflictKeys.has(relativePath)) {
115
132
  // Remote tombstone for a file the user is conflict-resolving locally.
116
133
  // Skip — record so callers can log/report, but do NOT delete.
@@ -204,12 +221,12 @@ export function resolveCompanyScope(
204
221
  };
205
222
  }
206
223
 
207
- let raw: string[];
224
+ let raw: ScopePrefixInput[];
208
225
  if (syncConfig.syncMode === "custom") {
209
- raw = syncConfig.customPaths ?? [];
226
+ raw = (syncConfig.customPaths ?? []).map(pathToScopePrefix);
210
227
  } else {
211
228
  // 'shared'
212
- raw = (explicitGrants ?? []).map((g) => g.path);
229
+ raw = (explicitGrants ?? []).map((g) => pathToScopePrefix(g.path));
213
230
  }
214
231
  const prefixSet = coalescePrefixes(raw);
215
232
 
@@ -239,9 +256,10 @@ export function batchPrefixesForVend(
239
256
  cap: number = VEND_PATH_CAP,
240
257
  ): string[][] {
241
258
  if (cap <= 0) throw new Error(`batchPrefixesForVend: cap must be > 0`);
259
+ const vendPrefixes = toScopePrefixEntries(prefixes).map((entry) => entry.prefix);
242
260
  const batches: string[][] = [];
243
- for (let i = 0; i < prefixes.length; i += cap) {
244
- batches.push(prefixes.slice(i, i + cap));
261
+ for (let i = 0; i < vendPrefixes.length; i += cap) {
262
+ batches.push(vendPrefixes.slice(i, i + cap));
245
263
  }
246
264
  return batches;
247
265
  }
@@ -320,14 +338,15 @@ export async function listRemoteForScope(
320
338
 
321
339
  if (scope.strategy === "broad-postfilter") {
322
340
  const all = await list(ctx);
323
- return all.filter((f) =>
324
- scope.prefixSet.some((p) => f.key.startsWith(p)),
325
- );
341
+ const scopeEntries = toScopePrefixEntries(scope.prefixSet);
342
+ return all.filter((f) => isCoveredByAny(f.key, scopeEntries));
326
343
  }
327
344
 
328
345
  // vend-fanout
329
346
  if (scope.prefixSet.length === 0) return [];
330
- const batches = batchPrefixesForVend(scope.prefixSet);
347
+ const scopeEntries = toScopePrefixEntries(scope.prefixSet);
348
+ const listPrefixes = scopeEntries.map((entry) => entry.prefix);
349
+ const batches = batchPrefixesForVend(listPrefixes);
331
350
  const perBatch = await mapWithConcurrency(
332
351
  batches,
333
352
  VEND_FANOUT_CONCURRENCY,
@@ -343,7 +362,9 @@ export async function listRemoteForScope(
343
362
  return lists.flat();
344
363
  },
345
364
  );
346
- return dedupByKey(perBatch.flat());
365
+ return dedupByKey(
366
+ perBatch.flat().filter((f) => isCoveredByAny(f.key, scopeEntries)),
367
+ );
347
368
  }
348
369
 
349
370
  function dedupByKey(files: RemoteFile[]): RemoteFile[] {
@@ -434,11 +455,12 @@ export async function pullCompany(
434
455
  // `all` -> `shared` flip this correctly flags shared-mode orphans.
435
456
  [companyPrefixOf(input.scope, last)];
436
457
 
458
+ const currentPrefixSet = input.scope.prefixSet;
437
459
  const scopeShrinkPlan = buildScopeShrinkPlan({
438
460
  journal: input.journal,
439
461
  hqRoot: input.hqRoot,
440
462
  lastPrefixSet,
441
- currentPrefixSet: input.scope.prefixSet,
463
+ currentPrefixSet,
442
464
  callerSub: input.callerSub,
443
465
  // Background runner pull: protect the caller's own work and don't make a
444
466
  // destructive guess about unknown-author (legacy) orphans. The explicit
@@ -479,6 +501,13 @@ export async function pullCompany(
479
501
  remoteFiles,
480
502
  journal: input.journal,
481
503
  conflictKeys,
504
+ protectedMissingKeys: collectScopeProtectedMissingKeys({
505
+ journal: input.journal,
506
+ lastPrefixSet,
507
+ currentPrefixSet,
508
+ callerSub: input.callerSub,
509
+ protectUnknownAuthors: true,
510
+ }),
482
511
  });
483
512
 
484
513
  const completedAt = now().toISOString();
@@ -509,6 +538,39 @@ export async function pullCompany(
509
538
  };
510
539
  }
511
540
 
541
+ function collectScopeProtectedMissingKeys(input: {
542
+ journal: SyncJournal;
543
+ lastPrefixSet: readonly ScopePrefixInput[];
544
+ currentPrefixSet: readonly ScopePrefixInput[];
545
+ callerSub?: string;
546
+ protectUnknownAuthors: boolean;
547
+ }): Set<string> {
548
+ const protectedKeys = new Set<string>();
549
+ for (const [relPath, entry] of Object.entries(input.journal.files)) {
550
+ if (entry.removedAt) continue;
551
+ if (entry.direction !== "down") continue;
552
+ if (isCoveredByAny(relPath, input.currentPrefixSet)) {
553
+ if (entry.outOfScopeProtected) delete entry.outOfScopeProtected;
554
+ continue;
555
+ }
556
+ if (entry.outOfScopeProtected) {
557
+ protectedKeys.add(relPath);
558
+ continue;
559
+ }
560
+ if (!isCoveredByAny(relPath, input.lastPrefixSet)) continue;
561
+ if (input.callerSub && entry.createdBySub === input.callerSub) {
562
+ entry.outOfScopeProtected = true;
563
+ protectedKeys.add(relPath);
564
+ continue;
565
+ }
566
+ if (input.protectUnknownAuthors && entry.createdBySub === undefined) {
567
+ entry.outOfScopeProtected = true;
568
+ protectedKeys.add(relPath);
569
+ }
570
+ }
571
+ return protectedKeys;
572
+ }
573
+
512
574
  /**
513
575
  * Recover the "company prefix" for a v1-migrated record with no recorded
514
576
  * `prefixSet`. We derive it from the current scope's first prefix's parent
@@ -521,10 +583,11 @@ function companyPrefixOf(
521
583
  _last: PullRecord | undefined,
522
584
  ): string {
523
585
  // For `all` mode, scope.prefixSet[0] IS the company prefix.
524
- if (scope.strategy === "all" && scope.prefixSet[0]) return scope.prefixSet[0];
586
+ const firstEntry = toScopePrefixEntries(scope.prefixSet)[0];
587
+ if (scope.strategy === "all" && firstEntry) return firstEntry.prefix;
525
588
  // Otherwise, derive `companies/{slug}/` from the first prefix. ACL grant
526
589
  // paths always start with `companies/{slug}/...`.
527
- const first = scope.prefixSet[0] ?? "";
590
+ const first = firstEntry?.prefix ?? "";
528
591
  const m = first.match(/^(companies\/[^/]+\/)/);
529
592
  return m ? m[1]! : first;
530
593
  }
package/src/s3.test.ts CHANGED
@@ -10,6 +10,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
10
  import * as fs from "fs";
11
11
  import * as os from "os";
12
12
  import * as path from "path";
13
+ import * as crypto from "crypto";
13
14
 
14
15
  // Capture every command sent to the S3Client across the test suite. Cleared
15
16
  // in beforeEach so per-test assertions don't leak from neighbours.
@@ -96,6 +97,7 @@ import {
96
97
  import {
97
98
  setObjectIOFactory,
98
99
  presignObjectIOFactory,
100
+ type ObjectIO,
99
101
  type PresignTransportClient,
100
102
  } from "./object-io.js";
101
103
  import type { PresignResultRow } from "./vault-client.js";
@@ -896,6 +898,130 @@ describe("downloadFile", () => {
896
898
  expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
897
899
  });
898
900
 
901
+ it("F11: failed symlink downloads preserve the previous local file", async () => {
902
+ const localPath = path.join(tmpRoot, "preserve-on-failure.md");
903
+ fs.writeFileSync(localPath, "existing local copy");
904
+
905
+ nextGetObjectResponse = {
906
+ Body: (async function* () {
907
+ yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + "x".repeat(10_000));
908
+ })(),
909
+ Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
910
+ };
911
+
912
+ await expect(
913
+ downloadFile(makeCtx(), "preserve-on-failure.md", localPath),
914
+ ).rejects.toThrow();
915
+
916
+ expect(fs.existsSync(localPath)).toBe(true);
917
+ expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
918
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("existing local copy");
919
+ });
920
+
921
+ it("R-F11: downloads to a near component-limit basename via a bounded temp name", async () => {
922
+ nextGetObjectResponse = {
923
+ Body: (async function* () {
924
+ yield Buffer.from("near-limit bytes");
925
+ })(),
926
+ Metadata: {},
927
+ };
928
+
929
+ const localPath = path.join(tmpRoot, "x".repeat(240));
930
+ await downloadFile(makeCtx(), "near-limit.bin", localPath);
931
+
932
+ expect(fs.readFileSync(localPath, "utf-8")).toBe("near-limit bytes");
933
+ });
934
+
935
+ it("F20: regular downloads do not decode the full body as UTF-8", async () => {
936
+ const body = Buffer.alloc(64 * 1024, 0x61);
937
+ const originalToString = body.toString;
938
+ body.toString = ((encoding?: BufferEncoding, start?: number, end?: number) => {
939
+ const sliceStart = start ?? 0;
940
+ const sliceEnd = end ?? body.length;
941
+ if (sliceEnd - sliceStart > SYMLINK_BODY_PREFIX.length) {
942
+ throw new Error("full-body UTF-8 decode attempted");
943
+ }
944
+ return originalToString.call(body, encoding, start, end);
945
+ }) as Buffer["toString"];
946
+
947
+ const io: ObjectIO = {
948
+ putObject: async () => ({ etag: '"unused"' }),
949
+ getObject: async () => ({ body, metadata: {} }),
950
+ listObjects: async () => ({ objects: [] }),
951
+ deleteObject: async () => undefined,
952
+ headObject: async () => null,
953
+ };
954
+
955
+ const localPath = path.join(tmpRoot, "large-regular.bin");
956
+ setObjectIOFactory(() => io);
957
+ try {
958
+ await downloadFile(makeCtx(), "large-regular.bin", localPath);
959
+ } finally {
960
+ setObjectIOFactory(null);
961
+ }
962
+
963
+ const written = fs.readFileSync(localPath);
964
+ expect(written.length).toBe(body.length);
965
+ expect(written[0]).toBe(0x61);
966
+ expect(written[written.length - 1]).toBe(0x61);
967
+ });
968
+
969
+ it("R-F20: streams regular downloads with hash metadata and still detects symlink records", async () => {
970
+ const regularChunks = [
971
+ Buffer.from("alpha-"),
972
+ Buffer.from("beta-"),
973
+ Buffer.from("gamma"),
974
+ ];
975
+ const regularBody = Buffer.concat(regularChunks);
976
+ const linkTarget = "../target.md";
977
+
978
+ const io: ObjectIO = {
979
+ putObject: async () => ({ etag: '"unused"' }),
980
+ getObject: async () => {
981
+ throw new Error("buffered getObject attempted");
982
+ },
983
+ getObjectStream: async (key: string) => {
984
+ if (key === "streamed-link") {
985
+ return {
986
+ body: (async function* () {
987
+ yield Buffer.from("hq-");
988
+ yield Buffer.from("symlink:");
989
+ yield Buffer.from(linkTarget);
990
+ })(),
991
+ metadata: {},
992
+ };
993
+ }
994
+ return {
995
+ body: (async function* () {
996
+ for (const chunk of regularChunks) yield chunk;
997
+ })(),
998
+ metadata: {},
999
+ };
1000
+ },
1001
+ listObjects: async () => ({ objects: [] }),
1002
+ deleteObject: async () => undefined,
1003
+ headObject: async () => null,
1004
+ };
1005
+
1006
+ setObjectIOFactory(() => io);
1007
+ try {
1008
+ const localPath = path.join(tmpRoot, "streamed.bin");
1009
+ const result = await downloadFile(makeCtx(), "streamed.bin", localPath);
1010
+ expect(fs.readFileSync(localPath)).toEqual(regularBody);
1011
+ expect(result.contentHash).toBe(
1012
+ crypto.createHash("sha256").update(regularBody).digest("hex"),
1013
+ );
1014
+ expect(result.contentSize).toBe(regularBody.length);
1015
+
1016
+ const linkPath = path.join(tmpRoot, "streamed-link");
1017
+ await downloadFile(makeCtx(), "streamed-link", linkPath);
1018
+ expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
1019
+ expect(fs.readlinkSync(linkPath)).toBe(linkTarget);
1020
+ } finally {
1021
+ setObjectIOFactory(null);
1022
+ }
1023
+ });
1024
+
899
1025
  it("applies hq-mode metadata via chmod after byte write (Bug #5 — preserve permissions)", async () => {
900
1026
  // Round-trip pair to the s3.upload test: source-side mode lives in
901
1027
  // \`Metadata['hq-mode']\` as an octal string; the receiver must chmod