@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.
- package/dist/fixtures.d.ts +174 -0
- package/dist/fixtures.js +334 -0
- package/dist/host.d.ts +21 -0
- package/dist/host.js +90 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +434 -0
- package/dist/lib/managers.d.ts +172 -0
- package/dist/lib/managers.js +266 -0
- package/dist/lib/names.d.ts +106 -0
- package/dist/lib/names.js +174 -0
- package/dist/lib/parse.d.ts +308 -0
- package/dist/lib/parse.js +572 -0
- package/dist/lib/plan.d.ts +88 -0
- package/dist/lib/plan.js +351 -0
- package/dist/lib/query.d.ts +106 -0
- package/dist/lib/query.js +552 -0
- package/dist/lib/unknown.d.ts +68 -0
- package/dist/lib/unknown.js +85 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/dist/run.d.ts +115 -0
- package/dist/run.js +295 -0
- package/package.json +46 -0
|
@@ -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
|
+
}
|