tests/read-only.test.js
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: GPL-3.0-or-later
/**
* The product promise is "read-only by construction": fixes are printed and
* never executed. That promise is only worth anything if it is checked, so
* this test reads every command the checks hand to ctx.run() and fails on
* anything that could change the machine — sudo, a mutating verb, or a
* service manager told to start/stop/restart.
*
* Commands that are only PRINTED (in a fix string) are deliberately not
* covered: telling the user to run `sudo dnf install` is the product working.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
function jsFiles(dir) {
const out = [];
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = dir + "/" + e.name;
if (e.isDirectory()) out.push(...jsFiles(p));
else if (e.name.endsWith(".js")) out.push(p);
}
return out;
}
/* Verbs that change state when they are the command being run. A check that
needs to know "is X installed" uses `command -v`, not `apt install`. */
const MUTATING = new Set([
"rm", "mv", "cp", "ln", "touch", "mkdir", "rmdir", "chmod", "chown", "truncate",
"dd", "tee", "mkfs", "fdisk", "parted", "mount", "umount", "sysctl", "fstrim", "blockdev",
"useradd", "usermod", "userdel", "groupadd", "passwd", "kill", "pkill", "killall",
"reboot", "shutdown", "poweroff", "halt", "systemd-run",
]);
/* Package managers, and the token patterns that make an invocation a QUESTION
rather than an action. `apt-get -s autoremove` and `dnf autoremove
--assumeno` change nothing; `apt-get autoremove` does. */
const PKG_QUERY = {
apt: (t) => t.some((x) => /^(-s|--simulate|check|list|show|policy|cache|search|--print-uris)$/.test(x)),
"apt-get": (t) => PKG_QUERY.apt(t),
dnf: (t) => t.some((x) => /^(check|repoquery|list|info|search|history|provides|--assumeno|--cacheonly)$/.test(x)),
dnf5: (t) => PKG_QUERY.dnf(t),
yum: (t) => PKG_QUERY.dnf(t),
pacman: (t) => t.some((x) => /^-Q|^-Dk$|^-D$|^-T$|^-Ss$|^-Si$/.test(x)),
zypper: (t) => t.some((x) => /^(packages|lu|list-updates|verify|search|info|patches|repos|--non-interactive)$/.test(x)),
apk: (t) => t.some((x) => /^(version|info|list|audit|dot|policy|search|--simulate)$/.test(x)),
"xbps-query": () => true,
"xbps-install": (t) => t.some((x) => /^(-S?un|-u?n|-n)$/.test(x)),
"xbps-remove": (t) => t.some((x) => /^-n$/.test(x)),
emerge: (t) => t.some((x) => /^(-p|--pretend|--info|--search|-s|--fetchonly)$/.test(x)),
flatpak: (t) => t.some((x) => /^(list|remote-ls|remote-info|info|search|history|permissions)$/.test(x)),
snap: (t) => t.some((x) => /^(list|info|version|changes|--list)$/.test(x)),
"rpm-ostree": (t) =>
t.includes("status") || t.includes("db") ||
(t.includes("upgrade") && t.includes("--check")) ||
(t.includes("kargs") && !t.some((x) => /^--(append|delete|editor|unset)/.test(x))),
swapon: (t) => t.some((x) => /^(--show|-s)$/.test(x)),
swaponx: () => true,
fstrim: (t) => t.some((x) => /^(--dry-run|-n)$/.test(x)),
"systemd-analyze": (t) => t.some((x) => /^(blame|critical-chain|verify|dump|unit-paths|time)$/.test(x)),
};
/* Service managers are fine when asked a question, never when told to act. */
const READ_SERVICE_ACTIONS = new Set([
"is-active", "is-enabled", "is-failed", "show", "status", "cat", "list-units",
"list-timers", "list-unit-files", "get-default", "daemon-reload-check",
]);
function segments(cmd) {
return cmd.split(/;|&&|\|\||\|/).map((s) => s.trim()).filter(Boolean);
}
/* First real token of a segment, skipping env assignments and wrappers. */
function firstToken(seg) {
const toks = seg.split(/\s+/).filter((t) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(t));
return { tok: (toks[0] || "").replace(/^[(]+/, ""), rest: toks.slice(1) };
}
function offence(cmd) {
for (const seg of segments(cmd)) {
const { tok, rest } = firstToken(seg);
const base = tok.split("/").pop();
if (base === "sudo" || base === "doas" || base === "pkexec") return "privilege escalation";
if (base === "systemctl" || base === "sv" || base === "rc-service" || base === "rc-update" || base === "service") {
const verb = rest.find((t) => !t.startsWith("-"));
if (verb && /^(start|stop|restart|reload|enable|disable|mask|reset-failed|set-default|kill|daemon-reexec)$/.test(verb)) {
return "service action: " + verb;
}
continue;
}
if (PKG_QUERY[base]) {
if (!PKG_QUERY[base](rest)) return "package manager in a non-query mode: " + base;
continue;
}
if (MUTATING.has(base)) return "mutating verb: " + base;
if (/(^|\s)>\s*[^&]/.test(seg) && !/2>/.test(seg)) return "output redirection to a file";
}
return null;
}
test("the doctor never EXECUTES a command that changes the machine", () => {
const offenders = [];
for (const f of jsFiles("src")) {
readFileSync(f, "utf8").split("\n").forEach((ln, i) => {
for (const m of ln.matchAll(/ctx\.run\(\s*[`"]([^`"]+)[`"]/g)) {
const why = offence(m[1]);
if (why) offenders.push(`${f}:${i + 1} — ${why}\n ${m[1].slice(0, 140)}`);
}
});
}
assert.deepEqual(offenders, [], "executed commands must be read-only:\n" + offenders.join("\n"));
});