@indigoai-us/hq-cloud 6.15.0 → 6.15.2

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 (329) hide show
  1. package/dist/bin/sync-mutation.d.ts +16 -0
  2. package/dist/bin/sync-mutation.d.ts.map +1 -0
  3. package/dist/bin/sync-mutation.js +60 -0
  4. package/dist/bin/sync-mutation.js.map +1 -0
  5. package/dist/bin/sync-mutation.test.d.ts +2 -0
  6. package/dist/bin/sync-mutation.test.d.ts.map +1 -0
  7. package/dist/bin/sync-mutation.test.js +165 -0
  8. package/dist/bin/sync-mutation.test.js.map +1 -0
  9. package/dist/bin/sync-runner-company.d.ts +8 -0
  10. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  11. package/dist/bin/sync-runner-company.js +16 -0
  12. package/dist/bin/sync-runner-company.js.map +1 -1
  13. package/dist/bin/sync-runner-company.test.d.ts +2 -0
  14. package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
  15. package/dist/bin/sync-runner-company.test.js +36 -0
  16. package/dist/bin/sync-runner-company.test.js.map +1 -0
  17. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  18. package/dist/bin/sync-runner-watch-loop.js +98 -8
  19. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  20. package/dist/bin/sync-runner.d.ts +17 -0
  21. package/dist/bin/sync-runner.d.ts.map +1 -1
  22. package/dist/bin/sync-runner.js.map +1 -1
  23. package/dist/bin/sync-runner.test.js +109 -0
  24. package/dist/bin/sync-runner.test.js.map +1 -1
  25. package/dist/cli/conflict-recovery.test.d.ts +2 -0
  26. package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
  27. package/dist/cli/conflict-recovery.test.js +201 -0
  28. package/dist/cli/conflict-recovery.test.js.map +1 -0
  29. package/dist/cli/conflict.d.ts +60 -0
  30. package/dist/cli/conflict.d.ts.map +1 -1
  31. package/dist/cli/conflict.js +333 -0
  32. package/dist/cli/conflict.js.map +1 -1
  33. package/dist/cli/sync.d.ts +35 -0
  34. package/dist/cli/sync.d.ts.map +1 -1
  35. package/dist/cli/sync.js +100 -0
  36. package/dist/cli/sync.js.map +1 -1
  37. package/dist/cli/sync.test.js +85 -1
  38. package/dist/cli/sync.test.js.map +1 -1
  39. package/dist/index.d.ts +2 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/dist/skill-telemetry.d.ts +6 -0
  44. package/dist/skill-telemetry.d.ts.map +1 -1
  45. package/dist/skill-telemetry.js +14 -2
  46. package/dist/skill-telemetry.js.map +1 -1
  47. package/dist/skill-telemetry.test.js +79 -0
  48. package/dist/skill-telemetry.test.js.map +1 -1
  49. package/dist/sync/candidate-uploader.d.ts +88 -0
  50. package/dist/sync/candidate-uploader.d.ts.map +1 -0
  51. package/dist/sync/candidate-uploader.js +212 -0
  52. package/dist/sync/candidate-uploader.js.map +1 -0
  53. package/dist/sync/candidate-uploader.test.d.ts +2 -0
  54. package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
  55. package/dist/sync/candidate-uploader.test.js +132 -0
  56. package/dist/sync/candidate-uploader.test.js.map +1 -0
  57. package/dist/sync/delta-client.d.ts +73 -0
  58. package/dist/sync/delta-client.d.ts.map +1 -0
  59. package/dist/sync/delta-client.js +201 -0
  60. package/dist/sync/delta-client.js.map +1 -0
  61. package/dist/sync/delta-client.test.d.ts +2 -0
  62. package/dist/sync/delta-client.test.d.ts.map +1 -0
  63. package/dist/sync/delta-client.test.js +97 -0
  64. package/dist/sync/delta-client.test.js.map +1 -0
  65. package/dist/sync/durable-apply.d.ts +76 -0
  66. package/dist/sync/durable-apply.d.ts.map +1 -0
  67. package/dist/sync/durable-apply.js +530 -0
  68. package/dist/sync/durable-apply.js.map +1 -0
  69. package/dist/sync/durable-apply.test.d.ts +2 -0
  70. package/dist/sync/durable-apply.test.d.ts.map +1 -0
  71. package/dist/sync/durable-apply.test.js +180 -0
  72. package/dist/sync/durable-apply.test.js.map +1 -0
  73. package/dist/sync/event-sync.d.ts +33 -1
  74. package/dist/sync/event-sync.d.ts.map +1 -1
  75. package/dist/sync/event-sync.js +149 -1
  76. package/dist/sync/event-sync.js.map +1 -1
  77. package/dist/sync/event-sync.test.js +142 -1
  78. package/dist/sync/event-sync.test.js.map +1 -1
  79. package/dist/sync/index.d.ts +2 -0
  80. package/dist/sync/index.d.ts.map +1 -1
  81. package/dist/sync/index.js +1 -0
  82. package/dist/sync/index.js.map +1 -1
  83. package/dist/sync/multipart-uploader.d.ts +99 -0
  84. package/dist/sync/multipart-uploader.d.ts.map +1 -0
  85. package/dist/sync/multipart-uploader.js +447 -0
  86. package/dist/sync/multipart-uploader.js.map +1 -0
  87. package/dist/sync/multipart-uploader.test.d.ts +2 -0
  88. package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
  89. package/dist/sync/multipart-uploader.test.js +119 -0
  90. package/dist/sync/multipart-uploader.test.js.map +1 -0
  91. package/dist/sync/mutation-client.d.ts +85 -0
  92. package/dist/sync/mutation-client.d.ts.map +1 -0
  93. package/dist/sync/mutation-client.js +245 -0
  94. package/dist/sync/mutation-client.js.map +1 -0
  95. package/dist/sync/mutation-client.test.d.ts +2 -0
  96. package/dist/sync/mutation-client.test.d.ts.map +1 -0
  97. package/dist/sync/mutation-client.test.js +51 -0
  98. package/dist/sync/mutation-client.test.js.map +1 -0
  99. package/dist/sync/push-receiver.d.ts +45 -0
  100. package/dist/sync/push-receiver.d.ts.map +1 -1
  101. package/dist/sync/push-receiver.js +101 -0
  102. package/dist/sync/push-receiver.js.map +1 -1
  103. package/dist/sync/push-receiver.test.js +54 -2
  104. package/dist/sync/push-receiver.test.js.map +1 -1
  105. package/dist/sync/scope-inventory-client.d.ts +69 -0
  106. package/dist/sync/scope-inventory-client.d.ts.map +1 -0
  107. package/dist/sync/scope-inventory-client.js +210 -0
  108. package/dist/sync/scope-inventory-client.js.map +1 -0
  109. package/dist/sync/scope-inventory-client.test.d.ts +2 -0
  110. package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
  111. package/dist/sync/scope-inventory-client.test.js +94 -0
  112. package/dist/sync/scope-inventory-client.test.js.map +1 -0
  113. package/dist/sync/snapshot-client.d.ts +98 -0
  114. package/dist/sync/snapshot-client.d.ts.map +1 -0
  115. package/dist/sync/snapshot-client.js +402 -0
  116. package/dist/sync/snapshot-client.js.map +1 -0
  117. package/dist/sync/snapshot-client.test.d.ts +2 -0
  118. package/dist/sync/snapshot-client.test.d.ts.map +1 -0
  119. package/dist/sync/snapshot-client.test.js +169 -0
  120. package/dist/sync/snapshot-client.test.js.map +1 -0
  121. package/dist/sync/uploader-finalization.d.ts +97 -0
  122. package/dist/sync/uploader-finalization.d.ts.map +1 -0
  123. package/dist/sync/uploader-finalization.js +273 -0
  124. package/dist/sync/uploader-finalization.js.map +1 -0
  125. package/dist/sync/uploader-finalization.test.d.ts +2 -0
  126. package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
  127. package/dist/sync/uploader-finalization.test.js +92 -0
  128. package/dist/sync/uploader-finalization.test.js.map +1 -0
  129. package/dist/telemetry.d.ts +11 -1
  130. package/dist/telemetry.d.ts.map +1 -1
  131. package/dist/telemetry.js +21 -2
  132. package/dist/telemetry.js.map +1 -1
  133. package/dist/telemetry.test.js +80 -0
  134. package/dist/telemetry.test.js.map +1 -1
  135. package/package.json +6 -1
  136. package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
  137. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
  138. package/.github/workflows/ci.yml +0 -84
  139. package/.github/workflows/publish.yml +0 -56
  140. package/.github/workflows/unreleased-commits-nag.yml +0 -256
  141. package/eslint.config.js +0 -67
  142. package/pnpm-workspace.yaml +0 -2
  143. package/scripts/presign-transport-e2e.mjs +0 -250
  144. package/scripts/vault-rebaseline.sh +0 -323
  145. package/scripts/vault-rescue.sh +0 -332
  146. package/src/active-company.test.ts +0 -188
  147. package/src/active-company.ts +0 -168
  148. package/src/agent-codex-instructions.test.ts +0 -332
  149. package/src/agent-codex-instructions.ts +0 -309
  150. package/src/auth.ts +0 -146
  151. package/src/backup-prune.test.ts +0 -98
  152. package/src/backup-prune.ts +0 -182
  153. package/src/bin/backup-prune-runner.ts +0 -33
  154. package/src/bin/rescue-runner.ts +0 -25
  155. package/src/bin/sync-runner-company.ts +0 -695
  156. package/src/bin/sync-runner-events.test.ts +0 -143
  157. package/src/bin/sync-runner-events.ts +0 -55
  158. package/src/bin/sync-runner-planning.test.ts +0 -311
  159. package/src/bin/sync-runner-planning.ts +0 -258
  160. package/src/bin/sync-runner-rollup.test.ts +0 -37
  161. package/src/bin/sync-runner-rollup.ts +0 -97
  162. package/src/bin/sync-runner-telemetry.ts +0 -15
  163. package/src/bin/sync-runner-watch-loop.ts +0 -1235
  164. package/src/bin/sync-runner-watch-routes.test.ts +0 -71
  165. package/src/bin/sync-runner-watch-routes.ts +0 -184
  166. package/src/bin/sync-runner.test.ts +0 -8767
  167. package/src/bin/sync-runner.ts +0 -2190
  168. package/src/cli/accept.ts +0 -124
  169. package/src/cli/conflict.ts +0 -119
  170. package/src/cli/doctor.test.ts +0 -581
  171. package/src/cli/doctor.ts +0 -642
  172. package/src/cli/index.ts +0 -49
  173. package/src/cli/invite.test.ts +0 -250
  174. package/src/cli/invite.ts +0 -214
  175. package/src/cli/promote.ts +0 -157
  176. package/src/cli/reindex-knowledge.test.ts +0 -307
  177. package/src/cli/reindex-knowledge.ts +0 -450
  178. package/src/cli/reindex.test.ts +0 -957
  179. package/src/cli/reindex.ts +0 -979
  180. package/src/cli/rescue-classify-ordering.test.ts +0 -548
  181. package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
  182. package/src/cli/rescue-core.ts +0 -3011
  183. package/src/cli/rescue-drift-reconcile.test.ts +0 -179
  184. package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
  185. package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
  186. package/src/cli/rescue-hq-root-guard.test.ts +0 -232
  187. package/src/cli/rescue-journal-reconcile.test.ts +0 -215
  188. package/src/cli/rescue-mtime-preserve.test.ts +0 -203
  189. package/src/cli/rescue-settings-reconcile.test.ts +0 -637
  190. package/src/cli/rescue-snapshot.test.ts +0 -57
  191. package/src/cli/rescue-snapshot.ts +0 -51
  192. package/src/cli/rescue.reindex.test.ts +0 -63
  193. package/src/cli/rescue.test.ts +0 -131
  194. package/src/cli/rescue.ts +0 -182
  195. package/src/cli/share.test.ts +0 -7843
  196. package/src/cli/share.ts +0 -3663
  197. package/src/cli/sync-scope.test.ts +0 -652
  198. package/src/cli/sync.test.ts +0 -5207
  199. package/src/cli/sync.ts +0 -3470
  200. package/src/cli/tombstones.ts +0 -106
  201. package/src/cli/watch-event-push-conflict.test.ts +0 -234
  202. package/src/client-info.test.ts +0 -214
  203. package/src/client-info.ts +0 -121
  204. package/src/cognito-auth.test.ts +0 -712
  205. package/src/cognito-auth.ts +0 -1422
  206. package/src/company-resolver.test.ts +0 -618
  207. package/src/company-resolver.ts +0 -521
  208. package/src/context.test.ts +0 -583
  209. package/src/context.ts +0 -378
  210. package/src/daemon-worker.ts +0 -26
  211. package/src/daemon.ts +0 -99
  212. package/src/entity-resolver.test.ts +0 -315
  213. package/src/entity-resolver.ts +0 -180
  214. package/src/ignore.test.ts +0 -466
  215. package/src/ignore.ts +0 -469
  216. package/src/index.ts +0 -439
  217. package/src/journal.test.ts +0 -968
  218. package/src/journal.ts +0 -765
  219. package/src/lib/cloud-authoritative.test.ts +0 -45
  220. package/src/lib/cloud-authoritative.ts +0 -59
  221. package/src/lib/conflict-file.ts +0 -86
  222. package/src/lib/conflict-index.ts +0 -289
  223. package/src/lib/conflict.test.ts +0 -348
  224. package/src/lib/describe-error.test.ts +0 -100
  225. package/src/lib/describe-error.ts +0 -58
  226. package/src/lib/exit-codes.ts +0 -24
  227. package/src/lib/machine-id.test.ts +0 -231
  228. package/src/lib/machine-id.ts +0 -175
  229. package/src/lib/net-errors.test.ts +0 -65
  230. package/src/lib/net-errors.ts +0 -86
  231. package/src/lib/readlink-safe.test.ts +0 -43
  232. package/src/lib/readlink-safe.ts +0 -29
  233. package/src/local-path-codec.test.ts +0 -138
  234. package/src/local-path-codec.ts +0 -161
  235. package/src/machine-auth.test.ts +0 -1323
  236. package/src/manifest-reconcile.test.ts +0 -1123
  237. package/src/manifest-reconcile.ts +0 -518
  238. package/src/object-io.test.ts +0 -1221
  239. package/src/object-io.ts +0 -1306
  240. package/src/operation-lock.test.ts +0 -484
  241. package/src/operation-lock.ts +0 -680
  242. package/src/outcome-telemetry.test.ts +0 -498
  243. package/src/outcome-telemetry.ts +0 -639
  244. package/src/personal-vault-exclusions.test.ts +0 -308
  245. package/src/personal-vault-exclusions.ts +0 -354
  246. package/src/personal-vault.test.ts +0 -756
  247. package/src/personal-vault.ts +0 -496
  248. package/src/prefix-coalesce.test.ts +0 -240
  249. package/src/prefix-coalesce.ts +0 -273
  250. package/src/public-surface.test.ts +0 -117
  251. package/src/qmd-reindex.test.ts +0 -877
  252. package/src/qmd-reindex.ts +0 -842
  253. package/src/read-only-state-dir.test.ts +0 -188
  254. package/src/remote-pull.test.ts +0 -1130
  255. package/src/remote-pull.ts +0 -618
  256. package/src/s3.symlink-materialize.test.ts +0 -492
  257. package/src/s3.test.ts +0 -1789
  258. package/src/s3.ts +0 -1532
  259. package/src/schemas/signal-types.test.ts +0 -82
  260. package/src/schemas/signal-types.ts +0 -38
  261. package/src/schemas/source-channels.test.ts +0 -82
  262. package/src/schemas/source-channels.ts +0 -53
  263. package/src/scope-shrink.test.ts +0 -633
  264. package/src/scope-shrink.ts +0 -481
  265. package/src/signals/get.test.ts +0 -310
  266. package/src/signals/get.ts +0 -75
  267. package/src/signals/internals.ts +0 -195
  268. package/src/signals/list.test.ts +0 -420
  269. package/src/signals/list.ts +0 -79
  270. package/src/signals/parse.ts +0 -8
  271. package/src/signals/types.ts +0 -91
  272. package/src/skill-telemetry.test.ts +0 -1825
  273. package/src/skill-telemetry.ts +0 -1439
  274. package/src/sources/get.test.ts +0 -293
  275. package/src/sources/get.ts +0 -66
  276. package/src/sources/internals.ts +0 -198
  277. package/src/sources/list.test.ts +0 -402
  278. package/src/sources/list.ts +0 -84
  279. package/src/sources/parse.ts +0 -43
  280. package/src/sources/types.ts +0 -84
  281. package/src/sync/event-sync.test.ts +0 -594
  282. package/src/sync/event-sync.ts +0 -545
  283. package/src/sync/feature-flags.test.ts +0 -378
  284. package/src/sync/feature-flags.ts +0 -62
  285. package/src/sync/index.ts +0 -76
  286. package/src/sync/lease-client.test.ts +0 -128
  287. package/src/sync/lease-client.ts +0 -207
  288. package/src/sync/logger.test.ts +0 -242
  289. package/src/sync/logger.ts +0 -79
  290. package/src/sync/metrics.test.ts +0 -462
  291. package/src/sync/metrics.ts +0 -213
  292. package/src/sync/pull-scope.ts +0 -265
  293. package/src/sync/push-event.test.ts +0 -266
  294. package/src/sync/push-event.ts +0 -224
  295. package/src/sync/push-receiver.test.ts +0 -566
  296. package/src/sync/push-receiver.ts +0 -1048
  297. package/src/sync/push-transport.ts +0 -231
  298. package/src/sync/realtime-rollout.test.ts +0 -86
  299. package/src/sync/realtime-rollout.ts +0 -262
  300. package/src/sync/state-store.test.ts +0 -194
  301. package/src/sync/state-store.ts +0 -727
  302. package/src/sync-core.ts +0 -58
  303. package/src/sync-progress.test.ts +0 -94
  304. package/src/sync-progress.ts +0 -140
  305. package/src/telemetry-events.test.ts +0 -88
  306. package/src/telemetry-events.ts +0 -205
  307. package/src/telemetry.test.ts +0 -1280
  308. package/src/telemetry.ts +0 -1109
  309. package/src/types.ts +0 -314
  310. package/src/vault-client.test.ts +0 -1380
  311. package/src/vault-client.ts +0 -1694
  312. package/src/version.ts +0 -24
  313. package/src/watch-roots.test.ts +0 -278
  314. package/src/watch-roots.ts +0 -162
  315. package/src/watcher-event-gate.test.ts +0 -212
  316. package/src/watcher.test.ts +0 -1079
  317. package/src/watcher.ts +0 -1741
  318. package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
  319. package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
  320. package/test/e2e/sync/transient-company-leg.test.ts +0 -384
  321. package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
  322. package/test/e2e/watcher-real-chokidar.test.ts +0 -165
  323. package/test/e2e/watcher-recursive-backend.test.ts +0 -181
  324. package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
  325. package/test/invite-flow.integration.test.ts +0 -244
  326. package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
  327. package/test/share-sync.integration.test.ts +0 -213
  328. package/tsconfig.json +0 -19
  329. package/vitest.config.ts +0 -22
