@tikoci/quickchr 0.4.6 → 0.4.8

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Resource-name validation — machine names and named-socket names.
3
+ *
4
+ * Both become a path segment under the data dir (`machines/<name>/`,
5
+ * `networks/<name>.json`), so a name is not free text: it has to be safe to join
6
+ * onto a path and safe to type back into a command. A name starting with `-`
7
+ * also reads as a flag everywhere it is echoed (`quickchr remove --help`), which
8
+ * is how `networks sockets create --help` persisted a socket called `--help` (#156).
9
+ */
10
+
11
+ import { QuickCHRError } from "./types.ts";
12
+
13
+ /** Letters, digits, dot, underscore, hyphen — the charset already produced by
14
+ * `generateMachineName()` (e.g. `7.24.4-x86-1`), minus anything a shell or a
15
+ * path would reinterpret. */
16
+ const VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
17
+
18
+ const MAX_NAME_LENGTH = 64;
19
+
20
+ /** Throw `INVALID_NAME` unless `name` is safe as both a path segment and a CLI argument.
21
+ * `kind` names the resource in the message ("machine", "named socket"). */
22
+ export function assertValidResourceName(name: string, kind: string): void {
23
+ const reject = (why: string) => {
24
+ throw new QuickCHRError("INVALID_NAME", `Invalid ${kind} name "${name}" — ${why}`);
25
+ };
26
+
27
+ if (name.length === 0) reject("names cannot be empty");
28
+ if (name.startsWith("-")) reject(`names cannot start with "-" (it would be read as a flag)`);
29
+ if (name.length > MAX_NAME_LENGTH) reject(`names are limited to ${MAX_NAME_LENGTH} characters`);
30
+ if (!VALID_NAME.test(name)) {
31
+ reject("names may contain only letters, digits, dot, underscore and hyphen, and must start with a letter or digit");
32
+ }
33
+ }
34
+
35
+ /** The weaker rule that applies to a name being *used*, not created.
36
+ *
37
+ * `assertValidResourceName()` cannot guard a lookup or a delete: machines created
38
+ * under the older, looser rules have to stay addressable. But a name still becomes a
39
+ * path segment, and `join(machinesDir, name)` happily normalizes `..` into the data
40
+ * directory itself — so `removeOrphan("..")` would `rmSync` every machine, the cache
41
+ * and the socket registry. This is the minimum that stops that while passing every
42
+ * name a real machine can have. */
43
+ export function isPathSafeName(name: string): boolean {
44
+ if (name.length === 0) return false;
45
+ if (name === "." || name === "..") return false;
46
+ if (name.includes("/") || name.includes("\0")) return false;
47
+ // `\` is a path separator on Windows and an ordinary filename character on POSIX.
48
+ // Rejecting it everywhere would strand a machine named `lab\old` under the older,
49
+ // looser creation rules: `list` still shows it, so `remove` has to still clear it.
50
+ if (process.platform === "win32" && name.includes("\\")) return false;
51
+ return true;
52
+ }
53
+
54
+ /** Throwing form of {@link isPathSafeName}, for the paths that delete. */
55
+ export function assertPathSafeName(name: string, kind: string): void {
56
+ if (!isPathSafeName(name)) {
57
+ throw new QuickCHRError(
58
+ "INVALID_NAME",
59
+ `Invalid ${kind} name ${JSON.stringify(name)} — a name cannot be empty, "." or "..", or contain a path separator`,
60
+ );
61
+ }
62
+ }
63
+
64
+ /** Non-throwing form, for callers that want to branch rather than fail. */
65
+ export function isValidResourceName(name: string): boolean {
66
+ try {
67
+ assertValidResourceName(name, "resource");
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
package/src/lib/net.ts CHANGED
@@ -19,7 +19,9 @@
19
19
  * error), we retry by resolving the A record against a public DNS server
20
20
  * directly (~10 ms), bypassing the host's resolv.conf, and connecting to the
21
21
  * IPv4 literal with the `Host` header and TLS SNI preserved so certificate
22
- * validation still passes.
22
+ * validation still passes. If that attempt fails too, both failures are reported
23
+ * together as a `ResilientFetchError` — the fallback's own error names only an IP
24
+ * literal, which on its own reads as a host nobody configured.
23
25
  *
24
26
  * Only connection-class failures trigger the fallback; HTTP responses (incl.
25
27
  * 5xx) and aborts (`AbortError`, e.g. from `AbortSignal.timeout`) pass through
@@ -32,6 +34,49 @@ import { promises as dns } from "node:dns";
32
34
  const PUBLIC_DNS_SERVERS = ["1.1.1.1", "8.8.8.8", "1.0.0.1"];
33
35
  const DNS_TIMEOUT_MS = 3000;
34
36
 
37
+ /** `CODE: message` when the error carries a code, else just the message. */
38
+ function describeError(err: unknown): string {
39
+ if (!err || typeof err !== "object") return String(err);
40
+ const e = err as { name?: string; message?: string; code?: string; cause?: { code?: string } };
41
+ const code = e.code ?? e.cause?.code;
42
+ const label = code ?? e.name;
43
+ const message = e.message ?? String(err);
44
+ return label ? `${label}: ${message}` : message;
45
+ }
46
+
47
+ /**
48
+ * Both transports failed: the direct fetch with a connection-class error, and
49
+ * then the public-DNS/IPv4 fallback too.
50
+ *
51
+ * It exists because the fallback's own error is unreadable on its own — it names
52
+ * an IP literal nobody configured and no hostname, so a log cannot distinguish
53
+ * "the system resolver was broken" from "the host refused us" from "public DNS
54
+ * handed back a bad address" (#121). The message carries the URL, both failures,
55
+ * and which address the fallback tried; the direct failure is also the `cause`.
56
+ */
57
+ export class ResilientFetchError extends Error {
58
+ /** The hostname both transports were aiming at. */
59
+ readonly host: string;
60
+ /** The IPv4 literal the fallback connected to, from public DNS. */
61
+ readonly address: string;
62
+ /** The fallback transport's failure. The direct attempt's is `cause`. */
63
+ readonly fallbackError: unknown;
64
+
65
+ constructor(url: string, address: string, directError: unknown, fallbackError: unknown) {
66
+ const host = new URL(url).hostname;
67
+ super(
68
+ `Fetch failed for ${url} on both transports: ` +
69
+ `system resolver — ${describeError(directError)}; ` +
70
+ `public DNS (${PUBLIC_DNS_SERVERS.join(", ")}) → ${address} — ${describeError(fallbackError)}`,
71
+ { cause: directError },
72
+ );
73
+ this.name = "ResilientFetchError";
74
+ this.host = host;
75
+ this.address = address;
76
+ this.fallbackError = fallbackError;
77
+ }
78
+ }
79
+
35
80
  /**
36
81
  * True for the connection-class failures raised when a socket cannot be opened
37
82
  * (e.g. IPv4 blocked on an IPv6-only network, or Bun's `errno: 0`
@@ -39,9 +84,14 @@ const DNS_TIMEOUT_MS = 3000;
39
84
  * the standard connect errors. Excludes `AbortError` (aborts/`AbortSignal`
40
85
  * timeouts) and HTTP-level outcomes (those carry a Response and never throw
41
86
  * here). Used to decide whether to fall back from the normal fetch to the
42
- * public-DNS IPv4 attempt.
87
+ * public-DNS IPv4 attempt, and by callers deciding whether a failure is worth
88
+ * another attempt.
43
89
  */
44
90
  export function isConnectionFailure(err: unknown): boolean {
91
+ // Connection-class by construction: fetchResilient raises this only after a
92
+ // direct failure that already passed this test. Callers retrying on a
93
+ // connection failure must keep retrying once the fallback also fails.
94
+ if (err instanceof ResilientFetchError) return true;
45
95
  if (!err || typeof err !== "object") return false;
46
96
  const e = err as { name?: string; code?: string; errno?: number; cause?: { code?: string } };
47
97
  if (e.name === "AbortError") return false;
@@ -142,6 +192,12 @@ export async function fetchResilient(url: string, init?: BunFetchRequestInit): P
142
192
  const address = await resolveIpv4(new URL(url).hostname);
143
193
  // Public DNS can't help (unreachable / no answer) — surface the original failure.
144
194
  if (address === undefined) throw err;
145
- return fetchOverIpv4(url, address, init);
195
+ try {
196
+ return await fetchOverIpv4(url, address, init);
197
+ } catch (fallbackErr) {
198
+ // Never let the fallback's error stand alone: it names only an IP literal,
199
+ // which is the one thing the reader cannot map back to what was attempted.
200
+ throw new ResilientFetchError(url, address, err, fallbackErr);
201
+ }
146
202
  }
147
203
  }
@@ -20,7 +20,13 @@ import {
20
20
  SERVICE_PORTS,
21
21
  QuickCHRError,
22
22
  } from "./types.ts";
23
- import { getNamedSocket } from "./socket-registry.ts";
23
+ import {
24
+ getNamedSocket,
25
+ getSocketSlot,
26
+ socketEndpointPath,
27
+ type SocketEntry,
28
+ } from "./socket-registry.ts";
29
+ import { assertValidResourceName } from "./names.ts";
24
30
  import { resolveInterfaceAlias, isSocketVmnetDaemonRunning } from "./platform.ts";
25
31
 
26
32
  /** Allocate a port block for a new instance, avoiding conflicts with existing machines. */
@@ -329,6 +335,9 @@ function parseSocketSpecifier(input: string): NetworkSpecifier {
329
335
  "socket:: requires a link name (e.g. socket::mylink)",
330
336
  );
331
337
  }
