@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.
@@ -0,0 +1,146 @@
1
+ /** Public cache operations for CI consumers and the quickchr CLI. */
2
+
3
+ import { join } from "node:path";
4
+ import { ensureCachedImage, isUsableCachedImage } from "./images.ts";
5
+ import type { ProgressLogger } from "./log.ts";
6
+ import { getCacheDir } from "./state.ts";
7
+ import { ARCHES, CHANNELS, QuickCHRError, type Arch, type Channel } from "./types.ts";
8
+ import { chrImageBasename, isValidVersion, resolveVersion } from "./versions.ts";
9
+
10
+ export const CACHE_VERSION_UNRESOLVED = "unresolved";
11
+ const CACHE_VERSION_MAX_LENGTH = 32;
12
+
13
+ export interface CacheTargetOptions {
14
+ /** Resolve this RouterOS channel. Mutually exclusive with `version`. */
15
+ channel?: Channel;
16
+ /** Use this concrete RouterOS version without a network resolution request. */
17
+ version?: string;
18
+ /** Override the cache directory. Defaults to quickchr's platform-correct cache. */
19
+ cacheDir?: string;
20
+ /** CHR architecture. `auto` and omission select the Bun process architecture. */
21
+ arch?: Arch | "auto";
22
+ }
23
+
24
+ export interface CacheKeyResult {
25
+ dir: string;
26
+ /** Concrete RouterOS version, or `unresolved` when channel resolution is offline. */
27
+ version: string;
28
+ arch: Arch;
29
+ }
30
+
31
+ export interface CacheAddOptions extends CacheTargetOptions {
32
+ logger?: ProgressLogger;
33
+ }
34
+
35
+ export interface CacheAddResult extends CacheKeyResult {
36
+ path: string;
37
+ cacheHit: boolean;
38
+ }
39
+
40
+ export type CacheVersionResolver = (channel: Channel) => Promise<string>;
41
+
42
+ function validateTarget(options: CacheTargetOptions): { channel?: Channel; version?: string } {
43
+ if (options.channel !== undefined && options.version !== undefined) {
44
+ throw new QuickCHRError("INVALID_VERSION", "Choose either a RouterOS channel or a concrete version, not both");
45
+ }
46
+ if (options.channel !== undefined && !(CHANNELS as readonly string[]).includes(options.channel)) {
47
+ throw new QuickCHRError(
48
+ "INVALID_VERSION",
49
+ `Invalid RouterOS channel "${options.channel}" — expected one of ${CHANNELS.join(", ")}`,
50
+ );
51
+ }
52
+ if (options.version !== undefined && !isValidVersion(options.version)) {
53
+ throw new QuickCHRError("INVALID_VERSION", `Invalid RouterOS version: ${options.version}`);
54
+ }
55
+ return { channel: options.channel, version: options.version };
56
+ }
57
+
58
+ async function concreteVersion(
59
+ options: CacheTargetOptions,
60
+ resolver: CacheVersionResolver,
61
+ ): Promise<{ channel?: Channel; version: string }> {
62
+ const target = validateTarget(options);
63
+ if (target.version !== undefined) return { version: target.version };
64
+ const channel = target.channel ?? "stable";
65
+ const version = await resolver(channel);
66
+ if (!isValidVersion(version)) {
67
+ throw new QuickCHRError(
68
+ "INVALID_VERSION",
69
+ `Unexpected version "${version}" resolved for channel "${channel}"`,
70
+ );
71
+ }
72
+ return { channel, version };
73
+ }
74
+
75
+ function concreteArch(arch: Arch | "auto" | undefined): Arch {
76
+ if (arch === undefined || arch === "auto") return process.arch === "arm64" ? "arm64" : "x86";
77
+ if (!(ARCHES as readonly string[]).includes(arch)) {
78
+ throw new QuickCHRError("INVALID_ARCH", `Invalid architecture "${arch}" — expected one of ${ARCHES.join(", ")}, auto`);
79
+ }
80
+ return arch;
81
+ }
82
+
83
+ /**
84
+ * Return quickchr's cache directory and the concrete version for a CI key.
85
+ *
86
+ * An explicit version is validated and returned without a network call. A
87
+ * channel resolution failure degrades to the stable `unresolved` sentinel so
88
+ * cache lookup cannot make an otherwise useful offline job fail.
89
+ */
90
+ export async function cacheKey(
91
+ options: CacheTargetOptions = {},
92
+ resolver: CacheVersionResolver = resolveVersion,
93
+ ): Promise<CacheKeyResult> {
94
+ const target = validateTarget(options);
95
+ const dir = options.cacheDir ?? getCacheDir();
96
+ const arch = concreteArch(options.arch);
97
+ if (target.version !== undefined) {
98
+ return {
99
+ dir,
100
+ version: target.version.length <= CACHE_VERSION_MAX_LENGTH ? target.version : CACHE_VERSION_UNRESOLVED,
101
+ arch,
102
+ };
103
+ }
104
+ try {
105
+ const resolved = await concreteVersion(options, resolver);
106
+ return {
107
+ dir,
108
+ version: resolved.version.length <= CACHE_VERSION_MAX_LENGTH ? resolved.version : CACHE_VERSION_UNRESOLVED,
109
+ arch,
110
+ };
111
+ } catch (error) {
112
+ if (error instanceof QuickCHRError && error.code === "DOWNLOAD_FAILED") {
113
+ return { dir, version: CACHE_VERSION_UNRESOLVED, arch };
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
119
+ /** Resolve and cache one CHR image without creating or booting a machine. */
120
+ export async function cacheAdd(
121
+ options: CacheAddOptions = {},
122
+ resolver: CacheVersionResolver = resolveVersion,
123
+ ): Promise<CacheAddResult> {
124
+ const resolved = await concreteVersion(options, resolver);
125
+ if (resolved.version.length > CACHE_VERSION_MAX_LENGTH) {
126
+ throw new QuickCHRError("INVALID_VERSION", `RouterOS version is too long: ${resolved.version}`);
127
+ }
128
+ const arch = concreteArch(options.arch);
129
+ const dir = options.cacheDir ?? getCacheDir();
130
+ const expectedPath = join(dir, `${chrImageBasename(resolved.version, arch)}.img`);
131
+ const cacheHit = await isUsableCachedImage(expectedPath);
132
+
133
+ try {
134
+ const path = await ensureCachedImage(resolved.version, arch, dir, options.logger);
135
+ return { dir, version: resolved.version, arch, path, cacheHit };
136
+ } catch (error) {
137
+ if (resolved.channel && error instanceof QuickCHRError) {
138
+ throw new QuickCHRError(
139
+ error.code,
140
+ `Failed to cache channel "${resolved.channel}" -> ${resolved.version} (${arch}): ${error.message}`,
141
+ error.installHint,
142
+ );
143
+ }
144
+ throw error;
145
+ }
146
+ }
@@ -328,7 +328,7 @@ _quickchr() {
328
328
  cword=${D}COMP_CWORD
329
329
  }
330
330
 
331
- local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help"
331
+ local commands="add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help"
332
332
 
333
333
  if [[ ${D}cword -eq 1 ]]; then
334
334
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
@@ -352,6 +352,10 @@ _quickchr() {
352
352
  local opts="--install --uninstall --status --dry-run --shell"
353
353
  COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
354
354
  ;;
355
+ cache)
356
+ local opts="add key list prune clear"
357
+ COMPREPLY=( $(compgen -W "${D}opts" -- "${D}cur") )
358
+ ;;
355
359
  help)
356
360
  COMPREPLY=( $(compgen -W "${D}commands" -- "${D}cur") )
357
361
  ;;
@@ -401,6 +405,7 @@ _quickchr() {
401
405
  'snap:Manage snapshots (alias)'
402
406
  'networks:Network discovery and socket management'
403
407
  'net:Network discovery (alias)'
408
+ 'cache:Prefetch and manage downloaded CHR images'
404
409
  'doctor:Check prerequisites'
405
410
  'settings:Manage global quickchr settings'
406
411
  'setup:Interactive setup wizard'
@@ -434,8 +439,12 @@ _quickchr() {
434
439
  )
435
440
  _describe 'option' opts
436
441
  ;;
442
+ cache)
443
+ local subcommands=(add key list prune clear)
444
+ _describe 'cache subcommand' subcommands
445
+ ;;
437
446
  help)
438
- local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks doctor settings setup completions version)
447
+ local commands=(add start stop list status inspect env remove clean console exec qga license disk snapshot networks cache doctor settings setup completions version)
439
448
  _describe 'command' commands
