@celilo/cli 2.0.0 → 2.2.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.
Files changed (103) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +1 -0
  3. package/drizzle/0031_module_config_source.sql +20 -0
  4. package/drizzle/meta/_journal.json +8 -1
  5. package/package.json +2 -2
  6. package/schemas/system_config.json +2 -1
  7. package/src/capabilities/public-web-publish.test.ts +61 -0
  8. package/src/cli/commands/firewall-interface-list.test.ts +156 -7
  9. package/src/cli/commands/firewall-interface-list.ts +73 -7
  10. package/src/cli/commands/machine-add.ts +12 -55
  11. package/src/cli/commands/module-config.test.ts +20 -1
  12. package/src/cli/commands/module-import.ts +1 -1
  13. package/src/cli/commands/module-update.test.ts +82 -0
  14. package/src/cli/commands/module-update.ts +14 -4
  15. package/src/cli/commands/monitor.ts +2 -10
  16. package/src/cli/commands/restore.ts +16 -6
  17. package/src/cli/generate-zsh-completion.ts +1 -1
  18. package/src/cli/index.ts +4 -3
  19. package/src/cli/restore-migration-failure.test.ts +159 -0
  20. package/src/db/client.ts +5 -0
  21. package/src/db/migrate.test.ts +61 -135
  22. package/src/db/migrate.ts +7 -2
  23. package/src/db/schema.ts +10 -0
  24. package/src/hooks/broker.test.ts +106 -2
  25. package/src/hooks/broker.ts +91 -1
  26. package/src/hooks/capability-loader-firewall.test.ts +37 -0
  27. package/src/hooks/capability-loader.ts +16 -3
  28. package/src/hooks/define-hook.test.ts +4 -3
  29. package/src/hooks/executor.test.ts +19 -18
  30. package/src/hooks/executor.ts +88 -11
  31. package/src/hooks/hook-jail-toolchain-reach.test.ts +79 -29
  32. package/src/hooks/hook-jail-unreachability.test.ts +55 -29
  33. package/src/hooks/hook-protocol.ts +46 -1
  34. package/src/hooks/hook-runner.ts +36 -0
  35. package/src/hooks/hook-store-proxy.test.ts +109 -0
  36. package/src/hooks/hook-store-proxy.ts +85 -0
  37. package/src/hooks/hook-store.test.ts +162 -0
  38. package/src/hooks/hook-store.ts +290 -0
  39. package/src/hooks/hook-timeout.test.ts +3 -2
  40. package/src/hooks/hook-trespass.test.ts +94 -14
  41. package/src/hooks/jail.test.ts +1 -1
  42. package/src/hooks/jail.ts +194 -32
  43. package/src/hooks/mount-set.test.ts +296 -1
  44. package/src/hooks/mount-set.ts +216 -13
  45. package/src/hooks/run-named-hook.ts +2 -0
  46. package/src/hooks/test-fixtures/jail-probe-hook.ts +1 -1
  47. package/src/hooks/test-fixtures/on-restore-staging-hook.ts +26 -0
  48. package/src/hooks/test-fixtures/store-backed.ts +47 -0
  49. package/src/hooks/test-fixtures/store-writing-hook.ts +63 -0
  50. package/src/hooks/unjailed-lint.test.ts +27 -8
  51. package/src/manifest/schema.ts +1 -0
  52. package/src/module/packaging/build.ts +70 -2
  53. package/src/module/web-root.ts +17 -1
  54. package/src/policy/fixture-capability-coverage.test.ts +322 -0
  55. package/src/policy/module-script-scan.test.ts +42 -1
  56. package/src/policy/module-script-scan.ts +275 -5
  57. package/src/policy/no-hand-built-ssh.test.ts +34 -1
  58. package/src/policy/no-swallowed-refusal.test.ts +265 -0
  59. package/src/policy/no-tar-shell-out-in-services.test.ts +43 -0
  60. package/src/registry/client.test.ts +149 -0
  61. package/src/registry/client.ts +203 -11
  62. package/src/services/alerting/coverage-source.test.ts +86 -0
  63. package/src/services/alerting/coverage-source.ts +11 -1
  64. package/src/services/alerting/format.test.ts +57 -0
  65. package/src/services/alerting/format.ts +24 -0
  66. package/src/services/alerting/run-monitor.ts +2 -2
  67. package/src/services/backup-create.ts +7 -7
  68. package/src/services/backup-envelope-roundtrip.test.ts +45 -2
  69. package/src/services/backup-restore.ts +8 -4
  70. package/src/services/bus-interview.ts +37 -14
  71. package/src/services/config-provenance.ts +4 -0
  72. package/src/services/control-plane-bootstrap.test.ts +297 -0
  73. package/src/services/control-plane-bootstrap.ts +223 -0
  74. package/src/services/control-plane-health.test.ts +66 -0
  75. package/src/services/control-plane-health.ts +67 -0
  76. package/src/services/deploy-preflight.ts +8 -2
  77. package/src/services/deploy-validation.test.ts +22 -0
  78. package/src/services/deploy-validation.ts +8 -0
  79. package/src/services/deployed-systems.ts +12 -0
  80. package/src/services/dns-discovery.test.ts +147 -0
  81. package/src/services/dns-discovery.ts +134 -0
  82. package/src/services/fleet-checks.ts +6 -2
  83. package/src/services/fleet-key.test.ts +66 -2
  84. package/src/services/fleet-key.ts +54 -0
  85. package/src/services/health-runner.ts +36 -3
  86. package/src/services/module-config.ts +20 -2
  87. package/src/services/module-deploy.dns-repoint.test.ts +187 -0
  88. package/src/services/module-deploy.ts +214 -1
  89. package/src/services/module-validator/git-hygiene.test.ts +122 -3
  90. package/src/services/module-validator/git-hygiene.ts +83 -14
  91. package/src/services/remote-access.test.ts +88 -4
  92. package/src/services/remote-access.ts +52 -1
  93. package/src/services/restore-from-file.test.ts +20 -0
  94. package/src/services/restore-from-file.ts +21 -6
  95. package/src/services/static-content-converge.test.ts +140 -2
  96. package/src/services/static-content-converge.ts +55 -8
  97. package/src/services/system-config-schema-types.ts +1 -1
  98. package/src/services/system-config-validator.test.ts +36 -0
  99. package/src/services/system-config-validator.ts +11 -0
  100. package/src/services/trusted-sources.test.ts +30 -0
  101. package/src/services/trusted-sources.ts +47 -10
  102. package/src/templates/generator.ts +9 -2
  103. package/src/variables/context.ts +16 -5
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Recurrence gate for openspec/changes/e2e-suite-recovery §4: **a hook whose
3
+ * framework call is refused fails, rather than continuing with an answer it
4
+ * invented** (spec delta `specs/module-lifecycle/spec.md`, scenario "The
5
+ * pattern cannot be reintroduced").
6
+ *
7
+ * The rule itself lives in `./module-script-scan`, because this is not the only
8
+ * place it runs — `.netapp` packaging applies the same scan at publish time.
9
+ * One definition, two callers.
10
+ *
11
+ * The two sites this gate exists for (e2e-suite-recovery tasks 4.1/4.2, all
12
+ * still in the tree on the swallow debt list; a third, knot-unbound-internal
13
+ * scripts/on-install.ts:69, converted on 2026-09-07 by ce-pvii and deleted
14
+ * from both debt lists in the same change):
15
+ * modules/celilo-mgmt/scripts/on_backup.ts:52 catch { return [] }
16
+ * modules/wireguard/scripts/health-check.ts:73 catch { return null }
17
+ */
18
+
19
+ import { describe, expect, test } from 'bun:test';
20
+ import {
21
+ existsSync,
22
+ mkdirSync,
23
+ readFileSync,
24
+ readdirSync,
25
+ rmSync,
26
+ statSync,
27
+ writeFileSync,
28
+ } from 'node:fs';
29
+ import { join, resolve } from 'node:path';
30
+ import {
31
+ SWALLOWED_REFUSAL_DEBT,
32
+ SWALLOWED_REFUSAL_RULE,
33
+ formatViolations,
34
+ moduleScriptFiles,
35
+ scanModuleDirectory,
36
+ scanModuleScriptSource,
37
+ } from './module-script-scan';
38
+
39
+ /** Walk up from this test to the repo root (the dir holding both modules/ and apps/). */
40
+ function repoRoot(): string {
41
+ let dir = import.meta.dir;
42
+ for (let i = 0; i < 8; i++) {
43
+ if (existsSync(join(dir, 'modules')) && existsSync(join(dir, 'apps'))) return dir;
44
+ dir = resolve(dir, '..');
45
+ }
46
+ throw new Error('could not locate repo root (no ancestor with modules/ + apps/)');
47
+ }
48
+
49
+ function moduleDirs(): string[] {
50
+ const modulesRoot = join(repoRoot(), 'modules');
51
+ return readdirSync(modulesRoot)
52
+ .map((m) => join(modulesRoot, m))
53
+ .filter((d) => statSync(d).isDirectory() && existsSync(join(d, 'scripts')));
54
+ }
55
+
56
+ /** Swallow-rule violations only — the other rules are different gates with their own tests. */
57
+ function swallowRule(violations: ReturnType<typeof scanModuleScriptSource>) {
58
+ return violations.filter((v) => v.rule === SWALLOWED_REFUSAL_RULE);
59
+ }
60
+
61
+ describe('recurrence gate: a hook does not swallow a refused framework call', () => {
62
+ describe('the shapes it must reject', () => {
63
+ test('catch returning a literal below a spawned framework call (on_backup shape)', () => {
64
+ const source = [
65
+ 'function snapshot(): unknown[] {',
66
+ ' try {',
67
+ " const stdout = execSync('celilo machine list --json', { encoding: 'utf-8' });",
68
+ ' return JSON.parse(stdout);',
69
+ ' } catch {',
70
+ ' // older celilo, or no machines yet',
71
+ ' return [];',
72
+ ' }',
73
+ '}',
74
+ ].join('\n');
75
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
76
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
77
+ expect(hits[0].line).toBe(3); // the framework-reaching call, not the catch
78
+ });
79
+
80
+ test('comment-only catch below an injected CLI-runner call (on_install shape)', () => {
81
+ const source = [
82
+ 'function repoint(ip: string, runCelilo: (args: string[]) => string): void {',
83
+ ' let prior: string | undefined;',
84
+ ' try {',
85
+ " prior = parse(runCelilo(['system', 'config', 'get', 'dns.primary']));",
86
+ ' } catch {',
87
+ ' // dns.primary not set yet — nothing to demote.',
88
+ ' }',
89
+ ' runCelilo(["system", "apply-config", `dns.primary=${ip}`]);',
90
+ '}',
91
+ ].join('\n');
92
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
93
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
94
+ expect(hits[0].line).toBe(4);
95
+ });
96
+
97
+ test('catch returning null below execFileSync (health-check shape)', () => {
98
+ const source = [
99
+ 'function read(key: string): string | null {',
100
+ ' try {',
101
+ " const out = execFileSync('celilo', ['system', 'config', 'get', key], { encoding: 'utf-8' });",
102
+ ' return parse(key, out);',
103
+ ' } catch {',
104
+ ' return null;',
105
+ ' }',
106
+ '}',
107
+ ].join('\n');
108
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
109
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
110
+ expect(hits[0].line).toBe(3);
111
+ });
112
+
113
+ test('log-and-continue catch below a spawned framework call', () => {
114
+ const source = [
115
+ 'try {',
116
+ " execSync('celilo system apply-config dns.primary=10.0.0.1');",
117
+ '} catch (err) {',
118
+ ' logger.warn(`repoint failed: ${err}`);',
119
+ '}',
120
+ 'logger.success("dns.primary repointed");',
121
+ ].join('\n');
122
+ const hits = swallowRule(scanModuleScriptSource('scripts/x.ts', source));
123
+ expect(hits.length, `expected 1, got:\n${formatViolations(hits)}`).toBe(1);
124
+ });
125
+ });
126
+
127
+ describe('the shapes it must accept', () => {
128
+ test('a typed-absent reader: no catch between the hook and the answer', () => {
129
+ const source = [
130
+ 'export async function healthCheck(deps: HealthDeps): Promise<Output> {',
131
+ ' const { readConfig } = deps;',
132
+ ' const value = readConfig("server_public_key");',
133
+ ' if (!value.present) return { status: "absent", key: "server_public_key" };',
134
+ ' return { status: "ok", key: value.data };',
135
+ '}',
136
+ ].join('\n');
137
+ expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]);
138
+ });
139
+
140
+ test('a catch that rethrows: the refusal propagates', () => {
141
+ const source = [
142
+ 'try {',
143
+ " execSync('celilo system apply-config dns.primary=10.0.0.1');",
144
+ '} catch (err) {',
145
+ ' throw new Error(`dns.primary repoint refused: ${String(err)}`);',
146
+ '}',
147
+ ].join('\n');
148
+ expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]);
149
+ });
150
+
151
+ test('a catch below work that never reaches the framework', () => {
152
+ const source = [
153
+ 'function parse(raw: string): unknown[] {',
154
+ ' try {',
155
+ ' const parsed: unknown = JSON.parse(raw);',
156
+ ' return Array.isArray(parsed) ? parsed : [];',
157
+ ' } catch {',
158
+ ' return [];',
159
+ ' }',
160
+ '}',
161
+ ].join('\n');
162
+ expect(swallowRule(scanModuleScriptSource('scripts/x.ts', source))).toEqual([]);
163
+ });
164
+ });
165
+
166
+ test('reach: the scan walks every scripts/ directory and flags every planted violation', () => {
167
+ // Mirror of the real layout (modules/<m>/scripts/**), planted per the
168
+ // reach rule: a violating hook in EVERY directory the scan should reach —
169
+ // the module scripts root and a nested subdirectory — plus a clean module
170
+ // the scan must come back empty on.
171
+ const root = join(repoRoot(), '.tmp-swallow-reach-fixture');
172
+ rmSync(root, { recursive: true, force: true });
173
+ const violating = (name: string) =>
174
+ [
175
+ `// ${name}`,
176
+ 'export function run() {',
177
+ ' try {',
178
+ " execSync('celilo system config get dns.primary');",
179
+ ' } catch {',
180
+ ' return null;',
181
+ ' }',
182
+ '}',
183
+ ].join('\n');
184
+ try {
185
+ const planted = [
186
+ 'modules/alpha/scripts/one.ts',
187
+ 'modules/alpha/scripts/nested/two.ts',
188
+ 'modules/beta/scripts/three.ts',
189
+ ];
190
+ for (const p of planted) {
191
+ const f = join(root, p);
192
+ mkdirSync(resolve(f, '..'), { recursive: true });
193
+ writeFileSync(f, violating(p));
194
+ }
195
+ const cleanDir = join(root, 'modules/clean/scripts');
196
+ mkdirSync(cleanDir, { recursive: true });
197
+ writeFileSync(
198
+ join(cleanDir, 'clean.ts'),
199
+ 'export function run(readConfig: (k: string) => { present: boolean }) {\n return readConfig("k");\n}\n',
200
+ );
201
+
202
+ // Per-module expectation, in the module-relative paths the scan
203
+ // reports: every planted file came back exactly once, and the clean
204
+ // module contributed nothing.
205
+ const flagged = (m: string) =>
206
+ new Set(swallowRule(scanModuleDirectory(join(root, 'modules', m))).map((v) => v.file));
207
+ expect([...flagged('alpha')].sort()).toEqual(['scripts/nested/two.ts', 'scripts/one.ts']);
208
+ expect([...flagged('beta')]).toEqual(['scripts/three.ts']);
209
+ expect([...flagged('clean')]).toEqual([]);
210
+ } finally {
211
+ rmSync(root, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test('today’s tree: the remaining known sites are flagged, and nothing else is', () => {
216
+ // Raw scan, deliberately NOT scanModuleDirectory: the debt list below
217
+ // exempts these same files so the merged gate stays green while
218
+ // hook-owned-state converts them, and this assertion has to outlive that
219
+ // exemption to prove the rule still reaches them.
220
+ const violations = moduleDirs().flatMap((d) =>
221
+ swallowRule(
222
+ moduleScriptFiles(join(d, 'scripts')).flatMap((f) =>
223
+ scanModuleScriptSource(f, readFileSync(f, 'utf-8')),
224
+ ),
225
+ ),
226
+ );
227
+ const sites = violations.map((v) => v.file.replace(`${repoRoot()}/`, '')).sort();
228
+ // Pinned to the exact sites from e2e-suite-recovery task 4.2, as measured
229
+ // on 2026-09-07 at on_backup.ts:52 and health-check.ts:73 (a third,
230
+ // on-install.ts:69, converted by ce-pvii the same day). A converted site
231
+ // drops out and the debt entry must go; a NEW site appearing here is the
232
+ // next instance this scan exists to stop.
233
+ expect(sites).toEqual([
234
+ 'modules/celilo-mgmt/scripts/on_backup.ts',
235
+ 'modules/wireguard/scripts/health-check.ts',
236
+ ]);
237
+ });
238
+ });
239
+
240
+ describe('recurrence gate: the swallow debt list is honest', () => {
241
+ // Same three ways this list could silently rot as the jailed-CLI-spawn debt
242
+ // guard, same guard for each.
243
+ test('every entry names an existing file carrying exactly its pinned swallow count', () => {
244
+ for (const entry of SWALLOWED_REFUSAL_DEBT) {
245
+ const f = join(repoRoot(), 'modules', entry.module, entry.file);
246
+ expect(
247
+ existsSync(f),
248
+ `${entry.module}/${entry.file} no longer exists — delete its debt entry`,
249
+ ).toBe(true);
250
+ const hits = scanModuleScriptSource(entry.file, readFileSync(f, 'utf-8')).filter(
251
+ (v) => v.rule === SWALLOWED_REFUSAL_RULE,
252
+ );
253
+ expect(
254
+ hits.length,
255
+ `${entry.module}/${entry.file} carries ${hits.length} swallowed refusals, the debt entry pins ${entry.matches} (${entry.reason}) — convert or update the entry`,
256
+ ).toBe(entry.matches);
257
+ }
258
+ });
259
+
260
+ test('the debt list is non-trivial (the guard actually reached the entries)', () => {
261
+ // The list held three entries until ce-pvii converted the on-install.ts
262
+ // entry (2026-09-07); two remain, so the guard still proves engagement.
263
+ expect(SWALLOWED_REFUSAL_DEBT.length).toBeGreaterThan(1);
264
+ });
265
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Recurrence gate for celilo#1235: **services never shell out to `tar`; they
3
+ * use the `tar` package.**
4
+ *
5
+ * `tar@^7` is a declared dependency of @celilo/cli, but backup/restore built
6
+ * tar command lines and handed them to execSync. That made `tar` an undeclared
7
+ * runtime dependency on a host binary: Essential on Debian, so it never bit,
8
+ * but a container base image is where "always there" stops being true. The
9
+ * services now use the library (same shape the hook boundary is moving to via
10
+ * pack_directory), and this file keeps them there.
11
+ *
12
+ * Scoped to apps/celilo/src/services (the surface celilo#1235 covers). Test
13
+ * files are excluded — they legitimately drive the system tar to cross-check
14
+ * archives written by the library.
15
+ */
16
+
17
+ import { describe, expect, test } from 'bun:test';
18
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ function serviceSourceFiles(): string[] {
22
+ const dir = join(import.meta.dir, '../services');
23
+ const walk = (d: string): string[] =>
24
+ readdirSync(d).flatMap((name) => {
25
+ const path = join(d, name);
26
+ if (statSync(path).isDirectory()) return walk(path);
27
+ return name.endsWith('.ts') && !name.endsWith('.test.ts') ? [path] : [];
28
+ });
29
+ return walk(dir);
30
+ }
31
+
32
+ describe('recurrence gate: services never shell out to tar', () => {
33
+ test('scans a non-trivial set of service sources (sanity — the scan actually ran)', () => {
34
+ expect(serviceSourceFiles().length).toBeGreaterThan(10);
35
+ });
36
+
37
+ test('no service source passes a "tar ..." string to execSync', () => {
38
+ const offenders = serviceSourceFiles().filter((path) =>
39
+ /execSync\(\s*[`'"]tar /.test(readFileSync(path, 'utf-8')),
40
+ );
41
+ expect(offenders).toEqual([]);
42
+ });
43
+ });
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import { createHash } from 'node:crypto';
2
3
  import type { IndexEntry } from './client';
3
4
  import { DEFAULT_REGISTRY, RegistryClient } from './client';
4
5
 
@@ -312,3 +313,151 @@ describe('RegistryClient owner methods', () => {
312
313
  );
313
314
  });
314
315
  });
316
+
317
+ // ── get() retry ───────────────────────────────────────────────────────────────
318
+ //
319
+ // Every registry read goes through one private `get()`. It used to make exactly
320
+ // one attempt, so a single transient — the shape the 15s→30s timeout bump was
321
+ // already chasing — failed a module install outright. Measured 2026-09-04:
322
+ // `module import iptables` failed twice with "Download failed: The operation
323
+ // timed out" while the identical call had succeeded minutes earlier.
324
+
325
+ describe('RegistryClient.get retry', () => {
326
+ let fetchSpy: ReturnType<typeof spyOn>;
327
+
328
+ beforeEach(() => {
329
+ fetchSpy = spyOn(globalThis, 'fetch');
330
+ });
331
+
332
+ afterEach(() => {
333
+ fetchSpy.mockRestore();
334
+ });
335
+
336
+ test('a transient network failure is retried and the download succeeds', async () => {
337
+ const payload = new Uint8Array([1, 2, 3, 4]);
338
+ fetchSpy
339
+ .mockRejectedValueOnce(new DOMException('The operation timed out.', 'TimeoutError'))
340
+ .mockResolvedValueOnce(new Response(payload, { status: 200 }));
341
+
342
+ const client = new RegistryClient('https://reg.example.com');
343
+ const data = await client.download('iptables', '3.1.4');
344
+
345
+ expect(new Uint8Array(data)).toEqual(payload);
346
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
347
+ });
348
+
349
+ test('a 5xx is retried', async () => {
350
+ fetchSpy
351
+ .mockResolvedValueOnce(new Response(null, { status: 503 }))
352
+ .mockResolvedValueOnce(new Response(new Uint8Array([9]), { status: 200 }));
353
+
354
+ const client = new RegistryClient('https://reg.example.com');
355
+ await client.download('iptables', '3.1.4');
356
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
357
+ });
358
+
359
+ test('a 404 is NOT retried — it is an answer, not a transient', async () => {
360
+ fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
361
+
362
+ const client = new RegistryClient('https://reg.example.com');
363
+ await expect(client.download('nope', '1.0.0')).rejects.toThrow('HTTP 404');
364
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
365
+ });
366
+
367
+ test('gives up after a bounded number of attempts', async () => {
368
+ fetchSpy.mockRejectedValue(new DOMException('The operation timed out.', 'TimeoutError'));
369
+
370
+ const client = new RegistryClient('https://reg.example.com');
371
+ const error = await client.download('iptables', '3.1.4').then(
372
+ () => undefined,
373
+ (e: unknown) => e,
374
+ );
375
+ expect(error).toBeInstanceOf(Error);
376
+ // The exhaustion report is the event, not the last attempt (celilo#1264):
377
+ // the bare lastError read as an unretried first attempt, because that is
378
+ // exactly what the same string meant before the retry existed.
379
+ expect((error as Error).message).toMatch(/failed after 3 attempts over \d+(\.\d+)?s/);
380
+ expect((error as Error).message).toContain('The operation timed out.');
381
+ expect((error as { cause?: unknown }).cause).toBeInstanceOf(DOMException);
382
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
383
+ });
384
+ });
385
+
386
+ // ── download integrity ────────────────────────────────────────────────────────
387
+ //
388
+ // `cksum` is computed at publish time, shipped in every index entry, and was
389
+ // never checked on the consuming side. A short download was therefore accepted
390
+ // and blew up later in gunzip as "zlib: unexpected end of file", which blames
391
+ // the package rather than the transfer. Measured 2026-09-04 in the e2e rig.
392
+ // Retrying cannot help a fault it cannot detect, so verification is what makes
393
+ // the retry meaningful.
394
+
395
+ describe('RegistryClient.download integrity', () => {
396
+ let fetchSpy: ReturnType<typeof spyOn>;
397
+ const body = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
398
+ // sha256 of the 8 bytes above, in the `sha256:<hex>` shape publish() writes.
399
+ const goodCksum = `sha256:${createHash('sha256').update(body).digest('hex')}`;
400
+
401
+ beforeEach(() => {
402
+ fetchSpy = spyOn(globalThis, 'fetch');
403
+ });
404
+
405
+ afterEach(() => {
406
+ fetchSpy.mockRestore();
407
+ });
408
+
409
+ test('a truncated payload is rejected and retried, then succeeds', async () => {
410
+ fetchSpy
411
+ .mockResolvedValueOnce(new Response(body.slice(0, 3), { status: 200 })) // short
412
+ .mockResolvedValueOnce(new Response(body, { status: 200 }));
413
+
414
+ const client = new RegistryClient('https://reg.example.com');
415
+ const data = await client.download('iptables', '3.1.4', goodCksum);
416
+
417
+ expect(new Uint8Array(data)).toEqual(body);
418
+ expect(fetchSpy).toHaveBeenCalledTimes(2);
419
+ });
420
+
421
+ test('a payload that never verifies fails with an error naming the integrity check', async () => {
422
+ // A fresh Response per call: one shared instance would be consumed by the
423
+ // first attempt and the retry would report "Body already used", hiding the
424
+ // integrity failure this test exists to assert.
425
+ fetchSpy.mockImplementation(() => new Response(body.slice(0, 3), { status: 200 }));
426
+
427
+ const client = new RegistryClient('https://reg.example.com');
428
+ await expect(client.download('iptables', '3.1.4', goodCksum)).rejects.toThrow(
429
+ /integrity check/i,
430
+ );
431
+ expect(fetchSpy).toHaveBeenCalledTimes(3);
432
+ });
433
+
434
+ test('a correct payload verifies on the first attempt', async () => {
435
+ fetchSpy.mockResolvedValue(new Response(body, { status: 200 }));
436
+
437
+ const client = new RegistryClient('https://reg.example.com');
438
+ await client.download('iptables', '3.1.4', goodCksum);
439
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
440
+ });
441
+
442
+ test('no expected cksum still downloads — callers may not have one', async () => {
443
+ fetchSpy.mockResolvedValue(new Response(body, { status: 200 }));
444
+
445
+ const client = new RegistryClient('https://reg.example.com');
446
+ const data = await client.download('iptables', '3.1.4');
447
+ expect(new Uint8Array(data)).toEqual(body);
448
+ });
449
+
450
+ test('a non-digest cksum sentinel skips verification instead of failing', async () => {
451
+ // The registry's bootstrap path publishes `cksum: 'bootstrap'`
452
+ // (packages/registry-server/src/bootstrap.ts:114) because those modules are
453
+ // packaged on demand and have no stable digest. Treating a sentinel as a
454
+ // digest rejects a perfectly good 2.7MB package on every import.
455
+ fetchSpy.mockImplementation(() => new Response(body, { status: 200 }));
456
+
457
+ const client = new RegistryClient('https://reg.example.com');
458
+ const data = await client.download('iptables', '3.1.4', 'bootstrap');
459
+
460
+ expect(new Uint8Array(data)).toEqual(body);
461
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
462
+ });
463
+ });