@indigoai-us/hq-cloud 6.14.37 → 6.14.39
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.test.js +44 -0
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +1 -9
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/share.d.ts +13 -14
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +61 -16
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +141 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts +8 -5
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +68 -10
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +154 -0
- package/dist/cli/sync.test.js.map +1 -1
- package/dist/lib/readlink-safe.d.ts +11 -0
- package/dist/lib/readlink-safe.d.ts.map +1 -0
- package/dist/lib/readlink-safe.js +27 -0
- package/dist/lib/readlink-safe.js.map +1 -0
- package/dist/lib/readlink-safe.test.d.ts +2 -0
- package/dist/lib/readlink-safe.test.d.ts.map +1 -0
- package/dist/lib/readlink-safe.test.js +34 -0
- package/dist/lib/readlink-safe.test.js.map +1 -0
- package/package.json +1 -1
- package/src/bin/sync-runner.test.ts +52 -0
- package/src/cli/reindex.ts +1 -9
- package/src/cli/share.test.ts +180 -0
- package/src/cli/share.ts +75 -30
- package/src/cli/sync.test.ts +167 -0
- package/src/cli/sync.ts +78 -15
- package/src/lib/readlink-safe.test.ts +43 -0
- package/src/lib/readlink-safe.ts +29 -0
- package/test/e2e/sync/windows-unreadable-link-leg.test.ts +191 -0
package/src/cli/sync.test.ts
CHANGED
|
@@ -10,6 +10,13 @@ import { clearContextCache } from "../context.js";
|
|
|
10
10
|
import type { VaultServiceConfig } from "../types.js";
|
|
11
11
|
import { lockPathFor } from "../operation-lock.js";
|
|
12
12
|
|
|
13
|
+
// Re-export node:fs as a mutable module so unreadable-link regressions can
|
|
14
|
+
// inject a win32 EINVAL without needing a real Windows reparse point.
|
|
15
|
+
vi.mock("fs", async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import("fs")>();
|
|
17
|
+
return { ...actual };
|
|
18
|
+
});
|
|
19
|
+
|
|
13
20
|
// Mock s3 module at the top level
|
|
14
21
|
vi.mock("../s3.js", async (importOriginal) => {
|
|
15
22
|
const actual = await importOriginal<typeof import("../s3.js")>();
|
|
@@ -67,6 +74,12 @@ const mockConfig: VaultServiceConfig = {
|
|
|
67
74
|
region: "us-east-1",
|
|
68
75
|
};
|
|
69
76
|
|
|
77
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
78
|
+
const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
|
|
79
|
+
err.code = code;
|
|
80
|
+
return err;
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
const mockEntity = {
|
|
71
84
|
uid: "cmp_01ABCDEF",
|
|
72
85
|
slug: "acme",
|
|
@@ -383,6 +396,52 @@ describe("sync", () => {
|
|
|
383
396
|
expect(journal.files["docs/handoff.md"]?.localDiverges).toBeFalsy();
|
|
384
397
|
});
|
|
385
398
|
|
|
399
|
+
it("leaves a downloaded unreadable link unjournaled without emitting a fatal error", async () => {
|
|
400
|
+
const linkKey = "policies/downloaded-unreadable-link";
|
|
401
|
+
const linkPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
402
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
403
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
404
|
+
]);
|
|
405
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
406
|
+
async (_ctx: unknown, _key: string, localPath: string) => {
|
|
407
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
408
|
+
fs.symlinkSync("missing-target.md", localPath);
|
|
409
|
+
return { metadata: {} };
|
|
410
|
+
},
|
|
411
|
+
);
|
|
412
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
413
|
+
const readlinkSpy = vi
|
|
414
|
+
.spyOn(fs, "readlinkSync")
|
|
415
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
416
|
+
if (candidate === linkPath) throw errnoError("EINVAL");
|
|
417
|
+
return realReadlinkSync(candidate);
|
|
418
|
+
}) as typeof fs.readlinkSync);
|
|
419
|
+
const events: SyncProgressEvent[] = [];
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const result = await sync({
|
|
423
|
+
company: "acme",
|
|
424
|
+
vaultConfig: mockConfig,
|
|
425
|
+
hqRoot: tmpDir,
|
|
426
|
+
onEvent: (event) => events.push(event),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
expect(result.filesDownloaded).toBe(0);
|
|
430
|
+
expect(result.filesSkipped).toBe(1);
|
|
431
|
+
expect(events).toContainEqual({
|
|
432
|
+
type: "not-shipped",
|
|
433
|
+
reason: "unreadable-link",
|
|
434
|
+
count: 1,
|
|
435
|
+
samplePaths: [linkKey],
|
|
436
|
+
});
|
|
437
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
438
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
439
|
+
expect(journal.files[linkKey]).toBeUndefined();
|
|
440
|
+
} finally {
|
|
441
|
+
readlinkSpy.mockRestore();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
386
445
|
it("emits a conflict event with path + resolution on hash mismatch", async () => {
|
|
387
446
|
const companyDocs = path.join(tmpDir, "companies", "acme", "docs");
|
|
388
447
|
fs.mkdirSync(companyDocs, { recursive: true });
|
|
@@ -505,6 +564,73 @@ describe("sync", () => {
|
|
|
505
564
|
expect(journalAfter.files["docs/handoff.md"].remoteEtag).toBeTruthy();
|
|
506
565
|
});
|
|
507
566
|
|
|
567
|
+
it("defers a conflict whose downloaded link target cannot be read", async () => {
|
|
568
|
+
const linkKey = "docs/handoff.md";
|
|
569
|
+
const localPath = path.join(tmpDir, "companies", "acme", linkKey);
|
|
570
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
571
|
+
fs.writeFileSync(localPath, "local version");
|
|
572
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
573
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"remote-link"' },
|
|
574
|
+
]);
|
|
575
|
+
let mirrorPath = "";
|
|
576
|
+
vi.mocked(s3Module.downloadFile).mockImplementationOnce(
|
|
577
|
+
async (_ctx: unknown, _key: string, destination: string) => {
|
|
578
|
+
mirrorPath = destination;
|
|
579
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
580
|
+
fs.symlinkSync("missing-target.md", destination);
|
|
581
|
+
return { metadata: {} };
|
|
582
|
+
},
|
|
583
|
+
);
|
|
584
|
+
fs.writeFileSync(
|
|
585
|
+
journalPath,
|
|
586
|
+
JSON.stringify({
|
|
587
|
+
version: "1",
|
|
588
|
+
lastSync: new Date().toISOString(),
|
|
589
|
+
files: {
|
|
590
|
+
[linkKey]: {
|
|
591
|
+
hash: "stale-hash",
|
|
592
|
+
size: 20,
|
|
593
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
594
|
+
direction: "down",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
600
|
+
const readlinkSpy = vi
|
|
601
|
+
.spyOn(fs, "readlinkSync")
|
|
602
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
603
|
+
if (candidate === mirrorPath) throw errnoError("EINVAL");
|
|
604
|
+
return realReadlinkSync(candidate);
|
|
605
|
+
}) as typeof fs.readlinkSync);
|
|
606
|
+
const events: SyncProgressEvent[] = [];
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const result = await sync({
|
|
610
|
+
company: "acme",
|
|
611
|
+
onConflict: "keep",
|
|
612
|
+
vaultConfig: mockConfig,
|
|
613
|
+
hqRoot: tmpDir,
|
|
614
|
+
onEvent: (event) => events.push(event),
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
expect(result.conflicts).toBe(0);
|
|
618
|
+
expect(result.filesSkipped).toBeGreaterThanOrEqual(1);
|
|
619
|
+
expect(events).toContainEqual({
|
|
620
|
+
type: "not-shipped",
|
|
621
|
+
reason: "unreadable-link",
|
|
622
|
+
count: 1,
|
|
623
|
+
samplePaths: [linkKey],
|
|
624
|
+
});
|
|
625
|
+
expect(events.some((event) => event.type === "error")).toBe(false);
|
|
626
|
+
expect(fs.readFileSync(localPath, "utf-8")).toBe("local version");
|
|
627
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
|
|
628
|
+
expect(journal.files[linkKey].hash).toBe("stale-hash");
|
|
629
|
+
} finally {
|
|
630
|
+
readlinkSpy.mockRestore();
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
508
634
|
it("still writes a `.conflict-*` mirror when remote genuinely diverges from local", async () => {
|
|
509
635
|
// Guard the other side of the convergence branch: when the probe bytes
|
|
510
636
|
// differ from local, it remains a real conflict — counted, kept, and the
|
|
@@ -4131,6 +4257,47 @@ describe("sync", () => {
|
|
|
4131
4257
|
expect(result.conflicts).toBe(0);
|
|
4132
4258
|
});
|
|
4133
4259
|
|
|
4260
|
+
it("defers an unreadable local symlink instead of aborting the pull plan", async () => {
|
|
4261
|
+
const linkKey = "policies/unreadable-link";
|
|
4262
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
4263
|
+
const linkPath = path.join(companyRoot, linkKey);
|
|
4264
|
+
fs.mkdirSync(path.dirname(linkPath), { recursive: true });
|
|
4265
|
+
fs.symlinkSync("target.md", linkPath);
|
|
4266
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
4267
|
+
{ key: linkKey, size: 0, lastModified: new Date(), etag: '"link-etag"' },
|
|
4268
|
+
]);
|
|
4269
|
+
|
|
4270
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
4271
|
+
const readlinkSpy = vi
|
|
4272
|
+
.spyOn(fs, "readlinkSync")
|
|
4273
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
4274
|
+
if (candidate === linkPath) throw errnoError("EINVAL");
|
|
4275
|
+
return realReadlinkSync(candidate);
|
|
4276
|
+
}) as typeof fs.readlinkSync);
|
|
4277
|
+
|
|
4278
|
+
try {
|
|
4279
|
+
const events: SyncProgressEvent[] = [];
|
|
4280
|
+
const result = await sync({
|
|
4281
|
+
company: "acme",
|
|
4282
|
+
vaultConfig: mockConfig,
|
|
4283
|
+
hqRoot: tmpDir,
|
|
4284
|
+
onEvent: (event) => events.push(event),
|
|
4285
|
+
});
|
|
4286
|
+
|
|
4287
|
+
expect(result.filesDownloaded).toBe(0);
|
|
4288
|
+
expect(result.filesSkipped).toBe(1);
|
|
4289
|
+
expect(s3Module.downloadFile).not.toHaveBeenCalled();
|
|
4290
|
+
expect(events).toContainEqual({
|
|
4291
|
+
type: "not-shipped",
|
|
4292
|
+
reason: "unreadable-link",
|
|
4293
|
+
count: 1,
|
|
4294
|
+
samplePaths: [linkKey],
|
|
4295
|
+
});
|
|
4296
|
+
} finally {
|
|
4297
|
+
readlinkSpy.mockRestore();
|
|
4298
|
+
}
|
|
4299
|
+
});
|
|
4300
|
+
|
|
4134
4301
|
it("classifies a dangling-symlink + remote-change as a conflict without crashing on statSync", async () => {
|
|
4135
4302
|
// Codex round-11 P2 follow-up: pre-fix, the conflict executor
|
|
4136
4303
|
// built the resolveConflict prompt with `fs.statSync(localPath).
|
package/src/cli/sync.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
SyncJournal,
|
|
14
14
|
} from "../types.js";
|
|
15
15
|
import { VaultAuthError, VaultClient, type SyncMode } from "../vault-client.js";
|
|
16
|
+
import { readlinkOrNull } from "../lib/readlink-safe.js";
|
|
16
17
|
import {
|
|
17
18
|
emitCloudTelemetry,
|
|
18
19
|
type TelemetryClaims,
|
|
@@ -436,11 +437,11 @@ export type SyncProgressEvent =
|
|
|
436
437
|
}
|
|
437
438
|
| {
|
|
438
439
|
/**
|
|
439
|
-
* Emitted
|
|
440
|
-
*
|
|
441
|
-
* vanish from every push bucket without a trace (feedback_258e4a86 /
|
|
440
|
+
* Emitted when local content is deliberately not transferred and would
|
|
441
|
+
* otherwise vanish from sync output without a trace (feedback_258e4a86 /
|
|
442
442
|
* feedback_a51cb63d — an hour lost because a "Pushed 0 file(s)" success
|
|
443
|
-
* named nothing that was skipped).
|
|
443
|
+
* named nothing that was skipped). Push batches by reason; pull emits an
|
|
444
|
+
* unreadable link by its remote key. `reason` distinguishes the cause:
|
|
444
445
|
*
|
|
445
446
|
* - `"unreachable-path"`: a path the caller EXPLICITLY named exists on
|
|
446
447
|
* disk but the resolver could not place it under the company folder,
|
|
@@ -450,12 +451,15 @@ export type SyncProgressEvent =
|
|
|
450
451
|
* the company folder was recorded as a link but its contents were not
|
|
451
452
|
* descended/uploaded. They sync via their own repo, not the vault;
|
|
452
453
|
* this is informational, not an error.
|
|
454
|
+
* - `"unreadable-link"`: the OS identified a local symbolic link but
|
|
455
|
+
* did not expose a readable target. The link is skipped rather than
|
|
456
|
+
* dereferenced, and the company leg remains complete.
|
|
453
457
|
*
|
|
454
458
|
* `count` is the number of distinct paths for that reason; `samplePaths`
|
|
455
459
|
* carries up to 10 for display. Not emitted when `count === 0`.
|
|
456
460
|
*/
|
|
457
461
|
type: "not-shipped";
|
|
458
|
-
reason: "unreachable-path" | "linked-subtree";
|
|
462
|
+
reason: "unreachable-path" | "unreadable-link" | "linked-subtree";
|
|
459
463
|
count: number;
|
|
460
464
|
samplePaths: string[];
|
|
461
465
|
};
|
|
@@ -1261,6 +1265,19 @@ async function executeConflictExecutor(
|
|
|
1261
1265
|
counters.filesSkipped++;
|
|
1262
1266
|
continue;
|
|
1263
1267
|
}
|
|
1268
|
+
if (item.action === "skip-unreadable-link") {
|
|
1269
|
+
// An unreadable local link must stay non-fatal: the runner treats a
|
|
1270
|
+
// generic error event as a failed company leg. Surface the exact remote
|
|
1271
|
+
// key through the recoverable not-shipped channel instead.
|
|
1272
|
+
counters.filesSkipped++;
|
|
1273
|
+
run.emit({
|
|
1274
|
+
type: "not-shipped",
|
|
1275
|
+
reason: "unreadable-link",
|
|
1276
|
+
count: 1,
|
|
1277
|
+
samplePaths: [item.remoteFile.key],
|
|
1278
|
+
});
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1264
1281
|
if (item.action === "skip-excluded-policy") {
|
|
1265
1282
|
continue;
|
|
1266
1283
|
}
|
|
@@ -1408,10 +1425,23 @@ async function executeConflictItem(
|
|
|
1408
1425
|
try {
|
|
1409
1426
|
const downloaded = await downloadFile(run.ctx, remoteFile.key, conflictAbs);
|
|
1410
1427
|
remoteFetched = true;
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1428
|
+
if (fs.lstatSync(conflictAbs).isSymbolicLink()) {
|
|
1429
|
+
const target = readlinkOrNull(conflictAbs);
|
|
1430
|
+
if (target === null) {
|
|
1431
|
+
counters.filesSkipped++;
|
|
1432
|
+
run.emit({
|
|
1433
|
+
type: "not-shipped",
|
|
1434
|
+
reason: "unreadable-link",
|
|
1435
|
+
count: 1,
|
|
1436
|
+
samplePaths: [remoteFile.key],
|
|
1437
|
+
});
|
|
1438
|
+
return null;
|
|
1439
|
+
} else {
|
|
1440
|
+
converged = hashSymlinkTarget(target) === item.localHash;
|
|
1441
|
+
}
|
|
1442
|
+
} else {
|
|
1443
|
+
converged = (downloaded.contentHash ?? hashFile(conflictAbs)) === item.localHash;
|
|
1444
|
+
}
|
|
1415
1445
|
} catch (probeErr) {
|
|
1416
1446
|
if (probeErr instanceof VaultAuthError) throw probeErr;
|
|
1417
1447
|
run.emit({
|
|
@@ -1635,9 +1665,23 @@ async function downloadOne(
|
|
|
1635
1665
|
|
|
1636
1666
|
const localLstat = fs.lstatSync(localPath);
|
|
1637
1667
|
const isLocalSymlink = localLstat.isSymbolicLink();
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1668
|
+
let hash: string;
|
|
1669
|
+
if (isLocalSymlink) {
|
|
1670
|
+
const target = readlinkOrNull(localPath);
|
|
1671
|
+
if (target === null) {
|
|
1672
|
+
counters.filesSkipped++;
|
|
1673
|
+
run.emit({
|
|
1674
|
+
type: "not-shipped",
|
|
1675
|
+
reason: "unreadable-link",
|
|
1676
|
+
count: 1,
|
|
1677
|
+
samplePaths: [remoteFile.key],
|
|
1678
|
+
});
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
hash = hashSymlinkTarget(target);
|
|
1682
|
+
} else {
|
|
1683
|
+
hash = contentHash ?? hashFile(localPath);
|
|
1684
|
+
}
|
|
1641
1685
|
const size = isLocalSymlink ? 0 : (contentSize ?? fs.statSync(localPath).size);
|
|
1642
1686
|
|
|
1643
1687
|
updateEntry(
|
|
@@ -2166,6 +2210,11 @@ type PullPlanItem =
|
|
|
2166
2210
|
| { action: "skip-personal-mode"; remoteFile: RemoteFile; localPath: string }
|
|
2167
2211
|
| { action: "skip-unchanged"; remoteFile: RemoteFile; localPath: string }
|
|
2168
2212
|
| { action: "skip-local-only"; remoteFile: RemoteFile; localPath: string }
|
|
2213
|
+
// A local symlink was classified as a link but Windows (or a filesystem
|
|
2214
|
+
// race) would not expose its target. This stays distinct from generic
|
|
2215
|
+
// skip-local-only so the executor can report the exact path without making
|
|
2216
|
+
// the whole company leg fail.
|
|
2217
|
+
| { action: "skip-unreadable-link"; remoteFile: RemoteFile; localPath: string }
|
|
2169
2218
|
// A stale personal-overlay symlink marker in the vault sitting at a key that
|
|
2170
2219
|
// is ALSO a real, release-shipped core directory (e.g. an old
|
|
2171
2220
|
// `core/knowledge/public/agent-browser` overlay marker after a release
|
|
@@ -2800,9 +2849,19 @@ function computePullPlan(
|
|
|
2800
2849
|
) {
|
|
2801
2850
|
localHash = journalEntry.hash;
|
|
2802
2851
|
} else {
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2852
|
+
if (isLocalSymlink) {
|
|
2853
|
+
const target = readlinkOrNull(localPath);
|
|
2854
|
+
if (target === null) {
|
|
2855
|
+
// We cannot prove whether the local link diverged, so never
|
|
2856
|
+
// overwrite or tombstone it from this plan. A later pass can retry
|
|
2857
|
+
// once the OS exposes a readable target.
|
|
2858
|
+
items.push({ action: "skip-unreadable-link", remoteFile, localPath });
|
|
2859
|
+
continue;
|
|
2860
|
+
}
|
|
2861
|
+
localHash = hashSymlinkTarget(target);
|
|
2862
|
+
} else {
|
|
2863
|
+
localHash = hashFile(localPath);
|
|
2864
|
+
}
|
|
2806
2865
|
}
|
|
2807
2866
|
const localChanged = !!journalEntry && journalEntry.hash !== localHash;
|
|
2808
2867
|
plannedLocalSnapshot = {
|
|
@@ -3329,6 +3388,10 @@ function defaultConsoleLogger(event: SyncProgressEvent): void {
|
|
|
3329
3388
|
console.warn(
|
|
3330
3389
|
` ! ${event.count} named path${event.count === 1 ? "" : "s"} could NOT be pushed — the file exists but is not reachable under the company folder (nothing was uploaded for ${event.count === 1 ? "it" : "them"}):`,
|
|
3331
3390
|
);
|
|
3391
|
+
} else if (event.reason === "unreadable-link") {
|
|
3392
|
+
console.warn(
|
|
3393
|
+
` ! ${event.count} symbolic link${event.count === 1 ? "" : "s"} could NOT be read and was skipped without dereferencing its target:`,
|
|
3394
|
+
);
|
|
3332
3395
|
} else {
|
|
3333
3396
|
console.warn(
|
|
3334
3397
|
` ! ${event.count} linked subtree${event.count === 1 ? "" : "s"} recorded but NOT uploaded — contents sync via their own repo, not the vault:`,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readlinkOrNull } from "./readlink-safe.js";
|
|
3
|
+
|
|
4
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
5
|
+
const err = new Error(`${code}: scripted readlink failure`) as NodeJS.ErrnoException;
|
|
6
|
+
err.code = code;
|
|
7
|
+
return err;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("readlinkOrNull", () => {
|
|
11
|
+
it("uses the filesystem reader by default", () => {
|
|
12
|
+
expect(readlinkOrNull(`/hq-cloud-missing-link-${process.pid}`)).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns the target string verbatim when readlink succeeds", () => {
|
|
16
|
+
expect(readlinkOrNull("/test/link", () => "../target with spaces")).toBe(
|
|
17
|
+
"../target with spaces",
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each(["EINVAL", "ENOENT", "EPERM"])("returns null for %s", (code) => {
|
|
22
|
+
expect(readlinkOrNull("/test/link", () => {
|
|
23
|
+
throw errnoError(code);
|
|
24
|
+
})).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rethrows non-filesystem failures", () => {
|
|
28
|
+
const defect = new Error("unexpected test defect");
|
|
29
|
+
|
|
30
|
+
expect(() => readlinkOrNull("/test/link", () => {
|
|
31
|
+
throw defect;
|
|
32
|
+
})).toThrow(defect);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rethrows code-bearing programming errors", () => {
|
|
36
|
+
const defect = new TypeError("path must be a string") as TypeError & { code: string };
|
|
37
|
+
defect.code = "ERR_INVALID_ARG_TYPE";
|
|
38
|
+
|
|
39
|
+
expect(() => readlinkOrNull("/test/link", () => {
|
|
40
|
+
throw defect;
|
|
41
|
+
})).toThrow(defect);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read a link target without turning an expected filesystem race or an
|
|
5
|
+
* unsupported link shape into a caller-wide failure.
|
|
6
|
+
*
|
|
7
|
+
* A `null` result is deliberately distinct from an empty target string: it
|
|
8
|
+
* means the caller must preserve the link boundary and choose its conservative
|
|
9
|
+
* skip/defer path. Non-filesystem exceptions still propagate so programming
|
|
10
|
+
* errors cannot be mistaken for an unreadable link.
|
|
11
|
+
*/
|
|
12
|
+
export function readlinkOrNull(
|
|
13
|
+
linkPath: string,
|
|
14
|
+
readlink: (path: string) => string = (path) => fs.readlinkSync(path, "utf8"),
|
|
15
|
+
): string | null {
|
|
16
|
+
try {
|
|
17
|
+
return readlink(linkPath);
|
|
18
|
+
} catch (err: unknown) {
|
|
19
|
+
const code =
|
|
20
|
+
err && typeof err === "object" && "code" in err
|
|
21
|
+
? (err as { code?: unknown }).code
|
|
22
|
+
: undefined;
|
|
23
|
+
// Node system errors use POSIX-style errno names such as EINVAL and
|
|
24
|
+
// ENOENT. Runtime/programming errors use ERR_* codes; those must remain
|
|
25
|
+
// loud rather than being misclassified as an unreadable link.
|
|
26
|
+
if (typeof code === "string" && /^E[A-Z0-9]+$/.test(code)) return null;
|
|
27
|
+
throw err;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact-level regression for the Windows EINVAL reparse-point failure.
|
|
3
|
+
*
|
|
4
|
+
* The Linux test runner cannot create the production reparse-point shape, so
|
|
5
|
+
* the fs seam makes a real symlink's readlink raise EINVAL while the actual
|
|
6
|
+
* runner → company fanout → share walk executes over a temp HQ tree.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import * as fs from "fs";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import {
|
|
12
|
+
runRunner,
|
|
13
|
+
type RunnerDeps,
|
|
14
|
+
type RunnerEvent,
|
|
15
|
+
type VaultClientSurface,
|
|
16
|
+
} from "../../../src/bin/sync-runner.js";
|
|
17
|
+
import { share } from "../../../src/cli/share.js";
|
|
18
|
+
import type { EntityContext, EntityInfo } from "../../../src/types.js";
|
|
19
|
+
import { uploadFile, uploadSymlink } from "../../../src/s3.js";
|
|
20
|
+
|
|
21
|
+
vi.mock("fs", async (importOriginal) => {
|
|
22
|
+
const actual = await importOriginal<typeof import("fs")>();
|
|
23
|
+
return { ...actual };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
vi.mock("../../../src/s3.js", async (importOriginal) => {
|
|
27
|
+
const actual = await importOriginal<typeof import("../../../src/s3.js")>();
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
uploadFile: vi.fn().mockResolvedValue({ etag: '"upload-etag"' }),
|
|
31
|
+
uploadSymlink: vi.fn().mockResolvedValue({ etag: '"symlink-etag"' }),
|
|
32
|
+
headRemoteFile: vi.fn().mockResolvedValue(null),
|
|
33
|
+
primeObjectTransport: vi.fn().mockResolvedValue(undefined),
|
|
34
|
+
primeUploads: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function errnoError(code: string): NodeJS.ErrnoException {
|
|
39
|
+
const err = new Error(`${code}: invalid argument, readlink`) as NodeJS.ErrnoException;
|
|
40
|
+
err.code = code;
|
|
41
|
+
return err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeWriter(): {
|
|
45
|
+
write: (chunk: string) => boolean;
|
|
46
|
+
events: () => RunnerEvent[];
|
|
47
|
+
} {
|
|
48
|
+
let output = "";
|
|
49
|
+
return {
|
|
50
|
+
write: (chunk) => {
|
|
51
|
+
output += chunk;
|
|
52
|
+
return true;
|
|
53
|
+
},
|
|
54
|
+
events: () =>
|
|
55
|
+
output
|
|
56
|
+
.split("\n")
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.map((line) => JSON.parse(line) as RunnerEvent),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let hqRoot: string;
|
|
63
|
+
let stateDir: string;
|
|
64
|
+
let unreadableLink: string;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
// Keep fixture roots inside the checkout so operator runs never create a
|
|
68
|
+
// scratch HQ tree outside the task worktree.
|
|
69
|
+
hqRoot = fs.mkdtempSync(path.join(process.cwd(), ".hqcloud-einval-e2e-"));
|
|
70
|
+
stateDir = fs.mkdtempSync(path.join(process.cwd(), ".hqcloud-einval-state-"));
|
|
71
|
+
process.env.HQ_STATE_DIR = stateDir;
|
|
72
|
+
const companyRoot = path.join(hqRoot, "companies", "acme");
|
|
73
|
+
const policiesDir = path.join(companyRoot, "policies");
|
|
74
|
+
const targetDir = path.join(hqRoot, "outside", "target");
|
|
75
|
+
fs.mkdirSync(policiesDir, { recursive: true });
|
|
76
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
77
|
+
fs.writeFileSync(path.join(policiesDir, "safe.md"), "safe bytes");
|
|
78
|
+
fs.writeFileSync(path.join(targetDir, "must-not-upload.md"), "target bytes");
|
|
79
|
+
unreadableLink = path.join(policiesDir, "unreadable-link");
|
|
80
|
+
fs.symlinkSync(targetDir, unreadableLink, "dir");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
vi.restoreAllMocks();
|
|
85
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
86
|
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
|
87
|
+
delete process.env.HQ_STATE_DIR;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("sync runner unreadable Windows link", () => {
|
|
91
|
+
it("keeps the leg complete and emits the offending relative path", async () => {
|
|
92
|
+
const stdout = makeWriter();
|
|
93
|
+
const stderr = makeWriter();
|
|
94
|
+
const entityContext: EntityContext = {
|
|
95
|
+
uid: "cmp_acme",
|
|
96
|
+
slug: "acme",
|
|
97
|
+
bucketName: "hq-vault-acme-test",
|
|
98
|
+
region: "us-east-1",
|
|
99
|
+
credentials: {
|
|
100
|
+
accessKeyId: "test-key",
|
|
101
|
+
secretAccessKey: "test-secret",
|
|
102
|
+
sessionToken: "test-session",
|
|
103
|
+
},
|
|
104
|
+
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
105
|
+
};
|
|
106
|
+
const client = {
|
|
107
|
+
listMyMemberships: async () => [{ companyUid: "cmp_acme" }],
|
|
108
|
+
listMyPendingInvitesByEmail: async () => [],
|
|
109
|
+
claimPendingInvitesByEmail: async () => undefined,
|
|
110
|
+
ensureMyPersonEntity: async () => ({}) as EntityInfo,
|
|
111
|
+
entity: {
|
|
112
|
+
get: async (uid: string) => ({
|
|
113
|
+
uid,
|
|
114
|
+
type: "company",
|
|
115
|
+
slug: "acme",
|
|
116
|
+
bucketName: "hq-vault-acme-test",
|
|
117
|
+
status: "active",
|
|
118
|
+
}) as EntityInfo,
|
|
119
|
+
listByType: async () => [],
|
|
120
|
+
},
|
|
121
|
+
} as unknown as VaultClientSurface;
|
|
122
|
+
const realReadlinkSync = fs.readlinkSync;
|
|
123
|
+
const readlinkSpy = vi
|
|
124
|
+
.spyOn(fs, "readlinkSync")
|
|
125
|
+
.mockImplementation(((candidate: fs.PathLike) => {
|
|
126
|
+
if (candidate === unreadableLink) throw errnoError("EINVAL");
|
|
127
|
+
return realReadlinkSync(candidate);
|
|
128
|
+
}) as typeof fs.readlinkSync);
|
|
129
|
+
const deps: RunnerDeps = {
|
|
130
|
+
stdout,
|
|
131
|
+
stderr,
|
|
132
|
+
getAccessToken: async () => "test-access-token",
|
|
133
|
+
getIdTokenClaims: () => null,
|
|
134
|
+
createVaultClient: () => client,
|
|
135
|
+
share: async (options) => {
|
|
136
|
+
const { vaultConfig: _vaultConfig, ...shareOptions } = options;
|
|
137
|
+
return share({ ...shareOptions, entityContext });
|
|
138
|
+
},
|
|
139
|
+
reindex: () => ({ status: 0 }) as ReturnType<NonNullable<RunnerDeps["reindex"]>>,
|
|
140
|
+
qmdReindex: () => ({
|
|
141
|
+
qmdAvailable: true,
|
|
142
|
+
collectionsAdded: [],
|
|
143
|
+
pathDriftDetected: [],
|
|
144
|
+
collectionsRepaired: [],
|
|
145
|
+
updated: false,
|
|
146
|
+
embedded: false,
|
|
147
|
+
pendingDirty: false,
|
|
148
|
+
lockBusy: false,
|
|
149
|
+
timedOut: false,
|
|
150
|
+
corruptionQuarantined: false,
|
|
151
|
+
corruptionQuarantineFailed: false,
|
|
152
|
+
indexDir: null,
|
|
153
|
+
}) as ReturnType<NonNullable<RunnerDeps["qmdReindex"]>>,
|
|
154
|
+
reconcileManifest: async () => ({
|
|
155
|
+
written: false,
|
|
156
|
+
added: [],
|
|
157
|
+
updated: [],
|
|
158
|
+
skipped: [],
|
|
159
|
+
}) as Awaited<ReturnType<NonNullable<RunnerDeps["reconcileManifest"]>>>,
|
|
160
|
+
collectTelemetry: async () => undefined,
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const code = await runRunner(
|
|
165
|
+
["--companies", "--direction", "push", "--hq-root", hqRoot],
|
|
166
|
+
deps,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(code).toBe(0);
|
|
170
|
+
expect(stdout.events()).toContainEqual({
|
|
171
|
+
type: "not-shipped",
|
|
172
|
+
company: "acme",
|
|
173
|
+
reason: "unreadable-link",
|
|
174
|
+
count: 1,
|
|
175
|
+
samplePaths: ["policies/unreadable-link"],
|
|
176
|
+
});
|
|
177
|
+
expect(stdout.events()).toContainEqual(
|
|
178
|
+
expect.objectContaining({
|
|
179
|
+
type: "all-complete",
|
|
180
|
+
errors: [],
|
|
181
|
+
partial: false,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
expect(stderr.events()).toEqual([]);
|
|
185
|
+
expect(uploadFile).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(uploadSymlink).not.toHaveBeenCalled();
|
|
187
|
+
} finally {
|
|
188
|
+
readlinkSpy.mockRestore();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|