440
449
  ;;
441
450
  esac
@@ -451,7 +460,7 @@ function generateFishCompletion(): string {
451
460
  return `# quickchr fish completion
452
461
  # Generated by quickchr. Fish auto-loads files from ~/.config/fish/completions/.
453
462
 
454
- set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net doctor settings setup completions version help
463
+ set -l quickchr_commands add start stop list ls status inspect env remove rm clean console exec qga license disk snapshot snap networks net cache doctor settings setup completions version help
455
464
 
456
465
  # Disable file completions globally for quickchr
457
466
  complete -c quickchr -f
@@ -489,6 +498,8 @@ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
489
498
  -a "snapshot snap" -d "Manage snapshots"
490
499
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
491
500
  -a "networks net" -d "Network discovery and socket management"
501
+ complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
502
+ -a "cache" -d "Prefetch and manage downloaded CHR images"
492
503
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
493
504
  -a "doctor" -d "Check prerequisites"
494
505
  complete -c quickchr -n "not __fish_seen_subcommand_from $quickchr_commands" \\
@@ -518,5 +529,8 @@ complete -c quickchr -n "__fish_seen_subcommand_from completions" -l uninstall -
518
529
  complete -c quickchr -n "__fish_seen_subcommand_from completions" -l status -d "Show install status"
519
530
  complete -c quickchr -n "__fish_seen_subcommand_from completions" -l dry-run -d "Show what would happen"
520
531
  complete -c quickchr -n "__fish_seen_subcommand_from completions" -l shell -d "Specify shell" -a "bash zsh fish"
532
+
533
+ # cache subcommands
534
+ complete -c quickchr -n "__fish_seen_subcommand_from cache" -a "add key list prune clear" -d "cache subcommand"
521
535
  `;
522
536
  }
