@indigoai-us/hq-cloud 5.16.0 → 5.18.1
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/.github/workflows/ci.yml +19 -0
- package/.github/workflows/publish.yml +53 -0
- package/dist/cli/share.d.ts +28 -0
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +227 -24
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +414 -2
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +98 -17
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +302 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/journal.d.ts +26 -0
- package/dist/journal.d.ts.map +1 -1
- package/dist/journal.js +31 -0
- package/dist/journal.js.map +1 -1
- package/dist/s3.d.ts +91 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +245 -0
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +347 -1
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +510 -2
- package/src/cli/share.ts +305 -28
- package/src/cli/sync.test.ts +345 -0
- package/src/cli/sync.ts +133 -24
- package/src/journal.ts +33 -0
- package/src/s3.test.ts +415 -1
- package/src/s3.ts +271 -0
- package/tsconfig.json +12 -1
package/src/cli/share.test.ts
CHANGED
|
@@ -11,9 +11,12 @@ import type { VaultServiceConfig } from "../types.js";
|
|
|
11
11
|
|
|
12
12
|
// Mock s3 module at the top level. uploadFile resolves to a synthetic ETag
|
|
13
13
|
// so share() can record it on the journal entry — the real PutObject
|
|
14
|
-
// response shape is `{ ETag: '"<hex>"' }`.
|
|
14
|
+
// response shape is `{ ETag: '"<hex>"' }`. uploadSymlink is the symlink-
|
|
15
|
+
// preserving sibling that puts a zero-byte object with target metadata
|
|
16
|
+
// instead of dereferencing the link.
|
|
15
17
|
vi.mock("../s3.js", () => ({
|
|
16
18
|
uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
|
|
19
|
+
uploadSymlink: vi.fn().mockResolvedValue({ etag: '"upload-symlink-etag"' }),
|
|
17
20
|
downloadFile: vi.fn().mockResolvedValue(undefined),
|
|
18
21
|
listRemoteFiles: vi.fn().mockResolvedValue([]),
|
|
19
22
|
deleteRemoteFile: vi.fn().mockResolvedValue(undefined),
|
|
@@ -21,7 +24,7 @@ vi.mock("../s3.js", () => ({
|
|
|
21
24
|
}));
|
|
22
25
|
|
|
23
26
|
import { share } from "./share.js";
|
|
24
|
-
import { deleteRemoteFile, headRemoteFile, uploadFile } from "../s3.js";
|
|
27
|
+
import { deleteRemoteFile, headRemoteFile, uploadFile, uploadSymlink } from "../s3.js";
|
|
25
28
|
import type { EntityContext } from "../types.js";
|
|
26
29
|
|
|
27
30
|
const mockConfig: VaultServiceConfig = {
|
|
@@ -106,6 +109,7 @@ describe("share", () => {
|
|
|
106
109
|
// clearAllMocks wipes the default ETag impl set in vi.mock(), so
|
|
107
110
|
// re-prime it for the next test.
|
|
108
111
|
vi.mocked(uploadFile).mockResolvedValue({ etag: '"upload-etag"' });
|
|
112
|
+
vi.mocked(uploadSymlink).mockResolvedValue({ etag: '"upload-symlink-etag"' });
|
|
109
113
|
vi.mocked(headRemoteFile).mockResolvedValue(null);
|
|
110
114
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
111
115
|
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
@@ -855,6 +859,110 @@ describe("share", () => {
|
|
|
855
859
|
// versioning enabled, so DeleteObject is soft (a delete-marker becomes the
|
|
856
860
|
// current version; prior object versions remain recoverable).
|
|
857
861
|
|
|
862
|
+
it("propagateDeletes: deletes a journal entry whose key matches only a dir-only allowlist (dual-hint shouldSync)", async () => {
|
|
863
|
+
// Codex round-8 P2 follow-up: third instance of the dual-hint
|
|
864
|
+
// pattern. By the time computeDeletePlan considers an entry for
|
|
865
|
+
// remote deletion, the local file is already gone — we don't know
|
|
866
|
+
// whether it was a regular file or a symlink record. Pre-fix, the
|
|
867
|
+
// single isDir=false probe of shouldSync rejected paths whose
|
|
868
|
+
// only matching .hqinclude pattern was dir-only (e.g.
|
|
869
|
+
// `companies/*/knowledge/`), so a deleted symlink at such a path
|
|
870
|
+
// would leave the remote record orphaned forever. Mirrors the
|
|
871
|
+
// walkDir/collectFiles (push) and computePullPlan (pull) fixes.
|
|
872
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
873
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
874
|
+
// Allowlist with ONLY a dir-only pattern. Without the dual-hint
|
|
875
|
+
// probe, the slashless probe wouldn't match and the entry would
|
|
876
|
+
// not be queued for delete.
|
|
877
|
+
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
878
|
+
|
|
879
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
880
|
+
fs.writeFileSync(
|
|
881
|
+
journalPath,
|
|
882
|
+
JSON.stringify({
|
|
883
|
+
version: "1",
|
|
884
|
+
lastSync: new Date().toISOString(),
|
|
885
|
+
files: {
|
|
886
|
+
// Locally absent — was a symlink record. Should
|
|
887
|
+
// delete-propagate now that the path is no longer ignored
|
|
888
|
+
// under the dual-hint probe.
|
|
889
|
+
knowledge: {
|
|
890
|
+
hash: "irrelevant-not-checked",
|
|
891
|
+
size: 0,
|
|
892
|
+
syncedAt: new Date().toISOString(),
|
|
893
|
+
direction: "up",
|
|
894
|
+
remoteEtag: "knowledge-etag",
|
|
895
|
+
},
|
|
896
|
+
},
|
|
897
|
+
}),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
const result = await share({
|
|
901
|
+
paths: [companyRoot],
|
|
902
|
+
company: "acme",
|
|
903
|
+
vaultConfig: mockConfig,
|
|
904
|
+
hqRoot: tmpDir,
|
|
905
|
+
skipUnchanged: true,
|
|
906
|
+
propagateDeletes: true,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
expect(result.filesDeleted).toBe(1);
|
|
910
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "knowledge");
|
|
911
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
912
|
+
expect(journal.files["knowledge"]).toBeUndefined();
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
it("propagateDeletes: a dangling local symlink is NOT classified as gone (lstat, not existsSync)", async () => {
|
|
916
|
+
// Codex round-3 P2 follow-up: pre-fix, computeDeletePlan used
|
|
917
|
+
// fs.existsSync which follows symlinks → returns false for a
|
|
918
|
+
// dangling link → the link's journal entry was queued for remote
|
|
919
|
+
// DeleteObject in the same sync cycle that just uploaded it via
|
|
920
|
+
// uploadSymlink. The link round-tripped as "upload, then delete"
|
|
921
|
+
// in one pass. Switching to lstat means the link file itself
|
|
922
|
+
// counts as locally present even when its target is missing.
|
|
923
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
924
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
925
|
+
// Dangling symlink: target file deliberately not created.
|
|
926
|
+
const danglingLink = path.join(companyRoot, "dangling-link.md");
|
|
927
|
+
fs.symlinkSync("./missing-target.md", danglingLink);
|
|
928
|
+
|
|
929
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
930
|
+
fs.writeFileSync(
|
|
931
|
+
journalPath,
|
|
932
|
+
JSON.stringify({
|
|
933
|
+
version: "1",
|
|
934
|
+
lastSync: new Date().toISOString(),
|
|
935
|
+
files: {
|
|
936
|
+
"dangling-link.md": {
|
|
937
|
+
// sha256("./missing-target.md") so the planner's
|
|
938
|
+
// skipUnchanged gate would also see this as unchanged on
|
|
939
|
+
// a normal upload pass.
|
|
940
|
+
hash: "irrelevant-not-checked-here",
|
|
941
|
+
size: 0,
|
|
942
|
+
syncedAt: new Date().toISOString(),
|
|
943
|
+
direction: "up",
|
|
944
|
+
remoteEtag: "dangling-etag",
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
}),
|
|
948
|
+
);
|
|
949
|
+
|
|
950
|
+
const result = await share({
|
|
951
|
+
paths: [companyRoot],
|
|
952
|
+
company: "acme",
|
|
953
|
+
vaultConfig: mockConfig,
|
|
954
|
+
hqRoot: tmpDir,
|
|
955
|
+
skipUnchanged: true,
|
|
956
|
+
propagateDeletes: true,
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
expect(result.filesDeleted).toBe(0);
|
|
960
|
+
expect(deleteRemoteFile).not.toHaveBeenCalled();
|
|
961
|
+
// Journal entry must survive the run.
|
|
962
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
963
|
+
expect(journal.files["dangling-link.md"]).toBeDefined();
|
|
964
|
+
});
|
|
965
|
+
|
|
858
966
|
it("propagateDeletes: deletes journal-tracked files whose local copy is gone", async () => {
|
|
859
967
|
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
860
968
|
fs.mkdirSync(companyRoot, { recursive: true });
|
|
@@ -1083,6 +1191,151 @@ describe("share", () => {
|
|
|
1083
1191
|
expect(journal.files["flaky.md"]).toBeDefined();
|
|
1084
1192
|
});
|
|
1085
1193
|
|
|
1194
|
+
// ── Delete propagation safety: direction + filter guards (Bug B + C) ──────
|
|
1195
|
+
//
|
|
1196
|
+
// The pre-fix planner converted any journal-tracked path whose local file
|
|
1197
|
+
// was missing into a `DeleteObject`. Two real-world failure modes followed:
|
|
1198
|
+
//
|
|
1199
|
+
// B. A behind machine's first `sync now` ran push-before-pull. Its
|
|
1200
|
+
// journal had `direction: "down"` entries for files it had pulled
|
|
1201
|
+
// historically (or seeded from another machine); the moment a peer
|
|
1202
|
+
// uploaded a new file, the behind machine's next push concluded
|
|
1203
|
+
// "local missing → delete" and erased the peer's upload from the
|
|
1204
|
+
// vault before the pull leg ever ran.
|
|
1205
|
+
//
|
|
1206
|
+
// C. The pull's ignore filter and the push's local walk used the same
|
|
1207
|
+
// `createIgnoreFilter(hqRoot)` symbol, but `hqRoot` differed across
|
|
1208
|
+
// machines (older HQ layout had `knowledge/public/` and
|
|
1209
|
+
// `workers/public/` at top level; the post-v14 layout collapses
|
|
1210
|
+
// them under `core/`). When a new-layout machine pulled, the
|
|
1211
|
+
// old-layout paths were filtered locally; the journal still
|
|
1212
|
+
// recorded them as "down"; the next push concluded "local missing
|
|
1213
|
+
// → delete" and erased other machines' content from the vault.
|
|
1214
|
+
//
|
|
1215
|
+
// Both fixes live in `computeDeletePlan` and are belt-and-suspenders:
|
|
1216
|
+
// (i) `direction === "up"` requirement under the default policy.
|
|
1217
|
+
// (ii) `shouldSync` must accept the key — same filter the pull uses.
|
|
1218
|
+
|
|
1219
|
+
it("propagateDeletes: under owned-only (default), skips direction:'down' entries", async () => {
|
|
1220
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1221
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1222
|
+
// No local files. One journal entry pulled from elsewhere (direction:'down')
|
|
1223
|
+
// and one this machine uploaded (direction:'up'). Only the 'up' one should
|
|
1224
|
+
// be eligible for delete-propagation.
|
|
1225
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1226
|
+
fs.writeFileSync(
|
|
1227
|
+
journalPath,
|
|
1228
|
+
JSON.stringify({
|
|
1229
|
+
version: "1",
|
|
1230
|
+
lastSync: new Date().toISOString(),
|
|
1231
|
+
files: {
|
|
1232
|
+
"pulled-from-peer.md": {
|
|
1233
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1234
|
+
direction: "down",
|
|
1235
|
+
},
|
|
1236
|
+
"i-uploaded.md": {
|
|
1237
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1238
|
+
direction: "up",
|
|
1239
|
+
},
|
|
1240
|
+
},
|
|
1241
|
+
}),
|
|
1242
|
+
);
|
|
1243
|
+
|
|
1244
|
+
const result = await share({
|
|
1245
|
+
paths: [companyRoot],
|
|
1246
|
+
company: "acme",
|
|
1247
|
+
vaultConfig: mockConfig,
|
|
1248
|
+
hqRoot: tmpDir,
|
|
1249
|
+
skipUnchanged: true,
|
|
1250
|
+
propagateDeletes: true,
|
|
1251
|
+
// propagateDeletePolicy omitted ⇒ defaults to "owned-only".
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
// Only the 'up' entry is deleted; the 'down' entry is left alone so a
|
|
1255
|
+
// behind-machine first-sync can't erase peer uploads.
|
|
1256
|
+
expect(result.filesDeleted).toBe(1);
|
|
1257
|
+
expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
|
|
1258
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "i-uploaded.md");
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
it("propagateDeletes: policy:'all' opts back into legacy any-direction deletes", async () => {
|
|
1262
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1263
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1264
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1265
|
+
fs.writeFileSync(
|
|
1266
|
+
journalPath,
|
|
1267
|
+
JSON.stringify({
|
|
1268
|
+
version: "1",
|
|
1269
|
+
lastSync: new Date().toISOString(),
|
|
1270
|
+
files: {
|
|
1271
|
+
"pulled.md": {
|
|
1272
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1273
|
+
direction: "down",
|
|
1274
|
+
},
|
|
1275
|
+
},
|
|
1276
|
+
}),
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
const result = await share({
|
|
1280
|
+
paths: [companyRoot],
|
|
1281
|
+
company: "acme",
|
|
1282
|
+
vaultConfig: mockConfig,
|
|
1283
|
+
hqRoot: tmpDir,
|
|
1284
|
+
skipUnchanged: true,
|
|
1285
|
+
propagateDeletes: true,
|
|
1286
|
+
propagateDeletePolicy: "all",
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
expect(result.filesDeleted).toBe(1);
|
|
1290
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "pulled.md");
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
it("propagateDeletes: skips entries the ignore filter would reject (filter symmetry)", async () => {
|
|
1294
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1295
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1296
|
+
// Author a .hqignore at hqRoot that filters out a path the journal still
|
|
1297
|
+
// tracks. The push must NOT delete that path from the vault — the local
|
|
1298
|
+
// absence is a filter outcome, not a user-intended delete.
|
|
1299
|
+
fs.writeFileSync(
|
|
1300
|
+
path.join(tmpDir, ".hqignore"),
|
|
1301
|
+
"companies/acme/legacy/**\n",
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1305
|
+
fs.writeFileSync(
|
|
1306
|
+
journalPath,
|
|
1307
|
+
JSON.stringify({
|
|
1308
|
+
version: "1",
|
|
1309
|
+
lastSync: new Date().toISOString(),
|
|
1310
|
+
files: {
|
|
1311
|
+
"legacy/old-layout.md": {
|
|
1312
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1313
|
+
direction: "up",
|
|
1314
|
+
},
|
|
1315
|
+
"active/current.md": {
|
|
1316
|
+
hash: "h", size: 1, syncedAt: new Date().toISOString(),
|
|
1317
|
+
direction: "up",
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
}),
|
|
1321
|
+
);
|
|
1322
|
+
|
|
1323
|
+
const result = await share({
|
|
1324
|
+
paths: [companyRoot],
|
|
1325
|
+
company: "acme",
|
|
1326
|
+
vaultConfig: mockConfig,
|
|
1327
|
+
hqRoot: tmpDir,
|
|
1328
|
+
skipUnchanged: true,
|
|
1329
|
+
propagateDeletes: true,
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
// legacy/old-layout.md is filter-skipped; only active/current.md is
|
|
1333
|
+
// eligible for delete.
|
|
1334
|
+
expect(result.filesDeleted).toBe(1);
|
|
1335
|
+
expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "active/current.md");
|
|
1336
|
+
expect(deleteRemoteFile).not.toHaveBeenCalledWith(expect.anything(), "legacy/old-layout.md");
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1086
1339
|
// ── personalMode ───────────────────────────────────────────────────────────
|
|
1087
1340
|
//
|
|
1088
1341
|
// The personal vault (slug "personal" in the runner's fanout plan) shares
|
|
@@ -1228,4 +1481,259 @@ describe("share", () => {
|
|
|
1228
1481
|
expect(uploadFile).not.toHaveBeenCalled();
|
|
1229
1482
|
});
|
|
1230
1483
|
});
|
|
1484
|
+
|
|
1485
|
+
describe("symlinks", () => {
|
|
1486
|
+
// Pre-fix bug: collectFiles called fs.statSync (which dereferences) on
|
|
1487
|
+
// top-level paths and walkDir relied on Dirent.isFile()/isDirectory()
|
|
1488
|
+
// (both false for symlinks), so a top-level symlink got uploaded as the
|
|
1489
|
+
// target's bytes under the link's key while a nested symlink was
|
|
1490
|
+
// silently dropped from every push. The link topology never survived a
|
|
1491
|
+
// round trip — fresh-machine pulls landed in a state where overlay
|
|
1492
|
+
// symlinks just didn't exist until master-sync.sh recreated them
|
|
1493
|
+
// locally. The fix detects symlinks via lstat / Dirent.isSymbolicLink
|
|
1494
|
+
// and routes them to a new uploadSymlink primitive that PUTs a
|
|
1495
|
+
// zero-byte object with x-amz-meta-hq-symlink-target carrying the
|
|
1496
|
+
// readlink string verbatim.
|
|
1497
|
+
|
|
1498
|
+
it("uploads a top-level symlink as a symlink record (not the target's bytes)", async () => {
|
|
1499
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1500
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1501
|
+
const target = path.join(companyRoot, "target.md");
|
|
1502
|
+
fs.writeFileSync(target, "I am the target");
|
|
1503
|
+
const link = path.join(companyRoot, "link.md");
|
|
1504
|
+
fs.symlinkSync("target.md", link);
|
|
1505
|
+
|
|
1506
|
+
const result = await share({
|
|
1507
|
+
paths: [link],
|
|
1508
|
+
company: "acme",
|
|
1509
|
+
vaultConfig: mockConfig,
|
|
1510
|
+
hqRoot: tmpDir,
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
expect(result.filesUploaded).toBe(1);
|
|
1514
|
+
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
1515
|
+
expect.anything(),
|
|
1516
|
+
"target.md",
|
|
1517
|
+
"link.md",
|
|
1518
|
+
);
|
|
1519
|
+
// The link itself must NOT be uploaded as a regular file. Pre-fix,
|
|
1520
|
+
// fs.statSync(link) followed the link and uploadFile got called with
|
|
1521
|
+
// the link's path → cloud stored a copy of target.md's bytes under
|
|
1522
|
+
// the key "link.md", silently flattening the link.
|
|
1523
|
+
const fileCalls = vi.mocked(uploadFile).mock.calls.filter(
|
|
1524
|
+
(c) => c[2] === "link.md",
|
|
1525
|
+
);
|
|
1526
|
+
expect(fileCalls).toHaveLength(0);
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
it("uploads a nested symlink discovered during walkDir as a symlink record", async () => {
|
|
1530
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1531
|
+
const policiesDir = path.join(companyRoot, "policies");
|
|
1532
|
+
fs.mkdirSync(policiesDir, { recursive: true });
|
|
1533
|
+
const realPolicy = path.join(policiesDir, "real.md");
|
|
1534
|
+
fs.writeFileSync(realPolicy, "real content");
|
|
1535
|
+
const linkPolicy = path.join(policiesDir, "link.md");
|
|
1536
|
+
// Mirrors the master-sync.sh overlay shape: relative target pointing
|
|
1537
|
+
// to a sibling in the same dir.
|
|
1538
|
+
fs.symlinkSync("real.md", linkPolicy);
|
|
1539
|
+
|
|
1540
|
+
const result = await share({
|
|
1541
|
+
paths: [companyRoot],
|
|
1542
|
+
company: "acme",
|
|
1543
|
+
vaultConfig: mockConfig,
|
|
1544
|
+
hqRoot: tmpDir,
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
// Two uploads: one regular file (real.md), one symlink record (link.md).
|
|
1548
|
+
// Pre-fix, walkDir's Dirent.isFile() returned false for the symlink and
|
|
1549
|
+
// it was silently dropped — only the real file was uploaded.
|
|
1550
|
+
expect(result.filesUploaded).toBe(2);
|
|
1551
|
+
expect(uploadFile).toHaveBeenCalledWith(
|
|
1552
|
+
expect.anything(),
|
|
1553
|
+
realPolicy,
|
|
1554
|
+
"policies/real.md",
|
|
1555
|
+
);
|
|
1556
|
+
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
1557
|
+
expect.anything(),
|
|
1558
|
+
"real.md",
|
|
1559
|
+
"policies/link.md",
|
|
1560
|
+
);
|
|
1561
|
+
});
|
|
1562
|
+
|
|
1563
|
+
it("accepts a symlink inside the company folder even when its target lives outside", async () => {
|
|
1564
|
+
// Codex P2 follow-up: pre-fix, isWithin canonicalized the link
|
|
1565
|
+
// via realpathSync, so a directory symlink whose target lived
|
|
1566
|
+
// outside the company folder (the canonical motivating shape:
|
|
1567
|
+
// companies/{co}/knowledge → repos/private/knowledge-{co}/) was
|
|
1568
|
+
// rejected with "outside company folder" and the link was
|
|
1569
|
+
// dropped entirely. The link's own pathname is inside the
|
|
1570
|
+
// company folder; that's what containment must compare against.
|
|
1571
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1572
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1573
|
+
const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
|
|
1574
|
+
fs.mkdirSync(externalTarget, { recursive: true });
|
|
1575
|
+
const linkPath = path.join(companyRoot, "knowledge");
|
|
1576
|
+
fs.symlinkSync(externalTarget, linkPath);
|
|
1577
|
+
|
|
1578
|
+
const warnSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1579
|
+
const result = await share({
|
|
1580
|
+
paths: [linkPath],
|
|
1581
|
+
company: "acme",
|
|
1582
|
+
vaultConfig: mockConfig,
|
|
1583
|
+
hqRoot: tmpDir,
|
|
1584
|
+
});
|
|
1585
|
+
warnSpy.mockRestore();
|
|
1586
|
+
|
|
1587
|
+
expect(result.filesUploaded).toBe(1);
|
|
1588
|
+
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
1589
|
+
expect.anything(),
|
|
1590
|
+
externalTarget,
|
|
1591
|
+
"knowledge",
|
|
1592
|
+
);
|
|
1593
|
+
// No "outside company folder" warning for this case.
|
|
1594
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
1595
|
+
expect.stringMatching(/outside company folder/i),
|
|
1596
|
+
);
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
it("uploads a symlink even when its target string equals the prior regular file's contents (skipUnchanged distinguishability)", async () => {
|
|
1600
|
+
// Codex round-5 P1 follow-up: pre-fix, the journal hash for a
|
|
1601
|
+
// symlink was sha256(target). For a key whose journal entry was
|
|
1602
|
+
// a regular file containing the bytes "real.md", a local
|
|
1603
|
+
// replacement with a symlink → "real.md" produced the same
|
|
1604
|
+
// hash, so skipUnchanged short-circuited the upload and the
|
|
1605
|
+
// representation drift never propagated. The pull side also
|
|
1606
|
+
// saw no etag change (because we never uploaded), so the remote
|
|
1607
|
+
// never repaired. Fix: hash symlinks as sha256("hq-symlink:" +
|
|
1608
|
+
// target), making the two representations structurally
|
|
1609
|
+
// inequal in journal-hash space.
|
|
1610
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1611
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1612
|
+
const target = "real.md";
|
|
1613
|
+
// Pre-stamp the journal as if the prior sync had uploaded a
|
|
1614
|
+
// regular file whose contents were exactly the target string.
|
|
1615
|
+
const journalPath = path.join(stateDir, "sync-journal.acme.json");
|
|
1616
|
+
const crypto = await import("crypto");
|
|
1617
|
+
const regularFileHash = crypto
|
|
1618
|
+
.createHash("sha256")
|
|
1619
|
+
.update(target)
|
|
1620
|
+
.digest("hex");
|
|
1621
|
+
fs.writeFileSync(
|
|
1622
|
+
journalPath,
|
|
1623
|
+
JSON.stringify({
|
|
1624
|
+
version: "1",
|
|
1625
|
+
lastSync: new Date().toISOString(),
|
|
1626
|
+
files: {
|
|
1627
|
+
"case-key.md": {
|
|
1628
|
+
hash: regularFileHash,
|
|
1629
|
+
size: target.length,
|
|
1630
|
+
syncedAt: new Date().toISOString(),
|
|
1631
|
+
direction: "up",
|
|
1632
|
+
remoteEtag: "regular-file-etag",
|
|
1633
|
+
},
|
|
1634
|
+
},
|
|
1635
|
+
}),
|
|
1636
|
+
);
|
|
1637
|
+
// Now locally, the user has replaced that key with a symlink to
|
|
1638
|
+
// the same target string.
|
|
1639
|
+
fs.symlinkSync(target, path.join(companyRoot, "case-key.md"));
|
|
1640
|
+
|
|
1641
|
+
const result = await share({
|
|
1642
|
+
paths: [companyRoot],
|
|
1643
|
+
company: "acme",
|
|
1644
|
+
vaultConfig: mockConfig,
|
|
1645
|
+
hqRoot: tmpDir,
|
|
1646
|
+
skipUnchanged: true, // the gate the bug lives behind
|
|
1647
|
+
});
|
|
1648
|
+
|
|
1649
|
+
// Upload MUST happen — the hash-namespace prefix means the
|
|
1650
|
+
// symlink's journal hash differs from the regular-file hash.
|
|
1651
|
+
expect(result.filesUploaded).toBe(1);
|
|
1652
|
+
expect(result.filesSkipped).toBe(0);
|
|
1653
|
+
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
1654
|
+
expect.anything(),
|
|
1655
|
+
target,
|
|
1656
|
+
"case-key.md",
|
|
1657
|
+
);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it("includes a directory symlink whose only matching allowlist pattern is dir-only", async () => {
|
|
1661
|
+
// Codex round-6 P1 follow-up: pre-fix, walkDir called the filter
|
|
1662
|
+
// with isDir = entry.isDirectory(), which returns false for any
|
|
1663
|
+
// symlink — including directory symlinks. With an .hqinclude
|
|
1664
|
+
// dir-only allowlist pattern like `companies/*/knowledge/`, the
|
|
1665
|
+
// filter's `ignore.ignores('foo')` vs `ignore.ignores('foo/')`
|
|
1666
|
+
// distinction means the slashless probe doesn't match and the
|
|
1667
|
+
// symlink is dropped before reaching the record-only branch.
|
|
1668
|
+
// Same story for collectFiles' top-level path classification.
|
|
1669
|
+
// Fix: probe the filter with both isDir hints for symlinks; the
|
|
1670
|
+
// filter is pure path lookup, so two calls cost nothing.
|
|
1671
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1672
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1673
|
+
const externalTarget = path.join(tmpDir, "repos", "private", "knowledge-acme");
|
|
1674
|
+
fs.mkdirSync(externalTarget, { recursive: true });
|
|
1675
|
+
// Create the directory symlink AND a sibling regular file at the
|
|
1676
|
+
// same depth. The .hqinclude pattern matches only the dir-only
|
|
1677
|
+
// form; the regular file should NOT make it past the filter,
|
|
1678
|
+
// proving the include rule actually applies (otherwise this
|
|
1679
|
+
// test would pass for the wrong reason).
|
|
1680
|
+
fs.symlinkSync(externalTarget, path.join(companyRoot, "knowledge"));
|
|
1681
|
+
fs.writeFileSync(path.join(companyRoot, "stray.md"), "not in allowlist");
|
|
1682
|
+
// Allowlist mode: only `companies/*/knowledge/` is in scope.
|
|
1683
|
+
fs.writeFileSync(path.join(tmpDir, ".hqinclude"), "companies/*/knowledge/\n");
|
|
1684
|
+
|
|
1685
|
+
const result = await share({
|
|
1686
|
+
paths: [companyRoot],
|
|
1687
|
+
company: "acme",
|
|
1688
|
+
vaultConfig: mockConfig,
|
|
1689
|
+
hqRoot: tmpDir,
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
// The symlink record uploaded; the sibling regular file did NOT.
|
|
1693
|
+
expect(result.filesUploaded).toBe(1);
|
|
1694
|
+
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
1695
|
+
expect.anything(),
|
|
1696
|
+
externalTarget,
|
|
1697
|
+
"knowledge",
|
|
1698
|
+
);
|
|
1699
|
+
expect(uploadFile).not.toHaveBeenCalledWith(
|
|
1700
|
+
expect.anything(),
|
|
1701
|
+
expect.stringContaining("stray.md"),
|
|
1702
|
+
expect.anything(),
|
|
1703
|
+
);
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
it("does not recurse into directory symlinks (record-only)", async () => {
|
|
1707
|
+
// Following directory symlinks during walk would duplicate content
|
|
1708
|
+
// into the wrong vault path — exactly what the legacy "silently
|
|
1709
|
+
// skipped" topology accidentally prevented. The fix preserves that
|
|
1710
|
+
// topology while now actually recording the link.
|
|
1711
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
1712
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
1713
|
+
const realDir = path.join(tmpDir, "outside");
|
|
1714
|
+
fs.mkdirSync(realDir, { recursive: true });
|
|
1715
|
+
fs.writeFileSync(path.join(realDir, "secret.md"), "do not upload me");
|
|
1716
|
+
|
|
1717
|
+
const linkDir = path.join(companyRoot, "linked-dir");
|
|
1718
|
+
fs.symlinkSync(realDir, linkDir);
|
|
1719
|
+
|
|
1720
|
+
const result = await share({
|
|
1721
|
+
paths: [companyRoot],
|
|
1722
|
+
company: "acme",
|
|
1723
|
+
vaultConfig: mockConfig,
|
|
1724
|
+
hqRoot: tmpDir,
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
// The link record itself uploads; the target's contents do NOT.
|
|
1728
|
+
expect(result.filesUploaded).toBe(1);
|
|
1729
|
+
expect(uploadSymlink).toHaveBeenCalledWith(
|
|
1730
|
+
expect.anything(),
|
|
1731
|
+
realDir,
|
|
1732
|
+
"linked-dir",
|
|
1733
|
+
);
|
|
1734
|
+
// Crucially, no upload of the dir's contents.
|
|
1735
|
+
const calls = vi.mocked(uploadFile).mock.calls;
|
|
1736
|
+
expect(calls.find((c) => c[2].includes("secret.md"))).toBeUndefined();
|
|
1737
|
+
});
|
|
1738
|
+
});
|
|
1231
1739
|
});
|