338
+ // start() auto-creates a registry entry (and a networks/<name>.json file) for an
339
+ // unknown name, so the specifier is the other door into createNamedSocket().
340
+ assertValidResourceName(name, "named socket");
332
341
  return { type: "socket", name };
333
342
  }
334
343
 
@@ -388,6 +397,80 @@ export function resolveStartNetworks(
388
397
  return [{ specifier: "user", id: "net0" }];
389
398
  }
390
399
 
400
+ /** Derive a stable MAC for one NIC of one machine.
401
+ *
402
+ * QEMU's default is `52:54:00:12:34:56` incremented per NIC **within** a guest, so
403
+ * NIC index *N* holds the same address in *every* guest. Harmless while machines
404
+ * only have `user` NICs (separate SLIRP namespaces), fatal the moment two share an
405
+ * L2 segment — a named socket, a listen/connect pair, a TAP, or a RouterOS bridge
406
+ * on a hub VM. Forwarding then breaks in ways that look like a RouterOS or overlay
407
+ * defect rather than a launcher one (#154).
408
+ *
409
+ * The first octet is `0x02`: locally administered (bit 1 set) and unicast (bit 0
410
+ * clear), so these never collide with a vendor-assigned address. The remaining five
411
+ * octets come from a SHA-256 of the machine name, NIC index and `salt`, which makes
412
+ * the address reproducible for a given name instead of random per boot — the same
413
+ * lab rebuilt under the same names presents the same addresses.
414
+ */
415
+ export function deriveMac(name: string, index: number, salt = 0): string {
416
+ const digest = new Bun.CryptoHasher("sha256")
417
+ .update(`${name}\u0000${index}\u0000${salt}`)
418
+ .digest();
419
+ // Five octets after the fixed `02`, so a NIC's address is 48 bits total.
420
+ const octets = Array.from(digest.subarray(0, 5), (o) =>
421
+ o.toString(16).padStart(2, "0"),
422
+ );
423
+ return ["02", ...octets].join(":");
424
+ }
425
+
426
+ /** Assign a MAC to every NIC that lacks one, avoiding `taken`.
427
+ *
428
+ * Mutates and returns `networks`. Idempotent: a NIC that already has a MAC keeps it,
429
+ * so a machine's addresses are fixed at creation and survive restart, a re-resolve on
430
+ * a different platform, and any later change to {@link deriveMac} itself.
431
+ *
432
+ * **Call this only where a machine is created.** Changing a MAC on a machine that has
433
+ * already booted breaks it: RouterOS ties its persisted interface identity to the
434
+ * address, so a new one orphans the `ether1` holding the DHCP client and the guest
435
+ * comes up with no IP. Verified by A/B on a live CHR — see DESIGN.md, "NIC MAC
436
+ * Addresses". Machines created before #154 keep QEMU's defaults and must be recreated.
437
+ *
438
+ * A SHA-256 collision across five octets is vanishingly unlikely, but `taken` makes
439
+ * the guarantee structural rather than probabilistic — a duplicate here is the exact
440
+ * silent-L2 failure this exists to prevent, so it is worth not leaving to odds.
441
+ *
442
+ * `taken` is a snapshot, so two concurrent creations can each miss the other. That
443
+ * only changes an outcome when the two would otherwise have collided — i.e. on top
444
+ * of that 2^-40 event — but the window is real. It is the same read-then-write
445
+ * window the port allocator has on the adjacent line (`getUsedPortBases()` →
446
+ * `findAvailablePortBlock()` → `saveMachine()`), where it fires routinely and
447
+ * breaks starts outright: #140. Both allocators want one lock around
448
+ * read-allocate-persist; adding a second, MAC-only lock here would leave the
449
+ * frequent half of the race unfixed while implying it was handled.
450
+ */
451
+ export function assignMacs(
452
+ name: string,
453
+ networks: NetworkConfig[],
454
+ taken: ReadonlySet<string> = new Set(),
455
+ ): NetworkConfig[] {
456
+ const used = new Set(taken);
457
+ for (const n of networks) {
458
+ if (n.mac) used.add(n.mac);
459
+ }
460
+ networks.forEach((n, i) => {
461
+ if (n.mac) return;
462
+ let salt = 0;
463
+ let mac = deriveMac(name, i, salt);
464
+ while (used.has(mac)) {
465
+ salt++;
466
+ mac = deriveMac(name, i, salt);
467
+ }
468
+ used.add(mac);
469
+ n.mac = mac;
470
+ });
471
+ return networks;
472
+ }
473
+
391
474
  /** Returns true when at least one network uses QEMU user-mode (hostfwd) networking.
392
475
  * Only user-mode networks forward localhost ports — shared/bridged/tap/socket networks
393
476
  * assign a DHCP address that is not reachable from host localhost. */
