@indigoai-us/hq-cloud 5.33.0 → 5.35.0
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-runner.d.ts +9 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +43 -9
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/bin/sync-runner.test.js +69 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/share.d.ts +60 -4
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +103 -6
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +78 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +20 -0
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +259 -6
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +469 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/ignore.d.ts.map +1 -1
- package/dist/ignore.js +20 -1
- package/dist/ignore.js.map +1 -1
- package/dist/ignore.test.js +47 -3
- package/dist/ignore.test.js.map +1 -1
- package/dist/s3.d.ts +21 -0
- package/dist/s3.d.ts.map +1 -1
- package/dist/s3.js +69 -2
- package/dist/s3.js.map +1 -1
- package/dist/s3.test.js +129 -2
- package/dist/s3.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +85 -0
- package/src/bin/sync-runner.ts +52 -9
- package/src/cli/share.test.ts +89 -0
- package/src/cli/share.ts +122 -6
- package/src/cli/sync.test.ts +529 -0
- package/src/cli/sync.ts +294 -7
- package/src/ignore.test.ts +57 -3
- package/src/ignore.ts +21 -1
- package/src/s3.test.ts +142 -2
- package/src/s3.ts +71 -2
package/dist/cli/sync.test.js
CHANGED
|
@@ -350,6 +350,475 @@ describe("sync", () => {
|
|
|
350
350
|
expect(fs.existsSync(path.join(tmpDir, "companies", "anything", "file.md"))).toBe(false);
|
|
351
351
|
expect(fs.existsSync(path.join(tmpDir, "docs", "readme.md"))).toBe(true);
|
|
352
352
|
});
|
|
353
|
+
it("applies cross-machine deletes via journal-vs-remote-LIST diff (Bug #9 — tombstone propagation)", async () => {
|
|
354
|
+
// Bug #9 (data-loss class — never reaches consistency): peer A
|
|
355
|
+
// deleted a file and pushed; the push side correctly removed the
|
|
356
|
+
// object from S3 (verified post-push with \`aws s3 ls\`). Receiver
|
|
357
|
+
// B's pull then enumerated the remote LIST, didn't see the key, and
|
|
358
|
+
// did NOTHING — the pull walker never consumed "absence from remote"
|
|
359
|
+
// as a delete signal. The file stayed on B forever and
|
|
360
|
+
// \`filesTombstoned: 0\` showed up on every pull (cross-machine
|
|
361
|
+
// delete-propagation impossible).
|
|
362
|
+
//
|
|
363
|
+
// Fix: walk the journal, find every entry whose key is missing from
|
|
364
|
+
// the remote LIST, and apply each as a local delete + journal
|
|
365
|
+
// removal. Same ignore-filter + personalMode gating applied so a
|
|
366
|
+
// path the operator just added to .hqignore can't trigger mass-
|
|
367
|
+
// delete. Excludes ephemeral conflict-mirror paths.
|
|
368
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
369
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
370
|
+
// Local file is journal-known but no longer in S3 (peer deleted it).
|
|
371
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "deleted-by-peer.md"), "old content");
|
|
372
|
+
// Local file matches a remote entry — stays after the pull.
|
|
373
|
+
fs.writeFileSync(path.join(companyRoot, "docs", "kept.md"), "current");
|
|
374
|
+
// Compute real sha256 baselines so the local-edit-divergence check
|
|
375
|
+
// (Codex P1 round 3) sees the local files as matching the journal
|
|
376
|
+
// baseline and proceeds with tombstone. Fake-string hashes would
|
|
377
|
+
// be classified as "diverged → defer" by that guard.
|
|
378
|
+
const crypto = await import("node:crypto");
|
|
379
|
+
const baselineDeleted = crypto
|
|
380
|
+
.createHash("sha256")
|
|
381
|
+
.update("old content")
|
|
382
|
+
.digest("hex");
|
|
383
|
+
const baselineKept = crypto
|
|
384
|
+
.createHash("sha256")
|
|
385
|
+
.update("current")
|
|
386
|
+
.digest("hex");
|
|
387
|
+
// Seed the journal as if the receiver had previously synced both files.
|
|
388
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
389
|
+
version: "1",
|
|
390
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
391
|
+
files: {
|
|
392
|
+
"docs/deleted-by-peer.md": {
|
|
393
|
+
hash: baselineDeleted,
|
|
394
|
+
size: 11,
|
|
395
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
396
|
+
direction: "down",
|
|
397
|
+
remoteEtag: "remote-old",
|
|
398
|
+
},
|
|
399
|
+
"docs/kept.md": {
|
|
400
|
+
hash: baselineKept,
|
|
401
|
+
size: 7,
|
|
402
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
403
|
+
direction: "down",
|
|
404
|
+
remoteEtag: "remote-current",
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
}));
|
|
408
|
+
// Remote LIST contains kept.md only — deleted-by-peer.md is gone.
|
|
409
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
410
|
+
{
|
|
411
|
+
key: "docs/kept.md",
|
|
412
|
+
size: 7,
|
|
413
|
+
lastModified: new Date(),
|
|
414
|
+
etag: '"remote-current"',
|
|
415
|
+
},
|
|
416
|
+
]);
|
|
417
|
+
const result = await sync({
|
|
418
|
+
company: "acme",
|
|
419
|
+
vaultConfig: mockConfig,
|
|
420
|
+
hqRoot: tmpDir,
|
|
421
|
+
});
|
|
422
|
+
// The peer-deleted file MUST be removed locally.
|
|
423
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "deleted-by-peer.md"))).toBe(false);
|
|
424
|
+
// The kept file MUST stay.
|
|
425
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "kept.md"))).toBe(true);
|
|
426
|
+
// Tombstone counter exposed for the runner's `complete` event.
|
|
427
|
+
expect(result.filesTombstoned).toBeGreaterThanOrEqual(1);
|
|
428
|
+
// Journal entry for the deleted path must be gone too — otherwise
|
|
429
|
+
// the next sync would tombstone the same key on every run.
|
|
430
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
431
|
+
expect(journal.files["docs/deleted-by-peer.md"]).toBeUndefined();
|
|
432
|
+
expect(journal.files["docs/kept.md"]).toBeDefined();
|
|
433
|
+
});
|
|
434
|
+
it("does NOT tombstone keys whose local copy has diverged from the journal (Codex P1 — delete-vs-edit race)", async () => {
|
|
435
|
+
// Codex review on PR #24 round 3 caught: in a delete-vs-local-edit
|
|
436
|
+
// race, peer A deletes a file remotely while peer B edits it
|
|
437
|
+
// locally before the next pull. Pre-fix the tombstone executor
|
|
438
|
+
// unlinked the locally-edited file and dropped the journal entry,
|
|
439
|
+
// silently destroying the operator's unsynced work. Fix: hash the
|
|
440
|
+
// local file before tombstoning; if it diverges from the journal
|
|
441
|
+
// baseline, defer the tombstone so the next push surfaces the
|
|
442
|
+
// divergence (or the operator re-pushes deliberately).
|
|
443
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
444
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
445
|
+
const editedPath = path.join(companyRoot, "docs", "edited-locally.md");
|
|
446
|
+
// Local file contents DIFFER from the journal-recorded baseline.
|
|
447
|
+
fs.writeFileSync(editedPath, "unsynced LOCAL edit — must not be deleted");
|
|
448
|
+
// Compute the journal's stale baseline hash from different content.
|
|
449
|
+
const crypto = await import("node:crypto");
|
|
450
|
+
const baselineHash = crypto
|
|
451
|
+
.createHash("sha256")
|
|
452
|
+
.update("original synced content (pre-edit)")
|
|
453
|
+
.digest("hex");
|
|
454
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
455
|
+
version: "1",
|
|
456
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
457
|
+
files: {
|
|
458
|
+
"docs/edited-locally.md": {
|
|
459
|
+
hash: baselineHash,
|
|
460
|
+
size: 33,
|
|
461
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
462
|
+
direction: "down",
|
|
463
|
+
remoteEtag: "e-baseline",
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
}));
|
|
467
|
+
// Peer deleted it: LIST is empty AND HEAD returns null (confirmed
|
|
468
|
+
// gone). Without the local-edit check, this would tombstone.
|
|
469
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
470
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
|
|
471
|
+
const result = await sync({
|
|
472
|
+
company: "acme",
|
|
473
|
+
vaultConfig: mockConfig,
|
|
474
|
+
hqRoot: tmpDir,
|
|
475
|
+
});
|
|
476
|
+
// Local file with unsynced edits MUST survive.
|
|
477
|
+
expect(fs.existsSync(editedPath)).toBe(true);
|
|
478
|
+
expect(fs.readFileSync(editedPath, "utf-8")).toBe("unsynced LOCAL edit — must not be deleted");
|
|
479
|
+
expect(result.filesTombstoned).toBe(0);
|
|
480
|
+
// Journal entry retained — next sync (push) will re-evaluate and
|
|
481
|
+
// either re-upload the divergent local or surface as a conflict.
|
|
482
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
483
|
+
expect(journal.files["docs/edited-locally.md"]).toBeDefined();
|
|
484
|
+
expect(journal.files["docs/edited-locally.md"].hash).toBe(baselineHash);
|
|
485
|
+
});
|
|
486
|
+
it("does NOT tombstone symlinks whose readlink target has diverged from the journal (Codex P1 round 4)", async () => {
|
|
487
|
+
// Codex review on PR #24 round 4 caught: the round-3 local-edit
|
|
488
|
+
// divergence guard only covered regular files (`isFile()` is false
|
|
489
|
+
// for symlinks), so a locally-edited symlink (`ln -sfn new-target
|
|
490
|
+
// old-link` before the peer's remote-delete) fell through the
|
|
491
|
+
// guard and was unlinked silently. Exact same race class —
|
|
492
|
+
// symlinks have target strings that count as content too.
|
|
493
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
494
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
495
|
+
const linkPath = path.join(companyRoot, "docs", "link.md");
|
|
496
|
+
// Local symlink now points to a NEW target (the local edit).
|
|
497
|
+
fs.symlinkSync("new-target.md", linkPath);
|
|
498
|
+
// Journal records the OLD target's hash.
|
|
499
|
+
const { hashSymlinkTarget } = await import("../journal.js");
|
|
500
|
+
const baselineHash = hashSymlinkTarget("old-target.md");
|
|
501
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
502
|
+
version: "1",
|
|
503
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
504
|
+
files: {
|
|
505
|
+
"docs/link.md": {
|
|
506
|
+
hash: baselineHash,
|
|
507
|
+
size: 0,
|
|
508
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
509
|
+
direction: "down",
|
|
510
|
+
remoteEtag: "e-link",
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
}));
|
|
514
|
+
// Peer deleted remotely.
|
|
515
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
516
|
+
vi.mocked(s3Module.headRemoteFile).mockResolvedValue(null);
|
|
517
|
+
const result = await sync({
|
|
518
|
+
company: "acme",
|
|
519
|
+
vaultConfig: mockConfig,
|
|
520
|
+
hqRoot: tmpDir,
|
|
521
|
+
});
|
|
522
|
+
// Locally-edited symlink MUST survive + still point at the new
|
|
523
|
+
// target. Use lstatSync — fs.existsSync follows symlinks and
|
|
524
|
+
// returns false for a dangling link, hiding the survival signal.
|
|
525
|
+
expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true);
|
|
526
|
+
expect(fs.readlinkSync(linkPath)).toBe("new-target.md");
|
|
527
|
+
expect(result.filesTombstoned).toBe(0);
|
|
528
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
529
|
+
expect(journal.files["docs/link.md"]).toBeDefined();
|
|
530
|
+
expect(journal.files["docs/link.md"].hash).toBe(baselineHash);
|
|
531
|
+
});
|
|
532
|
+
it("does NOT tombstone keys that HEAD-verify as still present (Codex P1 — STS scope gating)", async () => {
|
|
533
|
+
// Codex review on PR #24 caught: `listRemoteFiles` is STS-scoped — a
|
|
534
|
+
// guest session with `allowedPrefixes`, a downgraded role, or any
|
|
535
|
+
// custom sync-mode that narrows prefix coverage can return a LIST
|
|
536
|
+
// that does NOT include keys still genuinely present in the bucket.
|
|
537
|
+
// Pre-fix the tombstone planner classified absence-from-LIST as
|
|
538
|
+
// "peer deleted it"; the executor then unlinked local files for
|
|
539
|
+
// keys outside the session's visibility. Data loss + journal-
|
|
540
|
+
// baseline loss for the next broader-scope sync.
|
|
541
|
+
//
|
|
542
|
+
// Fix: HEAD-verify each candidate. HEAD returns metadata → key
|
|
543
|
+
// exists, just invisible to LIST → SKIP. HEAD returns null
|
|
544
|
+
// (NotFound) → confirmed deleted → proceed.
|
|
545
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
546
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
547
|
+
const invisiblePath = path.join(companyRoot, "docs", "invisible-to-list.md");
|
|
548
|
+
const reallyDeletedPath = path.join(companyRoot, "docs", "really-deleted.md");
|
|
549
|
+
fs.writeFileSync(invisiblePath, "still on bucket");
|
|
550
|
+
fs.writeFileSync(reallyDeletedPath, "peer removed");
|
|
551
|
+
// Real sha256 baselines so the local-edit-divergence guard
|
|
552
|
+
// (Codex P1 round 3) doesn't defer both as "diverged".
|
|
553
|
+
const crypto = await import("node:crypto");
|
|
554
|
+
const hInv = crypto.createHash("sha256").update("still on bucket").digest("hex");
|
|
555
|
+
const hDel = crypto.createHash("sha256").update("peer removed").digest("hex");
|
|
556
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
557
|
+
version: "1",
|
|
558
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
559
|
+
files: {
|
|
560
|
+
"docs/invisible-to-list.md": {
|
|
561
|
+
hash: hInv,
|
|
562
|
+
size: 15,
|
|
563
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
564
|
+
direction: "down",
|
|
565
|
+
remoteEtag: "e-inv",
|
|
566
|
+
},
|
|
567
|
+
"docs/really-deleted.md": {
|
|
568
|
+
hash: hDel,
|
|
569
|
+
size: 12,
|
|
570
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
571
|
+
direction: "down",
|
|
572
|
+
remoteEtag: "e-del",
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
}));
|
|
576
|
+
// LIST is empty (narrowed-scope session); both keys are absent.
|
|
577
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
578
|
+
// HEAD says invisible-to-list still exists; really-deleted is gone.
|
|
579
|
+
vi.mocked(s3Module.headRemoteFile).mockImplementation(async (_ctx, key) => {
|
|
580
|
+
if (key === "docs/invisible-to-list.md") {
|
|
581
|
+
return { lastModified: new Date(), etag: "e-inv", size: 15 };
|
|
582
|
+
}
|
|
583
|
+
return null; // NotFound → safe to tombstone
|
|
584
|
+
});
|
|
585
|
+
const result = await sync({
|
|
586
|
+
company: "acme",
|
|
587
|
+
vaultConfig: mockConfig,
|
|
588
|
+
hqRoot: tmpDir,
|
|
589
|
+
});
|
|
590
|
+
// The invisible-but-still-present file MUST be retained.
|
|
591
|
+
expect(fs.existsSync(invisiblePath)).toBe(true);
|
|
592
|
+
// The really-deleted file MUST be tombstoned.
|
|
593
|
+
expect(fs.existsSync(reallyDeletedPath)).toBe(false);
|
|
594
|
+
expect(result.filesTombstoned).toBe(1);
|
|
595
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
596
|
+
// Journal entry retained for the invisible key (so next sync at
|
|
597
|
+
// broader scope can re-evaluate).
|
|
598
|
+
expect(journal.files["docs/invisible-to-list.md"]).toBeDefined();
|
|
599
|
+
expect(journal.files["docs/invisible-to-list.md"].hash).toBe(hInv);
|
|
600
|
+
// Confirmed-deleted entry dropped.
|
|
601
|
+
expect(journal.files["docs/really-deleted.md"]).toBeUndefined();
|
|
602
|
+
});
|
|
603
|
+
it("does NOT tombstone keys when HEAD returns AccessDenied (Codex P1 — defensive STS skip)", async () => {
|
|
604
|
+
// Same Codex P1, AccessDenied branch: a guest STS session may deny
|
|
605
|
+
// both LIST and HEAD on out-of-scope keys. The tombstone planner
|
|
606
|
+
// must treat AccessDenied as "can't tell — defer" not as
|
|
607
|
+
// "confirmed deleted". Otherwise narrow-scope sessions would still
|
|
608
|
+
// tombstone keys they can't even see.
|
|
609
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
610
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
611
|
+
const deniedPath = path.join(companyRoot, "docs", "out-of-scope.md");
|
|
612
|
+
fs.writeFileSync(deniedPath, "exists but denied");
|
|
613
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
614
|
+
version: "1",
|
|
615
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
616
|
+
files: {
|
|
617
|
+
"docs/out-of-scope.md": {
|
|
618
|
+
hash: "h-denied",
|
|
619
|
+
size: 17,
|
|
620
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
621
|
+
direction: "down",
|
|
622
|
+
remoteEtag: "e-denied",
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
}));
|
|
626
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
627
|
+
const denied = new Error("Access Denied");
|
|
628
|
+
denied.name = "AccessDenied";
|
|
629
|
+
vi.mocked(s3Module.headRemoteFile).mockRejectedValueOnce(denied);
|
|
630
|
+
const result = await sync({
|
|
631
|
+
company: "acme",
|
|
632
|
+
vaultConfig: mockConfig,
|
|
633
|
+
hqRoot: tmpDir,
|
|
634
|
+
});
|
|
635
|
+
expect(fs.existsSync(deniedPath)).toBe(true);
|
|
636
|
+
expect(result.filesTombstoned).toBe(0);
|
|
637
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
638
|
+
expect(journal.files["docs/out-of-scope.md"]).toBeDefined();
|
|
639
|
+
});
|
|
640
|
+
it("retains the journal entry when tombstone unlink fails with EACCES (Codex P1 — Bug #9 follow-up)", async () => {
|
|
641
|
+
// Codex review on PR #24 caught this: pre-fix the tombstone branch
|
|
642
|
+
// dropped the journal entry unconditionally even when the local
|
|
643
|
+
// unlink failed with a non-ENOENT error (EACCES / EPERM / EBUSY).
|
|
644
|
+
// The pull side then forgot the peer's delete, and on a subsequent
|
|
645
|
+
// sync the still-present local file would be classified as a fresh
|
|
646
|
+
// upload candidate and re-pushed to S3 — silently undoing the
|
|
647
|
+
// peer's delete. ENOENT is safe to drop (local is already gone),
|
|
648
|
+
// but any other error MUST keep the journal entry so the next sync
|
|
649
|
+
// retries after the operator fixes the permission.
|
|
650
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
651
|
+
fs.mkdirSync(path.join(companyRoot, "docs"), { recursive: true });
|
|
652
|
+
const lockedPath = path.join(companyRoot, "docs", "locked-by-peer.md");
|
|
653
|
+
fs.writeFileSync(lockedPath, "still here");
|
|
654
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
655
|
+
version: "1",
|
|
656
|
+
lastSync: new Date(Date.now() - 60_000).toISOString(),
|
|
657
|
+
files: {
|
|
658
|
+
"docs/locked-by-peer.md": {
|
|
659
|
+
hash: "locked-hash",
|
|
660
|
+
size: 10,
|
|
661
|
+
syncedAt: new Date(Date.now() - 60_000).toISOString(),
|
|
662
|
+
direction: "down",
|
|
663
|
+
remoteEtag: "remote-locked",
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
}));
|
|
667
|
+
// Remote LIST is empty → tombstone candidate.
|
|
668
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
669
|
+
// Strip write perms from the parent dir so the unlink throws EACCES
|
|
670
|
+
// (and EPERM on some POSIX variants — both must be retained). The
|
|
671
|
+
// dir read perm is retained so the planner can still lstat the file.
|
|
672
|
+
const parentDir = path.join(companyRoot, "docs");
|
|
673
|
+
fs.chmodSync(parentDir, 0o500);
|
|
674
|
+
try {
|
|
675
|
+
const result = await sync({
|
|
676
|
+
company: "acme",
|
|
677
|
+
vaultConfig: mockConfig,
|
|
678
|
+
hqRoot: tmpDir,
|
|
679
|
+
});
|
|
680
|
+
// Local file is still present (unlink failed).
|
|
681
|
+
expect(fs.existsSync(lockedPath)).toBe(true);
|
|
682
|
+
// Tombstone NOT counted — the delete wasn't honored.
|
|
683
|
+
expect(result.filesTombstoned).toBe(0);
|
|
684
|
+
// Journal entry MUST be retained so the next sync retries.
|
|
685
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
686
|
+
expect(journal.files["docs/locked-by-peer.md"]).toBeDefined();
|
|
687
|
+
expect(journal.files["docs/locked-by-peer.md"].hash).toBe("locked-hash");
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
// Restore write perms so afterEach rmSync can clean the tmp dir.
|
|
691
|
+
fs.chmodSync(parentDir, 0o700);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
it("does NOT tombstone keys that are now ignored by .hqignore (safety guard)", async () => {
|
|
695
|
+
// If the operator just added a path to .hqignore, the local file
|
|
696
|
+
// shouldn't be deleted on the next sync because "remote no longer has
|
|
697
|
+
// it" (the push side stopped uploading it too). Skip-tombstone any
|
|
698
|
+
// key the current ignore filter rejects.
|
|
699
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
700
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
701
|
+
fs.writeFileSync(path.join(companyRoot, "secret.txt"), "stays");
|
|
702
|
+
// Ignore secret.txt going forward.
|
|
703
|
+
fs.writeFileSync(path.join(tmpDir, ".hqignore"), "secret.txt\n");
|
|
704
|
+
// Journal says we previously synced it.
|
|
705
|
+
fs.writeFileSync(journalPath, JSON.stringify({
|
|
706
|
+
version: "1",
|
|
707
|
+
lastSync: new Date().toISOString(),
|
|
708
|
+
files: {
|
|
709
|
+
"secret.txt": {
|
|
710
|
+
hash: "old-hash",
|
|
711
|
+
size: 5,
|
|
712
|
+
syncedAt: new Date().toISOString(),
|
|
713
|
+
direction: "down",
|
|
714
|
+
remoteEtag: "etag",
|
|
715
|
+
},
|
|
716
|
+
},
|
|
717
|
+
}));
|
|
718
|
+
// Remote LIST is empty.
|
|
719
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([]);
|
|
720
|
+
const result = await sync({
|
|
721
|
+
company: "acme",
|
|
722
|
+
vaultConfig: mockConfig,
|
|
723
|
+
hqRoot: tmpDir,
|
|
724
|
+
});
|
|
725
|
+
// The ignored file MUST NOT be tombstoned — operator's choice.
|
|
726
|
+
expect(fs.existsSync(path.join(companyRoot, "secret.txt"))).toBe(true);
|
|
727
|
+
expect(result.filesTombstoned).toBe(0);
|
|
728
|
+
});
|
|
729
|
+
it("dir-vs-file collision (local-file, cloud-dir): warns, continues, no ENOTDIR crash (Bug #10)", async () => {
|
|
730
|
+
// The 5.33.0 verification report's V10/Bug #10: cloud has a directory
|
|
731
|
+
// at \`v4-dir-vs-file/inside.txt\`, but local has a regular FILE at
|
|
732
|
+
// \`v4-dir-vs-file\`. Pre-fix, computePullPlan called \`lstatSync\` on the
|
|
733
|
+
// file-as-if-it-were-a-dir path and threw uncaught ENOTDIR, aborting
|
|
734
|
+
// the entire company sync — every later file (including an unrelated
|
|
735
|
+
// direct-injected v2 file) was skipped. Personal company status:
|
|
736
|
+
// \"errored\". Critical regression vector — one collision wedged the
|
|
737
|
+
// whole company.
|
|
738
|
+
//
|
|
739
|
+
// Fix: try/catch the lstat; on ENOTDIR, emit the same "manual
|
|
740
|
+
// reconciliation required" surface as the existing local-dir/cloud-
|
|
741
|
+
// file warning and continue with the rest of the LIST.
|
|
742
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
743
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
744
|
+
// Local has a regular file where cloud has a directory.
|
|
745
|
+
fs.writeFileSync(path.join(companyRoot, "v4-dir-vs-file"), "local file content");
|
|
746
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
747
|
+
// The cloud-dir entry that would crash lstat(...localPath).
|
|
748
|
+
{ key: "v4-dir-vs-file/inside.txt", size: 20, lastModified: new Date(), etag: '"a"' },
|
|
749
|
+
// An unrelated file at a sibling prefix — must STILL download after the
|
|
750
|
+
// collision is detected and skipped, proving the company isn't wedged.
|
|
751
|
+
{ key: "v4-other/sibling.txt", size: 10, lastModified: new Date(), etag: '"b"' },
|
|
752
|
+
]);
|
|
753
|
+
const result = await sync({
|
|
754
|
+
company: "acme",
|
|
755
|
+
vaultConfig: mockConfig,
|
|
756
|
+
hqRoot: tmpDir,
|
|
757
|
+
});
|
|
758
|
+
// No crash, no aborted: true.
|
|
759
|
+
expect(result.aborted).toBe(false);
|
|
760
|
+
// The collision entry is skipped (counted as skip-local-only).
|
|
761
|
+
// The sibling file MUST have downloaded — sync is not wedged.
|
|
762
|
+
expect(fs.existsSync(path.join(companyRoot, "v4-other", "sibling.txt"))).toBe(true);
|
|
763
|
+
expect(result.filesDownloaded).toBe(1);
|
|
764
|
+
});
|
|
765
|
+
it("dir-vs-file collision (local-dir, cloud-file): warns, continues, no crash (Bug #4)", async () => {
|
|
766
|
+
// Symmetric to Bug #10: cloud has a single object at a key where local
|
|
767
|
+
// has a directory. The existing code already warned + skipped this case;
|
|
768
|
+
// this test pins the contract so a future refactor doesn't regress it
|
|
769
|
+
// alongside the Bug #10 fix in the opposite topology.
|
|
770
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
771
|
+
fs.mkdirSync(path.join(companyRoot, "v4-collision"), { recursive: true });
|
|
772
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
773
|
+
{ key: "v4-collision", size: 50, lastModified: new Date(), etag: '"a"' },
|
|
774
|
+
{ key: "v4-other/sibling.txt", size: 10, lastModified: new Date(), etag: '"b"' },
|
|
775
|
+
]);
|
|
776
|
+
const result = await sync({
|
|
777
|
+
company: "acme",
|
|
778
|
+
vaultConfig: mockConfig,
|
|
779
|
+
hqRoot: tmpDir,
|
|
780
|
+
});
|
|
781
|
+
expect(result.aborted).toBe(false);
|
|
782
|
+
// The sibling MUST land — wedge regression test.
|
|
783
|
+
expect(fs.existsSync(path.join(companyRoot, "v4-other", "sibling.txt"))).toBe(true);
|
|
784
|
+
expect(result.filesDownloaded).toBe(1);
|
|
785
|
+
});
|
|
786
|
+
it("skips remote keys matching EPHEMERAL_PATH_PATTERN (Bug #2 — pull-side ephemeral filter)", async () => {
|
|
787
|
+
// The push side has refused to upload conflict-mirror files since 5.33.0
|
|
788
|
+
// (see `EPHEMERAL_PATH_PATTERN` + collectFiles/walkDir/computeDeletePlan).
|
|
789
|
+
// But the pull walker never filtered the same pattern — so legacy
|
|
790
|
+
// `.conflict-*` files already in cloud staging were downloaded into
|
|
791
|
+
// clean trees on every sync. The verification report's V2 test proved
|
|
792
|
+
// this with a direct `aws s3 cp` inject and observed
|
|
793
|
+
// `filesExcludedByPolicy: 0` on the pull, even though every push-side
|
|
794
|
+
// walker would have refused the same key.
|
|
795
|
+
//
|
|
796
|
+
// Fix: classify ephemeral remote keys as skip-excluded at planning
|
|
797
|
+
// time, increment `filesExcludedByPolicy`, and never call downloadFile.
|
|
798
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
799
|
+
// Legacy conflict mirror — must be filtered.
|
|
800
|
+
{
|
|
801
|
+
key: "docs/notes.md.conflict-2026-05-24T05-30-00Z-deadbe.txt",
|
|
802
|
+
size: 44,
|
|
803
|
+
lastModified: new Date(),
|
|
804
|
+
etag: '"abc"',
|
|
805
|
+
},
|
|
806
|
+
// Regular file at the same prefix — must still download.
|
|
807
|
+
{ key: "docs/notes.md", size: 30, lastModified: new Date(), etag: '"def"' },
|
|
808
|
+
]);
|
|
809
|
+
const result = await sync({
|
|
810
|
+
company: "acme",
|
|
811
|
+
vaultConfig: mockConfig,
|
|
812
|
+
hqRoot: tmpDir,
|
|
813
|
+
});
|
|
814
|
+
// The ephemeral file MUST NOT be downloaded — at any path.
|
|
815
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
816
|
+
expect(fs.existsSync(path.join(companyRoot, "docs/notes.md.conflict-2026-05-24T05-30-00Z-deadbe.txt"))).toBe(false);
|
|
817
|
+
// The legitimate file MUST download.
|
|
818
|
+
expect(fs.existsSync(path.join(companyRoot, "docs", "notes.md"))).toBe(true);
|
|
819
|
+
expect(result.filesDownloaded).toBe(1);
|
|
820
|
+
expect(result.filesExcludedByPolicy).toBeGreaterThanOrEqual(1);
|
|
821
|
+
});
|
|
353
822
|
it("overwrites local on --on-conflict overwrite", async () => {
|
|
354
823
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
355
824
|
fs.mkdirSync(companyDocs, { recursive: true });
|