@tikoci/quickchr 0.4.7 → 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.
package/src/lib/state.ts CHANGED
@@ -7,6 +7,7 @@ import { join, } from "node:path";
7
7
  import type { MachineState, } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
9
9
  import { networkModeToConfigs } from "./network.ts";
10
+ import { assertPathSafeName, isPathSafeName } from "./names.ts";
10
11
 
11
12
  /** Get the quickchr data directory root. Override with QUICKCHR_DATA_DIR env var. */
12
13
  export function getDataDir(): string {
@@ -91,12 +92,72 @@ export function saveMachine(state: MachineState): void {
91
92
  writeFileSync(machineJsonPath(state.name), JSON.stringify(state, null, "\t") + "\n");
92
93
  }
93
94
 
94
- /** Load machine state from disk. Returns undefined if not found. */
95
+ /** The fields every caller dereferences without guarding, with the `typeof` each must
96
+ * answer. Deliberately not a schema: it is the set that turns a bad file into a crash
97
+ * somewhere else, which is the thing being prevented. `cpu`/`mem` are absent because a
98
+ * missing one prints oddly rather than throwing, and a narrow check cannot wrongly
99
+ * condemn a machine that works today. `networks` is absent because `loadMachine()`
100
+ * migrates it just below.
101
+ *
102
+ * Every field listed here has been required on `MachineState` since the commit that
103
+ * introduced it (5cfe052), so no `machine.json` any version of quickchr wrote can be
104
+ * condemned by this check — which is the thing that would make it worse than the bug
105
+ * it fixes. */
106
+ const REQUIRED_STATE_FIELDS: ReadonlyArray<[keyof MachineState, "string" | "number" | "object"]> = [
107
+ ["name", "string"],
108
+ ["version", "string"],
109
+ ["arch", "string"],
110
+ ["status", "string"],
111
+ ["machineDir", "string"],
112
+ ["portBase", "number"],
113
+ ["ports", "object"],
114
+ ["packages", "object"],
115
+ ];
116
+
117
+ /** Why `parsed` is not usable as a `MachineState`, or `undefined` when it is.
118
+ *
119
+ * Parsing successfully is not the same as being state. `{"name":"lab"}` parses, and
120
+ * `JSON.parse` is happy with `null` and `[]` too; casting any of them to `MachineState`
121
+ * only moves the failure — `{"name":"lab"}` reached `formatPorts(m.ports)` and aborted
122
+ * `quickchr list` exactly as an unparseable file used to (#165). */
123
+ function stateShapeProblem(parsed: unknown): string | undefined {
124
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
125
+ return "not a JSON object";
126
+ }
127
+ const record = parsed as Record<string, unknown>;
128
+ const bad = REQUIRED_STATE_FIELDS
129
+ .filter(([key, kind]) => typeof record[key] !== kind || record[key] === null)
130
+ .map(([key]) => key);
131
+ return bad.length > 0 ? `missing or invalid: ${bad.join(", ")}` : undefined;
132
+ }
133
+
134
+ /** Load machine state from disk. Returns undefined if not found.
135
+ *
136
+ * Throws `STATE_ERROR` when the file is there but unusable. A targeted lookup by name
137
+ * has to fail: the caller named *this* machine, and answering `undefined` would report
138
+ * a machine that exists on disk as one that never did. `loadAllMachines()` is the
139
+ * enumeration and takes the other policy — see `tryLoadMachine()` (#165). */
95
140
  export function loadMachine(name: string): MachineState | undefined {
96
141
  const path = machineJsonPath(name);
97
142
  if (!existsSync(path)) return undefined;
98
- const data = readFileSync(path, "utf-8");
99
- const state = JSON.parse(data) as MachineState;
143
+ let state: MachineState;
144
+ try {
145
+ state = JSON.parse(readFileSync(path, "utf-8")) as MachineState;
146
+ } catch (e) {
147
+ throw new QuickCHRError(
148
+ "STATE_ERROR",
149
+ `Machine "${name}" has an unreadable machine.json (${e instanceof Error ? e.message : String(e)})`,
150
+ `Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
151
+ );
152
+ }
153
+ const shapeProblem = stateShapeProblem(state);
154
+ if (shapeProblem) {
155
+ throw new QuickCHRError(
156
+ "STATE_ERROR",
157
+ `Machine "${name}" has an unreadable machine.json (${shapeProblem})`,
158
+ `Inspect ${path}, or clear the machine with 'quickchr remove ${name}'`,
159
+ );
160
+ }
100
161
  // Migrate legacy `network` field → `networks` array
101
162
  if (!state.networks && (state as unknown as Record<string, unknown>).network) {
102
163
  const legacy = (state as unknown as Record<string, unknown>).network as MachineState["networks"][0]["specifier"] | "user" | "vmnet-shared" | { type: "vmnet-bridge"; iface: string };
@@ -107,7 +168,33 @@ export function loadMachine(name: string): MachineState | undefined {
107
168
  return state;
108
169
  }
109
170
 
110
- /** Load all machine states. */
171
+ /** The three things a machine directory can turn out to be.
172
+ *
173
+ * `loadMachine()` collapses `missing` and `unreadable` into "undefined or throw", which
174
+ * is the right contract for a lookup and the wrong one for an enumeration. This keeps
175
+ * them apart so each caller can pick (#165). Internal for now: promoting it to public
176
+ * API is a rename if #58 lands. */
177
+ type MachineLoad =
178
+ | { status: "ok"; state: MachineState }
179
+ | { status: "missing" }
180
+ | { status: "unreadable"; error: string };
181
+
182
+ /** `loadMachine()` without the throw. */
183
+ function tryLoadMachine(name: string): MachineLoad {
184
+ try {
185
+ const state = loadMachine(name);
186
+ return state ? { status: "ok", state } : { status: "missing" };
187
+ } catch (e) {
188
+ return { status: "unreadable", error: e instanceof Error ? e.message : String(e) };
189
+ }
190
+ }
191
+
192
+ /** Load all machine states, skipping directories with no usable `machine.json`.
193
+ *
194
+ * An enumeration is best-effort across many: one corrupt file used to abort the whole
195
+ * listing, which loses the state of every healthy machine as well. Nothing is hidden by
196
+ * the skip — `listUnreadableMachines()` names what was left out, and `quickchr list`
197
+ * shows it as a row (#165). */
111
198
  export function loadAllMachines(): MachineState[] {
112
199
  const dir = getMachinesDir();
113
200
  if (!existsSync(dir)) return [];
@@ -117,13 +204,27 @@ export function loadAllMachines(): MachineState[] {
117
204
 
118
205
  for (const entry of entries) {
119
206
  if (!entry.isDirectory()) continue;
120
- const state = loadMachine(entry.name);
121
- if (state) machines.push(state);
207
+ const loaded = tryLoadMachine(entry.name);
208
+ if (loaded.status === "ok") machines.push(loaded.state);
122
209
  }
123
210
 
124
211
  return machines;
125
212
  }
126
213
 
214
+ /** Every machine directory whose `machine.json` exists but cannot be read.
215
+ *
216
+ * Distinct from `listOrphanMachineDirs()`, which also counts a directory with no
217
+ * `machine.json` at all — a create that never finished. This one is a machine that did
218
+ * exist and whose state went bad, so it still holds a disk image worth naming. */
219
+ export function listUnreadableMachines(): Array<{ name: string; error: string }> {
220
+ const out: Array<{ name: string; error: string }> = [];
221
+ for (const name of listMachineNames()) {
222
+ const loaded = tryLoadMachine(name);
223
+ if (loaded.status === "unreadable") out.push({ name, error: loaded.error });
224
+ }
225
+ return out;
226
+ }
227
+
127
228
  /** Get all existing machine names. */
128
229
  export function listMachineNames(): string[] {
129
230
  const dir = getMachinesDir();
@@ -133,13 +234,53 @@ export function listMachineNames(): string[] {
133
234
  .map((e) => e.name);
134
235
  }
135
236
 
237
+ /** True when `name` has a machine directory but no readable `machine.json` — a create
238
+ * that did not finish. `list` cannot show it and `start` cannot boot it, but
239
+ * `listMachineNames()` (directory-based) still counts it, so it blocks re-add (#155). */
240
+ export function isOrphanMachineDir(name: string): boolean {
241
+ // A traversal name resolves to a directory that exists and holds no machine.json —
242
+ // the data dir itself, for ".." — so the path check has to come first or the
243
+ // predicate answers "orphan" about something that is not a machine directory.
244
+ if (!isPathSafeName(name)) return false;
245
+ const dir = getMachineDir(name);
246
+ if (!existsSync(dir)) return false;
247
+ return !isReadableMachine(name);
248
+ }
249
+
250
+ /** Every machine directory with no readable `machine.json`. */
251
+ export function listOrphanMachineDirs(): string[] {
252
+ return listMachineNames().filter((name) => !isReadableMachine(name));
253
+ }
254
+
255
+ /** A truncated or corrupt `machine.json` is as unusable as a missing one, and both make
256
+ * the directory an orphan. */
257
+ function isReadableMachine(name: string): boolean {
258
+ return tryLoadMachine(name).status === "ok";
259
+ }
260
+
136
261
  /** Get all port bases currently in use by existing machines. */
137
262
  export function getUsedPortBases(): number[] {
138
263
  return loadAllMachines().map((m) => m.portBase);
139
264
  }
140
265
 
266
+ /** Every NIC MAC currently assigned across all machines.
267
+ * Passed to `assignMacs()` so a new machine cannot be handed an address another
268
+ * machine already presents on a shared L2 segment (#154). */
269
+ export function getUsedMacs(): Set<string> {
270
+ const macs = new Set<string>();
271
+ for (const m of loadAllMachines()) {
272
+ for (const n of m.networks ?? []) {
273
+ if (n.mac) macs.add(n.mac);
274
+ }
275
+ }
276
+ return macs;
277
+ }
278
+
141
279
  /** Delete a machine and all its files. */
142
280
  export function removeMachine(name: string): void {
281
+ // This is an `rmSync(..., { recursive: true })`; the name must never be able to
282
+ // point outside machines/<name>/.
283
+ assertPathSafeName(name, "machine");
143
284
  const dir = getMachineDir(name);
144
285
  if (!existsSync(dir)) {
145
286
  throw new QuickCHRError("MACHINE_NOT_FOUND", `Machine "${name}" not found`);
package/src/lib/types.ts CHANGED
@@ -55,13 +55,14 @@ export type NetworkMode =
55
55
  * - **Host needs to see guest L2 frames** (MNDP, MAC-Telnet, raw Ethernet,
56
56
  * broadcasts) → `{type:"socket-connect", port}` against a host TCP server
57
57
  * (rootless, cross-platform; recipe in `docs/mndp.md`).
58
- * - **L2 link between two VMs** → `{type:"socket", name}` (named pair) or
59
- * `socket-listen`/`socket-connect`/`socket-mcast` (low-level).
58
+ * - **L2 link between two VMs** → `{type:"socket", name}`, a named link whose
59
+ * transport (`dgram`, `listen-connect`, `mcast`) is fixed when the name is created;
60
+ * or `socket-listen`/`socket-connect`/`socket-mcast` for a raw netdev.
60
61
  * - **Real LAN / DHCP-from-host presence** → `"shared"` or `{type:"bridged", iface}`
61
62
  * (rootless via socket_vmnet on macOS / pre-created TAP on Linux).
62
63
  *
63
64
  * Note: receiving guest-*originated* UDP (e.g. a guest replying to `10.0.2.2`)
64
- * needs no extra NIC and no forward — see {@link ChrInstance.tzspGatewayIp}.
65
+ * needs no extra NIC and no forward — see {@link ChrInstance.hostGatewayIp}.
65
66
  */
66
67
  export type NetworkSpecifier =
67
68
  /** SLIRP user-mode NAT + `hostfwd` port mapping. Management default; the only
@@ -106,6 +107,12 @@ export interface NetworkConfig {
106
107
  specifier: NetworkSpecifier;
107
108
  /** QEMU netdev id (net0, net1, ...). */
108
109
  id: string;
110
+ /** Locally-administered MAC, assigned at creation and persisted so it survives
111
+ * restart. Without it QEMU hands every guest the same default sequence and two
112
+ * machines on one L2 segment collide — see `deriveMac` in `network.ts` (#154).
113
+ * Never reassigned: changing it on a booted machine orphans RouterOS's interface
114
+ * identity. Absent on machines created before #154, which keep QEMU's defaults. */
115
+ mac?: string;
109
116
  /** Resolved QEMU args (populated at launch time, not persisted). */
110
117
  resolved?: ResolvedNetwork;
111
118
  }
@@ -170,6 +177,11 @@ export const SERVICE_NAMES = Object.keys(SERVICE_PORTS) as ServiceName[];
170
177
  export const PORTS_PER_BLOCK = 10;
171
178
  export const DEFAULT_PORT_BASE = 9100;
172
179
 
180
+ /** QEMU user-mode (SLIRP) gateway — the host's address as seen from inside a guest.
181
+ * Fixed by QEMU's built-in 10.0.2.0/24 user network; surfaced on a running instance
182
+ * as {@link ChrInstance.hostGatewayIp}. */
183
+ export const HOST_GATEWAY_IP = "10.0.2.2";
184
+
173
185
  /**
174
186
  * TCP endpoint for QEMU IPC channels on Windows.
175
187
  * Used instead of named pipes when QEMU's Winsock bind() can't handle \\.\pipe\ paths.
@@ -341,7 +353,7 @@ export interface StartOptions {
341
353
  * {@link lookupGuestPort} resolve guest port + proto for common service names.
342
354
  *
343
355
  * For *receiving* guest-originated UDP (no forward needed), see
344
- * {@link ChrInstance.tzspGatewayIp} and `docs/networking-recipes.md`.
356
+ * {@link ChrInstance.hostGatewayIp} and `docs/networking-recipes.md`.
345
357
  * @example
346
358
  * extraPorts: [{ name: "snmp", host: 9161, guest: 161, proto: "udp" }]
347
359
  * @example
@@ -591,8 +603,16 @@ export interface ChrInstance {
591
603
  * `docs/networking-recipes.md`, `examples/udp-gateway/`.
592
604
  * @example
593
605
  * // RouterOS TZSP sniffer streaming to the host:
594
- * await instance.exec(`/tool/sniffer/set streaming-server=${instance.tzspGatewayIp}:37008`);
606
+ * await instance.exec(`/tool/sniffer/set streaming-server=${instance.hostGatewayIp}:37008`);
595
607
  */
608
+ hostGatewayIp: string;
609
+ /** @deprecated Renamed to {@link ChrInstance.hostGatewayIp} — the value was never
610
+ * TZSP-specific, it is the host as seen from inside the guest and carries any
611
+ * guest→host UDP (remote syslog, NetFlow, a plain socket). The old name led
612
+ * readers to assume a sniffer-only primitive.
613
+ *
614
+ * Still populated with the identical value, and will be removed no earlier than
615
+ * the next minor. Migration is a rename at the call site — nothing else changes. */
596
616
  tzspGatewayIp: string;
597
617
 
598
618
  /** Check or wait for REST API readiness.
@@ -805,6 +825,10 @@ export type ErrorCode =
805
825
  | "INVALID_VERSION"
806
826
  | "INVALID_ARCH"
807
827
  | "INVALID_NAME"
828
+ // A flag or argument the CLI cannot honour as written — a removed flag name, or a
829
+ // value it will not accept. Distinct from INVALID_SETTING_VALUE, which is about a
830
+ // stored setting rather than what was typed.
831
+ | "INVALID_ARGUMENT"
808
832
  | "INVALID_DISK_SIZE"
809
833
  | "MACHINE_LOCKED"
810
834
  | "EXEC_FAILED"