@tikoci/quickchr 0.4.2 → 0.4.4

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.
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Post-boot provisioning — user creation, admin disable via REST API,
3
3
  * with a console transport fallback when REST is unavailable.
4
+ * cspell:ignore NUL
4
5
  */
5
6
 
7
+ import { createHash } from "node:crypto";
6
8
  import { mkdirSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { QuickCHRError } from "./types.ts";
9
+ import { dirname, join } from "node:path";
10
+ import { QuickCHRError, type ManagedSshKey } from "./types.ts";
9
11
  import { saveInstanceCredentials } from "./credentials.ts";
10
12
  import { generatePassword } from "./password.ts";
11
13
  import { waitForBoot } from "./qemu.ts";
@@ -19,6 +21,9 @@ export const QUICKCHR_USER = "quickchr";
19
21
  export interface ProvisionResult {
20
22
  /** The user that was created (or null if none). */
21
23
  user: { name: string; password: string } | null;
24
+ /** The managed SSH key installed for that user, if provisioning installed one
25
+ * (issue #74). Absent when no key was installed. */
26
+ managedSshKey?: ManagedSshKey;
22
27
  }
23
28
 
24
29
  async function readUser(httpPort: number, auth: string, name: string): Promise<Record<string, unknown> | undefined> {
@@ -261,35 +266,230 @@ async function consoleProvision(
261
266
  }
262
267
 
263
268
  /**
264
- * Generate an ed25519 SSH keypair and install the public key on the CHR for `username`.
269
+ * Console markers that mean RouterOS rejected a `/user/ssh-keys/add`. RouterOS
270
+ * reports rejection inline (no status code) — these are the parser/command error
271
+ * prefixes it emits. Kept deliberately specific (not bare "expected"/"invalid",
272
+ * which appear in benign output) so it doesn't false-positive. Exported so the
273
+ * ssh-keys lab runner classifies rejections identically instead of duplicating it.
274
+ */
275
+ export const SSH_KEY_REJECTION_PATTERN = /failure:|syntax error|no such item|bad command name|expected end of/i;
276
+
277
+ /** Managed-key algorithm quickchr generates. Grounded as accepted across the
278
+ * provisioning floor (7.20.8) and current stable in REPORT.md — issue #74. */
279
+ const MANAGED_SSH_KEY_ALGORITHM = "ed25519";
280
+ export const SSH_NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
281
+
282
+ /** OpenSSH's `-F <config>` needs a file it can actually open — Win32-OpenSSH's
283
+ * config-file loader rejects the null device itself ("Can't open user config
284
+ * file NUL: No such file or directory", exit 255; grounded on windows-latest
285
+ * OpenSSH_10.3p1 in issue #87), unlike POSIX where /dev/null opens fine as an
286
+ * empty config. A real empty file on disk is the config-suppression trick
287
+ * that works identically on every platform, so `-F` always gets one of these
288
+ * instead of {@link SSH_NULL_DEVICE}. */
289
+ export async function ensureEmptySshConfig(dir: string): Promise<string> {
290
+ const path = join(dir, "empty_ssh_config");
291
+ await Bun.write(path, "");
292
+ return path;
293
+ }
294
+
295
+ export type SshKeyListRow = {
296
+ user?: string;
297
+ info?: string;
298
+ "key-owner"?: string;
299
+ "key-type"?: string;
300
+ fingerprint?: string;
301
+ };
302
+
303
+ function sshKeyOwner(row: SshKeyListRow): string {
304
+ return row.info ?? row["key-owner"] ?? "";
305
+ }
306
+
307
+ export function opensshSha256Fingerprint(publicKey: string): string | undefined {
308
+ const keyBlob = publicKey.trim().split(/\s+/)[1];
309
+ if (!keyBlob) return undefined;
310
+ try {
311
+ const digest = createHash("sha256").update(Buffer.from(keyBlob, "base64")).digest("base64").replace(/=+$/, "");
312
+ return `SHA256:${digest}`;
313
+ } catch {
314
+ return undefined;
315
+ }
316
+ }
317
+
318
+ function normalizeSshFingerprint(fingerprint: string): string {
319
+ return fingerprint.replace(/=+$/, "");
320
+ }
321
+
322
+ export function matchesManagedSshKey(row: SshKeyListRow, username: string, keyComment: string, fingerprint?: string): boolean {
323
+ if (row.user !== username) return false;
324
+ if (sshKeyOwner(row) !== keyComment) return false;
325
+ if (row["key-type"] && row["key-type"] !== MANAGED_SSH_KEY_ALGORITHM) return false;
326
+ if (fingerprint && row.fingerprint && normalizeSshFingerprint(row.fingerprint) !== normalizeSshFingerprint(fingerprint)) return false;
327
+ return true;
328
+ }
329
+
330
+ const SSH_KEY_LIST_BUDGET_MS = 30_000;
331
+
332
+ export interface SshKeyListingVerification {
333
+ listed: boolean;
334
+ attempts: number;
335
+ elapsedMs: number;
336
+ lastDiagnostic: string;
337
+ }
338
+
339
+ /** Wait for RouterOS to expose the generated key through `/user/ssh-keys`.
340
+ * A request receives the full remaining convergence budget because the first
341
+ * read of this endpoint can itself take many seconds under TCG. */
342
+ export async function waitForManagedSshKeyListing(
343
+ httpPort: number,
344
+ auth: string,
345
+ username: string,
346
+ keyComment: string,
347
+ fingerprint: string,
348
+ timeoutMs = SSH_KEY_LIST_BUDGET_MS,
349
+ ): Promise<SshKeyListingVerification> {
350
+ const startedAt = Date.now();
351
+ const deadline = startedAt + timeoutMs;
352
+ let attempts = 0;
353
+ let lastDiagnostic = "no REST attempt completed";
354
+
355
+ while (Date.now() < deadline) {
356
+ attempts++;
357
+ const remainingMs = Math.max(1, deadline - Date.now());
358
+ try {
359
+ const { status, body } = await restGet(
360
+ `http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
361
+ auth,
362
+ remainingMs,
363
+ );
364
+ if (status >= 200 && status < 300) {
365
+ let keys: SshKeyListRow[] | undefined;
366
+ try {
367
+ keys = JSON.parse(body) as SshKeyListRow[];
368
+ } catch (parseErr) {
369
+ lastDiagnostic = `HTTP ${status}: non-JSON body (${String(parseErr)}): ${body}`;
370
+ }
371
+ if (Array.isArray(keys)) {
372
+ if (keys.some((key) => matchesManagedSshKey(key, username, keyComment, fingerprint))) {
373
+ return { listed: true, attempts, elapsedMs: Date.now() - startedAt, lastDiagnostic: body };
374
+ }
375
+ lastDiagnostic = `HTTP ${status}: no matching key in ${keys.length} row(s)`;
376
+ } else if (keys !== undefined) {
377
+ lastDiagnostic = `HTTP ${status}: expected an array, received ${body}`;
378
+ }
379
+ } else {
380
+ lastDiagnostic = `HTTP ${status}: ${body}`;
381
+ }
382
+ } catch (e) {
383
+ lastDiagnostic = String(e);
384
+ }
385
+
386
+ const sleepMs = Math.min(500, Math.max(0, deadline - Date.now()));
387
+ if (sleepMs > 0) await Bun.sleep(sleepMs);
388
+ }
389
+
390
+ return { listed: false, attempts, elapsedMs: Date.now() - startedAt, lastDiagnostic };
391
+ }
392
+
393
+ /** Cap on the diagnostic text surfaced from a failed batch-login probe — enough
394
+ * to see the ssh client's actual error, not so much a noisy client fills logs. */
395
+ const BATCH_LOGIN_DIAGNOSTIC_LIMIT = 2000;
396
+
397
+ interface BatchLoginResult {
398
+ verified: boolean;
399
+ /** ssh's combined stdout+stderr (or the spawn error), truncated. Always
400
+ * populated so a failure is diagnosable instead of collapsing to a bool
401
+ * (issue #87 — this was the missing piece on the first Windows CI run). */
402
+ diagnostic: string;
403
+ }
404
+
405
+ /** Attempt a real host-OpenSSH batch login with the managed private key — the
406
+ * exact mode (`BatchMode=yes`, `PasswordAuthentication=no`, `IdentitiesOnly=yes`,
407
+ * ignoring ssh_config) that centrs and the #71 descriptor need. Verified only
408
+ * on a clean passwordless login. Best-effort: never throws, and is killed after
409
+ * 15s rather than hanging provisioning. */
410
+ async function verifyBatchLogin(sshPort: number, username: string, privateKeyPath: string): Promise<BatchLoginResult> {
411
+ // No SSH port forwarded (or not passed in) → can't prove batch auth from the host.
412
+ if (!sshPort || sshPort <= 0) return { verified: false, diagnostic: "no SSH port forwarded" };
413
+ try {
414
+ const emptyConfig = await ensureEmptySshConfig(dirname(privateKeyPath));
415
+ const proc = Bun.spawn(
416
+ [
417
+ "ssh",
418
+ "-F", emptyConfig,
419
+ "-o", "StrictHostKeyChecking=no",
420
+ "-o", `UserKnownHostsFile=${SSH_NULL_DEVICE}`,
421
+ "-o", "PasswordAuthentication=no",
422
+ "-o", "IdentitiesOnly=yes",
423
+ "-o", "BatchMode=yes",
424
+ "-o", "ConnectTimeout=10",
425
+ "-i", privateKeyPath,
426
+ `${username}@127.0.0.1`, "-p", String(sshPort),
427
+ ':put "quickchr-ssh-batch-ok"',
428
+ ],
429
+ { stdout: "pipe", stderr: "pipe", stdin: "ignore" },
430
+ );
431
+ const timer = setTimeout(() => { try { proc.kill(); } catch { /* already gone */ } }, 15_000);
432
+ try {
433
+ const [out, err] = await Promise.all([
434
+ new Response(proc.stdout).text(),
435
+ new Response(proc.stderr).text(),
436
+ ]);
437
+ const exitCode = await proc.exited;
438
+ const combined = (out + err).trim();
439
+ const verified = combined.includes("quickchr-ssh-batch-ok");
440
+ const diagnostic = verified
441
+ ? "ok"
442
+ : `exit ${exitCode}: ${(combined || "<no output>").slice(0, BATCH_LOGIN_DIAGNOSTIC_LIMIT)}`;
443
+ return { verified, diagnostic };
444
+ } finally {
445
+ clearTimeout(timer);
446
+ }
447
+ } catch (e) {
448
+ return { verified: false, diagnostic: `ssh spawn failed: ${String(e)}` };
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Generate an ed25519 SSH keypair and install the public key on the CHR for `username`,
454
+ * then verify it works for a host-OpenSSH batch login.
265
455
  * Keys are stored in `<machineDir>/ssh/id_ed25519` (private) and `.pub` (public).
266
456
  * This enables SSH transport without passwords for the quickchr managed user.
457
+ *
458
+ * Returns the {@link ManagedSshKey} fact (key path, algorithm, and whether a real
459
+ * `BatchMode=yes` login succeeded) so callers can persist it for the #71 descriptor.
267
460
  */
268
461
  export async function installSshKey(
269
462
  httpPort: number,
463
+ sshPort: number,
270
464
  username: string,
271
465
  machineName: string,
272
466
  machineDir: string,
273
467
  portBase?: number,
274
- ): Promise<void> {
468
+ logger?: ProgressLogger,
469
+ ): Promise<ManagedSshKey> {
275
470
  const sshDir = join(machineDir, "ssh");
276
471
  mkdirSync(sshDir, { recursive: true });
277
472
 
278
473
  const privateKeyPath = join(sshDir, "id_ed25519");
474
+ const keyComment = `quickchr@${machineName}`;
279
475
 
280
476
  const keygen = Bun.spawnSync(
281
- ["ssh-keygen", "-t", "ed25519", "-f", privateKeyPath, "-N", "", "-C", `quickchr@${machineName}`],
477
+ ["ssh-keygen", "-t", MANAGED_SSH_KEY_ALGORITHM, "-f", privateKeyPath, "-N", "", "-C", keyComment],
282
478
  { stdout: "pipe", stderr: "pipe" },
283
479
  );
284
480
  if (keygen.exitCode !== 0) {
285
- throw new Error(`ssh-keygen failed: ${new TextDecoder().decode(keygen.stderr)}`);
481
+ throw new QuickCHRError("SPAWN_FAILED", `ssh-keygen failed: ${new TextDecoder().decode(keygen.stderr)}`);
286
482
  }
287
483
 
288
484
  const pubKey = (await Bun.file(`${privateKeyPath}.pub`).text()).trim();
485
+ const expectedFingerprint = opensshSha256Fingerprint(pubKey);
486
+ if (!expectedFingerprint) {
487
+ throw new QuickCHRError("PROCESS_FAILED", "Could not compute fingerprint for quickchr-generated SSH public key");
488
+ }
289
489
 
290
490
  // Install via serial console — commits synchronously unlike the REST endpoint
291
491
  // which may return 200 OK before the key is durable in RouterOS storage.
292
- await consoleExec(
492
+ const { output: addOutput } = await consoleExec(
293
493
  machineDir,
294
494
  `/user/ssh-keys/add user="${username}" key="${pubKey}"`,
295
495
  "admin",
@@ -298,30 +498,50 @@ export async function installSshKey(
298
498
  portBase,
299
499
  );
300
500
 
301
- // Verify the key appears in the REST listing.
302
- const auth = `Basic ${btoa("admin:")}`;
303
- const deadline = Date.now() + 10_000;
304
- let lastListBody = "";
305
- while (Date.now() < deadline) {
306
- try {
307
- const { status, body: listBody } = await restGet(
308
- `http://127.0.0.1:${httpPort}/rest/user/ssh-keys`,
309
- auth,
310
- 5_000,
311
- );
312
- if (status >= 200 && status < 300) {
313
- lastListBody = listBody;
314
- const keys = JSON.parse(listBody) as Array<{ user?: string }>;
315
- if (Array.isArray(keys) && keys.some((k) => k.user === username)) return;
316
- } else {
317
- lastListBody = `HTTP ${status}`;
318
- }
319
- } catch (e) {
320
- lastListBody = String(e);
321
- }
322
- await Bun.sleep(500);
501
+ // RouterOS reports key rejection (e.g. an unsupported algorithm on the running
502
+ // version, or a malformed key) inline on the console rather than by any status
503
+ // code. Surface it immediately so the failure is diagnosable, instead of letting
504
+ // it masquerade as the generic "did not appear in REST listing" timeout below.
505
+ if (SSH_KEY_REJECTION_PATTERN.test(addOutput)) {
506
+ throw new QuickCHRError("EXEC_FAILED", `RouterOS rejected the SSH key for ${username}: ${addOutput.trim()}`);
323
507
  }
324
- throw new Error(`SSH key for ${username} installed via console but did not appear in REST listing within 10s (last: ${lastListBody})`);
508
+
509
+ // Verify the generated key appears in the REST listing. Match the public-key
510
+ // comment (`key-owner` on older 7.x, `info` on newer 7.x) rather than just the
511
+ // user, so an older key for the same user cannot satisfy this check.
512
+ const listResult = await waitForManagedSshKeyListing(
513
+ httpPort,
514
+ `Basic ${btoa("admin:")}`,
515
+ username,
516
+ keyComment,
517
+ expectedFingerprint,
518
+ );
519
+ if (!listResult.listed) {
520
+ throw new QuickCHRError(
521
+ "PROCESS_FAILED",
522
+ `SSH key for ${username} installed via console but did not appear in REST listing within 30s ` +
523
+ `after ${listResult.attempts} attempt(s) and ${listResult.elapsedMs}ms ` +
524
+ `(console output: ${addOutput.trim() || "<empty>"}; last REST attempt: ${listResult.lastDiagnostic})`,
525
+ );
526
+ }
527
+ if (listResult.attempts > 1 || listResult.elapsedMs > 5_000) {
528
+ logger?.status(` SSH key listing verified after ${listResult.attempts} attempt(s) in ${listResult.elapsedMs}ms`);
529
+ }
530
+
531
+ // Presence in the listing is necessary but not sufficient — prove the key
532
+ // actually authenticates a host-OpenSSH batch client. Non-fatal: an unverified
533
+ // key is still installed, we just record batchVerified=false so the #71
534
+ // descriptor won't advertise batch key auth it can't stand behind.
535
+ const { verified: batchVerified, diagnostic: batchDiagnostic } = await verifyBatchLogin(sshPort, username, privateKeyPath);
536
+ if (!batchVerified) {
537
+ logger?.status(` SSH batch login not verified: ${batchDiagnostic}`);
538
+ }
539
+ return {
540
+ privateKeyPath,
541
+ algorithm: MANAGED_SSH_KEY_ALGORITHM,
542
+ batchVerified,
543
+ verifiedAt: batchVerified ? new Date().toISOString() : undefined,
544
+ };
325
545
  }
326
546
 
327
547
  /** Run all provisioning steps based on the config.
@@ -345,6 +565,7 @@ export async function provision(
345
565
  logger?: ProgressLogger,
346
566
  machineDir?: string,
347
567
  portBase?: number,
568
+ sshPort?: number,
348
569
  ): Promise<ProvisionResult> {
349
570
  const log = logger ?? createLogger();
350
571
 
@@ -364,20 +585,18 @@ export async function provision(
364
585
  // Try REST provisioning. When machineDir is provided, use a shorter timeout
365
586
  // so the console fallback can engage sooner if REST isn't available.
366
587
  const restTimeout = machineDir ? 30_000 : 60_000;
367
- let usedConsolePath = false;
368
588
  try {
369
589
  await waitForRest(httpPort, restTimeout);
370
590
  } catch (e) {
371
591
  if (machineDir) {
372
592
  log.warn("REST API unavailable — falling back to serial console provisioning");
373
593
  const result = await consoleProvision(machineDir, machineName, effectiveUser, !!shouldDisableAdmin, logger, portBase);
374
- usedConsolePath = true;
375
594
  // After console provisioning the machine is booted; REST comes up shortly.
376
595
  // Attempt SSH key install for the managed quickchr user — non-fatal if REST is still unavailable.
377
596
  if (secureLogin && !user && effectiveUser) {
378
597
  try {
379
598
  await waitForRest(httpPort, 30_000);
380
- await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
599
+ result.managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
381
600
  } catch (keyErr) {
382
601
  log.warn(`SSH key install failed after console provisioning (SSH transport will fall back to password): ${keyErr}`);
383
602
  }
@@ -387,36 +606,37 @@ export async function provision(
387
606
  throw e;
388
607
  }
389
608
 
390
- if (!usedConsolePath) {
391
- if (effectiveUser) {
392
- await createUser(httpPort, effectiveUser.name, effectiveUser.password);
393
- // Persist to secret store so resolveAuth() picks it up
394
- saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
395
- }
609
+ // REST provisioning path. The console fallback above always returns early,
610
+ // so reaching here means REST came up and we provision over it.
611
+ if (effectiveUser) {
612
+ await createUser(httpPort, effectiveUser.name, effectiveUser.password);
613
+ // Persist to secret store so resolveAuth() picks it up
614
+ saveInstanceCredentials(machineName, effectiveUser.name, effectiveUser.password);
615
+ }
396
616
 
397
- if (secureLogin && !user && effectiveUser && machineDir) {
398
- // Brief pause so RouterOS propagates the new user to all subsystems
399
- // (including the SSH key store) before we attempt key installation.
400
- await Bun.sleep(1000);
401
- try {
402
- await installSshKey(httpPort, effectiveUser.name, machineName, machineDir, portBase);
403
- } catch (e) {
404
- log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
405
- }
617
+ let managedSshKey: ManagedSshKey | undefined;
618
+ if (secureLogin && !user && effectiveUser && machineDir) {
619
+ // Brief pause so RouterOS propagates the new user to all subsystems
620
+ // (including the SSH key store) before we attempt key installation.
621
+ await Bun.sleep(1000);
622
+ try {
623
+ managedSshKey = await installSshKey(httpPort, sshPort ?? 0, effectiveUser.name, machineName, machineDir, portBase, log);
624
+ } catch (e) {
625
+ log.warn(`SSH key install failed (SSH transport will fall back to password): ${e}`);
406
626
  }
627
+ }
407
628
 
408
- if (shouldDisableAdmin) {
409
- if (!effectiveUser) {
410
- log.warn("Warning: disabling admin without creating another user — you may lose access");
411
- }
412
- // Pass the new user's auth for verification — after disabling admin,
413
- // admin creds may stop working for REST queries.
414
- const verifyAuth = effectiveUser
415
- ? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
416
- : undefined;
417
- await disableAdmin(httpPort, verifyAuth);
629
+ if (shouldDisableAdmin) {
630
+ if (!effectiveUser) {
631
+ log.warn("Warning: disabling admin without creating another user — you may lose access");
418
632
  }
633
+ // Pass the new user's auth for verification — after disabling admin,
634
+ // admin creds may stop working for REST queries.
635
+ const verifyAuth = effectiveUser
636
+ ? `Basic ${btoa(`${effectiveUser.name}:${effectiveUser.password}`)}`
637
+ : undefined;
638
+ await disableAdmin(httpPort, verifyAuth);
419
639
  }
420
640
 
421
- return { user: effectiveUser };
641
+ return { user: effectiveUser, managedSshKey };
422
642
  }
package/src/lib/qemu.ts CHANGED
@@ -9,7 +9,7 @@ import { existsSync, writeFileSync, readFileSync, copyFileSync, statSync, openSy
9
9
  import { join } from "node:path";
10
10
  import type { Arch, BootDiskFormat, MachineState, NetworkMode, NetworkConfig, PortMapping } from "./types.ts";
11
11
  import { QuickCHRError } from "./types.ts";
12
- import { detectAccel, requireQemu, requireFirmware } from "./platform.ts";
12
+ import { detectAccel, requireQemu, requireFirmware, requireQemuImg } from "./platform.ts";
13
13
  import { buildHostfwdString } from "./network.ts";
14
14
  import { ensureDir } from "./state.ts";
15
15
  import { restGet } from "./rest.ts";
@@ -80,16 +80,19 @@ export async function buildQemuArgs(config: QemuLaunchConfig): Promise<string[]>
80
80
  // UEFI firmware (arm64 only)
81
81
  if (arch === "arm64") {
82
82
  const fw = requireFirmware();
83
- const varsPath = join(machineDir, "efi-vars.fd");
84
-
85
- // Copy and size-match the vars file if not already done
83
+ // The per-machine EFI vars pflash is qcow2, NOT raw: QEMU refuses
84
+ // savevm/loadvm while any writable block device is non-qcow2 ("Device
85
+ // 'pflash1' is writable but does not support snapshots"), which made
86
+ // snapshots impossible on arm64 (#31). Legacy raw efi-vars.fd machines
87
+ // are converted in place, preserving their NVRAM state.
88
+ const varsPath = join(machineDir, "efi-vars.qcow2");
86
89
  if (!existsSync(varsPath)) {
87
- prepareEfiVars(fw.code, fw.vars, varsPath);
90
+ await prepareEfiVars(fw.code, fw.vars, varsPath, join(machineDir, "efi-vars.fd"));
88
91
  }
89
92
 
90
93
  args.push(
91
94
  "-drive", `if=pflash,format=raw,readonly=on,unit=0,file=${fw.code}`,
92
- "-drive", `if=pflash,format=raw,unit=1,file=${varsPath}`,
95
+ "-drive", `if=pflash,format=qcow2,unit=1,file=${varsPath}`,
93
96
  );
94
97
  }
95
98
 
@@ -248,26 +251,53 @@ function buildChannelArgs(
248
251
  }
249
252
  }
250
253
 
251
- /** Copy and size-match EFI vars file to match code ROM size. */
252
- function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string): void {
254
+ /** Build the per-machine qcow2 EFI vars pflash: start from the legacy raw
255
+ * vars file if the machine has one (preserves NVRAM state), else from the
256
+ * firmware template; size-match to the code ROM; convert raw → qcow2 so
257
+ * savevm/loadvm work (see the pflash comment at the call site / #31). */
258
+ async function prepareEfiVars(codePath: string, varsTemplatePath: string, destPath: string, legacyRawPath: string): Promise<void> {
253
259
  ensureDir(join(destPath, ".."));
254
- copyFileSync(varsTemplatePath, destPath);
255
260
 
256
- // pflash units must be identical size — pad/truncate vars to match code
261
+ // Stage the raw image (legacy per-machine file wins over the template).
262
+ const rawStage = `${destPath}.raw-stage`;
263
+ copyFileSync(existsSync(legacyRawPath) ? legacyRawPath : varsTemplatePath, rawStage);
264
+
265
+ // pflash units must be identical size — pad/truncate vars to match code.
266
+ // truncateSync extends with zero bytes when target is larger.
257
267
  const codeSize = statSync(codePath).size;
258
- const varsSize = statSync(destPath).size;
268
+ try {
269
+ if (statSync(rawStage).size !== codeSize) truncateSync(rawStage, codeSize);
270
+ } catch (e) {
271
+ try { unlinkSync(rawStage); } catch { /* best effort */ }
272
+ throw new QuickCHRError(
273
+ "PROCESS_FAILED",
274
+ `Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
275
+ );
276
+ }
259
277
 
260
- if (varsSize !== codeSize) {
261
- // Pad or truncate vars to match code ROM size — truncateSync extends with
262
- // zero bytes when target is larger, and truncates when smaller.
263
- try {
264
- truncateSync(destPath, codeSize);
265
- } catch (e) {
266
- throw new QuickCHRError(
267
- "PROCESS_FAILED",
268
- `Failed to size-match EFI vars: ${e instanceof Error ? e.message : String(e)}`,
269
- );
270
- }
278
+ const qemuImg = requireQemuImg();
279
+ const conversion = Bun.spawn([qemuImg, "convert", "-f", "raw", "-O", "qcow2", rawStage, destPath], {
280
+ stdout: "pipe",
281
+ stderr: "pipe",
282
+ });
283
+ const exitCode = await conversion.exited;
284
+ const [stderr, stdout] = await Promise.all([
285
+ new Response(conversion.stderr).text(),
286
+ new Response(conversion.stdout).text(),
287
+ ]);
288
+ try { unlinkSync(rawStage); } catch { /* best effort */ }
289
+ if (exitCode !== 0) {
290
+ // A failed convert can leave a partial destPath behind — remove it so the
291
+ // next launch regenerates instead of booting (and re-failing on) the stub.
292
+ try { unlinkSync(destPath); } catch { /* best effort */ }
293
+ throw new QuickCHRError(
294
+ "PROCESS_FAILED",
295
+ `qemu-img convert of EFI vars failed: ${stderr.trim() || stdout.trim() || `exit ${exitCode}`}`,
296
+ );
297
+ }
298
+ // Legacy raw file is superseded — remove so nothing boots the stale copy.
299
+ if (existsSync(legacyRawPath)) {
300
+ try { unlinkSync(legacyRawPath); } catch { /* best effort */ }
271
301
  }
272
302
  }
273
303