@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.
- package/dist/bin/sync-mutation.d.ts +16 -0
- package/dist/bin/sync-mutation.d.ts.map +1 -0
- package/dist/bin/sync-mutation.js +60 -0
- package/dist/bin/sync-mutation.js.map +1 -0
- package/dist/bin/sync-mutation.test.d.ts +2 -0
- package/dist/bin/sync-mutation.test.d.ts.map +1 -0
- package/dist/bin/sync-mutation.test.js +165 -0
- package/dist/bin/sync-mutation.test.js.map +1 -0
- package/dist/bin/sync-runner-company.d.ts +8 -0
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -0
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-company.test.d.ts +2 -0
- package/dist/bin/sync-runner-company.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-company.test.js +36 -0
- package/dist/bin/sync-runner-company.test.js.map +1 -0
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +98 -8
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.d.ts +17 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +109 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/conflict-recovery.test.d.ts +2 -0
- package/dist/cli/conflict-recovery.test.d.ts.map +1 -0
- package/dist/cli/conflict-recovery.test.js +201 -0
- package/dist/cli/conflict-recovery.test.js.map +1 -0
- package/dist/cli/conflict.d.ts +60 -0
- package/dist/cli/conflict.d.ts.map +1 -1
- package/dist/cli/conflict.js +333 -0
- package/dist/cli/conflict.js.map +1 -1
- package/dist/cli/sync.d.ts +35 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +100 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +85 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/skill-telemetry.d.ts +6 -0
- package/dist/skill-telemetry.d.ts.map +1 -1
- package/dist/skill-telemetry.js +14 -2
- package/dist/skill-telemetry.js.map +1 -1
- package/dist/skill-telemetry.test.js +79 -0
- package/dist/skill-telemetry.test.js.map +1 -1
- package/dist/sync/candidate-uploader.d.ts +88 -0
- package/dist/sync/candidate-uploader.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.js +212 -0
- package/dist/sync/candidate-uploader.js.map +1 -0
- package/dist/sync/candidate-uploader.test.d.ts +2 -0
- package/dist/sync/candidate-uploader.test.d.ts.map +1 -0
- package/dist/sync/candidate-uploader.test.js +132 -0
- package/dist/sync/candidate-uploader.test.js.map +1 -0
- package/dist/sync/delta-client.d.ts +73 -0
- package/dist/sync/delta-client.d.ts.map +1 -0
- package/dist/sync/delta-client.js +201 -0
- package/dist/sync/delta-client.js.map +1 -0
- package/dist/sync/delta-client.test.d.ts +2 -0
- package/dist/sync/delta-client.test.d.ts.map +1 -0
- package/dist/sync/delta-client.test.js +97 -0
- package/dist/sync/delta-client.test.js.map +1 -0
- package/dist/sync/durable-apply.d.ts +76 -0
- package/dist/sync/durable-apply.d.ts.map +1 -0
- package/dist/sync/durable-apply.js +530 -0
- package/dist/sync/durable-apply.js.map +1 -0
- package/dist/sync/durable-apply.test.d.ts +2 -0
- package/dist/sync/durable-apply.test.d.ts.map +1 -0
- package/dist/sync/durable-apply.test.js +180 -0
- package/dist/sync/durable-apply.test.js.map +1 -0
- package/dist/sync/event-sync.d.ts +33 -1
- package/dist/sync/event-sync.d.ts.map +1 -1
- package/dist/sync/event-sync.js +149 -1
- package/dist/sync/event-sync.js.map +1 -1
- package/dist/sync/event-sync.test.js +142 -1
- package/dist/sync/event-sync.test.js.map +1 -1
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -0
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/multipart-uploader.d.ts +99 -0
- package/dist/sync/multipart-uploader.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.js +447 -0
- package/dist/sync/multipart-uploader.js.map +1 -0
- package/dist/sync/multipart-uploader.test.d.ts +2 -0
- package/dist/sync/multipart-uploader.test.d.ts.map +1 -0
- package/dist/sync/multipart-uploader.test.js +119 -0
- package/dist/sync/multipart-uploader.test.js.map +1 -0
- package/dist/sync/mutation-client.d.ts +85 -0
- package/dist/sync/mutation-client.d.ts.map +1 -0
- package/dist/sync/mutation-client.js +245 -0
- package/dist/sync/mutation-client.js.map +1 -0
- package/dist/sync/mutation-client.test.d.ts +2 -0
- package/dist/sync/mutation-client.test.d.ts.map +1 -0
- package/dist/sync/mutation-client.test.js +51 -0
- package/dist/sync/mutation-client.test.js.map +1 -0
- package/dist/sync/push-receiver.d.ts +45 -0
- package/dist/sync/push-receiver.d.ts.map +1 -1
- package/dist/sync/push-receiver.js +101 -0
- package/dist/sync/push-receiver.js.map +1 -1
- package/dist/sync/push-receiver.test.js +54 -2
- package/dist/sync/push-receiver.test.js.map +1 -1
- package/dist/sync/scope-inventory-client.d.ts +69 -0
- package/dist/sync/scope-inventory-client.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.js +210 -0
- package/dist/sync/scope-inventory-client.js.map +1 -0
- package/dist/sync/scope-inventory-client.test.d.ts +2 -0
- package/dist/sync/scope-inventory-client.test.d.ts.map +1 -0
- package/dist/sync/scope-inventory-client.test.js +94 -0
- package/dist/sync/scope-inventory-client.test.js.map +1 -0
- package/dist/sync/snapshot-client.d.ts +98 -0
- package/dist/sync/snapshot-client.d.ts.map +1 -0
- package/dist/sync/snapshot-client.js +402 -0
- package/dist/sync/snapshot-client.js.map +1 -0
- package/dist/sync/snapshot-client.test.d.ts +2 -0
- package/dist/sync/snapshot-client.test.d.ts.map +1 -0
- package/dist/sync/snapshot-client.test.js +169 -0
- package/dist/sync/snapshot-client.test.js.map +1 -0
- package/dist/sync/uploader-finalization.d.ts +97 -0
- package/dist/sync/uploader-finalization.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.js +273 -0
- package/dist/sync/uploader-finalization.js.map +1 -0
- package/dist/sync/uploader-finalization.test.d.ts +2 -0
- package/dist/sync/uploader-finalization.test.d.ts.map +1 -0
- package/dist/sync/uploader-finalization.test.js +92 -0
- package/dist/sync/uploader-finalization.test.js.map +1 -0
- package/dist/telemetry.d.ts +11 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +21 -2
- package/dist/telemetry.js.map +1 -1
- package/dist/telemetry.test.js +80 -0
- package/dist/telemetry.test.js.map +1 -1
- package/package.json +6 -1
- package/.claude/policies/hq-cloud-esm-cannot-spy-fs-builtins.md +0 -30
- package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +0 -22
- package/.github/workflows/ci.yml +0 -84
- package/.github/workflows/publish.yml +0 -56
- package/.github/workflows/unreleased-commits-nag.yml +0 -256
- package/eslint.config.js +0 -67
- package/pnpm-workspace.yaml +0 -2
- package/scripts/presign-transport-e2e.mjs +0 -250
- package/scripts/vault-rebaseline.sh +0 -323
- package/scripts/vault-rescue.sh +0 -332
- package/src/active-company.test.ts +0 -188
- package/src/active-company.ts +0 -168
- package/src/agent-codex-instructions.test.ts +0 -332
- package/src/agent-codex-instructions.ts +0 -309
- package/src/auth.ts +0 -146
- package/src/backup-prune.test.ts +0 -98
- package/src/backup-prune.ts +0 -182
- package/src/bin/backup-prune-runner.ts +0 -33
- package/src/bin/rescue-runner.ts +0 -25
- package/src/bin/sync-runner-company.ts +0 -695
- package/src/bin/sync-runner-events.test.ts +0 -143
- package/src/bin/sync-runner-events.ts +0 -55
- package/src/bin/sync-runner-planning.test.ts +0 -311
- package/src/bin/sync-runner-planning.ts +0 -258
- package/src/bin/sync-runner-rollup.test.ts +0 -37
- package/src/bin/sync-runner-rollup.ts +0 -97
- package/src/bin/sync-runner-telemetry.ts +0 -15
- package/src/bin/sync-runner-watch-loop.ts +0 -1235
- package/src/bin/sync-runner-watch-routes.test.ts +0 -71
- package/src/bin/sync-runner-watch-routes.ts +0 -184
- package/src/bin/sync-runner.test.ts +0 -8767
- package/src/bin/sync-runner.ts +0 -2190
- package/src/cli/accept.ts +0 -124
- package/src/cli/conflict.ts +0 -119
- package/src/cli/doctor.test.ts +0 -581
- package/src/cli/doctor.ts +0 -642
- package/src/cli/index.ts +0 -49
- package/src/cli/invite.test.ts +0 -250
- package/src/cli/invite.ts +0 -214
- package/src/cli/promote.ts +0 -157
- package/src/cli/reindex-knowledge.test.ts +0 -307
- package/src/cli/reindex-knowledge.ts +0 -450
- package/src/cli/reindex.test.ts +0 -957
- package/src/cli/reindex.ts +0 -979
- package/src/cli/rescue-classify-ordering.test.ts +0 -548
- package/src/cli/rescue-clone-diagnostics.test.ts +0 -120
- package/src/cli/rescue-core.ts +0 -3011
- package/src/cli/rescue-drift-reconcile.test.ts +0 -179
- package/src/cli/rescue-drop-dir-symlink.test.ts +0 -224
- package/src/cli/rescue-exec-bit-preserve.test.ts +0 -187
- package/src/cli/rescue-hq-root-guard.test.ts +0 -232
- package/src/cli/rescue-journal-reconcile.test.ts +0 -215
- package/src/cli/rescue-mtime-preserve.test.ts +0 -203
- package/src/cli/rescue-settings-reconcile.test.ts +0 -637
- package/src/cli/rescue-snapshot.test.ts +0 -57
- package/src/cli/rescue-snapshot.ts +0 -51
- package/src/cli/rescue.reindex.test.ts +0 -63
- package/src/cli/rescue.test.ts +0 -131
- package/src/cli/rescue.ts +0 -182
- package/src/cli/share.test.ts +0 -7843
- package/src/cli/share.ts +0 -3663
- package/src/cli/sync-scope.test.ts +0 -652
- package/src/cli/sync.test.ts +0 -5207
- package/src/cli/sync.ts +0 -3470
- package/src/cli/tombstones.ts +0 -106
- package/src/cli/watch-event-push-conflict.test.ts +0 -234
- package/src/client-info.test.ts +0 -214
- package/src/client-info.ts +0 -121
- package/src/cognito-auth.test.ts +0 -712
- package/src/cognito-auth.ts +0 -1422
- package/src/company-resolver.test.ts +0 -618
- package/src/company-resolver.ts +0 -521
- package/src/context.test.ts +0 -583
- package/src/context.ts +0 -378
- package/src/daemon-worker.ts +0 -26
- package/src/daemon.ts +0 -99
- package/src/entity-resolver.test.ts +0 -315
- package/src/entity-resolver.ts +0 -180
- package/src/ignore.test.ts +0 -466
- package/src/ignore.ts +0 -469
- package/src/index.ts +0 -439
- package/src/journal.test.ts +0 -968
- package/src/journal.ts +0 -765
- package/src/lib/cloud-authoritative.test.ts +0 -45
- package/src/lib/cloud-authoritative.ts +0 -59
- package/src/lib/conflict-file.ts +0 -86
- package/src/lib/conflict-index.ts +0 -289
- package/src/lib/conflict.test.ts +0 -348
- package/src/lib/describe-error.test.ts +0 -100
- package/src/lib/describe-error.ts +0 -58
- package/src/lib/exit-codes.ts +0 -24
- package/src/lib/machine-id.test.ts +0 -231
- package/src/lib/machine-id.ts +0 -175
- package/src/lib/net-errors.test.ts +0 -65
- package/src/lib/net-errors.ts +0 -86
- package/src/lib/readlink-safe.test.ts +0 -43
- package/src/lib/readlink-safe.ts +0 -29
- package/src/local-path-codec.test.ts +0 -138
- package/src/local-path-codec.ts +0 -161
- package/src/machine-auth.test.ts +0 -1323
- package/src/manifest-reconcile.test.ts +0 -1123
- package/src/manifest-reconcile.ts +0 -518
- package/src/object-io.test.ts +0 -1221
- package/src/object-io.ts +0 -1306
- package/src/operation-lock.test.ts +0 -484
- package/src/operation-lock.ts +0 -680
- package/src/outcome-telemetry.test.ts +0 -498
- package/src/outcome-telemetry.ts +0 -639
- package/src/personal-vault-exclusions.test.ts +0 -308
- package/src/personal-vault-exclusions.ts +0 -354
- package/src/personal-vault.test.ts +0 -756
- package/src/personal-vault.ts +0 -496
- package/src/prefix-coalesce.test.ts +0 -240
- package/src/prefix-coalesce.ts +0 -273
- package/src/public-surface.test.ts +0 -117
- package/src/qmd-reindex.test.ts +0 -877
- package/src/qmd-reindex.ts +0 -842
- package/src/read-only-state-dir.test.ts +0 -188
- package/src/remote-pull.test.ts +0 -1130
- package/src/remote-pull.ts +0 -618
- package/src/s3.symlink-materialize.test.ts +0 -492
- package/src/s3.test.ts +0 -1789
- package/src/s3.ts +0 -1532
- package/src/schemas/signal-types.test.ts +0 -82
- package/src/schemas/signal-types.ts +0 -38
- package/src/schemas/source-channels.test.ts +0 -82
- package/src/schemas/source-channels.ts +0 -53
- package/src/scope-shrink.test.ts +0 -633
- package/src/scope-shrink.ts +0 -481
- package/src/signals/get.test.ts +0 -310
- package/src/signals/get.ts +0 -75
- package/src/signals/internals.ts +0 -195
- package/src/signals/list.test.ts +0 -420
- package/src/signals/list.ts +0 -79
- package/src/signals/parse.ts +0 -8
- package/src/signals/types.ts +0 -91
- package/src/skill-telemetry.test.ts +0 -1825
- package/src/skill-telemetry.ts +0 -1439
- package/src/sources/get.test.ts +0 -293
- package/src/sources/get.ts +0 -66
- package/src/sources/internals.ts +0 -198
- package/src/sources/list.test.ts +0 -402
- package/src/sources/list.ts +0 -84
- package/src/sources/parse.ts +0 -43
- package/src/sources/types.ts +0 -84
- package/src/sync/event-sync.test.ts +0 -594
- package/src/sync/event-sync.ts +0 -545
- package/src/sync/feature-flags.test.ts +0 -378
- package/src/sync/feature-flags.ts +0 -62
- package/src/sync/index.ts +0 -76
- package/src/sync/lease-client.test.ts +0 -128
- package/src/sync/lease-client.ts +0 -207
- package/src/sync/logger.test.ts +0 -242
- package/src/sync/logger.ts +0 -79
- package/src/sync/metrics.test.ts +0 -462
- package/src/sync/metrics.ts +0 -213
- package/src/sync/pull-scope.ts +0 -265
- package/src/sync/push-event.test.ts +0 -266
- package/src/sync/push-event.ts +0 -224
- package/src/sync/push-receiver.test.ts +0 -566
- package/src/sync/push-receiver.ts +0 -1048
- package/src/sync/push-transport.ts +0 -231
- package/src/sync/realtime-rollout.test.ts +0 -86
- package/src/sync/realtime-rollout.ts +0 -262
- package/src/sync/state-store.test.ts +0 -194
- package/src/sync/state-store.ts +0 -727
- package/src/sync-core.ts +0 -58
- package/src/sync-progress.test.ts +0 -94
- package/src/sync-progress.ts +0 -140
- package/src/telemetry-events.test.ts +0 -88
- package/src/telemetry-events.ts +0 -205
- package/src/telemetry.test.ts +0 -1280
- package/src/telemetry.ts +0 -1109
- package/src/types.ts +0 -314
- package/src/vault-client.test.ts +0 -1380
- package/src/vault-client.ts +0 -1694
- package/src/version.ts +0 -24
- package/src/watch-roots.test.ts +0 -278
- package/src/watch-roots.ts +0 -162
- package/src/watcher-event-gate.test.ts +0 -212
- package/src/watcher.test.ts +0 -1079
- package/src/watcher.ts +0 -1741
- package/test/e2e/sync/cross-tenant-isolation.test.ts +0 -630
- package/test/e2e/sync/skill-telemetry-oversized-transcript.test.ts +0 -124
- package/test/e2e/sync/transient-company-leg.test.ts +0 -384
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +0 -191
- package/test/e2e/watcher-real-chokidar.test.ts +0 -165
- package/test/e2e/watcher-recursive-backend.test.ts +0 -181
- package/test/e2e/watcher-scoped-coverage.test.ts +0 -381
- package/test/invite-flow.integration.test.ts +0 -244
- package/test/joiner-manifest-reconcile.integration.test.ts +0 -322
- package/test/share-sync.integration.test.ts +0 -213
- package/tsconfig.json +0 -19
- 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
|
-
});
|