@@ -400,10 +483,26 @@ export function hasUserModeNetwork(networks: NetworkConfig[]): boolean {
400
483
  export interface ResolutionContext {
401
484
  platform: PlatformInfo;
402
485
  socketVmnet?: SocketVmnetInfo;
486
+ /** Machine being resolved. A named socket's endpoint role is per-machine, so the
487
+ * resolver needs to know who is asking; absent for callers that only want the
488
+ * shape of a specifier (dry runs, unit tests over the non-socket modes). */
489
+ machine?: string;
490
+ /** QEMU version string, when the caller knows it. `-netdev dgram` landed in 7.2;
491
+ * an unknown version does not block, because refusing to run on a QEMU we merely
492
+ * failed to parse is worse than the error the spawn would give. */
493
+ qemuVersion?: string;
403
494
  }
404
495
 
405
- function deviceArgs(id: string): string[] {
406
- return ["-device", `virtio-net-pci,netdev=${id}`];
496
+ /** `-device` for one NIC — the **only** place a NIC device is emitted.
497
+ *
498
+ * Takes the whole {@link NetworkConfig} rather than an id so a caller physically
499
+ * cannot emit a NIC without its MAC. `qemu.ts`'s fallback path builds its own
500
+ * `-netdev` strings but shares this, so both paths carry the address; every guest
501
+ * would otherwise get QEMU's default `52:54:00:12:34:56` sequence and two machines
502
+ * bridged onto one L2 segment would collide (#154). */
503
+ export function deviceArgs(config: NetworkConfig): string[] {
504
+ const mac = config.mac ? `,mac=${config.mac}` : "";
505
+ return ["-device", `virtio-net-pci,netdev=${config.id}${mac}`];
407
506
  }
408
507
 
409
508
  function resolveUser(
@@ -416,7 +515,7 @@ function resolveUser(
416
515
  return {
417
516
  qemuNetdevArgs: [
418
517
  "-netdev", netdevValue,
419
- ...deviceArgs(config.id),
518
+ ...deviceArgs(config),
420
519
  ],
421
520
  };
422
521
  }
@@ -438,7 +537,7 @@ function resolveShared(
438
537
  return {
439
538
  qemuNetdevArgs: [
440
539
  "-netdev", `socket,id=${config.id},fd=3`,
441
- ...deviceArgs(config.id),
540
+ ...deviceArgs(config),
442
541
  ],
443
542
  wrapper: [svn.client, svn.sharedSocket],
444
543
  };
@@ -446,7 +545,7 @@ function resolveShared(
446
545
  return {
447
546
  qemuNetdevArgs: [
448
547
  "-netdev", `vmnet-shared,id=${config.id}`,
449
- ...deviceArgs(config.id),
548
+ ...deviceArgs(config),
450
549
  ],
451
550
  downgraded: {
452
551
  from: "shared (socket_vmnet)",
@@ -473,7 +572,7 @@ function resolveVmnetShared(
473
572
  return {
474
573
  qemuNetdevArgs: [
475
574
  "-netdev", `vmnet-shared,id=${config.id}`,
476
- ...deviceArgs(config.id),
575
+ ...deviceArgs(config),
477
576
  ],
478
577
  };
479
578
  }
@@ -498,7 +597,7 @@ function resolveBridged(
498
597
  return {
499
598
  qemuNetdevArgs: [
500
599
  "-netdev", `socket,id=${config.id},fd=3`,
501
- ...deviceArgs(config.id),
600
+ ...deviceArgs(config),
502
601
  ],
503
602
  wrapper: [svn.client, bridgeSocket],
504
603
  };
@@ -506,7 +605,7 @@ function resolveBridged(
506
605
  return {
507
606
  qemuNetdevArgs: [
508
607
  "-netdev", `vmnet-bridged,id=${config.id},ifname=${resolved}`,
509
- ...deviceArgs(config.id),
608
+ ...deviceArgs(config),
510
609
  ],
511
610
  downgraded: {
512
611
  from: `bridged:${iface} (socket_vmnet)`,
@@ -534,14 +633,45 @@ function resolveVmnetBridged(
534
633
  return {
535
634
  qemuNetdevArgs: [
536
635
  "-netdev", `vmnet-bridged,id=${config.id},ifname=${iface}`,
537
- ...deviceArgs(config.id),
636
+ ...deviceArgs(config),
538
637
  ],
539
638
  };
540
639
  }
541
640
 
641
+ /** One-line description of the transport a named socket resolved to, for the line
642
+ * `start` prints and for `list`/`info`. The acceptance bar for #158's visibility
643
+ * work is that nothing about a named socket requires opening a file under the data
644
+ * dir, and this is what makes that true. */
645
+ export function describeSocketTransport(entry: SocketEntry, slot?: number): string {
646
+ switch (entry.mode) {
647
+ case "mcast":
648
+ return `UDP multicast ${entry.mcastGroup}:${entry.port} (N-way)`;
649
+ case "listen-connect":
650
+ if (slot === undefined) return `TCP pair on 127.0.0.1:${entry.port}`;
651
+ return slot === 0
652
+ ? `TCP 127.0.0.1:${entry.port} (listening)`
653
+ : `TCP 127.0.0.1:${entry.port} (connecting)`;
654
+ case "dgram": {
655
+ // Unoccupied: name the pair once rather than printing the directory twice.
656
+ if (slot === undefined) {
657
+ return `unix datagram ${socketEndpointPath(entry.name, 0).replace(/\.0\.sock$/, ".{0,1}.sock")}`;
658
+ }
659
+ const peer = slot === 0 ? 1 : 0;
660
+ return `unix datagram ${socketEndpointPath(entry.name, slot)} -> ${socketEndpointPath(entry.name, peer)}`;
661
+ }
662
+ }
663
+ }
664
+
665
+ /** `-netdev dgram` was added in QEMU 7.2. */
666
+ export function qemuSupportsDgram(version: string): boolean {
667
+ const [major = 0, minor = 0] = version.split(".").map((p) => Number.parseInt(p, 10) || 0);
668
+ return major > 7 || (major === 7 && minor >= 2);
669
+ }
670
+
542
671
  function resolveSocketNamed(
543
672
  config: NetworkConfig,
544
673
  name: string,
674
+ ctx: ResolutionContext,
545
675
  ): ResolvedNetwork {
546
676
  const entry = getNamedSocket(name);
547
677
  if (!entry) {
@@ -554,18 +684,60 @@ function resolveSocketNamed(
554
684
  return {
555
685
  qemuNetdevArgs: [
556
686
  "-netdev", `socket,id=${config.id},mcast=${entry.mcastGroup}:${entry.port}`,
557
- ...deviceArgs(config.id),
687
+ ...deviceArgs(config),
688
+ ],
689
+ };
690
+ }
691
+
692
+ // Two-member modes carry a role. It comes from the persisted slot, claimed when the
693
+ // machine joined, so a listener that stops and restarts comes back as the listener
694
+ // instead of silently demoting itself to a second connector.
695
+ const slot = ctx.machine ? getSocketSlot(entry, ctx.machine) : undefined;
696
+ if (slot === undefined) {
697
+ const held = entry.endpoints?.filter((m): m is string => m !== null) ?? [];
698
+ const who = held.length > 0 ? held.join(" and ") : "no machine";
699
+ throw new QuickCHRError(
700
+ "NETWORK_UNAVAILABLE",
701
+ ctx.machine
702
+ ? `Machine "${ctx.machine}" holds no endpoint on named socket "${name}" — it is a ${entry.mode} link, and its two ends are held by ${who}.`
703
+ : `Named socket "${name}" is a ${entry.mode} link, so resolving it needs the machine's name (its endpoint role is per-machine). Pass 'machine' in the resolution context.`,
704
+ );
705
+ }
706
+
707
+ if (entry.mode === "dgram") {
708
+ // Windows first: it cannot have this transport at any QEMU version.
709
+ if (ctx.platform.os === "win32") {
710
+ throw new QuickCHRError(
711
+ "NETWORK_UNAVAILABLE",
712
+ `Named socket "${name}" is a unix-datagram link, which Windows cannot provide (AF_UNIX has no SOCK_DGRAM). ` +
713
+ `Recreate it with 'quickchr networks sockets create ${name} --mode listen-connect'.`,
714
+ );
715
+ }
716
+ if (ctx.qemuVersion && !qemuSupportsDgram(ctx.qemuVersion)) {
717
+ throw new QuickCHRError(
718
+ "NETWORK_UNAVAILABLE",
719
+ `Named socket "${name}" is a unix-datagram link, which needs QEMU 7.2 or newer (found ${ctx.qemuVersion}). ` +
720
+ `Upgrade QEMU, or recreate the socket with 'quickchr networks sockets create ${name} --mode listen-connect'.`,
721
+ );
722
+ }
723
+ const local = socketEndpointPath(name, slot);
724
+ const remote = socketEndpointPath(name, slot === 0 ? 1 : 0);
725
+ return {
726
+ qemuNetdevArgs: [
727
+ "-netdev",
728
+ `dgram,id=${config.id},local.type=unix,local.path=${local},remote.type=unix,remote.path=${remote}`,
729
+ ...deviceArgs(config),
558
730
  ],
559
731
  };
560
732
  }
561
- const isFirst = entry.members.length === 0;
562
- const netdevArg = isFirst
733
+
734
+ const netdevArg = slot === 0
563
735
  ? `socket,id=${config.id},listen=:${entry.port}`
564
736
  : `socket,id=${config.id},connect=127.0.0.1:${entry.port}`;
565
737
  return {
566
738
  qemuNetdevArgs: [
567
739
  "-netdev", netdevArg,
568
- ...deviceArgs(config.id),
740
+ ...deviceArgs(config),
569
741
  ],
570
742
  };
571
743
  }
@@ -577,7 +749,7 @@ function resolveSocketListen(
577
749
  return {
578
750
  qemuNetdevArgs: [
579
751
  "-netdev", `socket,id=${config.id},listen=:${port}`,
580
- ...deviceArgs(config.id),
752
+ ...deviceArgs(config),
581
753
  ],
582
754
  };
583
755
  }
@@ -589,7 +761,7 @@ function resolveSocketConnect(
589
761
  return {
590
762
  qemuNetdevArgs: [
591
763
  "-netdev", `socket,id=${config.id},connect=127.0.0.1:${port}`,
592
- ...deviceArgs(config.id),
764
+ ...deviceArgs(config),
593
765
  ],
594
766
  };
595
767
  }
@@ -602,7 +774,7 @@ function resolveSocketMcast(
602
774
  return {
603
775
  qemuNetdevArgs: [
604
776
  "-netdev", `socket,id=${config.id},mcast=${group}:${port}`,
605
- ...deviceArgs(config.id),
777
+ ...deviceArgs(config),
606
778
  ],
607
779
  };
608
780
  }
@@ -621,7 +793,7 @@ function resolveTap(
621
793
  return {
622
794
  qemuNetdevArgs: [
623
795
  "-netdev", `tap,id=${config.id},ifname=${ifname},script=no,downscript=no`,
624
- ...deviceArgs(config.id),
796
+ ...deviceArgs(config),
625
797
  ],
626
798
  };
627
799
  }
@@ -650,7 +822,7 @@ export function resolveNetworkConfig(
650
822
  resolved = resolveVmnetBridged(config, spec.iface, ctx);
651
823
  break;
652
824
  case "socket":
653
- resolved = resolveSocketNamed(config, spec.name);
825
+ resolved = resolveSocketNamed(config, spec.name, ctx);
654
826
  break;
655
827
  case "socket-listen":
656
828
  resolved = resolveSocketListen(config, spec.port);
@@ -2,18 +2,62 @@
2
2
  * Extra package download and installation for CHR instances.
3
3
  */
4
4
 
5
- import { existsSync, readdirSync } from "node:fs";
5
+ import { existsSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
- import type { Arch } from "./types.ts";
8
- import { QuickCHRError } from "./types.ts";
7
+ import { QuickCHRError, type Arch } from "./types.ts";
9
8
  import { packagesDownloadUrl } from "./versions.ts";
10
- import { fetchResilient } from "./net.ts";
9
+ import { downloadToFile } from "./download.ts";
11
10
  import { getCacheDir, ensureDir } from "./state.ts";
12
11
  import { createLogger, type ProgressLogger } from "./log.ts";
13
12
  import { restPost } from "./rest.ts";
14
13
  import { scpPush } from "./scp.ts";
15
14
  import { extractZip } from "./zip.ts";
16
15
 
16
+ const PACKAGE_CACHE_MANIFEST = ".quickchr-complete.json";
17
+
18
+ interface PackageCacheManifest {
19
+ version: string;
20
+ arch: Arch;
21
+ archiveSize: number;
22
+ packages: Array<{ name: string; size: number }>;
23
+ }
24
+
25
+ function packageFiles(extractDir: string): Array<{ name: string; size: number }> {
26
+ try {
27
+ return readdirSync(extractDir)
28
+ .filter((name) => name.endsWith(".npk"))
29
+ .map((name) => ({ name, size: statSync(join(extractDir, name)).size }))
30
+ .filter((entry) => entry.size > 0)
31
+ .sort((a, b) => a.name.localeCompare(b.name));
32
+ } catch {
33
+ return [];
34
+ }
35
+ }
36
+
37
+ /** Verify the archive and every package recorded after atomic extraction. */
38
+ export async function isCompletePackageCache(
39
+ version: string,
40
+ arch: Arch,
41
+ cacheDir?: string,
42
+ ): Promise<boolean> {
43
+ const cache = cacheDir ?? getCacheDir();
44
+ const zipPath = join(cache, `all_packages-${arch}-${version}.zip`);
45
+ const extractDir = join(cache, `packages-${arch}-${version}`);
46
+ try {
47
+ const archiveSize = statSync(zipPath).size;
48
+ if (archiveSize <= 0) return false;
49
+ const manifest = await Bun.file(join(extractDir, PACKAGE_CACHE_MANIFEST)).json() as PackageCacheManifest;
50
+ const packages = packageFiles(extractDir);
51
+ return manifest.version === version &&
52
+ manifest.arch === arch &&
53
+ manifest.archiveSize === archiveSize &&
54
+ packages.length > 0 &&
55
+ JSON.stringify(manifest.packages) === JSON.stringify(packages);
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
17
61
  /** Download and extract the all-packages ZIP for a version/arch. Returns the extract dir. */
18
62
  export async function downloadPackages(
19
63
  version: string,
@@ -29,30 +73,54 @@ export async function downloadPackages(
29
73
  const zipPath = join(cache, zipName);
30
74
  const extractDir = join(cache, `packages-${arch}-${version}`);
31
75
 
32
- if (existsSync(extractDir)) {
76
+ if (await isCompletePackageCache(version, arch, cache)) {
33
77
  const log = logger ?? createLogger();
34
78
  log.status(` Using cached packages: ${version} (${arch})`);
35
79
  return extractDir;
36
80
  }
37
81
 
38
- // Download if needed
82
+ // Download if needed. Same bounded path as the image download (#116) — this
83
+ // call site previously had neither a deadline nor retries, so its only bound
84
+ // was whatever the calling test happened to impose.
39
85
  if (!existsSync(zipPath)) {
40
86
  const log = logger ?? createLogger();
41
87
  log.status(`Downloading packages for ${version} (${arch})...`);
42
- const response = await fetchResilient(url);
43
- if (!response.ok) {
44
- throw new QuickCHRError(
45
- "DOWNLOAD_FAILED",
46
- `Failed to download packages: HTTP ${response.status} for ${url}`,
47
- );
88
+ log.status(` ${url}`);
89
+ await downloadToFile(url, zipPath, { logger: log });
90
+ }
91
+
92
+ // Extract into a sibling directory, record the exact complete set, then
93
+ // rename it into place. A killed extraction can never look like a cache hit.
94
+ const tempDir = mkdtempSync(join(cache, ".quickchr-packages-"));
95
+ try {
96
+ extractZip(zipPath, tempDir);
97
+ const packages = packageFiles(tempDir);
98
+ if (packages.length === 0) {
99
+ throw new Error(`No non-empty .npk files found in ${zipName}`);
48
100
  }
49
- const buf = await response.arrayBuffer();
50
- await Bun.write(zipPath, buf);
101
+ const manifest: PackageCacheManifest = {
102
+ version,
103
+ arch,
104
+ archiveSize: statSync(zipPath).size,
105
+ packages,
106
+ };
107
+ await Bun.write(join(tempDir, PACKAGE_CACHE_MANIFEST), `${JSON.stringify(manifest, null, "\t")}\n`);
108
+ if (existsSync(extractDir)) rmSync(extractDir, { recursive: true, force: true });
109
+ renameSync(tempDir, extractDir);
110
+ } catch (error) {
111
+ rmSync(tempDir, { recursive: true, force: true });
112
+ if (error instanceof Error && error.message.startsWith("No non-empty")) {
113
+ throw new QuickCHRError("PROCESS_FAILED", error.message);
114
+ }
115
+ throw error;
51
116
  }
52
117
 
53
- // Extract
54
- ensureDir(extractDir);
55
- extractZip(zipPath, extractDir);
118
+ if (!(await isCompletePackageCache(version, arch, cache))) {
119
+ throw new QuickCHRError(
120
+ "PROCESS_FAILED",
121
+ `Package cache verification failed for ${version} (${arch})`,
122
+ );
123
+ }
56
124
 
57
125
  return extractDir;
58
126
  }