@crewhaus/tool-pkgmgr 0.7.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,266 @@
1
+ export const MANAGER_IDS = Object.freeze([
2
+ "homebrew",
3
+ "apt",
4
+ "dnf",
5
+ "pacman",
6
+ "winget",
7
+ "choco",
8
+ ]);
9
+ /**
10
+ * Every command this package may run, as literals.
11
+ *
12
+ * Flag notes that matter to the parsers downstream:
13
+ *
14
+ * - `brew info --json=v2` is the ONE brew call that answers both halves of
15
+ * a query: `installed[]` for what is on disk and `versions.stable` for
16
+ * what the tap has. It also covers casks in the same document, so a name
17
+ * does not have to be classified before it is looked up.
18
+ * - `dpkg-query -W -f=...` is named-field output, not a table. The fields
19
+ * are `${db:Status-Abbrev}` rather than `${Status}` because the abbrev is
20
+ * three fixed characters (`ii `, `un `, `rc `) that mean the same thing in
21
+ * every locale, while `${Status}` is three English words.
22
+ * - `apt-cache policy` IS localised, which is why `LC_ALL=C` is pinned in
23
+ * `../run.ts`. Without that pin the `Installed:`/`Candidate:` labels come
24
+ * back in the operator's language and the parser silently finds nothing —
25
+ * the single most common way a package-manager parser fails in the field.
26
+ * - `rpm -q --queryformat` rather than `dnf list --installed`: rpm reads the
27
+ * local database, needs no repository metadata, and its format string is
28
+ * not localised. `dnf` is only asked what is AVAILABLE.
29
+ * - `dnf --cacheonly` so the query cannot reach the network. The cost is
30
+ * that a host with no cached metadata answers "no matching packages",
31
+ * which is indistinguishable from "not in the repos" — so the parser
32
+ * looks for dnf's own "no cache" complaint and reports unknown instead of
33
+ * "not available". "Could not determine" is not "no" (house rule 6).
34
+ * - `pacman -S --print --print-format '%r/%n %v'` is pacman's dry run. It
35
+ * prints every target INCLUDING dependencies, with versions, and needs no
36
+ * root — the best dry run of the six.
37
+ * - `winget list --exact --query` with `--disable-interactivity` and
38
+ * `--accept-source-agreements`: without those two, winget can sit waiting
39
+ * for a keypress or for an agreement to be accepted, which on a pipe is a
40
+ * command that never exits.
41
+ * - `choco list --limit-output` is chocolatey's documented machine-readable
42
+ * mode: `name|version`, one per line, no headers, no colour. The v1/v2
43
+ * split is real — `--local-only` was removed in Chocolatey 2.0 — so the
44
+ * version is probed first and the right form is chosen.
45
+ */
46
+ export const PKGMGR_COMMANDS = Object.freeze({
47
+ brewVersion: Object.freeze(["brew", "--version"]),
48
+ brewInfoJson: Object.freeze(["brew", "info", "--json=v2"]),
49
+ brewListVersions: Object.freeze(["brew", "list", "--formula", "--versions"]),
50
+ brewDeps: Object.freeze(["brew", "deps", "--formula"]),
51
+ brewInstall: Object.freeze(["brew", "install", "--formula"]),
52
+ dpkgVersion: Object.freeze(["dpkg-query", "--version"]),
53
+ dpkgQuery: Object.freeze([
54
+ "dpkg-query",
55
+ "-W",
56
+ "-f=${binary:Package}\t${Version}\t${db:Status-Abbrev}\n",
57
+ "--",
58
+ ]),
59
+ aptCachePolicy: Object.freeze(["apt-cache", "policy", "--"]),
60
+ aptSimulate: Object.freeze(["apt-get", "install", "-s", "-y", "--"]),
61
+ aptInstall: Object.freeze(["apt-get", "install", "-y", "--"]),
62
+ dnfVersion: Object.freeze(["dnf", "--version"]),
63
+ rpmQuery: Object.freeze(["rpm", "-q", "--queryformat", "%{NAME}\t%{EVR}\t%{ARCH}\n", "--"]),
64
+ dnfListAvailable: Object.freeze(["dnf", "--cacheonly", "--quiet", "list", "--available", "--"]),
65
+ dnfSimulate: Object.freeze(["dnf", "install", "--assumeno", "--"]),
66
+ dnfInstall: Object.freeze(["dnf", "install", "-y", "--"]),
67
+ pacmanVersion: Object.freeze(["pacman", "--version"]),
68
+ pacmanQuery: Object.freeze(["pacman", "-Q", "--"]),
69
+ pacmanSyncInfo: Object.freeze(["pacman", "-Si", "--"]),
70
+ pacmanSimulate: Object.freeze(["pacman", "-S", "--print", "--print-format", "%r/%n %v", "--"]),
71
+ pacmanInstall: Object.freeze(["pacman", "-S", "--noconfirm", "--"]),
72
+ wingetVersion: Object.freeze(["winget", "--version"]),
73
+ wingetList: Object.freeze([
74
+ "winget",
75
+ "list",
76
+ "--exact",
77
+ "--disable-interactivity",
78
+ "--accept-source-agreements",
79
+ "--query",
80
+ ]),
81
+ chocoVersion: Object.freeze(["choco", "--version"]),
82
+ /** Chocolatey 2.x: `choco list` is local by default. */
83
+ chocoListV2: Object.freeze(["choco", "list", "--exact", "--limit-output"]),
84
+ /** Chocolatey 1.x and 0.x: `list` searches the remote unless told otherwise. */
85
+ chocoListV1: Object.freeze(["choco", "list", "--local-only", "--exact", "--limit-output"]),
86
+ });
87
+ /** Flat view of the table, for the test that proves no argv is built. */
88
+ export const ALL_PKGMGR_COMMANDS = Object.freeze(Object.values(PKGMGR_COMMANDS));
89
+ /**
90
+ * The environment each manager needs on top of the pinned base in `../run.ts`.
91
+ *
92
+ * Every entry here exists to stop the manager doing something this package
93
+ * does not do: reach the network, write analytics, colour its output, or ask
94
+ * a question on a pipe that nothing will ever answer.
95
+ */
96
+ export const MANAGER_ENV = Object.freeze({
97
+ homebrew: Object.freeze({
98
+ // `brew info` will otherwise `git fetch` the taps first: a network call
99
+ // this package does not make, and a multi-second stall inside a query
100
+ // that is supposed to read a local checkout.
101
+ HOMEBREW_NO_AUTO_UPDATE: "1",
102
+ HOMEBREW_NO_ANALYTICS: "1",
103
+ HOMEBREW_NO_ENV_HINTS: "1",
104
+ HOMEBREW_NO_COLOR: "1",
105
+ // Homebrew's own switch for "there is no human here". Without it a
106
+ // formula with a caveat or a prompt blocks on stdin.
107
+ NONINTERACTIVE: "1",
108
+ }),
109
+ apt: Object.freeze({
110
+ // Without this, a package with a debconf question opens a dialog on a
111
+ // terminal that is not there and apt waits for it forever.
112
+ DEBIAN_FRONTEND: "noninteractive",
113
+ APT_LISTCHANGES_FRONTEND: "none",
114
+ }),
115
+ dnf: Object.freeze({}),
116
+ pacman: Object.freeze({}),
117
+ winget: Object.freeze({}),
118
+ choco: Object.freeze({}),
119
+ });
120
+ export const MANAGERS = Object.freeze({
121
+ homebrew: {
122
+ id: "homebrew",
123
+ label: "Homebrew",
124
+ // Linuxbrew is real, and is looked for after the distro managers.
125
+ platforms: ["darwin", "linux"],
126
+ detect: PKGMGR_COMMANDS.brewVersion,
127
+ availableSource: "local-index",
128
+ install: { supported: true, needsRoot: false },
129
+ versionPin: {
130
+ supported: false,
131
+ reason: "Homebrew installs whatever version the tap currently has and has no flag to select another; the only pin is a versioned formula, which is a different NAME (node@20, python@3.11) rather than a version",
132
+ },
133
+ // Homebrew's parser is Ruby's OptionParser wrapped in Homebrew::CLI, and
134
+ // this package has not recorded its `--` behaviour, so it is not relied on.
135
+ doubleDash: false,
136
+ operatorCommand: (operand) => `brew install --formula ${operand}`,
137
+ },
138
+ apt: {
139
+ id: "apt",
140
+ label: "apt/dpkg",
141
+ platforms: ["linux"],
142
+ detect: PKGMGR_COMMANDS.dpkgVersion,
143
+ availableSource: "local-index",
144
+ install: { supported: true, needsRoot: true },
145
+ versionPin: {
146
+ supported: true,
147
+ form: "name=version",
148
+ },
149
+ doubleDash: true,
150
+ operatorCommand: (operand) => `sudo apt-get install -y -- ${operand}`,
151
+ },
152
+ dnf: {
153
+ id: "dnf",
154
+ label: "dnf/rpm",
155
+ platforms: ["linux"],
156
+ detect: PKGMGR_COMMANDS.dnfVersion,
157
+ availableSource: "local-index",
158
+ install: { supported: true, needsRoot: true },
159
+ versionPin: {
160
+ supported: false,
161
+ reason: "dnf's versioned spec is name-version-release, and joining a name to a version cannot be done unambiguously when the name itself contains a dash — which most rpm names do; pass the full NEVRA (kernel-5.14.0-427.el9) as the package NAME instead, which is exact",
162
+ },
163
+ doubleDash: true,
164
+ operatorCommand: (operand) => `sudo dnf install -y -- ${operand}`,
165
+ },
166
+ pacman: {
167
+ id: "pacman",
168
+ label: "pacman",
169
+ platforms: ["linux"],
170
+ detect: PKGMGR_COMMANDS.pacmanVersion,
171
+ availableSource: "local-index",
172
+ install: { supported: true, needsRoot: true },
173
+ versionPin: {
174
+ supported: false,
175
+ reason: "pacman has no version selection: the sync database holds exactly one version per package and there is no flag to ask for another",
176
+ },
177
+ doubleDash: true,
178
+ operatorCommand: (operand) => `sudo pacman -S --noconfirm -- ${operand}`,
179
+ },
180
+ winget: {
181
+ id: "winget",
182
+ label: "winget",
183
+ platforms: ["win32"],
184
+ detect: PKGMGR_COMMANDS.wingetVersion,
185
+ availableSource: "network-only",
186
+ availableReason: "winget resolves an available version by querying its source over the network, and this tool makes no network call — only the local install state is read",
187
+ install: {
188
+ supported: false,
189
+ reason: "a winget install runs the package's own installer, which raises a UAC elevation prompt for any machine-scope package; this tool does not raise one and will not start an installer whose end it cannot see",
190
+ },
191
+ versionPin: {
192
+ supported: false,
193
+ reason: "winget installs are not performed by this tool at all, so there is nothing to pin",
194
+ },
195
+ // winget's parser does not document `--` as an end-of-options marker.
196
+ doubleDash: false,
197
+ operatorCommand: (operand) => `winget install --exact --id ${operand} --accept-package-agreements --accept-source-agreements`,
198
+ },
199
+ choco: {
200
+ id: "choco",
201
+ label: "Chocolatey",
202
+ platforms: ["win32"],
203
+ detect: PKGMGR_COMMANDS.chocoVersion,
204
+ availableSource: "network-only",
205
+ availableReason: "chocolatey resolves an available version with `choco search`, which queries the remote feed; this tool makes no network call — only the local install state is read",
206
+ install: {
207
+ supported: false,
208
+ reason: "chocolatey installs to a machine-wide location and must be run from an elevated shell, and its packages execute arbitrary PowerShell with those rights; this tool does not elevate",
209
+ },
210
+ versionPin: {
211
+ supported: false,
212
+ reason: "chocolatey installs are not performed by this tool at all, so there is nothing to pin",
213
+ },
214
+ doubleDash: false,
215
+ operatorCommand: (operand) => `choco install ${operand} -y`,
216
+ },
217
+ });
218
+ /**
219
+ * Which managers to look for, in order, on a platform.
220
+ *
221
+ * Homebrew comes LAST on Linux: a Linuxbrew installation sits alongside the
222
+ * distro's manager rather than replacing it, and on a Debian box the answer
223
+ * to "is curl installed" that an operator means is dpkg's.
224
+ *
225
+ * `"other"` is empty, and that is what the hostile default in `../host.ts`
226
+ * produces for any test that forgets to declare a platform — every backend
227
+ * refuses, identically, on every machine.
228
+ */
229
+ export function managersForPlatform(platform) {
230
+ switch (platform) {
231
+ case "darwin":
232
+ return ["homebrew"];
233
+ case "linux":
234
+ return ["apt", "dnf", "pacman", "homebrew"];
235
+ case "win32":
236
+ return ["winget", "choco"];
237
+ case "other":
238
+ return [];
239
+ }
240
+ }
241
+ /**
242
+ * Build the operand a manager receives for a name (optionally version-pinned).
243
+ *
244
+ * Kept in one place because it is the ONLY value this package ever appends to
245
+ * an argv, and the join is manager-specific: `name=version` is apt's grammar
246
+ * and nobody else's.
247
+ */
248
+ export function buildOperand(manager, name, version) {
249
+ if (version === undefined)
250
+ return name;
251
+ if (manager === "apt")
252
+ return `${name}=${version}`;
253
+ // Unreachable through the tools: `checkVersionPin` refuses a version for
254
+ // every other manager before this is called. Kept total rather than
255
+ // throwing, so a future caller gets the name rather than an exception.
256
+ return name;
257
+ }
258
+ /** Append the operand to a frozen prefix, with `--` where the manager takes it. */
259
+ export function withOperand(spec, prefix, operand) {
260
+ // The prefixes that end in `--` already carry it (it is part of the frozen
261
+ // constant so the test can see it), so it is never added twice.
262
+ const endsWithDoubleDash = prefix[prefix.length - 1] === "--";
263
+ if (spec.doubleDash && !endsWithDoubleDash)
264
+ return [...prefix, "--", operand];
265
+ return [...prefix, operand];
266
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The gate every caller-supplied package name and version passes through
3
+ * before an argv exists.
4
+ *
5
+ * ── WHY THIS FILE IS THE SECURITY BOUNDARY ────────────────────────────────
6
+ *
7
+ * A package name is a caller value that reaches an argv, and an argv element
8
+ * beginning with `-` is a FLAG, not an operand. `PackageInstall({name:"-y"})`
9
+ * against a manager that does not stop option parsing would not install a
10
+ * package called `-y`; it would add a flag to a command that modifies the
11
+ * system. This repository has already shipped exactly that bug once —
12
+ * `gitBranchCreate({name:"-D"})` ran `git branch -D victim` — and the cheapest
13
+ * way not to ship it twice is for a leading dash never to reach an argv.
14
+ *
15
+ * `--` is the usual defence, and it is used below wherever it is documented
16
+ * to work. But it is NOT the primary defence here, because the six managers
17
+ * genuinely differ: `apt-get`, `apt-cache`, `dpkg-query`, `pacman`, `rpm`
18
+ * (popt) and `dnf` (argparse) all honour `--`, while `winget` and `choco`
19
+ * have their own parsers that do not document it at all and Homebrew's is a
20
+ * Ruby `OptionParser` whose behaviour this package has not recorded. A
21
+ * defence that is true for four of six managers is not a defence. So a name
22
+ * that begins with `-` is REFUSED, on every manager, before anything is
23
+ * spawned — and `--` is passed on top, where it is known to work, as the belt
24
+ * to that brace.
25
+ *
26
+ * Everything else a hostile name might contain — a double quote, a backslash,
27
+ * a newline, `$(id)`, a backtick, `</toast>`, `&`, a NUL — is inert in an
28
+ * argv array, because this package has no shell (see `../run.ts`). It is
29
+ * still refused, for a different and simpler reason: none of it is a legal
30
+ * package name in any of these managers, so accepting it could only ever turn
31
+ * into a confusing error from a child process instead of a clear refusal from
32
+ * the tool. The tests assert both halves: that each hostile value is refused,
33
+ * and that when a name IS accepted it reaches the argv as exactly one element
34
+ * with byte-identical content.
35
+ */
36
+ export type ManagerId = "homebrew" | "apt" | "dnf" | "pacman" | "winget" | "choco";
37
+ export type NameRule = {
38
+ /** Names this manager will accept, anchored. */
39
+ readonly pattern: RegExp;
40
+ /** The charset, in words, for the refusal message. */
41
+ readonly charset: string;
42
+ };
43
+ /**
44
+ * Per-manager name grammars, each narrowed to what the manager documents.
45
+ *
46
+ * - homebrew: formula and cask tokens are lowercase `a-z0-9@+._-`, and a
47
+ * tap-qualified name adds two slashes (`homebrew/cask/firefox`). Upper
48
+ * case is allowed through because a tap's user segment can carry it.
49
+ * - apt: Debian policy §5.6.1 — `[a-z0-9][a-z0-9+.-]+`, plus an optional
50
+ * `:arch` qualifier (`libc6:i386`).
51
+ * - dnf: an rpm name is `[A-Za-z0-9._+-]`, and the whole NEVRA form
52
+ * (`kernel-5.14.0-427.el9.x86_64`) is spelled with the same characters,
53
+ * which is why a version is passed as part of the NAME for dnf rather
54
+ * than built into one here (see `versionSupport`).
55
+ * - pacman: `[a-z0-9@._+-]` per PKGBUILD(5), plus `repo/name`.
56
+ * - winget: an Id is `Publisher.Package`, ASCII alphanumerics with `.`,
57
+ * `-` and `_`. A DISPLAY name (which may contain spaces and CJK) is not
58
+ * accepted, because it is not what `--exact` matches on and guessing
59
+ * which one the caller meant is how a query answers about the wrong app.
60
+ * - choco: package ids are `[A-Za-z0-9._-]`.
61
+ *
62
+ * Every pattern rejects a leading `-` structurally, and the check below
63
+ * rejects it again by name so the refusal says WHY rather than "does not
64
+ * match".
65
+ */
66
+ export declare const NAME_RULES: Readonly<Record<ManagerId, NameRule>>;
67
+ /** Longest name accepted. Debian's own limit is far shorter; this is a sanity bound. */
68
+ export declare const MAX_NAME_LENGTH = 200;
69
+ /** Longest version specifier accepted. */
70
+ export declare const MAX_VERSION_LENGTH = 100;
71
+ /**
72
+ * Check one caller-supplied package name.
73
+ *
74
+ * Returns the refusal, or `undefined` when the name may become an argv
75
+ * element. The checks are ordered so the message names the SPECIFIC problem:
76
+ * "starts with -" is far more useful to a caller than "does not match the
77
+ * accepted characters", and both are more useful than a manager's own error.
78
+ */
79
+ export declare function checkPackageName(manager: ManagerId, name: string): string | undefined;
80
+ /**
81
+ * The version grammars the managers that CAN pin a version accept.
82
+ *
83
+ * Only apt is here, and that is the honest answer rather than a gap:
84
+ *
85
+ * - apt expresses an exact version as `name=version`, and a Debian version
86
+ * string is `[A-Za-z0-9.+~:-]` (policy §5.6.12). `=` is what makes this
87
+ * work and is why the version has its own grammar: a version containing
88
+ * `=` would produce `name=1=2`, which apt reads as a different package.
89
+ * - dnf CAN take `name-version-release`, and this package will not build
90
+ * one, because the join is ambiguous the moment the NAME contains a dash
91
+ * — which most rpm names do. A caller who wants an exact rpm passes the
92
+ * whole NEVRA as the name; that is unambiguous and needs no guessing.
93
+ * - pacman has no version selection at all. There is no flag, and the sync
94
+ * database holds exactly one version per package.
95
+ * - Homebrew installs the tap's current version. The only pin is a
96
+ * versioned FORMULA (`node@20`), which is a different name, not a version.
97
+ * - winget and choco can pin, but this package does not install through
98
+ * either of them at all (see `./managers.ts`), so the question does not
99
+ * arise.
100
+ *
101
+ * Every one of those is refused explicitly with that sentence, rather than
102
+ * silently installing latest — which is the failure mode the refusal exists
103
+ * to prevent.
104
+ */
105
+ export declare const DEBIAN_VERSION_PATTERN: RegExp;
106
+ export declare function checkVersionSyntax(version: string): string | undefined;
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The gate every caller-supplied package name and version passes through
3
+ * before an argv exists.
4
+ *
5
+ * ── WHY THIS FILE IS THE SECURITY BOUNDARY ────────────────────────────────
6
+ *
7
+ * A package name is a caller value that reaches an argv, and an argv element
8
+ * beginning with `-` is a FLAG, not an operand. `PackageInstall({name:"-y"})`
9
+ * against a manager that does not stop option parsing would not install a
10
+ * package called `-y`; it would add a flag to a command that modifies the
11
+ * system. This repository has already shipped exactly that bug once —
12
+ * `gitBranchCreate({name:"-D"})` ran `git branch -D victim` — and the cheapest
13
+ * way not to ship it twice is for a leading dash never to reach an argv.
14
+ *
15
+ * `--` is the usual defence, and it is used below wherever it is documented
16
+ * to work. But it is NOT the primary defence here, because the six managers
17
+ * genuinely differ: `apt-get`, `apt-cache`, `dpkg-query`, `pacman`, `rpm`
18
+ * (popt) and `dnf` (argparse) all honour `--`, while `winget` and `choco`
19
+ * have their own parsers that do not document it at all and Homebrew's is a
20
+ * Ruby `OptionParser` whose behaviour this package has not recorded. A
21
+ * defence that is true for four of six managers is not a defence. So a name
22
+ * that begins with `-` is REFUSED, on every manager, before anything is
23
+ * spawned — and `--` is passed on top, where it is known to work, as the belt
24
+ * to that brace.
25
+ *
26
+ * Everything else a hostile name might contain — a double quote, a backslash,
27
+ * a newline, `$(id)`, a backtick, `</toast>`, `&`, a NUL — is inert in an
28
+ * argv array, because this package has no shell (see `../run.ts`). It is
29
+ * still refused, for a different and simpler reason: none of it is a legal
30
+ * package name in any of these managers, so accepting it could only ever turn
31
+ * into a confusing error from a child process instead of a clear refusal from
32
+ * the tool. The tests assert both halves: that each hostile value is refused,
33
+ * and that when a name IS accepted it reaches the argv as exactly one element
34
+ * with byte-identical content.
35
+ */
36
+ /**
37
+ * Per-manager name grammars, each narrowed to what the manager documents.
38
+ *
39
+ * - homebrew: formula and cask tokens are lowercase `a-z0-9@+._-`, and a
40
+ * tap-qualified name adds two slashes (`homebrew/cask/firefox`). Upper
41
+ * case is allowed through because a tap's user segment can carry it.
42
+ * - apt: Debian policy §5.6.1 — `[a-z0-9][a-z0-9+.-]+`, plus an optional
43
+ * `:arch` qualifier (`libc6:i386`).
44
+ * - dnf: an rpm name is `[A-Za-z0-9._+-]`, and the whole NEVRA form
45
+ * (`kernel-5.14.0-427.el9.x86_64`) is spelled with the same characters,
46
+ * which is why a version is passed as part of the NAME for dnf rather
47
+ * than built into one here (see `versionSupport`).
48
+ * - pacman: `[a-z0-9@._+-]` per PKGBUILD(5), plus `repo/name`.
49
+ * - winget: an Id is `Publisher.Package`, ASCII alphanumerics with `.`,
50
+ * `-` and `_`. A DISPLAY name (which may contain spaces and CJK) is not
51
+ * accepted, because it is not what `--exact` matches on and guessing
52
+ * which one the caller meant is how a query answers about the wrong app.
53
+ * - choco: package ids are `[A-Za-z0-9._-]`.
54
+ *
55
+ * Every pattern rejects a leading `-` structurally, and the check below
56
+ * rejects it again by name so the refusal says WHY rather than "does not
57
+ * match".
58
+ */
59
+ export const NAME_RULES = Object.freeze({
60
+ homebrew: {
61
+ pattern: /^[A-Za-z0-9][A-Za-z0-9@+._-]*(\/[A-Za-z0-9][A-Za-z0-9@+._-]*){0,2}$/,
62
+ charset: "letters, digits and @ + . _ - , with at most two / segments for a tap-qualified name",
63
+ },
64
+ apt: {
65
+ pattern: /^[a-z0-9][a-z0-9+._-]*(:[a-z0-9][a-z0-9-]*)?$/,
66
+ charset: "lowercase letters, digits and + . _ - , with an optional :architecture suffix",
67
+ },
68
+ dnf: {
69
+ pattern: /^[A-Za-z0-9][A-Za-z0-9._+-]*$/,
70
+ charset: "letters, digits and . _ + -",
71
+ },
72
+ pacman: {
73
+ pattern: /^[A-Za-z0-9][A-Za-z0-9@._+-]*(\/[A-Za-z0-9][A-Za-z0-9@._+-]*)?$/,
74
+ charset: "letters, digits and @ . _ + - , with an optional repository/ prefix",
75
+ },
76
+ winget: {
77
+ pattern: /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
78
+ charset: "ASCII letters, digits and . _ - (a winget package Id, not its display name)",
79
+ },
80
+ choco: {
81
+ pattern: /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
82
+ charset: "letters, digits and . _ -",
83
+ },
84
+ });
85
+ /** Longest name accepted. Debian's own limit is far shorter; this is a sanity bound. */
86
+ export const MAX_NAME_LENGTH = 200;
87
+ /** Longest version specifier accepted. */
88
+ export const MAX_VERSION_LENGTH = 100;
89
+ /**
90
+ * Check one caller-supplied package name.
91
+ *
92
+ * Returns the refusal, or `undefined` when the name may become an argv
93
+ * element. The checks are ordered so the message names the SPECIFIC problem:
94
+ * "starts with -" is far more useful to a caller than "does not match the
95
+ * accepted characters", and both are more useful than a manager's own error.
96
+ */
97
+ export function checkPackageName(manager, name) {
98
+ if (name === "")
99
+ return "the package name is empty";
100
+ if (name.length > MAX_NAME_LENGTH) {
101
+ return `the package name is ${name.length} characters, over the ${MAX_NAME_LENGTH}-character limit`;
102
+ }
103
+ if (name.includes("\0")) {
104
+ // execve truncates an argument at a NUL, so a name that reads as harmless
105
+ // here would arrive at the kernel cut short with whatever followed gone.
106
+ return "the package name contains a NUL byte, which would be truncated at the execve boundary";
107
+ }
108
+ if (/[\n\r]/.test(name)) {
109
+ return "the package name contains a newline, which no package manager accepts in a name";
110
+ }
111
+ if (name.startsWith("-")) {
112
+ return `the package name starts with "-", which a package manager reads as a FLAG rather than a package (${JSON.stringify(name)}) — this is refused rather than defended with "--", because winget, choco and brew do not all document "--" as an end-of-options marker`;
113
+ }
114
+ if (name === "." || name === "..") {
115
+ return `"${name}" is a path, not a package name`;
116
+ }
117
+ // Ordered after the specific checks so a control character or a shell
118
+ // metacharacter is reported as itself rather than as a charset miss.
119
+ // \u escapes, not \x, and never the raw bytes biome folds \x into: this
120
+ // class is rewritten by the formatter, and bun 1.3.11 (what CI runs) rejects
121
+ // a class carrying raw control bytes as "range out of order" while 1.3.14
122
+ // (what this machine runs) accepts it. The suppression below must stay on
123
+ // the line directly above the regex — a comment between them makes the
124
+ // suppression unused AND leaves the rule unsuppressed, which is two errors.
125
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: refusing control characters is the point
126
+ if (/[\u0000-\u001f\u007f]/.test(name)) {
127
+ return "the package name contains a control character";
128
+ }
129
+ if (/\s/.test(name)) {
130
+ return "the package name contains whitespace; a package name is a single token";
131
+ }
132
+ const rule = NAME_RULES[manager];
133
+ if (!rule.pattern.test(name)) {
134
+ return `${JSON.stringify(name)} is not a valid ${manager} package name — accepted: ${rule.charset}`;
135
+ }
136
+ return undefined;
137
+ }
138
+ /**
139
+ * The version grammars the managers that CAN pin a version accept.
140
+ *
141
+ * Only apt is here, and that is the honest answer rather than a gap:
142
+ *
143
+ * - apt expresses an exact version as `name=version`, and a Debian version
144
+ * string is `[A-Za-z0-9.+~:-]` (policy §5.6.12). `=` is what makes this
145
+ * work and is why the version has its own grammar: a version containing
146
+ * `=` would produce `name=1=2`, which apt reads as a different package.
147
+ * - dnf CAN take `name-version-release`, and this package will not build
148
+ * one, because the join is ambiguous the moment the NAME contains a dash
149
+ * — which most rpm names do. A caller who wants an exact rpm passes the
150
+ * whole NEVRA as the name; that is unambiguous and needs no guessing.
151
+ * - pacman has no version selection at all. There is no flag, and the sync
152
+ * database holds exactly one version per package.
153
+ * - Homebrew installs the tap's current version. The only pin is a
154
+ * versioned FORMULA (`node@20`), which is a different name, not a version.
155
+ * - winget and choco can pin, but this package does not install through
156
+ * either of them at all (see `./managers.ts`), so the question does not
157
+ * arise.
158
+ *
159
+ * Every one of those is refused explicitly with that sentence, rather than
160
+ * silently installing latest — which is the failure mode the refusal exists
161
+ * to prevent.
162
+ */
163
+ export const DEBIAN_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.+~:-]*$/;
164
+ export function checkVersionSyntax(version) {
165
+ if (version === "")
166
+ return "the version is empty";
167
+ if (version.length > MAX_VERSION_LENGTH) {
168
+ return `the version is ${version.length} characters, over the ${MAX_VERSION_LENGTH}-character limit`;
169
+ }
170
+ if (!DEBIAN_VERSION_PATTERN.test(version)) {
171
+ return `${JSON.stringify(version)} is not a Debian version string — accepted: letters, digits and . + ~ : - , starting with a letter or digit`;
172
+ }
173
+ return undefined;
174
+ }