@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/DESIGN.md +366 -5
- package/MANUAL.md +199 -14
- package/README.md +29 -2
- package/package.json +4 -2
- package/src/cli/flags.ts +194 -0
- package/src/cli/format.ts +20 -3
- package/src/cli/index.ts +258 -34
- package/src/cli/tips.ts +57 -0
- package/src/index.ts +10 -0
- package/src/lib/cache-api.ts +146 -0
- package/src/lib/completions.ts +17 -3
- package/src/lib/images.ts +53 -22
- package/src/lib/names.ts +72 -0
- package/src/lib/network.ts +191 -19
- package/src/lib/packages.ts +80 -6
- package/src/lib/provision.ts +126 -5
- package/src/lib/qemu.ts +39 -12
- package/src/lib/quickchr.ts +248 -40
- package/src/lib/socket-registry.ts +409 -36
- package/src/lib/state.ts +147 -6
- package/src/lib/types.ts +29 -5
|
@@ -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
|
+
}
|
package/src/lib/completions.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
100
|
+
const tempDir = mkdtempSync(join(cache, ".quickchr-image-"));
|
|
101
|
+
try {
|
|
102
|
+
extractZip(zipPath, tempDir);
|
|
69
103
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
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 (
|
|
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);
|
package/src/lib/names.ts
ADDED
|
@@ -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
|
+
}
|