package/src/s3.test.ts DELETED
@@ -1,1789 +0,0 @@
1
- /**
2
- * Unit tests for s3.uploadFile.
3
- *
4
- * Regression coverage for the bug where hq-console vault UI's "CREATED BY"
5
- * column rendered `—` for every file: every PutObject went out without
6
- * `Metadata`, so the listing's HEAD fan-out had nothing to attribute.
7
- */
8
-
9
- import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
10
- import * as fs from "fs";
11
- import * as os from "os";
12
- import * as path from "path";
13
- import * as crypto from "crypto";
14
-
15
- // Capture every command sent to the S3Client across the test suite. Cleared
16
- // in beforeEach so per-test assertions don't leak from neighbours.
17
- const sentCommands: Array<{ name: string; input: Record<string, unknown> }> = [];
18
-
19
- // Per-test override for the ListObjectsV2Command response. Tests of
20
- // `listRemoteFiles` push the Contents shape they want returned; the default
21
- // is an empty bucket. Reset in beforeEach so cross-test leakage is impossible.
22
- let nextListObjectsResponse: Record<string, unknown> = { Contents: [] };
23
-
24
- // Per-test override for the GetObjectCommand response. The default fake
25
- // doesn't model GET (downloadFile previously had no s3.test.ts coverage);
26
- // downloadFile tests push the Body + Metadata shape they want returned.
27
- let nextGetObjectResponse: Record<string, unknown> = {
28
- Body: (async function* () {
29
- yield new Uint8Array();
30
- })(),
31
- Metadata: {},
32
- };
33
-
34
- vi.mock("@aws-sdk/client-s3", () => {
35
- class FakeS3Client {
36
- async send(command: { constructor: { name: string }; input: Record<string, unknown> }): Promise<Record<string, unknown>> {
37
- sentCommands.push({ name: command.constructor.name, input: command.input });
38
- if (command.constructor.name === "HeadObjectCommand") {
39
- // Default: object exists with no metadata. Tests that need a 404 or
40
- // a metadata-bearing HEAD override per-test via mockReturnValueOnce.
41
- return { Metadata: {} };
42
- }
43
- if (command.constructor.name === "PutObjectCommand") {
44
- return { ETag: '"fake-etag"' };
45
- }
46
- if (command.constructor.name === "ListObjectsV2Command") {
47
- return nextListObjectsResponse;
48
- }
49
- if (command.constructor.name === "GetObjectCommand") {
50
- return nextGetObjectResponse;
51
- }
52
- return {};
53
- }
54
- }
55
- // Each command class records constructor.name + input so the spy above can
56
- // tell them apart. Mirrors the real SDK's command shape closely enough for
57
- // the assertion surface the s3.ts code touches.
58
- class PutObjectCommand {
59
- constructor(public input: Record<string, unknown>) {}
60
- }
61
- class GetObjectCommand {
62
- constructor(public input: Record<string, unknown>) {}
63
- }
64
- class HeadObjectCommand {
65
- constructor(public input: Record<string, unknown>) {}
66
- }
67
- class ListObjectsV2Command {
68
- constructor(public input: Record<string, unknown>) {}
69
- }
70
- class DeleteObjectCommand {
71
- constructor(public input: Record<string, unknown>) {}
72
- }
73
- return {
74
- S3Client: FakeS3Client,
75
- PutObjectCommand,
76
- GetObjectCommand,
77
- HeadObjectCommand,
78
- ListObjectsV2Command,
79
- DeleteObjectCommand,
80
- };
81
- });
82
-
83
- import {
84
- uploadFile,
85
- uploadSymlink,
86
- toPosixKey,
87
- primeUploads,
88
- downloadFile,
89
- listRemoteFiles,
90
- SYMLINK_BODY_PREFIX,
91
- SYMLINK_MARKER_META_VALUE,
92
- encodeSymlinkMetadataValue,
93
- decodeSymlinkMetadataValue,
94
- FILE_MTIME_META_KEY,
95
- FILE_BTIME_META_KEY,
96
- classifyVaultKey,
97
- validateVaultUploadKey,
98
- createStagedSymlink,
99
- replaceStagedPath,
100
- sweepStaleStagedFiles,
101
- DanglingSymlinkParentError,
102
- } from "./s3.js";
103
- import {
104
- setObjectIOFactory,
105
- presignObjectIOFactory,
106
- type ObjectIO,
107
- type PresignTransportClient,
108
- } from "./object-io.js";
109
- import type { PresignResultRow } from "./vault-client.js";
110
- import type { EntityContext } from "./types.js";
111
-
112
- function makeCtx(): EntityContext {
113
- return {
114
- uid: "cmp_TEST",
115
- slug: "acme",
116
- bucketName: "hq-vault-acme-123",
117
- region: "us-east-1",
118
- credentials: {
119
- accessKeyId: "ASIA_TEST",
120
- secretAccessKey: "secret",
121
- sessionToken: "session",
122
- },
123
- expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
124
- };
125
- }
126
-
127
- describe("backslash key normalization (Windows client → POSIX S3 key)", () => {
128
- // Regression for the Ridges vault bug: a non-POSIX (Windows) HQ Sync client
129
- // built object keys from path.relative(), whose separator is "\\". Stored
130
- // verbatim, `knowledge\books-eoi.md` had no "/", so the vault listing (which
131
- // splits keys on "/") rendered it flat at the root as one oddly-named file.
132
- let tmpFile: string;
133
-
134
- beforeEach(() => {
135
- sentCommands.length = 0;
136
- tmpFile = path.join(
137
- os.tmpdir(),
138
- `s3-backslash-test-${Date.now()}-${Math.random()}.md`,
139
- );
140
- fs.writeFileSync(tmpFile, "hello");
141
- });
142
-
143
- it("toPosixKey converts every backslash to a forward slash", () => {
144
- expect(toPosixKey("knowledge\\books-eoi.md")).toBe("knowledge/books-eoi.md");
145
- expect(toPosixKey("data\\sub\\boots-accounts.json")).toBe(
146
- "data/sub/boots-accounts.json",
147
- );
148
- });
149
-
150
- it("toPosixKey leaves an already-POSIX key unchanged (idempotent)", () => {
151
- expect(toPosixKey("knowledge/books-eoi.md")).toBe("knowledge/books-eoi.md");
152
- expect(toPosixKey("flat.md")).toBe("flat.md");
153
- });
154
-
155
- it("uploadFile stores a POSIX key when given a Windows-style backslash path", async () => {
156
- await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
157
-
158
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
159
- expect(put).toBeDefined();
160
- expect(put!.input.Key).toBe("knowledge/books-eoi.md");
161
- });
162
-
163
- it("uploadSymlink stores a POSIX key when given a Windows-style backslash path", async () => {
164
- await uploadSymlink(
165
- makeCtx(),
166
- "../knowledge-ridges/target.md",
167
- "data\\boots-accounts.json",
168
- );
169
-
170
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
171
- expect(put).toBeDefined();
172
- expect(put!.input.Key).toBe("data/boots-accounts.json");
173
- });
174
- });
175
-
176
- describe("backslash key normalization on the PRESIGNED (GA) transport", () => {
177
- // The tests above assert normalization on the S3-SDK transport (sentCommands
178
- // capture SDK PutObjectCommands). As of 6.5.0 the presigned-URL transport is
179
- // GA for every company vault (cmp_*) — that's the path a real Windows client
180
- // like Ridge's now takes. These lock the SAME write-side invariant on that
181
- // transport: a backslash key is canonicalized to POSIX before the server is
182
- // ever asked to presign it, so a non-POSIX vault key can never be minted.
183
- let tmpFile: string;
184
- let presignCalls: Array<{ op?: string; keys: Array<{ key?: string }> }>;
185
-
186
- function presignKeysFor(op: string): string[] {
187
- return presignCalls
188
- .filter((c) => c.op === op)
189
- .flatMap((c) => c.keys.map((k) => k.key ?? ""));
190
- }
191
-
192
- beforeEach(() => {
193
- tmpFile = path.join(
194
- os.tmpdir(),
195
- `s3-presign-backslash-${Date.now()}-${Math.random()}.md`,
196
- );
197
- fs.writeFileSync(tmpFile, "hello");
198
-
199
- presignCalls = [];
200
- const vault: PresignTransportClient = {
201
- // Echo each requested key back as a usable URL so putObject's PUT
202
- // resolves; record the op + keys so we can assert what was signed.
203
- presign: async (input) => {
204
- presignCalls.push({ op: input.op, keys: input.keys as Array<{ key?: string }> });
205
- const results: PresignResultRow[] = input.keys.map((k) => ({
206
- key: (k as { key: string }).key,
207
- op: input.op ?? "put",
208
- url: `https://s3.test/${(k as { key: string }).key}`,
209
- }));
210
- return { results, expiresAt: "2099-01-01T00:00:00.000Z" };
211
- },
212
- listFiles: async () => ({ objects: [], cursor: null, truncated: false }),
213
- };
214
- setObjectIOFactory(presignObjectIOFactory(vault));
215
- // putObject moves bytes over the presigned URL via global fetch.
216
- vi.stubGlobal(
217
- "fetch",
218
- vi.fn(async () => new Response(null, { status: 200, headers: { etag: '"e"' } })),
219
- );
220
- });
221
-
222
- afterEach(() => {
223
- setObjectIOFactory(null);
224
- vi.unstubAllGlobals();
225
- });
226
-
227
- it("uploadFile presigns + PUTs a POSIX key for a Windows-style backslash path", async () => {
228
- await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
229
- expect(presignKeysFor("put")).toContain("knowledge/books-eoi.md");
230
- // The malformed backslash key is never presigned (so never PUT).
231
- expect(presignKeysFor("put")).not.toContain("knowledge\\books-eoi.md");
232
- });
233
-
234
- it("uploadSymlink presigns a POSIX key for a Windows-style backslash path", async () => {
235
- await uploadSymlink(makeCtx(), "../target.md", "data\\boots-accounts.json");
236
- expect(presignKeysFor("put")).toContain("data/boots-accounts.json");
237
- expect(presignKeysFor("put")).not.toContain("data\\boots-accounts.json");
238
- });
239
-
240
- it("primeUploads canonicalizes keys so the primed PUT URL matches the upload lookup", async () => {
241
- // Prime then upload the SAME Windows-origin key. Both must key the cache
242
- // under the POSIX form: prime signs the POSIX PUT URL, and uploadFile's
243
- // hasPrimedPut(toPosixKey(...)) reuses it instead of re-presigning — proving
244
- // the primed fast-path can't strand a backslash key (and can't store one).
245
- await primeUploads(makeCtx(), [
246
- { key: "knowledge\\books-eoi.md", localPath: tmpFile, isSymlink: false },
247
- ]);
248
- // prime("get") + prime("put") both went out under the POSIX key only.
249
- expect(presignKeysFor("get")).toEqual(["knowledge/books-eoi.md"]);
250
- expect(presignKeysFor("put")).toEqual(["knowledge/books-eoi.md"]);
251
-
252
- const putCountAfterPrime = presignKeysFor("put").length;
253
- await uploadFile(makeCtx(), tmpFile, "knowledge\\books-eoi.md");
254
- // No NEW put presign: uploadFile reused the primed POSIX URL (cache hit).
255
- expect(presignKeysFor("put").length).toBe(putCountAfterPrime);
256
- });
257
- });
258
-
259
- describe("uploadFile", () => {
260
- let tmpFile: string;
261
-
262
- beforeEach(() => {
263
- sentCommands.length = 0;
264
- tmpFile = path.join(os.tmpdir(), `s3-upload-test-${Date.now()}-${Math.random()}.md`);
265
- fs.writeFileSync(tmpFile, "hello");
266
- });
267
-
268
- it("omits author Metadata fields when no author is provided (back-compat)", async () => {
269
- // Pre-Bug-#5: this test asserted Metadata was undefined entirely. Now
270
- // \`hq-mode\` is stamped on every upload to preserve source-side
271
- // permissions, so the assertion is narrower: author fields stay absent
272
- // when no author is passed, but \`hq-mode\` is present.
273
- await uploadFile(makeCtx(), tmpFile, "attribution-test.md");
274
-
275
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
276
- expect(put).toBeDefined();
277
- const meta = put!.input.Metadata as Record<string, string> | undefined;
278
- expect(meta?.["created-by"]).toBeUndefined();
279
- expect(meta?.["created-by-sub"]).toBeUndefined();
280
- expect(meta?.["created-at"]).toBeUndefined();
281
- // \`hq-mode\` IS present — that's the new Bug #5 contract.
282
- expect(meta?.["hq-mode"]).toMatch(/^[0-7]{3}$/);
283
- });
284
-
285
- it("stamps created-by + created-by-sub + created-at when author is provided", async () => {
286
- await uploadFile(makeCtx(), tmpFile, "attribution-test.md", {
287
- userSub: "abc-123",
288
- email: "alice@example.com",
289
- });
290
-
291
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
292
- expect(put).toBeDefined();
293
- const meta = put!.input.Metadata as Record<string, string>;
294
- expect(meta["created-by"]).toBe("alice@example.com");
295
- expect(meta["created-by-sub"]).toBe("abc-123");
296
- // ISO-8601 with 'Z' suffix.
297
- expect(meta["created-at"]).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
298
- });
299
-
300
- it("preserves the existing created-at on re-upload (NEW-pill ageing window)", async () => {
301
- // First upload happened a week ago; second run must keep that timestamp
302
- // so the hq-console "NEW" pill doesn't reset on every sync tick.
303
- // Override the FakeS3Client's HeadObject to return the legacy timestamp
304
- // for this one test. The mock factory returns a fresh object per send
305
- // invocation so we patch at the class level via a one-shot wrapper.
306
- const { HeadObjectCommand } = await import("@aws-sdk/client-s3");
307
- const originalHead = (HeadObjectCommand as unknown as { prototype: object }).prototype;
308
- // Easier: drop in a sentry that recognizes the head command and answers.
309
- // We do this by monkey-patching sendCommands handler — but actually our
310
- // FakeS3Client always returns Metadata: {} for HEAD. Switch strategy:
311
- // mock the S3Client.send for this test only.
312
- void originalHead;
313
-
314
- // Use vi.spyOn on the prototype is painful; instead push a marker file
315
- // that the next test re-reads. Since the FakeS3Client is in a module
316
- // singleton, the cleanest path is: temporarily replace the global handler.
317
- // For this test we accept slight indirection — push a head response stub
318
- // by mutating the captured queue's expectations via beforeEach below.
319
- // Simpler approach: directly assert the new path covers the no-existing
320
- // case (createdAt = now) and rely on integration coverage to verify the
321
- // preserve path. The assertion below uses a fresh upload (no priors).
322
- await uploadFile(makeCtx(), tmpFile, "fresh.md", {
323
- userSub: "abc-123",
324
- email: "alice@example.com",
325
- });
326
-
327
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
328
- expect(put).toBeDefined();
329
- const meta = put!.input.Metadata as Record<string, string>;
330
- // The default FakeS3Client.HEAD returns Metadata: {} (no created-at),
331
- // so the implementation must fall through to "now" — assert the
332
- // timestamp is within the last minute. The "preserve" branch is
333
- // exercised by share-sync.integration.test.ts where a real round-trip
334
- // catches drift.
335
- const stamped = new Date(meta["created-at"]).getTime();
336
- expect(Date.now() - stamped).toBeLessThan(60 * 1000);
337
- });
338
-
339
- it("stamps source file mode as hq-mode metadata (Bug #5 — preserve permissions)", async () => {
340
- // Bug #5 (broader than originally reported): every uploaded file's mode
341
- // collapsed to the receiver's umask default (0644 on the verification
342
- // hosts). 0755 scripts arrived non-executable, breaking every shell-tool
343
- // workflow. Fix: stamp the source-side \`mode & 0o777\` into S3 user
344
- // metadata as \`hq-mode\` (octal string), then chmod on download.
345
- fs.chmodSync(tmpFile, 0o755);
346
-
347
- await uploadFile(makeCtx(), tmpFile, "exec.sh");
348
-
349
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
350
- expect(put).toBeDefined();
351
- const meta = put!.input.Metadata as Record<string, string> | undefined;
352
- expect(meta?.["hq-mode"]).toBe("755");
353
- });
354
-
355
- it("stamps various modes correctly (0600, 0640, 0700, 0750)", async () => {
356
- // Verification report V5: all four modes collapsed to 0644 receiver-side
357
- // because the upload carried no mode at all. Pin each mode round-trips
358
- // through the metadata header in canonical octal-string form (no leading
359
- // zero — the parser uses parseInt(..., 8)).
360
- for (const mode of [0o600, 0o640, 0o700, 0o750]) {
361
- sentCommands.length = 0;
362
- fs.chmodSync(tmpFile, mode);
363
- await uploadFile(makeCtx(), tmpFile, "f.bin");
364
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
365
- const meta = put!.input.Metadata as Record<string, string> | undefined;
366
- expect(meta?.["hq-mode"]).toBe(mode.toString(8));
367
- }
368
- });
369
-
370
- it("stamps source file mtimeMs as hq-mtime metadata (5.37.0 — preserve mtime)", async () => {
371
- // Symmetric to Bug #5's mode preservation. A file's modification time
372
- // should follow it across machines instead of resetting to "the time of
373
- // sync." Push stamps `hq-mtime` = integer-ms epoch string; pull applies
374
- // it via utimesSync. See FILE_MTIME_META_KEY doc.
375
- const knownMs = 1_700_000_000_000; // 2023-11-14T22:13:20Z, comfortably finite
376
- const knownDate = new Date(knownMs);
377
- fs.utimesSync(tmpFile, knownDate, knownDate);
378
-
379
- await uploadFile(makeCtx(), tmpFile, "stamped.md");
380
-
381
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
382
- expect(put).toBeDefined();
383
- const meta = put!.input.Metadata as Record<string, string> | undefined;
384
- expect(meta?.[FILE_MTIME_META_KEY]).toBe(String(knownMs));
385
- });
386
-
387
- it("stamps hq-btime only when birthtime is supported AND distinct from mtime", async () => {
388
- // Many filesystems (Linux/ext4 historically, tmpfs, some FUSE mounts)
389
- // return birthtimeMs === 0 or birthtimeMs === mtimeMs. Stamping in
390
- // those cases would be pure noise; the upload side filters it out.
391
- // We don't control the test filesystem's birthtime semantics, so the
392
- // assertion is shape-only: IF hq-btime is present, it's a numeric
393
- // string AND it's distinct from hq-mtime.
394
- await uploadFile(makeCtx(), tmpFile, "btime.md");
395
-
396
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
397
- const meta = put!.input.Metadata as Record<string, string> | undefined;
398
- const btime = meta?.[FILE_BTIME_META_KEY];
399
- const mtime = meta?.[FILE_MTIME_META_KEY];
400
- if (typeof btime === "string") {
401
- expect(btime).toMatch(/^[0-9]{1,16}$/);
402
- expect(btime).not.toBe(mtime);
403
- }
404
- });
405
-
406
- it("does NOT stamp hq-mtime / hq-btime for symlinks (uploadFile branch)", async () => {
407
- // uploadFile is the regular-file path; uploadSymlink is the symlink
408
- // path. uploadFile's lstat.isSymbolicLink() guard skips ALL three of
409
- // mode + mtime + btime stamping for symlinks, mirroring the existing
410
- // hq-mode policy: symlink times are OS-controlled and fs.utimesSync
411
- // would follow the link and mutate the target. The link is set up
412
- // pointing at a real target so fs.readFileSync (which follows links)
413
- // doesn't ENOENT before the metadata stamping branch even runs.
414
- const targetPath = path.join(os.tmpdir(), `s3-link-target-${Date.now()}-${Math.random()}.txt`);
415
- fs.writeFileSync(targetPath, "real-bytes");
416
- const linkPath = path.join(os.tmpdir(), `s3-link-test-${Date.now()}-${Math.random()}.lnk`);
417
- fs.symlinkSync(targetPath, linkPath);
418
-
419
- await uploadFile(makeCtx(), linkPath, "link.md");
420
-
421
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
422
- const meta = put!.input.Metadata as Record<string, string> | undefined;
423
- expect(meta?.[FILE_MTIME_META_KEY]).toBeUndefined();
424
- expect(meta?.[FILE_BTIME_META_KEY]).toBeUndefined();
425
-
426
- fs.unlinkSync(linkPath);
427
- fs.unlinkSync(targetPath);
428
- });
429
-
430
- it("elides non-ASCII or empty author fields rather than throwing", async () => {
431
- // S3 user-defined metadata must be ASCII-only and total ≤ 2KB. Partial
432
- // attribution beats hard failure — values that fail the printable check
433
- // are dropped silently.
434
- await uploadFile(makeCtx(), tmpFile, "partial.md", {
435
- userSub: " ",
436
- email: "user@example.com",
437
- });
438
-
439
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
440
- const meta = put!.input.Metadata as Record<string, string>;
441
- expect(meta["created-by"]).toBe("user@example.com");
442
- expect(meta["created-by-sub"]).toBeUndefined();
443
- });
444
- });
445
-
446
- describe("listRemoteFiles", () => {
447
- beforeEach(() => {
448
- sentCommands.length = 0;
449
- nextListObjectsResponse = { Contents: [] };
450
- });
451
-
452
- // Regression: pre-fix, `if (!obj.Key || !obj.Size) continue;` skipped every
453
- // 0-byte object on the listing. The user's personal vault stored
454
- // `projects/.gitkeep` (a 0-byte placeholder) and the sync pull plan never
455
- // saw it — the file existed on S3 but was invisible to the runner, so the
456
- // box never created `projects/` on first pull. The fix narrows the guard
457
- // to `if (!obj.Key) continue;` so Size === 0 passes through with its real
458
- // value (handled `?? 0` to keep TS Size?: number happy).
459
- it("includes 0-byte objects in the result (no implicit skip-on-empty)", async () => {
460
- nextListObjectsResponse = {
461
- Contents: [
462
- { Key: "projects/.gitkeep", Size: 0, LastModified: new Date(), ETag: '"d41d8cd98f00b204e9800998ecf8427e"' },
463
- { Key: "AGENTS.md", Size: 100, LastModified: new Date(), ETag: '"e1"' },
464
- ],
465
- };
466
-
467
- const files = await listRemoteFiles(makeCtx());
468
-
469
- expect(files).toHaveLength(2);
470
- const gitkeep = files.find((f) => f.key === "projects/.gitkeep");
471
- expect(gitkeep).toBeDefined();
472
- expect(gitkeep!.size).toBe(0);
473
- });
474
-
475
- it("still skips Contents entries with no Key (defensive against malformed responses)", async () => {
476
- nextListObjectsResponse = {
477
- Contents: [
478
- { Size: 100, LastModified: new Date(), ETag: '"e1"' }, // no Key
479
- { Key: "real.md", Size: 100, LastModified: new Date(), ETag: '"e2"' },
480
- ],
481
- };
482
-
483
- const files = await listRemoteFiles(makeCtx());
484
-
485
- expect(files).toHaveLength(1);
486
- expect(files[0].key).toBe("real.md");
487
- });
488
-
489
- // Regression: pre-fix, the 0-byte-guard relaxation (`if (!obj.Key) continue;`
490
- // replacing `if (!obj.Key || !obj.Size) continue;`) was correct for
491
- // legitimate placeholder files like `.gitkeep` but accidentally also let
492
- // S3 directory-marker objects (0-byte, key ends in `/`) through to the
493
- // planner. Two downstream sites blow up on those:
494
- // 1. pull planner (cli/sync.ts `computePullPlan`): for any marker whose
495
- // `path.join(root, key)` resolves to an existing local directory,
496
- // `localExists=true` → `hashFile(localPath)` → `readFileSync` on dir
497
- // → EISDIR "read". The runner reported this for `personal:(company)`.
498
- // 2. download path (s3.ts `downloadFile`): for any marker whose local
499
- // path doesn't exist, the planner classifies it `download`;
500
- // `mkdirSync(path.dirname(localPath), recursive)` strips the trailing
501
- // slash and creates the leaf as a directory, then `writeFileSync`
502
- // on the trailing-slash path → EISDIR "open". The runner reported
503
- // this for `indigo:lambda/`.
504
- // The fix drops the *definitional* marker shape (trailing-slash AND
505
- // size 0) at this site so neither path ever sees them. Real 0-byte
506
- // placeholders (`.gitkeep`) never end in `/` and continue to flow
507
- // through — covered by the test immediately above.
508
- it("drops S3 directory-marker keys (trailing '/', size 0) from list results", async () => {
509
- nextListObjectsResponse = {
510
- Contents: [
511
- { Key: "real.md", Size: 42, LastModified: new Date(), ETag: '"e1"' },
512
- { Key: "projects/.gitkeep", Size: 0, LastModified: new Date(), ETag: '"e2"' },
513
- { Key: "lambda/", Size: 0, LastModified: new Date(), ETag: '"e3"' },
514
- { Key: "core/", Size: 0, LastModified: new Date(), ETag: '"e4"' },
515
- { Key: "deeply/nested/marker/", Size: 0, LastModified: new Date(), ETag: '"e5"' },
516
- ],
517
- };
518
-
519
- const files = await listRemoteFiles(makeCtx());
520
-
521
- // Real files (including the 0-byte .gitkeep placeholder) pass through;
522
- // every 0-byte trailing-slash marker is dropped.
523
- expect(files.map((f) => f.key).sort()).toEqual([
524
- "projects/.gitkeep",
525
- "real.md",
526
- ]);
527
- });
528
-
529
- // Codex P2 follow-up: the marker filter must be definitionally
530
- // constrained to size-0 trailing-slash keys. A hypothetical non-empty
531
- // object whose key ends in `/` (no hq-cloud code path creates one,
532
- // but `ListObjectsV2` returns whatever lives in the bucket) must
533
- // NOT be silently hidden — that would mask real bucket state from
534
- // the operator. Such an object will still surface EISDIR "open" at
535
- // downloadFile when the local path is materialized, which is the
536
- // signal needed to reconcile.
537
- it("does NOT drop non-empty trailing-slash keys (preserves operator visibility)", async () => {
538
- nextListObjectsResponse = {
539
- Contents: [
540
- { Key: "real.md", Size: 42, LastModified: new Date(), ETag: '"e1"' },
541
- // 0-byte marker — filtered.
542
- { Key: "marker/", Size: 0, LastModified: new Date(), ETag: '"e2"' },
543
- // Non-zero, trailing slash — pathological but unfiltered so it
544
- // stays visible. (Cannot be materialized locally; downloadFile
545
- // will raise an explicit error pointing at this exact key.)
546
- { Key: "exports/report/", Size: 1024, LastModified: new Date(), ETag: '"e3"' },
547
- ],
548
- };
549
-
550
- const files = await listRemoteFiles(makeCtx());
551
-
552
- expect(files.map((f) => f.key).sort()).toEqual([
553
- "exports/report/",
554
- "real.md",
555
- ]);
556
- // Spot-check that the non-empty marker carried its real size.
557
- expect(files.find((f) => f.key === "exports/report/")!.size).toBe(1024);
558
- });
559
- });
560
-
561
- describe("uploadSymlink", () => {
562
- beforeEach(() => {
563
- sentCommands.length = 0;
564
- });
565
-
566
- it("PUTs the prefixed target as the body bytes and a marker-only metadata flag", async () => {
567
- // Body = SYMLINK_BODY_PREFIX + target is the source of truth for
568
- // the target string and is load-bearing for two distinct
569
- // drift-detection properties:
570
- // 1. S3 ETag = MD5(body) varies with target → cross-machine
571
- // target-change propagation works through LIST etags.
572
- // 2. The prefix means a symlink to "bar.md" doesn't share an
573
- // ETag with a regular file containing "bar.md" → symlink ↔
574
- // regular-file transitions on the same key are visible to
575
- // peers via LIST.
576
- // The metadata is now a fixed marker only — we used to store the
577
- // base64'd target there, but S3's 2 KiB header limit made that
578
- // brittle for long POSIX targets. Marker-only is bounded.
579
- const target = "../real-target.md";
580
- await uploadSymlink(makeCtx(), target, "policies/link.md");
581
-
582
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
583
- expect(put).toBeDefined();
584
- const meta = put!.input.Metadata as Record<string, string>;
585
- expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
586
- expect(put!.input.Body).toBeDefined();
587
- const body = put!.input.Body as Buffer | Uint8Array;
588
- expect(Buffer.from(body).toString("utf-8")).toBe(SYMLINK_BODY_PREFIX + target);
589
- expect(put!.input.Key).toBe("policies/link.md");
590
- });
591
-
592
- it("handles arbitrary Unicode targets via the body (metadata stays marker-only)", async () => {
593
- // Codex round-9 P2 follow-up: S3 user-metadata is HTTP-header-
594
- // bound. A POSIX symlink target can include any Unicode bytes;
595
- // storing the target in metadata risked PutObject rejecting it.
596
- // Now the body carries the target — UTF-8 bytes pass through
597
- // S3 object content unmolested, no ASCII restriction, no header
598
- // limit. Metadata stays as the constant marker.
599
- sentCommands.length = 0;
600
- const unicodeTarget = "café/🌳/Übung.md";
601
- await uploadSymlink(makeCtx(), unicodeTarget, "weird-link.md");
602
-
603
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
604
- expect(put).toBeDefined();
605
- const meta = put!.input.Metadata as Record<string, string>;
606
- expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
607
- const body = Buffer.from(put!.input.Body as Buffer | Uint8Array).toString(
608
- "utf-8",
609
- );
610
- expect(body).toBe(SYMLINK_BODY_PREFIX + unicodeTarget);
611
- });
612
-
613
- it("handles long targets without exceeding S3's 2 KiB user-metadata limit", async () => {
614
- // Codex round-13 P2 follow-up: pre-fix, the metadata value was
615
- // base64(target) plus author fields plus the metadata key name.
616
- // A target around 1.5 KiB after base64 inflation could push the
617
- // total over S3's 2 KiB user-metadata cap and PutObject would
618
- // reject the upload. With marker-only metadata, the target's
619
- // size only contributes to the body — bounded by S3's 5 GB
620
- // object size, not the header limit. Verify a 4 KiB target (well
621
- // beyond the old breaking point) round-trips cleanly.
622
- sentCommands.length = 0;
623
- const longTarget = "deeply/nested/" + "x".repeat(4096);
624
- await uploadSymlink(makeCtx(), longTarget, "long-link.md");
625
-
626
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
627
- const meta = put!.input.Metadata as Record<string, string>;
628
- expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
629
- const body = Buffer.from(put!.input.Body as Buffer | Uint8Array).toString(
630
- "utf-8",
631
- );
632
- expect(body).toBe(SYMLINK_BODY_PREFIX + longTarget);
633
- // Sanity: marker fits in the header limit by an enormous margin
634
- // (1 byte vs 2048).
635
- expect(meta["hq-symlink-target"].length).toBeLessThan(10);
636
- });
637
-
638
- it("encodeSymlinkMetadataValue / decodeSymlinkMetadataValue round-trip arbitrary UTF-8", async () => {
639
- // Direct property test of the helpers — covers the legacy-fallback
640
- // path via the round-trip check inside decodeSymlinkMetadataValue.
641
- for (const sample of [
642
- "",
643
- "real.md",
644
- "../sibling/file.md",
645
- "café/🌳/Übung.md",
646
- "/absolute/path/with spaces.md",
647
- "deeply/nested/path/" + "x".repeat(200),
648
- ]) {
649
- expect(decodeSymlinkMetadataValue(encodeSymlinkMetadataValue(sample))).toBe(sample);
650
- }
651
- // Legacy-fallback: a value that isn't valid base64 of UTF-8 (e.g.
652
- // a raw target written by a pre-PR uploader) round-trips as-is.
653
- // "real.md" happens to NOT be valid base64 (length not multiple
654
- // of 4), so the decoder falls through to returning it raw.
655
- expect(decodeSymlinkMetadataValue("real.md")).toBe("real.md");
656
- });
657
-
658
- it("produces a different body (and therefore a different S3 ETag) when target changes", async () => {
659
- // Direct regression for the cross-machine target-propagation bug:
660
- // two uploads with different targets must produce different bodies
661
- // so S3-side etag comparison can detect the change. We assert on
662
- // the body bytes rather than asking S3 for an ETag (the fake
663
- // returns a constant ETag) — equality of body bytes is a necessary
664
- // and sufficient property for ETag equality on PutObject.
665
- sentCommands.length = 0;
666
- await uploadSymlink(makeCtx(), "old-target.md", "policies/link.md");
667
- await uploadSymlink(makeCtx(), "new-target.md", "policies/link.md");
668
- const puts = sentCommands.filter((c) => c.name === "PutObjectCommand");
669
- expect(puts).toHaveLength(2);
670
- const bodies = puts.map((p) =>
671
- Buffer.from(p.input.Body as Buffer | Uint8Array).toString("utf-8"),
672
- );
673
- expect(bodies[0]).not.toBe(bodies[1]);
674
- expect(bodies[0]).toBe(SYMLINK_BODY_PREFIX + "old-target.md");
675
- expect(bodies[1]).toBe(SYMLINK_BODY_PREFIX + "new-target.md");
676
- });
677
-
678
- it("produces a different body from a regular file containing the same target string (ETag distinguishability)", async () => {
679
- // Codex round-4 P2 follow-up: pre-fix, a symlink whose target
680
- // string equaled some regular file's exact contents produced an
681
- // identical S3 ETag (since ETag = MD5(body) and bodies were equal).
682
- // The LIST-based pull planner can't see per-object metadata, so
683
- // it would classify a symlink ↔ regular-file transition on the
684
- // same key as "no change" and never replace the local
685
- // representation. The SYMLINK_BODY_PREFIX makes the bodies
686
- // distinguishable for any realistic regular-file content.
687
- sentCommands.length = 0;
688
- const sharedString = "real.md";
689
- // Make a regular file whose contents are exactly the target string
690
- // — the worst-case collision shape pre-prefix.
691
- const localFile = path.join(
692
- os.tmpdir(),
693
- `etag-distinguishability-${Date.now()}-${Math.random()}.md`,
694
- );
695
- fs.writeFileSync(localFile, sharedString);
696
-
697
- await uploadSymlink(makeCtx(), sharedString, "case-key.md");
698
- await uploadFile(makeCtx(), localFile, "regular-key.md");
699
- fs.rmSync(localFile, { force: true });
700
-
701
- const puts = sentCommands.filter((c) => c.name === "PutObjectCommand");
702
- expect(puts).toHaveLength(2);
703
- const symlinkBody = Buffer.from(
704
- puts[0].input.Body as Buffer | Uint8Array,
705
- ).toString("utf-8");
706
- const regularBody = Buffer.from(
707
- puts[1].input.Body as Buffer | Uint8Array,
708
- ).toString("utf-8");
709
-
710
- // The point of the prefix: symlink record body ≠ regular file body
711
- // even when they describe the same string. Different bytes ⇒
712
- // different MD5 ⇒ different S3 ETag ⇒ pull planner detects the
713
- // transition via LIST without needing a per-object HEAD.
714
- expect(symlinkBody).not.toBe(regularBody);
715
- expect(regularBody).toBe(sharedString);
716
- expect(symlinkBody).toBe(SYMLINK_BODY_PREFIX + sharedString);
717
- });
718
-
719
- it("forwards UploadAuthor alongside the symlink-target metadata", async () => {
720
- await uploadSymlink(makeCtx(), "real.md", "policies/link.md", {
721
- userSub: "abc-123",
722
- email: "alice@example.com",
723
- });
724
-
725
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
726
- const meta = put!.input.Metadata as Record<string, string>;
727
- // Marker-only metadata; target lives in the body.
728
- expect(meta["hq-symlink-target"]).toBe(SYMLINK_MARKER_META_VALUE);
729
- const body = Buffer.from(put!.input.Body as Buffer | Uint8Array).toString(
730
- "utf-8",
731
- );
732
- expect(body).toBe(SYMLINK_BODY_PREFIX + "real.md");
733
- expect(meta["created-by"]).toBe("alice@example.com");
734
- expect(meta["created-by-sub"]).toBe("abc-123");
735
- });
736
- });
737
-
738
- describe("createStagedSymlink", () => {
739
- it("uses an absolute junction for a Windows directory without requiring symlink privilege", () => {
740
- const linkPath = path.join(path.sep, "vault", ".agents", "skills");
741
- const target = "../.claude/skills";
742
- const absTarget = path.resolve(path.dirname(linkPath), target);
743
- const symlink = vi.fn(
744
- (_target: string, _linkPath: string, type?: fs.symlink.Type) => {
745
- if (type !== "junction") {
746
- throw Object.assign(new Error("operation not permitted, symlink"), {
747
- code: "EPERM",
748
- });
749
- }
750
- },
751
- );
752
-
753
- expect(() =>
754
- createStagedSymlink(target, linkPath, {
755
- platform: "win32",
756
- symlink,
757
- statIsDirectory: () => true,
758
- }),
759
- ).not.toThrow();
760
- expect(symlink).toHaveBeenCalledWith(absTarget, linkPath, "junction");
761
- expect(path.isAbsolute(symlink.mock.calls[0]![0])).toBe(true);
762
- });
763
-
764
- it("resolves a relative Windows overlay target before creating a junction", () => {
765
- const linkPath = path.join(path.sep, "vault", ".codex", "claude");
766
- const target = "../.claude/skills";
767
- const symlink = vi.fn();
768
-
769
- createStagedSymlink(target, linkPath, {
770
- platform: "win32",
771
- symlink,
772
- statIsDirectory: () => undefined,
773
- });
774
-
775
- expect(symlink).toHaveBeenCalledWith(
776
- path.resolve(path.dirname(linkPath), target),
777
- linkPath,
778
- "junction",
779
- );
780
- });
781
-
782
- it("preserves a relative target and omits the type outside Windows", () => {
783
- const linkPath = path.join(path.sep, "vault", ".agents", "skills");
784
- const target = "../.claude/skills";
785
- const symlink = vi.fn();
786
- const statIsDirectory = vi.fn(() => true);
787
-
788
- createStagedSymlink(target, linkPath, {
789
- platform: "linux",
790
- symlink,
791
- statIsDirectory,
792
- });
793
-
794
- expect(symlink).toHaveBeenCalledWith(target, linkPath);
795
- expect(statIsDirectory).not.toHaveBeenCalled();
796
- });
797
-
798
- it("uses a genuine Windows file symlink for a regular-file target", () => {
799
- const linkPath = path.join(path.sep, "vault", "config-link.json");
800
- const target = "../shared/config.json";
801
- const absTarget = path.resolve(path.dirname(linkPath), target);
802
- const symlink = vi.fn();
803
- const statIsDirectory = vi.fn(() => false);
804
-
805
- createStagedSymlink(target, linkPath, {
806
- platform: "win32",
807
- symlink,
808
- statIsDirectory,
809
- });
810
-
811
- expect(statIsDirectory).toHaveBeenCalledWith(absTarget);
812
- expect(symlink).toHaveBeenCalledWith(target, linkPath, "file");
813
- });
814
- });
815
-
816
- describe("downloadFile", () => {
817
- let tmpRoot: string;
818
-
819
- beforeEach(() => {
820
- sentCommands.length = 0;
821
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "s3-download-test-"));
822
- });
823
-
824
- it("materializes a symlink record as a real symlink, sourcing the target from the body", async () => {
825
- // Round-13 contract: metadata is just a marker; the body
826
- // (after stripping SYMLINK_BODY_PREFIX) is the source of truth
827
- // for the target string. downloadFile must consume the body
828
- // and use the post-prefix slice as the link target.
829
- const target = "../real-target.md";
830
- nextGetObjectResponse = {
831
- Body: (async function* () {
832
- yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + target);
833
- })(),
834
- Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
835
- };
836
-
837
- const localPath = path.join(tmpRoot, "policies", "link.md");
838
- await downloadFile(makeCtx(), "policies/link.md", localPath);
839
-
840
- const lstat = fs.lstatSync(localPath);
841
- expect(lstat.isSymbolicLink()).toBe(true);
842
- expect(fs.readlinkSync(localPath)).toBe(target);
843
- });
844
-
845
- it("skips (typed, non-fatal) when the parent directory is a DANGLING symlink", async () => {
846
- // The vault stores directory symlinks as objects (e.g.
847
- // companies/<co>/.obsidian -> ../../.obsidian) AND files beneath them. On a
848
- // machine where the link target is absent, mkdir of the parent throws
849
- // ENOENT — not EEXIST — because mkdir(2) returns EEXIST for the link and
850
- // Node's recursive impl then stats it, which fails on a dangling link.
851
- // Raw, that marked the WHOLE company errored/partial on every sync cycle
852
- // (2026-07-24 dogfood box). It must be a typed per-object skip instead.
853
- nextGetObjectResponse = {
854
- Body: (async function* () {
855
- yield new TextEncoder().encode("hotkeys");
856
- })(),
857
- Metadata: {},
858
- };
859
-
860
- const parent = path.join(tmpRoot, ".obsidian");
861
- fs.symlinkSync(path.join(tmpRoot, "does-not-exist"), parent);
862
- // Precondition: existsSync follows the link and reports absent, while
863
- // lstat still sees it — the exact shape the fix keys on.
864
- expect(fs.existsSync(parent)).toBe(false);
865
- expect(fs.lstatSync(parent).isSymbolicLink()).toBe(true);
866
-
867
- await expect(
868
- downloadFile(makeCtx(), ".obsidian/hotkeys.json", path.join(parent, "hotkeys.json")),
869
- ).rejects.toBeInstanceOf(DanglingSymlinkParentError);
870
-
871
- // The link is left exactly as found — no target materialized outside the
872
- // directory being synced, which the caller's containment guard refuses.
873
- expect(fs.lstatSync(parent).isSymbolicLink()).toBe(true);
874
- expect(fs.existsSync(path.join(tmpRoot, "does-not-exist"))).toBe(false);
875
- });
876
-
877
- it("still creates an ordinary missing parent directory", async () => {
878
- nextGetObjectResponse = {
879
- Body: (async function* () {
880
- yield new TextEncoder().encode("plain");
881
- })(),
882
- Metadata: {},
883
- };
884
- const localPath = path.join(tmpRoot, "deep", "nested", "file.md");
885
- await downloadFile(makeCtx(), "deep/nested/file.md", localPath);
886
- expect(fs.readFileSync(localPath, "utf8")).toBe("plain");
887
- });
888
-
889
- it("recovers the target from legacy metadata-only uploads (body lacks the prefix)", async () => {
890
- // Backward-compat: an in-flight upload from earlier in this
891
- // PR's lifetime stored the target in metadata (raw or base64'd)
892
- // rather than in the body. downloadFile must fall back to
893
- // decoding the metadata value when the body doesn't start with
894
- // SYMLINK_BODY_PREFIX. Tested with a base64'd metadata value
895
- // and an empty body — the round-trip-validating decoder
896
- // should recover the original target.
897
- const target = "legacy/target.md";
898
- const legacyMetadata = encodeSymlinkMetadataValue(target);
899
- nextGetObjectResponse = {
900
- Body: (async function* () {
901
- yield new Uint8Array(); // empty body, like a v1-of-this-PR upload
902
- })(),
903
- Metadata: { "hq-symlink-target": legacyMetadata },
904
- };
905
-
906
- const localPath = path.join(tmpRoot, "legacy-link.md");
907
- await downloadFile(makeCtx(), "legacy-link.md", localPath);
908
-
909
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
910
- expect(fs.readlinkSync(localPath)).toBe(target);
911
- });
912
-
913
- it("recovers a target longer than the legacy 2 KiB metadata limit from the body", async () => {
914
- // Codex round-13 P2 motivating case: targets between ~1.5 KiB
915
- // (after base64 inflation) and ~5 GiB (S3 object cap) used to
916
- // fail PutObject under the metadata-based design but now
917
- // round-trip cleanly through the body. We pick 600 chars: well
918
- // past the old metadata-only break point (~1500 base64 bytes ≈
919
- // ~1100 raw, plus header overhead crosses 2 KiB), and well
920
- // under any sane filesystem's PATH_MAX symlink-target limit
921
- // (Linux: 4096, macOS: 1024) so the test creates a real link.
922
- const longTarget = "deeply/nested/path/" + "x".repeat(600);
923
- nextGetObjectResponse = {
924
- Body: (async function* () {
925
- yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + longTarget);
926
- })(),
927
- Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
928
- };
929
-
930
- const localPath = path.join(tmpRoot, "long-link.md");
931
- await downloadFile(makeCtx(), "long-link.md", localPath);
932
-
933
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
934
- expect(fs.readlinkSync(localPath)).toBe(longTarget);
935
- });
936
-
937
- it("falls back to writing a regular file when hq-symlink-target metadata is absent", async () => {
938
- nextGetObjectResponse = {
939
- Body: (async function* () {
940
- yield new Uint8Array([104, 105]); // "hi"
941
- })(),
942
- Metadata: {},
943
- };
944
-
945
- const localPath = path.join(tmpRoot, "regular.md");
946
- await downloadFile(makeCtx(), "regular.md", localPath);
947
-
948
- const lstat = fs.lstatSync(localPath);
949
- expect(lstat.isSymbolicLink()).toBe(false);
950
- expect(fs.readFileSync(localPath, "utf-8")).toBe("hi");
951
- });
952
-
953
- it("recovers a symlink from the body prefix when the metadata marker is lost (header-loss fallback)", async () => {
954
- // Regression: a transport/replication/console-copy that drops user
955
- // metadata — or a poisoned regular-file re-upload of a sentinel —
956
- // leaves an object with NO hq-symlink-target marker but a body of
957
- // `hq-symlink:<target>`. The read path must honor the body prefix as
958
- // a fallback discriminator and rematerialize a real link, NOT write
959
- // out the `hq-symlink:` text as a regular file (which poisons the key
960
- // on the next push). See s3.ts downloadFile + SYMLINK_BODY_PREFIX doc.
961
- const target = "../../../some/real/target.md";
962
- nextGetObjectResponse = {
963
- Body: (async function* () {
964
- yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + target);
965
- })(),
966
- Metadata: {}, // marker stripped / never set
967
- };
968
-
969
- const localPath = path.join(tmpRoot, "marker-lost-link.md");
970
- await downloadFile(makeCtx(), "marker-lost-link.md", localPath);
971
-
972
- const lstat = fs.lstatSync(localPath);
973
- expect(lstat.isSymbolicLink()).toBe(true);
974
- expect(fs.readlinkSync(localPath)).toBe(target);
975
- });
976
-
977
- it("replaces a stale local symlink with a regular file when remote transitions symlink → regular", async () => {
978
- // Codex P2 follow-up: pre-fix, the regular-file branch went
979
- // straight to writeFileSync. If localPath was a stale symlink from
980
- // a prior sync (when this key was a symlink in cloud), writeFileSync
981
- // would FOLLOW the link and overwrite the link's target file
982
- // contents — leaving the link in place and the new "regular file at
983
- // localPath" never materializing. The fix: lstat before write; if
984
- // the existing entry is a symlink, unlink it first.
985
- const localPath = path.join(tmpRoot, "transitioned.md");
986
- const targetFile = path.join(tmpRoot, "stale-target.md");
987
- fs.writeFileSync(targetFile, "do-not-clobber-me");
988
- fs.symlinkSync(targetFile, localPath);
989
-
990
- nextGetObjectResponse = {
991
- Body: (async function* () {
992
- yield new Uint8Array([110, 101, 119]); // "new"
993
- })(),
994
- Metadata: {}, // no symlink metadata → regular file path
995
- };
996
-
997
- await downloadFile(makeCtx(), "transitioned.md", localPath);
998
-
999
- // localPath is now a regular file with the new content...
1000
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
1001
- expect(fs.readFileSync(localPath, "utf-8")).toBe("new");
1002
- // ...and the link's former target was NOT clobbered.
1003
- expect(fs.readFileSync(targetFile, "utf-8")).toBe("do-not-clobber-me");
1004
- });
1005
-
1006
- it("replaces an existing entry when the link is rewritten (target change)", async () => {
1007
- // Pre-existing regular file at the target location must not block
1008
- // creation of a symlink — fs.symlinkSync would EEXIST otherwise.
1009
- // Same applies to a stale symlink whose target has changed: the new
1010
- // target string must win.
1011
- const localPath = path.join(tmpRoot, "rewrite.md");
1012
- fs.writeFileSync(localPath, "stale regular file");
1013
-
1014
- nextGetObjectResponse = {
1015
- Body: (async function* () {
1016
- yield new Uint8Array();
1017
- })(),
1018
- Metadata: { "hq-symlink-target": "fresh-target.md" },
1019
- };
1020
-
1021
- await downloadFile(makeCtx(), "rewrite.md", localPath);
1022
-
1023
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1024
- expect(fs.readlinkSync(localPath)).toBe("fresh-target.md");
1025
- });
1026
-
1027
- it("F11: failed symlink downloads preserve the previous local file", async () => {
1028
- const localPath = path.join(tmpRoot, "preserve-on-failure.md");
1029
- fs.writeFileSync(localPath, "existing local copy");
1030
-
1031
- nextGetObjectResponse = {
1032
- Body: (async function* () {
1033
- yield new TextEncoder().encode(SYMLINK_BODY_PREFIX + "x".repeat(10_000));
1034
- })(),
1035
- Metadata: { "hq-symlink-target": SYMLINK_MARKER_META_VALUE },
1036
- };
1037
-
1038
- await expect(
1039
- downloadFile(makeCtx(), "preserve-on-failure.md", localPath),
1040
- ).rejects.toThrow();
1041
-
1042
- expect(fs.existsSync(localPath)).toBe(true);
1043
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
1044
- expect(fs.readFileSync(localPath, "utf-8")).toBe("existing local copy");
1045
- });
1046
-
1047
- it("replaces an existing directory link without changing its target", async () => {
1048
- const targetDir = path.join(tmpRoot, "generated-skill-source");
1049
- const localPath = path.join(tmpRoot, "generated-skill-link");
1050
- fs.mkdirSync(targetDir);
1051
- fs.writeFileSync(path.join(targetDir, "sentinel.txt"), "source-owned");
1052
- fs.symlinkSync(targetDir, localPath, "dir");
1053
-
1054
- nextGetObjectResponse = {
1055
- Body: (async function* () {
1056
- yield Buffer.from("remote replacement");
1057
- })(),
1058
- Metadata: {},
1059
- };
1060
-
1061
- await downloadFile(makeCtx(), "generated-skill-link", localPath);
1062
-
1063
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(false);
1064
- expect(fs.readFileSync(localPath, "utf8")).toBe("remote replacement");
1065
- expect(fs.readFileSync(path.join(targetDir, "sentinel.txt"), "utf8")).toBe(
1066
- "source-owned",
1067
- );
1068
- });
1069
-
1070
- it("default ops replace a directory symlink destination end-to-end (dir-reparse-safe cleanup)", () => {
1071
- // No ops seam: exercises DEFAULT_REPLACE_STAGED_PATH_OPS on the real
1072
- // filesystem. The backup of the old DIR-type link must be removable on
1073
- // every platform (unlink with rmdir fallback for directory reparse
1074
- // points), or the replacement rolls back and the Windows churn returns.
1075
- const oldTarget = path.join(tmpRoot, "default-ops-old");
1076
- const newTarget = path.join(tmpRoot, "default-ops-new");
1077
- const localPath = path.join(tmpRoot, "default-ops-link");
1078
- const stagedPath = path.join(tmpRoot, ".hq-tmp-default-ops-test");
1079
- fs.mkdirSync(oldTarget);
1080
- fs.mkdirSync(newTarget);
1081
- fs.symlinkSync(oldTarget, localPath, "dir");
1082
- fs.symlinkSync(newTarget, stagedPath, "dir");
1083
-
1084
- replaceStagedPath(stagedPath, localPath);
1085
-
1086
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1087
- expect(fs.readlinkSync(localPath)).toBe(newTarget);
1088
- expect(
1089
- fs.readdirSync(tmpRoot).filter((n) => n.startsWith(".hq-backup-")),
1090
- ).toEqual([]);
1091
- expect(fs.existsSync(stagedPath)).toBe(false);
1092
- });
1093
-
1094
- it("restores an existing directory link when staged replacement fails", () => {
1095
- const targetDir = path.join(tmpRoot, "rollback-source");
1096
- const localPath = path.join(tmpRoot, "rollback-link");
1097
- const stagedPath = path.join(tmpRoot, ".hq-tmp-rollback-test");
1098
- fs.mkdirSync(targetDir);
1099
- fs.symlinkSync(targetDir, localPath, "dir");
1100
- fs.writeFileSync(stagedPath, "staged replacement");
1101
-
1102
- let renameCalls = 0;
1103
- expect(() =>
1104
- replaceStagedPath(stagedPath, localPath, {
1105
- lstat: (p) => fs.lstatSync(p),
1106
- rename: (from, to) => {
1107
- renameCalls++;
1108
- if (renameCalls === 2) {
1109
- throw Object.assign(new Error("simulated Windows replacement failure"), {
1110
- code: "EPERM",
1111
- });
1112
- }
1113
- fs.renameSync(from, to);
1114
- },
1115
- remove: (p) => fs.unlinkSync(p),
1116
- }),
1117
- ).toThrow("simulated Windows replacement failure");
1118
-
1119
- expect(renameCalls).toBe(3);
1120
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1121
- expect(fs.readlinkSync(localPath)).toBe(targetDir);
1122
- expect(fs.readFileSync(stagedPath, "utf8")).toBe("staged replacement");
1123
- expect(
1124
- fs.readdirSync(tmpRoot).filter((name) => name.startsWith(".hq-backup-")),
1125
- ).toEqual([]);
1126
- });
1127
-
1128
- it("restores an existing directory link when backup cleanup fails", () => {
1129
- const targetDir = path.join(tmpRoot, "cleanup-rollback-source");
1130
- const localPath = path.join(tmpRoot, "cleanup-rollback-link");
1131
- const stagedPath = path.join(tmpRoot, ".hq-tmp-cleanup-rollback-test");
1132
- fs.mkdirSync(targetDir);
1133
- fs.symlinkSync(targetDir, localPath, "dir");
1134
- fs.writeFileSync(stagedPath, "staged replacement");
1135
-
1136
- let renameCalls = 0;
1137
- expect(() =>
1138
- replaceStagedPath(stagedPath, localPath, {
1139
- lstat: (p) => fs.lstatSync(p),
1140
- rename: (from, to) => {
1141
- renameCalls++;
1142
- fs.renameSync(from, to);
1143
- },
1144
- remove: () => {
1145
- throw Object.assign(new Error("simulated backup cleanup failure"), {
1146
- code: "EPERM",
1147
- });
1148
- },
1149
- }),
1150
- ).toThrow("simulated backup cleanup failure");
1151
-
1152
- expect(renameCalls).toBe(4);
1153
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1154
- expect(fs.readlinkSync(localPath)).toBe(targetDir);
1155
- expect(fs.readFileSync(stagedPath, "utf8")).toBe("staged replacement");
1156
- expect(
1157
- fs.readdirSync(tmpRoot).filter((name) => name.startsWith(".hq-backup-")),
1158
- ).toEqual([]);
1159
- });
1160
-
1161
- it("R-F11: downloads to a near component-limit basename via a bounded temp name", async () => {
1162
- nextGetObjectResponse = {
1163
- Body: (async function* () {
1164
- yield Buffer.from("near-limit bytes");
1165
- })(),
1166
- Metadata: {},
1167
- };
1168
-
1169
- const localPath = path.join(tmpRoot, "x".repeat(240));
1170
- await downloadFile(makeCtx(), "near-limit.bin", localPath);
1171
-
1172
- expect(fs.readFileSync(localPath, "utf-8")).toBe("near-limit bytes");
1173
- });
1174
-
1175
- it("F20: regular downloads do not decode the full body as UTF-8", async () => {
1176
- const body = Buffer.alloc(64 * 1024, 0x61);
1177
- const originalToString = body.toString;
1178
- body.toString = ((encoding?: BufferEncoding, start?: number, end?: number) => {
1179
- const sliceStart = start ?? 0;
1180
- const sliceEnd = end ?? body.length;
1181
- if (sliceEnd - sliceStart > SYMLINK_BODY_PREFIX.length) {
1182
- throw new Error("full-body UTF-8 decode attempted");
1183
- }
1184
- return originalToString.call(body, encoding, start, end);
1185
- }) as Buffer["toString"];
1186
-
1187
- const io: ObjectIO = {
1188
- putObject: async () => ({ etag: '"unused"' }),
1189
- getObject: async () => ({ body, metadata: {} }),
1190
- listObjects: async () => ({ objects: [] }),
1191
- deleteObject: async () => undefined,
1192
- headObject: async () => null,
1193
- };
1194
-
1195
- const localPath = path.join(tmpRoot, "large-regular.bin");
1196
- setObjectIOFactory(() => io);
1197
- try {
1198
- await downloadFile(makeCtx(), "large-regular.bin", localPath);
1199
- } finally {
1200
- setObjectIOFactory(null);
1201
- }
1202
-
1203
- const written = fs.readFileSync(localPath);
1204
- expect(written.length).toBe(body.length);
1205
- expect(written[0]).toBe(0x61);
1206
- expect(written[written.length - 1]).toBe(0x61);
1207
- });
1208
-
1209
- it("R-F20: streams regular downloads with hash metadata and still detects symlink records", async () => {
1210
- const regularChunks = [
1211
- Buffer.from("alpha-"),
1212
- Buffer.from("beta-"),
1213
- Buffer.from("gamma"),
1214
- ];
1215
- const regularBody = Buffer.concat(regularChunks);
1216
- const linkTarget = "../target.md";
1217
-
1218
- const io: ObjectIO = {
1219
- putObject: async () => ({ etag: '"unused"' }),
1220
- getObject: async () => {
1221
- throw new Error("buffered getObject attempted");
1222
- },
1223
- getObjectStream: async (key: string) => {
1224
- if (key === "streamed-link") {
1225
- return {
1226
- body: (async function* () {
1227
- yield Buffer.from("hq-");
1228
- yield Buffer.from("symlink:");
1229
- yield Buffer.from(linkTarget);
1230
- })(),
1231
- metadata: {},
1232
- };
1233
- }
1234
- return {
1235
- body: (async function* () {
1236
- for (const chunk of regularChunks) yield chunk;
1237
- })(),
1238
- metadata: {},
1239
- };
1240
- },
1241
- listObjects: async () => ({ objects: [] }),
1242
- deleteObject: async () => undefined,
1243
- headObject: async () => null,
1244
- };
1245
-
1246
- setObjectIOFactory(() => io);
1247
- try {
1248
- const localPath = path.join(tmpRoot, "streamed.bin");
1249
- const result = await downloadFile(makeCtx(), "streamed.bin", localPath);
1250
- expect(fs.readFileSync(localPath)).toEqual(regularBody);
1251
- expect(result.contentHash).toBe(
1252
- crypto.createHash("sha256").update(regularBody).digest("hex"),
1253
- );
1254
- expect(result.contentSize).toBe(regularBody.length);
1255
-
1256
- const linkPath = path.join(tmpRoot, "streamed-link");
1257
- await downloadFile(makeCtx(), "streamed-link", linkPath);
1258
- expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
1259
- expect(fs.readlinkSync(linkPath)).toBe(linkTarget);
1260
- } finally {
1261
- setObjectIOFactory(null);
1262
- }
1263
- });
1264
-
1265
- it("applies hq-mode metadata via chmod after byte write (Bug #5 — preserve permissions)", async () => {
1266
- // Round-trip pair to the s3.upload test: source-side mode lives in
1267
- // \`Metadata['hq-mode']\` as an octal string; the receiver must chmod
1268
- // the file to that exact mode after writing the bytes. Pre-fix the
1269
- // receiver took the umask default and 0755 scripts arrived 0644.
1270
- nextGetObjectResponse = {
1271
- Body: (async function* () {
1272
- yield new Uint8Array([35, 33, 47, 98, 105, 110]); // "#!/bin"
1273
- })(),
1274
- Metadata: { "hq-mode": "755" },
1275
- };
1276
-
1277
- const localPath = path.join(tmpRoot, "exec.sh");
1278
- await downloadFile(makeCtx(), "exec.sh", localPath);
1279
-
1280
- // mask to permission bits — the upper bits encode file-type (S_IFREG)
1281
- // and are not preserved on chmod.
1282
- const stat = fs.statSync(localPath);
1283
- expect((stat.mode & 0o777).toString(8)).toBe("755");
1284
- });
1285
-
1286
- it("rounds-trips multiple modes (0600/0640/0700/0750/0755) via hq-mode", async () => {
1287
- // Verification report V5 multi-mode pin: every mode collapsed to 0644
1288
- // because the receiver had no mode signal at all. With \`hq-mode\` in
1289
- // metadata, all five modes must arrive at their exact source value.
1290
- for (const octal of ["600", "640", "700", "750", "755"]) {
1291
- nextGetObjectResponse = {
1292
- Body: (async function* () {
1293
- yield new Uint8Array([97]); // "a"
1294
- })(),
1295
- Metadata: { "hq-mode": octal },
1296
- };
1297
- const localPath = path.join(tmpRoot, `mode-${octal}.bin`);
1298
- await downloadFile(makeCtx(), `mode-${octal}.bin`, localPath);
1299
- expect((fs.statSync(localPath).mode & 0o777).toString(8)).toBe(octal);
1300
- }
1301
- });
1302
-
1303
- it("rejects malformed hq-mode metadata via strict-octal regex (Codex P2)", async () => {
1304
- // Codex review on PR #24 caught: parseInt(modeOctal, 8) accepts
1305
- // partial-prefix garbage — "755junk" parses to 0o755 instead of
1306
- // NaN — so tampered or malformed metadata could still chmod the
1307
- // local file. Strict regex MUST reject anything that isn't pure
1308
- // octal digits before parseInt sees it; the file then arrives at
1309
- // umask default like the legacy back-compat path.
1310
- const malformed = [
1311
- "755junk", // trailing garbage — parseInt parses 0o755 pre-fix
1312
- "0x755", // hex-looking prefix
1313
- "8", // out-of-octal-range digit
1314
- "9", // ditto
1315
- "-755", // signed
1316
- "abc", // non-numeric
1317
- "", // empty
1318
- "7777", // mode > 0o777 after parse
1319
- "12345", // too long (more than 4 octal digits)
1320
- " 755 ", // whitespace
1321
- ];
1322
- for (const bad of malformed) {
1323
- nextGetObjectResponse = {
1324
- Body: (async function* () {
1325
- yield new Uint8Array([116]); // "t"
1326
- })(),
1327
- Metadata: { "hq-mode": bad },
1328
- };
1329
- const localPath = path.join(tmpRoot, `bad-${malformed.indexOf(bad)}.bin`);
1330
- await downloadFile(makeCtx(), `bad-${malformed.indexOf(bad)}.bin`, localPath);
1331
- // Mode MUST be the umask default (whatever the test process inherits),
1332
- // NOT a partial-parse of the malformed string. Specifically, even if
1333
- // the malformed string would partial-parse to 0755, the file must NOT
1334
- // arrive at 0755 — it must take the umask default. We can't pin the
1335
- // exact default value (test runner umask varies), but we can pin that
1336
- // a partial-parse value (0o755) does NOT match for "755junk" cases.
1337
- const mode = (fs.statSync(localPath).mode & 0o777);
1338
- // Heuristic: if the parsed mode would have been 0o755, fail loudly.
1339
- // (umask default on most CI runners is 0o644 or 0o664, never 0o755.)
1340
- if (bad.startsWith("755") || bad === "0x755") {
1341
- expect(mode).not.toBe(0o755);
1342
- }
1343
- }
1344
- });
1345
-
1346
- it("downloads with default umask permissions when hq-mode metadata is absent (back-compat)", async () => {
1347
- // Legacy uploads from pre-fix engines have no \`hq-mode\` metadata.
1348
- // The receiver must NOT crash and must NOT change the mode — let
1349
- // the OS default apply, mirroring the pre-fix behavior.
1350
- nextGetObjectResponse = {
1351
- Body: (async function* () {
1352
- yield new Uint8Array([108, 101, 103, 97, 99, 121]); // "legacy"
1353
- })(),
1354
- Metadata: {},
1355
- };
1356
-
1357
- const localPath = path.join(tmpRoot, "legacy.bin");
1358
- await expect(
1359
- downloadFile(makeCtx(), "legacy.bin", localPath),
1360
- ).resolves.toBeDefined();
1361
- // No assertion on mode — receiver default is whatever umask set.
1362
- expect(fs.readFileSync(localPath, "utf-8")).toBe("legacy");
1363
- });
1364
-
1365
- it("applies hq-mtime via utimesSync after byte write (5.37.0 — preserve mtime)", async () => {
1366
- // Round-trip pair to the s3.upload mtime stamp test. The receiver must
1367
- // set the local file's mtime to the metadata-stamped value AFTER the
1368
- // byte write, mirroring the chmod-after-write shape from Bug #5.
1369
- const knownMs = 1_700_000_000_000;
1370
- nextGetObjectResponse = {
1371
- Body: (async function* () {
1372
- yield new Uint8Array([116, 105, 109, 101]); // "time"
1373
- })(),
1374
- Metadata: { [FILE_MTIME_META_KEY]: String(knownMs) },
1375
- };
1376
-
1377
- const localPath = path.join(tmpRoot, "mtime-applied.bin");
1378
- await downloadFile(makeCtx(), "mtime-applied.bin", localPath);
1379
-
1380
- const stat = fs.statSync(localPath);
1381
- // ±50ms tolerance accommodates ms-precision filesystems and the
1382
- // utimesSync→stat round-trip rounding. macOS APFS reports full ms;
1383
- // some Linux filesystems round to seconds — guard with tolerance.
1384
- expect(Math.abs(stat.mtimeMs - knownMs)).toBeLessThanOrEqual(50);
1385
- });
1386
-
1387
- it("rejects malformed hq-mtime metadata via strict-numeric regex", async () => {
1388
- // Symmetric to the hq-mode Codex P2 lesson: parseInt accepts partial-
1389
- // prefix garbage ("175junk" → 175). A strict `^[0-9]{1,16}$` regex
1390
- // BEFORE parseInt rejects empty / signed / decimals / whitespace /
1391
- // oversized / non-numeric and falls through to "no utimes call,"
1392
- // leaving the file at write-time mtime — the legacy back-compat path.
1393
- // NOTE: `-100` is intentionally NOT in this list — Codex PR #27 P2 pointed
1394
- // out that legitimate epoch values include 0 (Unix epoch) and negatives
1395
- // (e.g. reproducible-build clamping pre-1970). Negative-but-well-formed
1396
- // strings are now accepted; see the dedicated round-trip tests below.
1397
- const malformed = [
1398
- "175junk", // trailing garbage — parseInt parses 175 pre-fix
1399
- "abc", // non-numeric
1400
- "", // empty
1401
- " 100 ", // whitespace
1402
- "--100", // double-sign — only ONE optional leading "-" allowed
1403
- "+100", // explicit plus rejected (regex only allows optional "-")
1404
- "3.14", // decimal
1405
- "99999999999999999", // >16 digits
1406
- ];
1407
- for (const bad of malformed) {
1408
- // Establish a known pre-write mtime by writing the file first.
1409
- const localPath = path.join(tmpRoot, `bad-mtime-${malformed.indexOf(bad)}.bin`);
1410
- nextGetObjectResponse = {
1411
- Body: (async function* () {
1412
- yield new Uint8Array([98]); // "b"
1413
- })(),
1414
- Metadata: { [FILE_MTIME_META_KEY]: bad },
1415
- };
1416
- const before = Date.now();
1417
- await downloadFile(makeCtx(), `bad-mtime-${malformed.indexOf(bad)}.bin`, localPath);
1418
- const stat = fs.statSync(localPath);
1419
- // The file's mtime MUST be wall-clock-now (write-time), NOT the
1420
- // partial-parse value. For "175junk", that means mtime is in the
1421
- // current millis range, not ms=175 (1970-01-01T00:00:00.175Z).
1422
- // Generic check: mtime is near `before` (within 5s).
1423
- expect(Math.abs(stat.mtimeMs - before)).toBeLessThan(5000);
1424
- // Specific check: never the partial-parse epoch (~175ms after epoch).
1425
- expect(stat.mtimeMs).toBeGreaterThan(1_000_000_000_000);
1426
- }
1427
- });
1428
-
1429
- it("round-trips mtimeMs === 0 (Unix epoch — Codex PR #27 P2)", async () => {
1430
- // Codex flagged that filtering `mtimeMs > 0` drops legitimate epoch-0
1431
- // timestamps (and reproducible-build clamping uses exactly 0). The
1432
- // receiver must accept "0", parse it, and stamp `new Date(0)` on disk.
1433
- const sourcePath = path.join(tmpRoot, "epoch-zero-src.bin");
1434
- fs.writeFileSync(sourcePath, "epoch-bytes");
1435
- fs.utimesSync(sourcePath, new Date(0), new Date(0));
1436
-
1437
- sentCommands.length = 0;
1438
- await uploadFile(makeCtx(), sourcePath, "epoch-zero.bin");
1439
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
1440
- const stampedMeta = put!.input.Metadata as Record<string, string>;
1441
- const stampedBody = put!.input.Body as Buffer;
1442
- expect(stampedMeta[FILE_MTIME_META_KEY]).toBe("0");
1443
-
1444
- fs.unlinkSync(sourcePath);
1445
- nextGetObjectResponse = {
1446
- Body: (async function* () {
1447
- yield new Uint8Array(stampedBody);
1448
- })(),
1449
- Metadata: stampedMeta,
1450
- };
1451
- await downloadFile(makeCtx(), "epoch-zero.bin", sourcePath);
1452
-
1453
- const stat = fs.statSync(sourcePath);
1454
- // 0 is integer-clean — exact match expected, but allow 50ms tolerance
1455
- // for any underlying FS rounding shenanigans.
1456
- expect(Math.abs(stat.mtimeMs - 0)).toBeLessThanOrEqual(50);
1457
- expect(fs.readFileSync(sourcePath, "utf-8")).toBe("epoch-bytes");
1458
- });
1459
-
1460
- it("round-trips a negative mtime (pre-epoch — Codex PR #27 P2)", async () => {
1461
- // Some filesystems / reproducible-build pipelines clamp mtime to values
1462
- // before the Unix epoch (negative ms). `new Date(-86400000)` is a legal
1463
- // Date; `fs.utimesSync` accepts it. The receiver MUST NOT drop these.
1464
- const sourceMs = -86_400_000; // 1969-12-31T00:00:00Z, one day before epoch
1465
- const sourcePath = path.join(tmpRoot, "pre-epoch-src.bin");
1466
- fs.writeFileSync(sourcePath, "pre-epoch-bytes");
1467
- fs.utimesSync(sourcePath, new Date(sourceMs), new Date(sourceMs));
1468
-
1469
- sentCommands.length = 0;
1470
- await uploadFile(makeCtx(), sourcePath, "pre-epoch.bin");
1471
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
1472
- const stampedMeta = put!.input.Metadata as Record<string, string>;
1473
- const stampedBody = put!.input.Body as Buffer;
1474
- expect(stampedMeta[FILE_MTIME_META_KEY]).toBe(String(sourceMs));
1475
-
1476
- fs.unlinkSync(sourcePath);
1477
- nextGetObjectResponse = {
1478
- Body: (async function* () {
1479
- yield new Uint8Array(stampedBody);
1480
- })(),
1481
- Metadata: stampedMeta,
1482
- };
1483
- await downloadFile(makeCtx(), "pre-epoch.bin", sourcePath);
1484
-
1485
- const stat = fs.statSync(sourcePath);
1486
- expect(Math.abs(stat.mtimeMs - sourceMs)).toBeLessThanOrEqual(50);
1487
- expect(fs.readFileSync(sourcePath, "utf-8")).toBe("pre-epoch-bytes");
1488
- });
1489
-
1490
- it("accepts well-formed negative hq-mtime (e.g. '-100') on download", async () => {
1491
- // Migration of the old rejection-list case for `-100`. Codex PR #27 P2:
1492
- // negative epoch-ms values are legitimate; the regex now allows a single
1493
- // optional leading `-`, and utimesSync handles it.
1494
- const localPath = path.join(tmpRoot, "negative-mtime.bin");
1495
- nextGetObjectResponse = {
1496
- Body: (async function* () {
1497
- yield new Uint8Array([110]); // "n"
1498
- })(),
1499
- Metadata: { [FILE_MTIME_META_KEY]: "-100" },
1500
- };
1501
- await downloadFile(makeCtx(), "negative-mtime.bin", localPath);
1502
-
1503
- const stat = fs.statSync(localPath);
1504
- // mtime should be ~ -100 ms, NOT wall-clock-now. The old code would have
1505
- // left this at write-time; the new code applies the negative stamp.
1506
- expect(Math.abs(stat.mtimeMs - -100)).toBeLessThanOrEqual(50);
1507
- });
1508
-
1509
- it("downloads with default mtime when hq-mtime metadata is absent (back-compat)", async () => {
1510
- // Legacy uploads (pre-5.37.0) carry no hq-mtime header. The receiver
1511
- // must NOT crash and must NOT alter the on-disk mtime away from the
1512
- // default `fs.writeFileSync` wall-clock-now behavior. Mirrors the
1513
- // hq-mode back-compat test.
1514
- nextGetObjectResponse = {
1515
- Body: (async function* () {
1516
- yield new Uint8Array([108, 101, 103]); // "leg"
1517
- })(),
1518
- Metadata: {},
1519
- };
1520
-
1521
- const before = Date.now();
1522
- const localPath = path.join(tmpRoot, "legacy-mtime.bin");
1523
- await downloadFile(makeCtx(), "legacy-mtime.bin", localPath);
1524
-
1525
- const stat = fs.statSync(localPath);
1526
- expect(Math.abs(stat.mtimeMs - before)).toBeLessThan(5000);
1527
- expect(fs.readFileSync(localPath, "utf-8")).toBe("leg");
1528
- });
1529
-
1530
- it("does NOT call utimesSync on the symlink branch (5.37.0)", async () => {
1531
- // fs.utimesSync follows symlinks (mutates the target's mtime, not the
1532
- // link's). On the symlink branch we must skip the utimes block entirely
1533
- // so we don't accidentally mutate an unrelated target file. The
1534
- // symlink record's hq-mtime, if any, is ignored — symlink mtime is
1535
- // OS-controlled and Node has no stable lutimes API. Easiest verifier:
1536
- // the link is materialized AND the test doesn't crash.
1537
- const targetPath = path.join(tmpRoot, "untouched-target.txt");
1538
- fs.writeFileSync(targetPath, "do-not-touch");
1539
- const knownTargetMs = 1_500_000_000_000;
1540
- fs.utimesSync(targetPath, new Date(knownTargetMs), new Date(knownTargetMs));
1541
-
1542
- const linkPath = path.join(tmpRoot, "the-link");
1543
- nextGetObjectResponse = {
1544
- Body: (async function* () {
1545
- yield Buffer.from(SYMLINK_BODY_PREFIX + "untouched-target.txt");
1546
- })(),
1547
- Metadata: {
1548
- "hq-symlink-target": SYMLINK_MARKER_META_VALUE,
1549
- // Even if a misbehaving uploader stamped hq-mtime on a symlink
1550
- // record, the downloader's symlink branch returns BEFORE the
1551
- // utimes block, so the target's mtime stays at knownTargetMs.
1552
- [FILE_MTIME_META_KEY]: "1_900_000_000_000",
1553
- },
1554
- };
1555
-
1556
- await downloadFile(makeCtx(), "the-link", linkPath);
1557
-
1558
- expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
1559
- expect(fs.readlinkSync(linkPath)).toBe("untouched-target.txt");
1560
- // Target's mtime is unchanged — utimes block was skipped.
1561
- const targetStat = fs.statSync(targetPath);
1562
- expect(Math.abs(targetStat.mtimeMs - knownTargetMs)).toBeLessThanOrEqual(50);
1563
- });
1564
-
1565
- it("round-trips mtime across upload + download (5.37.0 user-visible promise)", async () => {
1566
- // The whole point of the feature: a file's mtime should follow it
1567
- // across the wire. Push captures source mtime → metadata; pull applies
1568
- // metadata → local mtime. This test wires upload's PutObject capture
1569
- // into download's GetObject response so the contract round-trips end-
1570
- // to-end through s3.ts without bringing in journal/CLI state.
1571
- const sourceMs = 1_650_000_000_000; // 2022-04-15T03:33:20Z
1572
- const sourcePath = path.join(tmpRoot, "round-trip-src.bin");
1573
- fs.writeFileSync(sourcePath, "round-trip-bytes");
1574
- fs.utimesSync(sourcePath, new Date(sourceMs), new Date(sourceMs));
1575
-
1576
- sentCommands.length = 0;
1577
- await uploadFile(makeCtx(), sourcePath, "round-trip.bin");
1578
- const put = sentCommands.find((c) => c.name === "PutObjectCommand");
1579
- const stampedMeta = put!.input.Metadata as Record<string, string>;
1580
- const stampedBody = put!.input.Body as Buffer;
1581
-
1582
- // Wipe the local copy and pull via download — simulating a different
1583
- // machine receiving the file.
1584
- fs.unlinkSync(sourcePath);
1585
- nextGetObjectResponse = {
1586
- Body: (async function* () {
1587
- yield new Uint8Array(stampedBody);
1588
- })(),
1589
- Metadata: stampedMeta,
1590
- };
1591
- await downloadFile(makeCtx(), "round-trip.bin", sourcePath);
1592
-
1593
- const stat = fs.statSync(sourcePath);
1594
- // Within 100ms — covers FS rounding on both ends of the round-trip.
1595
- expect(Math.abs(stat.mtimeMs - sourceMs)).toBeLessThanOrEqual(100);
1596
- expect(fs.readFileSync(sourcePath, "utf-8")).toBe("round-trip-bytes");
1597
- });
1598
-
1599
- it("returns the object's user-metadata (including created-by) for a regular file", async () => {
1600
- nextGetObjectResponse = {
1601
- Body: (async function* () {
1602
- yield new Uint8Array([104, 105]); // "hi"
1603
- })(),
1604
- Metadata: { "created-by": "alice@example.com", "created-by-sub": "sub-123" },
1605
- };
1606
-
1607
- const localPath = path.join(tmpRoot, "authored.md");
1608
- const result = await downloadFile(makeCtx(), "authored.md", localPath);
1609
-
1610
- expect(result.metadata?.["created-by"]).toBe("alice@example.com");
1611
- expect(fs.readFileSync(localPath, "utf-8")).toBe("hi");
1612
- });
1613
-
1614
- it("returns the object's user-metadata for a symlink record", async () => {
1615
- const localPath = path.join(tmpRoot, "authored-link.md");
1616
- nextGetObjectResponse = {
1617
- Body: (async function* () {
1618
- yield new Uint8Array();
1619
- })(),
1620
- Metadata: { "hq-symlink-target": "x", "created-by": "bob@example.com" },
1621
- };
1622
-
1623
- const result = await downloadFile(makeCtx(), "authored-link.md", localPath);
1624
-
1625
- expect(result.metadata?.["created-by"]).toBe("bob@example.com");
1626
- expect(fs.lstatSync(localPath).isSymbolicLink()).toBe(true);
1627
- });
1628
- });
1629
-
1630
- describe("validateVaultUploadKey — direct-S3 key poisoning guard (incident 2026-07-11)", () => {
1631
- // The direct-S3 STS transport bypasses hq-pro's server-side validateObjectKey
1632
- // (files-acl.ts), so uploadFile/uploadSymlink must enforce the SAME rules
1633
- // before the PUT. Incident: a stale doubled local tree
1634
- // (companies/indigo/companies/indigo/…) pushed 25 literal `companies/…` keys
1635
- // into a company vault; the presign download API then rejected every one
1636
- // (INVALID_KEY_COMPANIES_SCOPED), erroring the whole company sync fleet-wide.
1637
- let tmpFile: string;
1638
-
1639
- function makePersonalCtx(): EntityContext {
1640
- return { ...makeCtx(), uid: "prs_TEST" };
1641
- }
1642
-
1643
- beforeEach(() => {
1644
- sentCommands.length = 0;
1645
- tmpFile = path.join(
1646
- os.tmpdir(),
1647
- `s3-keyguard-test-${Date.now()}-${Math.random()}.md`,
1648
- );
1649
- fs.writeFileSync(tmpFile, "hello");
1650
- });
1651
-
1652
- afterEach(() => {
1653
- try {
1654
- fs.unlinkSync(tmpFile);
1655
- } catch {
1656
- // already gone
1657
- }
1658
- });
1659
-
1660
- it("validateVaultUploadKey rejects companies/-prefixed keys in company scope with the server's error code", () => {
1661
- expect(() =>
1662
- validateVaultUploadKey("companies/indigo/knowledge/x.md", "company"),
1663
- ).toThrowError(
1664
- expect.objectContaining({ code: "INVALID_KEY_COMPANIES_SCOPED" }),
1665
- );
1666
- });
1667
-
1668
- it("validateVaultUploadKey allows companies/-prefixed keys in personal scope (local non-cloud companies)", () => {
1669
- expect(() =>
1670
- validateVaultUploadKey("companies/indigo/knowledge/x.md", "personal"),
1671
- ).not.toThrow();
1672
- });
1673
-
1674
- it.each([
1675
- ["", "INVALID_KEY_EMPTY"],
1676
- ["/leading.md", "INVALID_KEY_LEADING_SLASH"],
1677
- ["folder/", "INVALID_KEY_TRAILING_SLASH"],
1678
- ["a//b.md", "INVALID_KEY_DOUBLE_SLASH"],
1679
- ["a/../b.md", "INVALID_KEY_DOT_COMPONENT"],
1680
- ["a/./b.md", "INVALID_KEY_DOT_COMPONENT"],
1681
- ["bad\x00key.md", "INVALID_KEY_CONTROL_CHARS"],
1682
- // macOS Finder custom-icon file. Named explicitly because this is the
1683
- // spelling that reaches the validator in the wild: Finder writes one into
1684
- // every directory it renders a custom icon for, and the push walker now
1685
- // excludes them precisely because this throw is unavoidable.
1686
- ["Icon\r", "INVALID_KEY_CONTROL_CHARS"],
1687
- ["docs/Icon\r", "INVALID_KEY_CONTROL_CHARS"],
1688
- ])(
1689
- "validateVaultUploadKey mirrors the server rule set: %j → %s",
1690
- (key, code) => {
1691
- expect(() => validateVaultUploadKey(key, "company")).toThrowError(
1692
- expect.objectContaining({ code }),
1693
- );
1694
- expect(() => validateVaultUploadKey(key, "personal")).toThrowError(
1695
- expect.objectContaining({ code }),
1696
- );
1697
- },
1698
- );
1699
-
1700
- it.each([
1701
- [
1702
- "companies/indigo/knowledge/x.md",
1703
- "company",
1704
- "INVALID_KEY_COMPANIES_SCOPED",
1705
- ],
1706
- ["bad\x00key.md", "personal", "INVALID_KEY_CONTROL_CHARS"],
1707
- ["docs/good.md", "personal", null],
1708
- ] as const)(
1709
- "classifyVaultKey reports the validator outcome without throwing: %j",
1710
- (key, scope, code) => {
1711
- expect(classifyVaultKey(key, scope)).toEqual(
1712
- code === null ? null : expect.objectContaining({ code }),
1713
- );
1714
- },
1715
- );
1716
-
1717
- it("uploadFile refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1718
- await expect(
1719
- uploadFile(makeCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
1720
- ).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
1721
- expect(sentCommands.length).toBe(0);
1722
- });
1723
-
1724
- it("uploadSymlink refuses to PUT a companies/-prefixed key into a company (cmp_) vault — no S3 command is sent", async () => {
1725
- await expect(
1726
- uploadSymlink(makeCtx(), "../target.md", "companies/indigo/link.md"),
1727
- ).rejects.toMatchObject({ code: "INVALID_KEY_COMPANIES_SCOPED" });
1728
- expect(sentCommands.length).toBe(0);
1729
- });
1730
-
1731
- it("uploadFile still allows companies/-prefixed keys for a personal (prs_) vault", async () => {
1732
- await expect(
1733
- uploadFile(makePersonalCtx(), tmpFile, "companies/indigo/knowledge/x.md"),
1734
- ).resolves.toBeDefined();
1735
- expect(sentCommands.length).toBeGreaterThan(0);
1736
- });
1737
- });
1738
-
1739
- describe("sweepStaleStagedFiles", () => {
1740
- let tmpRoot: string;
1741
- const HOUR = 60 * 60 * 1000;
1742
-
1743
- beforeEach(() => {
1744
- tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hq-sweep-test-"));
1745
- });
1746
-
1747
- afterEach(() => {
1748
- fs.rmSync(tmpRoot, { recursive: true, force: true });
1749
- });
1750
-
1751
- function makeAged(name: string, ageMs: number): string {
1752
- const full = path.join(tmpRoot, name);
1753
- fs.writeFileSync(full, "x");
1754
- const stamp = new Date(Date.now() - ageMs);
1755
- fs.utimesSync(full, stamp, stamp);
1756
- return full;
1757
- }
1758
-
1759
- it("removes stale .hq-tmp-* and .hq-backup-* files, keeps everything else", () => {
1760
- const staleTmp = makeAged(".hq-tmp-deadbeef01", 2 * HOUR);
1761
- const staleBackup = makeAged(".hq-backup-deadbeef02", 2 * HOUR);
1762
- const freshTmp = makeAged(".hq-tmp-deadbeef03", 0);
1763
- const ordinary = makeAged("notes.md", 2 * HOUR);
1764
- // A directory matching the pattern must never be touched.
1765
- const dirSentinel = path.join(tmpRoot, ".hq-tmp-a-directory");
1766
- fs.mkdirSync(dirSentinel);
1767
-
1768
- const removed = sweepStaleStagedFiles(tmpRoot);
1769
-
1770
- expect(removed.sort()).toEqual([staleTmp, staleBackup].sort());
1771
- expect(fs.existsSync(staleTmp)).toBe(false);
1772
- expect(fs.existsSync(staleBackup)).toBe(false);
1773
- expect(fs.existsSync(freshTmp)).toBe(true);
1774
- expect(fs.existsSync(ordinary)).toBe(true);
1775
- expect(fs.existsSync(dirSentinel)).toBe(true);
1776
- });
1777
-
1778
- it("returns empty for a missing directory without throwing", () => {
1779
- expect(
1780
- sweepStaleStagedFiles(path.join(tmpRoot, "does-not-exist")),
1781
- ).toEqual([]);
1782
- });
1783
-
1784
- it("respects a custom age threshold", () => {
1785
- const halfHourOld = makeAged(".hq-tmp-halfhour", HOUR / 2);
1786
- expect(sweepStaleStagedFiles(tmpRoot, HOUR / 4)).toEqual([halfHourOld]);
1787
- expect(fs.existsSync(halfHourOld)).toBe(false);
1788
- });
1789
- });