@tikoci/quickchr 0.4.7 → 0.5.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.
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Cross-process file locks: the per-machine lifecycle lock and the data-dir-wide
3
+ * allocation lock.
4
+ *
5
+ * One primitive for both. A lock is a directory holding an append-only log of
6
+ * numbered records, and the **highest number is the lock's state**: a live holder's
7
+ * record means held; a released marker, a dead holder's record, or no record at all
8
+ * means free. Taking the lock is creating record top+1, published with `link()` so only
9
+ * one contender can create it and it never exists half-written. Releasing appends a
10
+ * released marker. Superseded records are collected by the holder while it holds.
11
+ *
12
+ * No recovery deletes a record another process may hold. A path has no
13
+ * compare-and-delete, so deleting a dead holder's lock by path leaves a window between
14
+ * judging it dead and deleting it, and a successor installed in that window is
15
+ * deleted too. Rename-aside-and-compare had that window, and so did a reap guard
16
+ * recovered by age (Codex review of #190, both reproduced). Here, recovering from a
17
+ * dead holder is just claiming the next number.
18
+ *
19
+ * **A claim is checked against the exact record it judged free**, not just that
20
+ * record's number. Every record carries a unique token, so once created, a claim
21
+ * stands only if nothing higher exists and its predecessor is still byte-for-byte
22
+ * what was judged. That turns away a contender that slept through other claims, or
23
+ * through the log being deleted and started again. A number can recur; a record
24
+ * cannot.
25
+ *
26
+ * **A machine's lock lives and dies with `machines/<name>/`**, so nothing is kept
27
+ * after a machine is gone. Only a holder deletes a lock directory: `remove()` takes
28
+ * the machine's directory with its lock inside, and a failed create discards what it
29
+ * made.
30
+ *
31
+ * A directory can be removed and a same-named one created while some process is
32
+ * paused holding paths into the old lock. So **every record is named for the lock's
33
+ * incarnation**, `<id>.<gen>`, with the id published once per directory. A stale
34
+ * process's paths cannot reach the new incarnation's records, and a claim re-checks
35
+ * that its id is still current before it stands. On top of that, a holder does all its
36
+ * destructive cleanup before it publishes its released marker, because from that
37
+ * moment it is no longer the holder.
38
+ *
39
+ * A holder records `{ pid, op, at, token }`. The pid decides liveness, the op and age
40
+ * go into `MACHINE_LOCKED` so the refusal can say what is holding the machine, and a
41
+ * creation carries its port/MAC reservation (#140).
42
+ *
43
+ * The lock is deliberately not reentrant. Two async operations in one process are as
44
+ * much in conflict as two processes are — a `stop()` and a `start()` in one
45
+ * `Promise.all` must be ordered — so a lock held by this pid is a lock held by someone
46
+ * else. Code that already holds a machine's lock calls the `*UnderLock` lifecycle
47
+ * helpers in quickchr.ts rather than the public methods.
48
+ */
49
+
50
+ import { linkSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
51
+ import { dirname, join } from "node:path";
52
+ import { QuickCHRError } from "./types.ts";
53
+ import { getDataDir, getMachinesDir } from "./state.ts";
54
+
55
+ /** What a lock holder is doing. `foreground` is a start whose QEMU runs attached to
56
+ * the caller's terminal: it holds the lock until the VM exits, so waiting for it is
57
+ * never useful. */
58
+ export type LockOperation =
59
+ | "add"
60
+ | "start"
61
+ | "foreground"
62
+ | "stop"
63
+ | "shutdown"
64
+ | "clean"
65
+ | "remove"
66
+ | "device-mode"
67
+ | "remove-orphan"
68
+ | "allocate";
69
+
70
+ /** Resources a machine creation has claimed but not yet persisted in `machine.json`.
71
+ * Carried in the creator's machine lock, so it lives exactly as long as the create
72
+ * does and dies with a crashed creator's pid (#140). */
73
+ export interface ResourceReservation {
74
+ portBase: number;
75
+ macs: string[];
76
+ }
77
+
78
+ export interface LockRecord {
79
+ pid: number;
80
+ op: LockOperation;
81
+ at: string;
82
+ token: string;
83
+ reserve?: ResourceReservation;
84
+ /** A released marker: the holder before it let go. */
85
+ released?: true;
86
+ }
87
+
88
+ export interface HeldLock {
89
+ readonly path: string;
90
+ /** Append a released marker after this holder's record. Idempotent, and a no-op
91
+ * once the holder has deleted the lock's directory. */
92
+ release(): void;
93
+ }
94
+
95
+ export interface AcquireLockOptions {
96
+ /** How long to wait for a live holder. 0 (the default) fails immediately. */
97
+ waitMs?: number;
98
+ /** A pre-0.5 `.start-lock` to honour alongside this lock. */
99
+ legacyPath?: string;
100
+ /** Called once, when the first attempt finds a live holder and waiting begins. */
101
+ onWait?: (holder: LockRecord) => void;
102
+ reserve?: ResourceReservation;
103
+ /** Noun for the refusal message, e.g. `Machine "foo"`. */
104
+ subject?: string;
105
+ }
106
+
107
+ /** The pre-0.5 lock file, in the machine directory. For one release a live holder is
108
+ * honoured, so a machine locked by an older quickchr is not taken from under it. A
109
+ * dead one is ignored, not deleted: the older quickchr recovers its own stale file. */
110
+ const LEGACY_MACHINE_LOCK_FILE = ".start-lock";
111
+ const POLL_MS = 100;
112
+ /** A record that does not parse only counts as abandoned once it is this old. An older
113
+ * quickchr creates `.start-lock` empty and fills it in with a second syscall. */
114
+ const UNREADABLE_GRACE_MS = 500;
115
+
116
+ /** A machine's lock, inside its directory so it goes when the machine does. */
117
+ export function machineLockPath(machineDir: string): string {
118
+ return join(machineDir, ".lock");
119
+ }
120
+
121
+ /** Where an older quickchr locks a machine. */
122
+ export function legacyMachineLockPath(machineDir: string): string {
123
+ return join(machineDir, LEGACY_MACHINE_LOCK_FILE);
124
+ }
125
+
126
+ /** Held across the read → probe → claim window of a machine creation, so two creates
127
+ * cannot pick the same port block or MAC (#140). Lives in the data dir root, not in
128
+ * `machines/`, where it would be listed as a machine. One fixed directory: its log
129
+ * collects down to two records. */
130
+ export function allocationLockPath(): string {
131
+ return join(getDataDir(), ".allocate.lock");
132
+ }
133
+
134
+ /** Test seam: called after each change this module makes inside a lock directory, so
135
+ * a test can interleave another contender at exactly that point. */
136
+ let mutationObserver: ((op: "link" | "unlink", path: string) => void) | undefined;
137
+ export function _observeLockMutations(fn: typeof mutationObserver): void {
138
+ mutationObserver = fn;
139
+ }
140
+
141
+ function parseRecord(text: string): LockRecord | undefined {
142
+ const trimmed = text.trim();
143
+ // A legacy `.start-lock` holds a bare pid.
144
+ if (/^\d+$/.test(trimmed)) {
145
+ return { pid: Number.parseInt(trimmed, 10), op: "start", at: "", token: "" };
146
+ }
147
+ try {
148
+ const parsed = JSON.parse(trimmed) as Partial<LockRecord>;
149
+ if (typeof parsed.pid === "number" && parsed.pid > 0 && typeof parsed.op === "string") {
150
+ return parsed as LockRecord;
151
+ }
152
+ } catch { /* fall through */ }
153
+ return undefined;
154
+ }
155
+
156
+ type Inspection =
157
+ | { state: "absent" }
158
+ | { state: "live"; record: LockRecord }
159
+ | { state: "free"; raw: string }
160
+ | { state: "unknown" };
161
+
162
+ function isAlive(pid: number): boolean {
163
+ try {
164
+ process.kill(pid, 0);
165
+ return true;
166
+ } catch (e) {
167
+ // EPERM: the process exists but belongs to someone else.
168
+ return (e as NodeJS.ErrnoException).code === "EPERM";
169
+ }
170
+ }
171
+
172
+ function readRaw(path: string): string | undefined {
173
+ try {
174
+ return readFileSync(path, "utf-8");
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ /** One record file. `unknown` is a file whose holder cannot be checked: unreadable
181
+ * (EACCES from another user's lock, EISDIR, I/O), or unparseable and new enough that
182
+ * its writer may not have finished. */
183
+ function inspect(path: string): Inspection {
184
+ let raw: string;
185
+ try {
186
+ raw = readFileSync(path, "utf-8");
187
+ } catch (e) {
188
+ return (e as NodeJS.ErrnoException).code === "ENOENT" ? { state: "absent" } : { state: "unknown" };
189
+ }
190
+ const record = parseRecord(raw);
191
+ if (!record) {
192
+ let ageMs = Number.POSITIVE_INFINITY;
193
+ try { ageMs = Date.now() - statSync(path).mtimeMs; } catch { /* gone */ }
194
+ return ageMs > UNREADABLE_GRACE_MS ? { state: "free", raw } : { state: "unknown" };
195
+ }
196
+ if (record.released) return { state: "free", raw };
197
+ return isAlive(record.pid) ? { state: "live", record } : { state: "free", raw };
198
+ }
199
+
200
+ function missing(lockDir: string): QuickCHRError {
201
+ return new QuickCHRError("MACHINE_NOT_FOUND", `${dirname(lockDir)} no longer exists — the machine was removed.`);
202
+ }
203
+
204
+ /** The record numbers of one incarnation of a lock. A lock whose parent directory has
205
+ * gone belongs to a machine that was removed. */
206
+ function generations(lockDir: string, id: string): number[] {
207
+ const prefix = `${id}.`;
208
+ try {
209
+ return readdirSync(lockDir)
210
+ .filter((f) => f.startsWith(prefix) && /^\d+$/.test(f.slice(prefix.length)))
211
+ .map((f) => Number(f.slice(prefix.length)));
212
+ } catch (e) {
213
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") throw missing(lockDir);
214
+ throw e;
215
+ }
216
+ }
217
+
218
+ function recordPath(lockDir: string, id: string, gen: number): string {
219
+ return join(lockDir, `${id}.${gen}`);
220
+ }
221
+
222
+ /** The lock directory's incarnation id, published once when the directory is first
223
+ * used, or undefined if it has none yet and `create` is false.
224
+ *
225
+ * Every record is named `<id>.<gen>`. A machine directory can be removed and created
226
+ * again under the same name, and a process paused across that still holds paths into
227
+ * the old lock. With the id in every name, those paths cannot reach the new lock's
228
+ * records: a collect, a rejected-claim cleanup or a released marker from a stale
229
+ * process touches only names nobody reads any more (Codex review of #190). */
230
+ function incarnation(lockDir: string, create: boolean): string | undefined {
231
+ const idPath = join(lockDir, "id");
232
+ const read = () => readRaw(idPath)?.trim() || undefined;
233
+ const existing = read();
234
+ if (existing || !create) return existing;
235
+ linkNew(idPath, crypto.randomUUID());
236
+ const id = read();
237
+ if (!id) throw missing(lockDir);
238
+ return id;
239
+ }
240
+
241
+ /** The lock directory, created if its parent exists. Never the parent: a waiter must
242
+ * not bring a removed machine's directory back as an empty orphan. */
243
+ function ensureLockDir(lockDir: string): void {
244
+ try {
245
+ mkdirSync(lockDir);
246
+ } catch (e) {
247
+ const code = (e as NodeJS.ErrnoException).code;
248
+ if (code === "ENOENT") throw missing(lockDir);
249
+ if (code !== "EEXIST") throw e;
250
+ }
251
+ }
252
+
253
+ /** Publish `content` at `path` only if nothing is there. Written to a temp file and
254
+ * `link()`ed into place, so the file never exists half-written. */
255
+ function linkNew(path: string, content: string): boolean {
256
+ const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
257
+ try {
258
+ writeFileSync(tmp, content);
259
+ } catch (e) {
260
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") throw missing(dirname(path));
261
+ throw e;
262
+ }
263
+ try {
264
+ linkSync(tmp, path);
265
+ mutationObserver?.("link", path);
266
+ return true;
267
+ } catch (e) {
268
+ const code = (e as NodeJS.ErrnoException).code;
269
+ if (code === "EEXIST") return false;
270
+ if (code === "ENOENT") throw missing(dirname(path));
271
+ throw e;
272
+ } finally {
273
+ try { unlinkSync(tmp); } catch { /* ignore */ }
274
+ }
275
+ }
276
+
277
+ /** Delete superseded records of one incarnation below `keepFrom`. Only a holder calls
278
+ * this, and only while it holds the lock: never the top, and never in a replacement
279
+ * directory, whose records carry another id. */
280
+ function collect(lockDir: string, id: string, keepFrom: number): void {
281
+ let gens: number[];
282
+ try { gens = generations(lockDir, id); } catch { return; }
283
+ for (const gen of gens) {
284
+ if (gen >= keepFrom) continue;
285
+ const path = recordPath(lockDir, id, gen);
286
+ try {
287
+ unlinkSync(path);
288
+ mutationObserver?.("unlink", path);
289
+ } catch { /* another collector got it */ }
290
+ }
291
+ }
292
+
293
+ type Top = { gen: number; seen: Inspection } | undefined;
294
+
295
+ /** The top record of an incarnation, or `"vanished"` when it went between listing and
296
+ * reading — a holder collected it — and the caller should look again. */
297
+ function readTop(lockDir: string, id: string): Top | "vanished" {
298
+ const gens = generations(lockDir, id);
299
+ if (gens.length === 0) return undefined;
300
+ const gen = Math.max(...gens);
301
+ const seen = inspect(recordPath(lockDir, id, gen));
302
+ return seen.state === "absent" ? "vanished" : { gen, seen };
303
+ }
304
+
305
+ /** Try to take incarnation `id` of the lock as record `gen`, having judged record
306
+ * `gen - 1` free with contents `judgedRaw` (or the incarnation empty, for `gen` 1 with
307
+ * no `judgedRaw`). Returns the lock, or undefined when another contender got there
308
+ * first or the judgement turned out to be stale.
309
+ *
310
+ * The claim stands only if, once created, it is the top, it follows exactly the
311
+ * record judged, and `id` is still the directory's incarnation. When `id` is still
312
+ * current after the first two checks, this record is the lock's live top, so nobody
313
+ * else holds it and nobody can remove the directory in between. */
314
+ export function claim(
315
+ lockDir: string,
316
+ id: string,
317
+ gen: number,
318
+ op: LockOperation,
319
+ judgedRaw?: string,
320
+ reserve?: ResourceReservation,
321
+ ): HeldLock | undefined {
322
+ const record: LockRecord = {
323
+ pid: process.pid,
324
+ op,
325
+ at: new Date().toISOString(),
326
+ token: crypto.randomUUID(),
327
+ ...(reserve ? { reserve } : {}),
328
+ };
329
+ const raw = `${JSON.stringify(record)}\n`;
330
+ const path = recordPath(lockDir, id, gen);
331
+ if (!linkNew(path, raw)) return undefined;
332
+
333
+ const gens = generations(lockDir, id);
334
+ const isTop = !gens.some((g) => g > gen);
335
+ const followsJudged = judgedRaw === undefined
336
+ ? gen === 1 && gens.length === 1
337
+ : readRaw(recordPath(lockDir, id, gen - 1)) === judgedRaw;
338
+ if (!isTop || !followsJudged || incarnation(lockDir, false) !== id) {
339
+ // Our own name, in our incarnation: if the directory has been replaced since,
340
+ // this name means nothing in the new one.
341
+ try { unlinkSync(path); } catch { /* collected already, or the directory went */ }
342
+ mutationObserver?.("unlink", path);
343
+ return undefined;
344
+ }
345
+ collect(lockDir, id, gen);
346
+ return heldLock(lockDir, id, gen, record, raw);
347
+ }
348
+
349
+ function heldLock(lockDir: string, id: string, gen: number, record: LockRecord, raw: string): HeldLock {
350
+ let released = false;
351
+ return {
352
+ path: lockDir,
353
+ release() {
354
+ if (released) return;
355
+ released = true;
356
+ // Gone means this holder deleted the directory — remove(), or a failed create
357
+ // discarding what it made.
358
+ if (readRaw(recordPath(lockDir, id, gen)) !== raw) return;
359
+ // Everything destructive happens first, while this is still the holder. Once
360
+ // the marker is published another process can take the lock, remove the
361
+ // machine and let a same-named one start over, so nothing may be deleted by
362
+ // path after that point.
363
+ collect(lockDir, id, gen);
364
+ const marker: LockRecord = { ...record, released: true, at: new Date().toISOString() };
365
+ delete marker.reserve;
366
+ // EEXIST (a false return) means someone judged this holder dead and took over:
367
+ // nothing of ours is left to release. A missing directory means the same. Any
368
+ // other failure (ENOSPC, EACCES) means the marker was not written and the lock
369
+ // is still held, so it is thrown, and a later release() may try again.
370
+ try {
371
+ linkNew(recordPath(lockDir, id, gen + 1), `${JSON.stringify(marker)}\n`);
372
+ } catch (e) {
373
+ if (e instanceof QuickCHRError && e.code === "MACHINE_NOT_FOUND") return;
374
+ released = false;
375
+ throw e;
376
+ }
377
+ },
378
+ };
379
+ }
380
+
381
+ function describeHolder(holder: LockRecord | undefined): string {
382
+ if (!holder) return "another operation (its lock record is unreadable or still being written)";
383
+ const age = holder.at ? Math.max(0, Math.round((Date.now() - Date.parse(holder.at)) / 1000)) : undefined;
384
+ return `\`${holder.op}\` (pid ${holder.pid}${age !== undefined && !Number.isNaN(age) ? `) since ${age}s` : ")"}`;
385
+ }
386
+
387
+ function lockedError(subject: string, holder: LockRecord | undefined, waitedMs: number): QuickCHRError {
388
+ const waited = waitedMs > 0 ? ` after waiting ${Math.round(waitedMs / 1000)}s` : "";
389
+ const hint = holder?.op === "foreground"
390
+ ? " It is running in the foreground: quit it from that console (Ctrl-A X)."
391
+ : "";
392
+ return new QuickCHRError("MACHINE_LOCKED", `${subject} is locked by ${describeHolder(holder)}${waited}.${hint}`);
393
+ }
394
+
395
+ /** Whether a legacy `.start-lock` stands in the way, and its holder when that can be
396
+ * read. One that is unreadable, or so new its holder may still be writing it, blocks:
397
+ * going ahead in that window would run alongside an older quickchr. */
398
+ function legacyBlocker(legacyPath: string | undefined): { holder: LockRecord | undefined } | undefined {
399
+ if (!legacyPath) return undefined;
400
+ const seen = inspect(legacyPath);
401
+ if (seen.state === "live") return { holder: seen.record };
402
+ if (seen.state === "unknown") return { holder: undefined };
403
+ return undefined;
404
+ }
405
+
406
+ /** One attempt: the lock, or the holder in the way (undefined when its record cannot
407
+ * be read). */
408
+ function attempt(lockDir: string, op: LockOperation, opts: AcquireLockOptions): { lock: HeldLock } | { holder: LockRecord | undefined } {
409
+ ensureLockDir(lockDir);
410
+ for (;;) {
411
+ const id = incarnation(lockDir, true) as string;
412
+ const top = readTop(lockDir, id);
413
+ if (top === "vanished") continue;
414
+ if (top?.seen.state === "live") return { holder: top.seen.record };
415
+ if (top?.seen.state === "unknown") return { holder: undefined };
416
+ const judged = top?.seen.state === "free" ? top.seen.raw : undefined;
417
+ const lock = claim(lockDir, id, (top?.gen ?? 0) + 1, op, judged, opts.reserve);
418
+ if (!lock) continue;
419
+ const legacy = legacyBlocker(opts.legacyPath);
420
+ if (!legacy) return { lock };
421
+ lock.release();
422
+ return legacy;
423
+ }
424
+ }
425
+
426
+ /** Take a lock or fail now with `MACHINE_LOCKED`. A dead holder's lock is taken over
427
+ * on sight. Synchronous, for callers whose answer to a live holder is always "no" — a
428
+ * second `start` of one machine would only boot it twice. Throws `MACHINE_NOT_FOUND`
429
+ * if the lock's machine directory does not exist. */
430
+ export function acquireLockNow(lockPath: string, op: LockOperation, opts: Omit<AcquireLockOptions, "waitMs" | "onWait"> = {}): HeldLock {
431
+ const result = attempt(lockPath, op, opts);
432
+ if ("lock" in result) return result.lock;
433
+ throw lockedError(opts.subject ?? "Machine", result.holder, 0);
434
+ }
435
+
436
+ /** Take a lock, waiting up to `waitMs` for a live holder to release it. A dead holder's
437
+ * lock is taken over on sight; a `foreground` holder is never waited for, since it
438
+ * keeps the lock until its VM exits.
439
+ *
440
+ * Throws `MACHINE_LOCKED` naming the holder's operation, pid and age when the wait
441
+ * runs out, and `MACHINE_NOT_FOUND` if the machine is removed while this caller
442
+ * waits. */
443
+ export async function acquireLock(lockPath: string, op: LockOperation, opts: AcquireLockOptions = {}): Promise<HeldLock> {
444
+ const waitMs = opts.waitMs ?? 0;
445
+ const started = Date.now();
446
+ let notified = false;
447
+ for (;;) {
448
+ const result = attempt(lockPath, op, opts);
449
+ if ("lock" in result) return result.lock;
450
+ const { holder } = result;
451
+ const waited = Date.now() - started;
452
+ if (waited >= waitMs || holder?.op === "foreground") {
453
+ throw lockedError(opts.subject ?? "Machine", holder, waited);
454
+ }
455
+ if (!notified && holder) {
456
+ notified = true;
457
+ opts.onWait?.(holder);
458
+ }
459
+ await Bun.sleep(Math.min(POLL_MS, waitMs - waited));
460
+ }
461
+ }
462
+
463
+ /** The live holder of a lock, if any. */
464
+ export function currentHolder(lockPath: string): LockRecord | undefined {
465
+ for (;;) {
466
+ let top: Top | "vanished";
467
+ try {
468
+ const id = incarnation(lockPath, false);
469
+ if (!id) return undefined;
470
+ top = readTop(lockPath, id);
471
+ } catch {
472
+ return undefined; // no lock directory, so no holder
473
+ }
474
+ if (top === "vanished") continue;
475
+ return top?.seen.state === "live" ? top.seen.record : undefined;
476
+ }
477
+ }
478
+
479
+ /** Machines whose creation is in flight, with the port block and MACs each has
480
+ * claimed. Read from the creators' live machine locks; a crashed creator's
481
+ * reservation disappears with its pid. */
482
+ export function listReservations(exceptName?: string): Array<{ name: string } & ResourceReservation> {
483
+ const machinesDir = getMachinesDir();
484
+ let names: string[];
485
+ try {
486
+ names = readdirSync(machinesDir);
487
+ } catch {
488
+ return [];
489
+ }
490
+ const out: Array<{ name: string } & ResourceReservation> = [];
491
+ for (const name of names) {
492
+ if (name === exceptName) continue;
493
+ const holder = currentHolder(machineLockPath(join(machinesDir, name)));
494
+ if (holder?.reserve) out.push({ name, ...holder.reserve });
495
+ }
496
+ return out;
497
+ }
@@ -0,0 +1,96 @@
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
+ }
73
+
74
+ /** POSIX-shell-safe form of `value`, for a command quickchr prints for someone to run.
75
+ *
76
+ * Needed because `assertValidResourceName()` only guards names at *creation*. A
77
+ * lookup has to keep older, looser names addressable — `isPathSafeName()` above
78
+ * rejects only empty, `.`, `..` and a path separator — so a real machine can be
79
+ * called `lab old` or `my;lab`, and `quickchr set lab old --device-mode rose` pasted
80
+ * into a shell addresses a machine called `lab`. Device-mode feature names have the
81
+ * same problem from the other end: `resolveDeviceModeOptions()` deliberately passes
82
+ * unknown features through for forward compatibility, so a typo with a space in it
83
+ * reaches the printed command intact.
84
+ *
85
+ * Values that need no quoting are returned unchanged, because the overwhelmingly
86
+ * common case is an ordinary name and a command in quotes reads like machine output
87
+ * rather than something a person would type.
88
+ *
89
+ * What this does **not** fix: a legacy name starting with `-` is read as a flag no
90
+ * matter how it is quoted, since quoting is the shell's business and the flag parse
91
+ * is quickchr's. New names cannot start with `-` (#156); an old one that does needs
92
+ * renaming, not escaping. */
93
+ export function shellQuote(value: string): string {
94
+ if (value.length > 0 && /^[A-Za-z0-9._:=@,+/-]+$/.test(value)) return value;
95
+ return `'${value.replaceAll("'", "'\\''")}'`;
96
+ }