package/src/lib/images.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Image download, ZIP extraction, and cache management.
3
3
  */
4
4
 
5
- import { existsSync, copyFileSync, readdirSync, renameSync, unlinkSync } from "node:fs";
5
+ import { existsSync, copyFileSync, mkdtempSync, readdirSync, renameSync, rmSync, unlinkSync } from "node:fs";
6
6
  import { join, basename } from "node:path";
7
7
  import type { Arch } from "./types.ts";
8
8
  import { QuickCHRError } from "./types.ts";
@@ -20,6 +20,37 @@ function finalizeExtractedImage(zipPath: string, imgPath: string): string {
20
20
  return imgPath;
21
21
  }
22
22
 
23
+ /**
24
+ * Check that a cached raw image has a complete DOS partition table.
25
+ *
26
+ * RouterOS CHR images for both supported architectures use a DOS partition
27
+ * table. Checking its signature and partition extents catches empty and
28
+ * truncated files while avoiding a version-specific image-size assumption.
29
+ */
30
+ export async function isUsableCachedImage(imgPath: string): Promise<boolean> {
31
+ const file = Bun.file(imgPath);
32
+ if (!(await file.exists()) || file.size < 512) return false;
33
+
34
+ try {
35
+ const sector = new Uint8Array(await file.slice(0, 512).arrayBuffer());
36
+ if (sector[510] !== 0x55 || sector[511] !== 0xaa) return false;
37
+
38
+ let hasPartition = false;
39
+ for (let offset = 446; offset < 510; offset += 16) {
40
+ const start = new DataView(sector.buffer, sector.byteOffset + offset + 8, 8);
41
+ const firstSector = start.getUint32(0, true);
42
+ const sectorCount = start.getUint32(4, true);
43
+ if (sectorCount === 0) continue;
44
+ hasPartition = true;
45
+ const end = (firstSector + sectorCount) * 512;
46
+ if (!Number.isSafeInteger(end) || end > file.size) return false;
47
+ }
48
+ return hasPartition;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
23
54
  /** Download a CHR image ZIP if not already cached. Returns path to the ZIP. */
24
55
  export async function downloadImage(
25
56
  version: string,
@@ -58,39 +89,38 @@ export async function extractImage(
58
89
  const imgName = basename(zipPath, ".zip");
59
90
  const imgPath = join(cache, imgName);
60
91
 
61
- if (existsSync(imgPath)) {
92
+ if (await isUsableCachedImage(imgPath)) {
62
93
  return finalizeExtractedImage(zipPath, imgPath);
63
94
  }
95
+ if (existsSync(imgPath)) unlinkSync(imgPath);
64
96
 
65
97
  const log = logger ?? createLogger();
66
98
  log.status(`Extracting: ${basename(zipPath)}`);
67
99
 
68
- extractZip(zipPath, cache);
100
+ const tempDir = mkdtempSync(join(cache, ".quickchr-image-"));
101
+ try {
102
+ extractZip(zipPath, tempDir);
69
103
 
70
- // MikroTik x86 ZIPs contain chr-X.Y.Z.img (no arch suffix).
71
- // Our ZIP is named chr-X.Y.Z.img.zip (without -x86 for x86). Check if we need to
72
- // find the extracted file.
73
- if (!existsSync(imgPath)) {
74
- const files = readdirSync(cache).filter(
75
- (f) => f.endsWith(".img") && f.startsWith("chr-"),
104
+ // MikroTik arm64 ZIPs may contain chr-X.Y.Z.img without the arch suffix.
105
+ const files = readdirSync(tempDir).filter((file) => file.endsWith(".img") && file.startsWith("chr-"));
106
+ const base = basename(zipPath, ".img.zip");
107
+ const expected = files.find(
108
+ (file) => file === `${base}.img` || file.replace("-arm64", "") === `${base.replace("-arm64", "")}.img`,
76
109
  );
77
- // Find the one that was just extracted (matching version)
78
- const expected = files.find((f) => {
79
- const base = basename(zipPath, ".img.zip");
80
- // chr-7.22.1.img matches chr-7.22.1.img.zip
81
- return f === base + ".img" || f.replace("-arm64", "") === base.replace("-arm64", "") + ".img";
82
- });
83
- if (expected) {
84
- const extractedPath = join(cache, expected);
85
- if (extractedPath !== imgPath) {
86
- renameSync(extractedPath, imgPath);
87
- }
88
- } else {
110
+ if (!expected) {
89
111
  throw new QuickCHRError(
90
112
  "PROCESS_FAILED",
91
113
  `Expected ${imgPath} after unzip, but not found. Files: ${files.join(", ")}`,
92
114
  );
93
115
  }
116
+
117
+ const extractedPath = join(tempDir, expected);
118
+ if (!(await isUsableCachedImage(extractedPath))) {
119
+ throw new QuickCHRError("PROCESS_FAILED", `Extracted CHR image is incomplete or invalid: ${expected}`);
120
+ }
121
+ renameSync(extractedPath, imgPath);
122
+ } finally {
123
+ rmSync(tempDir, { recursive: true, force: true });
94
124
  }
95
125
 
96
126
  return finalizeExtractedImage(zipPath, imgPath);
@@ -105,11 +135,12 @@ export async function ensureCachedImage(
105
135
  ): Promise<string> {
106
136
  const cache = cacheDir ?? getCacheDir();
107
137
  const imgPath = join(cache, `${chrImageBasename(version, arch)}.img`);
108
- if (existsSync(imgPath)) {
138
+ if (await isUsableCachedImage(imgPath)) {
109
139
  const log = logger ?? createLogger();
110
140
  log.status(` Using cached image: ${chrImageBasename(version, arch)}`);
111
141
  return imgPath;
112
142
  }
143
+ if (existsSync(imgPath)) unlinkSync(imgPath);
113
144
  assertSufficientQuickchrStorage(`cache CHR ${version} (${arch})`);
114
145
  const zipPath = await downloadImage(version, arch, cacheDir, logger);
115
146
  const extractedImgPath = await extractImage(zipPath, cacheDir, logger);
@@ -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
+ }