From d3cda70e10751b59cb6f744b69b8ad26022201df Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Mon, 19 Oct 2026 16:01:28 -0400 Subject: [PATCH 01/14] feat(os): support dnf, zypper, pacman and apk Pending updates, holds, automatic updates and reboot hints for RHEL and its rebuilds, Fedora, openSUSE and SLES, Arch and Alpine, checked against output captured on each distribution, which is kept as test fixtures. dnf 5 is never run as the web server: it needs a writable home and first copies its whole cache there, so the root helper reports pending updates on those systems. SUSE security patches mark the package updates they cover. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 9 +- lib/OsStatus/Packages/Apk.php | 149 +++++++ lib/OsStatus/Packages/Dnf.php | 380 ++++++++++++++++ .../Packages/PackageManagerFactory.php | 8 + lib/OsStatus/Packages/Pacman.php | 201 +++++++++ lib/OsStatus/Packages/PendingUpdate.php | 44 +- lib/OsStatus/Packages/Rpm.php | 95 ++++ lib/OsStatus/Packages/Zypper.php | 422 ++++++++++++++++++ tests/data/os/alma-9-dnf-automatic.conf | 97 ++++ tests/data/os/alma-9-dnf-needs-restarting-r | 5 + tests/data/os/alma-9-dnf-versionlock | 2 + tests/data/os/alpine-apk-list-upgradable | 3 + tests/data/os/alpine-apk-world-pinned | 7 + tests/data/os/arch-audit-u-pending | 1 + tests/data/os/arch-checkupdates-pending | 1 + tests/data/os/arch-pacman-conf-ignorepkg | 1 + tests/data/os/arch-pacman-qu-ignored | 1 + .../os/fedora-44-dnf-unwritable-home.stderr | 1 + tests/data/os/fedora-44-dnf-versionlock | 3 + tests/data/os/fedora-44-versionlock.toml | 19 + tests/data/os/leap-16-zypper-info-patch | 127 ++++++ tests/data/os/leap-16-zypper-locks.xml | 4 + .../os/leap-16-zypper-patches-security.xml | 90 ++++ tests/data/os/leap-16-zypper-updates.xml | 21 + tests/data/os/tumbleweed-os-release | 16 + tests/data/os/tumbleweed-zypper-updates.xml | 18 + tests/lib/OsStatus/DnfTest.php | 186 ++++++++ tests/lib/OsStatus/OsStatusCollectorTest.php | 8 +- tests/lib/OsStatus/PacmanApkTest.php | 84 ++++ tests/lib/OsStatus/RealOutputTest.php | 151 ++++++- tests/lib/OsStatus/ZypperTest.php | 122 +++++ 31 files changed, 2266 insertions(+), 10 deletions(-) create mode 100644 lib/OsStatus/Packages/Apk.php create mode 100644 lib/OsStatus/Packages/Dnf.php create mode 100644 lib/OsStatus/Packages/Pacman.php create mode 100644 lib/OsStatus/Packages/Rpm.php create mode 100644 lib/OsStatus/Packages/Zypper.php create mode 100644 tests/data/os/alma-9-dnf-automatic.conf create mode 100644 tests/data/os/alma-9-dnf-needs-restarting-r create mode 100644 tests/data/os/alma-9-dnf-versionlock create mode 100644 tests/data/os/alpine-apk-list-upgradable create mode 100644 tests/data/os/alpine-apk-world-pinned create mode 100644 tests/data/os/arch-audit-u-pending create mode 100644 tests/data/os/arch-checkupdates-pending create mode 100644 tests/data/os/arch-pacman-conf-ignorepkg create mode 100644 tests/data/os/arch-pacman-qu-ignored create mode 100644 tests/data/os/fedora-44-dnf-unwritable-home.stderr create mode 100644 tests/data/os/fedora-44-dnf-versionlock create mode 100644 tests/data/os/fedora-44-versionlock.toml create mode 100644 tests/data/os/leap-16-zypper-info-patch create mode 100644 tests/data/os/leap-16-zypper-locks.xml create mode 100644 tests/data/os/leap-16-zypper-patches-security.xml create mode 100644 tests/data/os/leap-16-zypper-updates.xml create mode 100644 tests/data/os/tumbleweed-os-release create mode 100644 tests/data/os/tumbleweed-zypper-updates.xml create mode 100644 tests/lib/OsStatus/DnfTest.php create mode 100644 tests/lib/OsStatus/PacmanApkTest.php create mode 100644 tests/lib/OsStatus/ZypperTest.php diff --git a/README.md b/README.md index 42c1a3dc..422040c6 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ secure: - automatic updates, package list age, held packages, failed system services, time synchronisation, SELinux and AppArmor -Debian and Ubuntu are supported. Inside Docker, Podman, Kubernetes or a snap the status says +Debian, Ubuntu, RHEL and its rebuilds, Fedora, openSUSE and SLES, Arch and +Alpine are supported. Inside Docker, Podman, Kubernetes or a snap the status says what can and cannot be known from inside; LXC and systemd-nspawn guests are treated like machines without a kernel of their own. @@ -69,8 +70,10 @@ MIT License); the app never looks them up online. ### Root helper The web server cannot see everything: only root can tell which services still -run code an update replaced, and only root can refresh the package lists. The -optional script `bin/serverinfo-os-report` adds that. Run as root, it writes a +run code an update replaced, and only root can refresh the package lists. On +Fedora and other systems with dnf 5, only root can list pending updates at all: +dnf 5 would first copy its whole cache into the web server's home. The +optional script `bin/serverinfo-os-report` adds all of that. Run as root, it writes a small JSON report that the app reads. It only reads the system; it never installs, upgrades, restarts or reboots anything. diff --git a/lib/OsStatus/Packages/Apk.php b/lib/OsStatus/Packages/Apk.php new file mode 100644 index 00000000..6fd815d6 --- /dev/null +++ b/lib/OsStatus/Packages/Apk.php @@ -0,0 +1,149 @@ +host->hasBinary('apk'); + } + + #[\Override] + public function classifiesSecurity(): bool { + return false; + } + + #[\Override] + public function getPendingUpdates(): array { + $result = $this->host->run('apk', ['list', '--upgradable'], 120); + if ($result === null) { + throw new ProbeException('apk could not be run.'); + } + if (!$result->succeeded()) { + throw new ProbeException('apk list --upgradable failed: ' . mb_substr(trim($result->stderr), 0, 200)); + } + + // apk lists packages pinned to an exact version too, though it never + // upgrades them. + $pinned = []; + foreach ($this->getHeldPackages() ?? [] as $held) { + if (str_starts_with($held->constraint ?? '', '=')) { + $pinned[$held->name] = true; + } + } + + return array_values(array_filter( + self::parseUpgradable($result->stdout), + static fn (PendingUpdate $update): bool => !isset($pinned[$update->name]), + )); + } + + /** + * Parses `apk list --upgradable`, e.g. + * busybox-1.36.1-r16 x86_64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r15] + * + * @return list + */ + public static function parseUpgradable(string $output): array { + $updates = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^(\S+)-(\d\S*-r\d+) \S+ \{[^}]*\} \([^)]*\) \[upgradable from: (\S+)\]$/', trim($line), $matches) !== 1) { + continue; + } + + $name = $matches[1]; + $installed = str_starts_with($matches[3], $name . '-') ? substr($matches[3], strlen($name) + 1) : null; + $updates[] = new PendingUpdate($name, $installed, $matches[2], false); + } + + return $updates; + } + + /** + * Packages pinned to a version or tag in /etc/apk/world. + */ + #[\Override] + public function getHeldPackages(): ?array { + $world = $this->host->readFile('/etc/apk/world', 262144); + if ($world === null) { + return null; + } + + $held = []; + foreach (preg_split('/\s+/', trim($world)) ?: [] as $entry) { + if (preg_match('/^([A-Za-z0-9._+-]+?)((?:[=<>~]|@)\S+)$/', $entry, $matches) === 1) { + $held[] = new HeldPackage($matches[1], 'apk world', $matches[2]); + } + } + + return $held; + } + + #[\Override] + public function getListsRefreshedAt(): ?int { + $times = []; + foreach ($this->host->glob('/var/cache/apk/APKINDEX.*.tar.gz') as $index) { + $time = $this->host->modifiedAt($index); + if ($time !== null) { + $times[] = $time; + } + } + + return $times === [] ? null : max($times); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + return $this->host->modifiedAt('/lib/apk/db/installed'); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + return AutomaticUpdates::unknown(); + } + + #[\Override] + public function getRebootReasons(): array { + return []; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + return false; + } + + #[\Override] + public function getUpgradeCommands(): array { + return ['all' => 'sudo apk upgrade --update-cache', 'security' => null]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return '/lib/apk/db/installed'; + } +} diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php new file mode 100644 index 00000000..c30ec6f8 --- /dev/null +++ b/lib/OsStatus/Packages/Dnf.php @@ -0,0 +1,380 @@ + true, + 'dnf5-automatic.timer' => null, + 'dnf-automatic.timer' => null, + 'dnf-automatic-download.timer' => false, + 'dnf-automatic-notifyonly.timer' => false, + ]; + + private ?bool $isDnf5 = null; + + public function __construct( + private IHost $host, + private Rpm $rpm, + private SystemdProbe $systemd, + ) { + } + + #[\Override] + public function getId(): string { + return 'dnf'; + } + + #[\Override] + public function isAvailable(): bool { + return $this->host->hasBinary('dnf') || $this->host->hasBinary('dnf5'); + } + + #[\Override] + public function classifiesSecurity(): bool { + return true; + } + + #[\Override] + public function getPendingUpdates(): array { + if ($this->isDnf5()) { + throw new ProbeException('dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.'); + } + + $result = $this->dnf(['-q', '--cacheonly', 'check-update'], 180); + if ($result === null) { + throw new ProbeException('dnf could not be run.'); + } + if ($result->exitCode !== 0 && $result->exitCode !== self::EXIT_UPDATES) { + throw new ProbeException('dnf check-update failed: ' . self::firstLine($result->stderr ?: $result->stdout)); + } + + $available = self::parseCheckUpdate($result->stdout); + if ($available === []) { + return []; + } + + $advisories = $this->securityAdvisories(); + $installed = $this->rpm->getInstalledVersions(); + + $updates = []; + foreach ($available as $name => [$version, $repository]) { + $advisory = $advisories[$name] ?? null; + $updates[] = new PendingUpdate( + $name, + $installed[$name] ?? null, + $version, + $advisory !== null, + $repository, + $advisory['ids'] ?? [], + $advisory['severity'] ?? null, + ); + } + + return $updates; + } + + /** + * Parses `dnf check-update`: "name.arch version-release repository", + * up to the "Obsoleting Packages" section. + * + * @return array name => [version, repository] + */ + public static function parseCheckUpdate(string $output): array { + $updates = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^Obsoleting|^Security:/i', $line) === 1) { + break; + } + if (preg_match('/^(\S+)\.(x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64)\s+(\S+)\s+(\S+)\s*$/', $line, $matches) === 1) { + $updates[$matches[1]] = [$matches[3], $matches[4]]; + } + } + + return $updates; + } + + /** + * Security advisories for the available updates, by package name. + * + * @return array, severity: ?string}> + */ + private function securityAdvisories(): array { + $result = $this->dnf(['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], 180); + if ($result === null || !$result->succeeded()) { + throw new ProbeException('dnf could not list security advisories: ' . self::firstLine($result?->stderr ?? '')); + } + + return self::parseAdvisories($result->stdout); + } + + /** + * Parses the advisory list of dnf 4 ("RHSA-2026:1234 Important/Sec. nevra") + * and dnf 5 ("FEDORA-2026-abc security Moderate nevra date"). + * + * @return array, severity: ?string}> + */ + public static function parseAdvisories(string $output): array { + $advisories = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + $columns = preg_split('/\s+/', trim($line)) ?: []; + if (count($columns) < 3 || preg_match('/^[A-Z][A-Za-z0-9]*-[\w:.-]+$/', $columns[0]) !== 1) { + continue; + } + + $severity = null; + $nevra = null; + if (preg_match('#^(\w+)/Sec\.$#', $columns[1], $matches) === 1) { + // dnf 4 + $severity = $matches[1] === 'None' ? null : $matches[1]; + $nevra = $columns[2]; + } elseif ($columns[1] === 'security' && isset($columns[3])) { + // dnf 5 + $severity = in_array($columns[2], ['None', 'none', 'Unknown'], true) ? null : $columns[2]; + $nevra = $columns[3]; + } + + $split = $nevra !== null ? Rpm::splitNevra($nevra) : null; + if ($split === null) { + continue; + } + + $name = $split[0]; + $advisories[$name] ??= ['ids' => [], 'severity' => null]; + if (!in_array($columns[0], $advisories[$name]['ids'], true)) { + $advisories[$name]['ids'][] = $columns[0]; + } + $advisories[$name]['severity'] = PendingUpdate::higherSeverity($advisories[$name]['severity'], $severity); + } + + return $advisories; + } + + #[\Override] + public function getHeldPackages(): ?array { + if ($this->isDnf5()) { + $locks = $this->host->readFile('/etc/dnf/versionlock.toml', 262144); + + return $locks === null ? ($this->host->fileExists('/etc/dnf') ? [] : null) : self::parseVersionlockToml($locks); + } + + // Without --cacheonly, dnf 4 downloads the full repository metadata + // into a per-user cache just to list the locks. + $result = $this->dnf(['-q', '--cacheonly', 'versionlock', 'list'], 60); + if ($result === null || !$result->succeeded()) { + return null; + } + + return self::parseVersionlock($result->stdout); + } + + /** + * dnf 4 prints one locked NEVRA pattern per line, dnf 5 a block per + * package with a "Package name:" line. + * + * @return list + */ + public static function parseVersionlock(string $output): array { + $held = []; + if (preg_match_all('/^Package name:\s*(\S+)/mi', $output, $matches) > 0) { + foreach ($matches[1] as $name) { + $held[$name] = new HeldPackage($name, 'dnf versionlock'); + } + + return array_values($held); + } + + foreach (preg_split('/\R/', $output) ?: [] as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || str_starts_with($line, 'Last metadata')) { + continue; + } + $pattern = preg_replace('/\.\*$/', '', $line) ?? $line; + $split = Rpm::splitNevra($pattern); + $name = $split[0] ?? $pattern; + if (preg_match('/^[A-Za-z0-9._+-]+$/', $name) === 1) { + $held[$name] = new HeldPackage($name, 'dnf versionlock', $split[1] ?? null); + } + } + + return array_values($held); + } + + /** + * dnf 5 keeps its locks in /etc/dnf/versionlock.toml, readable by everyone. + * + * @return list + */ + public static function parseVersionlockToml(string $toml): array { + $held = []; + $current = null; + foreach (preg_split('/\R/', $toml) ?: [] as $line) { + $line = trim($line); + if ($line === '[[packages]]') { + $current = null; + } elseif ($current === null && preg_match('/^name\s*=\s*"([A-Za-z0-9._+-]+)"$/', $line, $matches) === 1) { + $current = $matches[1]; + $held[$current] = new HeldPackage($current, 'dnf versionlock'); + } elseif ($current !== null && preg_match('/^value\s*=\s*"([^"]{1,100})"$/', $line, $matches) === 1) { + $held[$current] = new HeldPackage($current, 'dnf versionlock', $matches[1]); + } + } + + return array_values($held); + } + + /** + * Both dnf versions rewrite each repository's metalink.xml when they + * refresh it, whether or not the metadata changed. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + $times = []; + foreach (['/var/cache/dnf/*/metalink.xml', '/var/cache/libdnf5/*/metalink.xml', '/var/cache/dnf/*/repodata/repomd.xml', '/var/cache/libdnf5/*/repodata/repomd.xml', '/var/cache/dnf/last_makecache'] as $pattern) { + foreach (str_contains($pattern, '*') ? $this->host->glob($pattern) : [$pattern] as $file) { + $time = $this->host->modifiedAt($file); + if ($time !== null) { + $times[] = $time; + } + } + } + + return $times === [] ? null : max($times); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + return $this->rpm->getLastInstallTime(); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + $config = $this->host->readFile('/etc/dnf/automatic.conf', 65536) + ?? $this->host->readFile('/etc/dnf/dnf5-plugins/automatic.conf', 65536) + ?? ''; + $applies = preg_match('/^\s*apply_updates\s*=\s*(yes|true|1)\s*$/mi', $config) === 1; + $securityOnly = preg_match('/^\s*upgrade_type\s*=\s*security\s*$/mi', $config) === 1; + + $known = false; + foreach (self::AUTOMATIC_TIMERS as $timer => $installs) { + $state = $this->systemd->getUnitFileState($timer); + if ($state === null || $state === 'not-found') { + continue; + } + $known = true; + if ($state !== 'enabled') { + continue; + } + + $installs ??= $applies; + if (!$installs) { + return new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED, 'dnf-automatic', AutomaticUpdates::SCOPE_DOWNLOAD); + } + + return new AutomaticUpdates( + AutomaticUpdates::STATE_ENABLED, + 'dnf-automatic', + $securityOnly ? AutomaticUpdates::SCOPE_SECURITY : AutomaticUpdates::SCOPE_ALL, + ); + } + + return $known || $this->systemd->isRunning() + ? new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED) + : AutomaticUpdates::unknown(); + } + + /** + * `needs-restarting -r` exits 1 when core packages were updated since boot. + */ + #[\Override] + public function getRebootReasons(): array { + if ($this->isDnf5()) { + return []; + } + + $result = $this->dnf(['-q', '--cacheonly', 'needs-restarting', '-r'], 120); + if ($result === null || $result->exitCode !== 1 || $result->timedOut) { + return []; + } + + $packages = []; + if (preg_match_all('/^\s*\*\s*(\S+)/m', $result->stdout, $matches) > 0) { + $packages = array_values(array_unique($matches[1])); + } + + return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, $packages)]; + } + + /** + * dnf 5's needs-restarting cannot run unprivileged; there the generic + * kernel and core library checks answer instead. + */ + #[\Override] + public function hasOwnRebootCheck(): bool { + if ($this->isDnf5()) { + return false; + } + + $result = $this->dnf(['-q', 'needs-restarting', '--help'], 30); + + return $result !== null && $result->succeeded(); + } + + #[\Override] + public function getUpgradeCommands(): array { + return [ + 'all' => 'sudo dnf upgrade --refresh', + 'security' => 'sudo dnf upgrade --refresh --security', + ]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return $this->rpm->getDatabasePath(); + } + + /** + * Told apart by the binary rather than by running dnf, because running + * dnf 5 as the web server is what has to be avoided. + */ + private function isDnf5(): bool { + return $this->isDnf5 ??= $this->host->hasBinary('dnf5'); + } + + /** + * @param list $arguments + */ + private function dnf(array $arguments, int $timeout): ?CommandResult { + return $this->host->run($this->host->hasBinary('dnf') ? 'dnf' : 'dnf5', $arguments, $timeout); + } + + private static function firstLine(string $text): string { + return mb_substr(trim((string)strtok(trim($text), "\n")), 0, 200); + } +} diff --git a/lib/OsStatus/Packages/PackageManagerFactory.php b/lib/OsStatus/Packages/PackageManagerFactory.php index 52c2cebb..70450b9d 100644 --- a/lib/OsStatus/Packages/PackageManagerFactory.php +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -20,12 +20,20 @@ class PackageManagerFactory { public function __construct( private Apt $apt, + private Dnf $dnf, + private Zypper $zypper, + private Pacman $pacman, + private Apk $apk, ) { } public function forRelease(OsRelease $release): ?IPackageManager { $candidate = match (true) { $release->isLike('debian') || $release->isLike('ubuntu') => $this->apt->withRelease($release), + $release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles' || $release->id === 'sled' => $this->zypper, + $release->isLike('fedora') || $release->isLike('rhel') || $release->isLike('centos') || in_array($release->id, ['amzn', 'ol', 'almalinux', 'rocky'], true) => $this->dnf, + $release->isLike('arch') => $this->pacman, + $release->id === 'alpine' => $this->apk, default => null, }; diff --git a/lib/OsStatus/Packages/Pacman.php b/lib/OsStatus/Packages/Pacman.php new file mode 100644 index 00000000..fc76c4e7 --- /dev/null +++ b/lib/OsStatus/Packages/Pacman.php @@ -0,0 +1,201 @@ +host->hasBinary('pacman'); + } + + #[\Override] + public function classifiesSecurity(): bool { + return $this->host->hasBinary('arch-audit'); + } + + #[\Override] + public function getPendingUpdates(): array { + if ($this->host->hasBinary('checkupdates')) { + $result = $this->host->run('checkupdates', [], 180); + if ($result !== null && ($result->succeeded() || $result->exitCode === self::EXIT_NO_UPDATES)) { + return $this->withSecurity(self::parseUpdates($result->stdout)); + } + } + + // Falls back to the databases root last synced. + $result = $this->host->run('pacman', ['-Qu'], 60); + if ($result === null) { + throw new ProbeException('pacman could not be run.'); + } + // pacman -Qu exits 1 when nothing is upgradable. + if (!$result->succeeded() && trim($result->stdout) !== '') { + throw new ProbeException('pacman -Qu failed: ' . mb_substr(trim($result->stderr), 0, 200)); + } + + return $this->withSecurity(self::parseUpdates($result->stdout)); + } + + /** + * Parses "name old -> new" lines, as printed by checkupdates and pacman -Qu. + * Packages in IgnorePkg are held, not pending: pacman -Qu marks them + * "[ignored]", checkupdates leaves them out. + * + * @return list + */ + public static function parseUpdates(string $output): array { + $updates = []; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^(\S+) (\S+) -> (\S+)$/', trim($line), $matches) === 1) { + $updates[] = new PendingUpdate($matches[1], $matches[2], $matches[3], false); + } + } + + return $updates; + } + + /** + * Marks the updates that fix a known vulnerability, according to arch-audit. + * + * @param list $updates + * @return list + */ + private function withSecurity(array $updates): array { + if ($updates === [] || !$this->classifiesSecurity()) { + return $updates; + } + + $result = $this->host->run('arch-audit', ['--upgradable', '--quiet'], 120); + if ($result === null || !$result->succeeded()) { + throw new ProbeException('arch-audit could not be run.'); + } + + // Each line starts with the package name, followed by a description + // unless --quiet was honoured. + $vulnerable = []; + foreach (preg_split('/\R/', $result->stdout) ?: [] as $line) { + if (preg_match('/^([A-Za-z0-9@._+-]+)/', trim($line), $matches) === 1) { + $vulnerable[$matches[1]] = true; + } + } + + return array_map( + static fn (PendingUpdate $u): PendingUpdate => isset($vulnerable[$u->name]) + ? new PendingUpdate($u->name, $u->installedVersion, $u->candidateVersion, true, $u->origin) + : $u, + $updates, + ); + } + + #[\Override] + public function getHeldPackages(): ?array { + $config = $this->host->readFile('/etc/pacman.conf', 262144); + if ($config === null) { + return null; + } + + return self::parseIgnored($config); + } + + /** + * @return list + */ + public static function parseIgnored(string $config): array { + $held = []; + if (preg_match_all('/^\s*IgnorePkg\s*=\s*(.+)$/m', $config, $matches) > 0) { + foreach ($matches[1] as $list) { + foreach (preg_split('/\s+/', trim(preg_replace('/#.*$/', '', $list) ?? '')) ?: [] as $name) { + if ($name !== '') { + $held[$name] = new HeldPackage($name, 'pacman IgnorePkg'); + } + } + } + } + + return array_values($held); + } + + /** + * checkupdates fetches fresh databases itself, so there are no lists to age. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + return null; + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + $log = $this->host->readFileTail(self::LOG, 512 * 1024); + if ($log === null || preg_match_all('/^\[([0-9T:+-]{19,25})\] \[ALPM\] (?:upgraded|installed) /m', $log, $matches) < 1) { + return $this->host->modifiedAt('/var/lib/pacman/local'); + } + + $time = strtotime((string)end($matches[1])); + + return $time === false ? null : $time; + } + + /** + * Arch has no standard way to upgrade unattended and advises against it. + */ + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + return AutomaticUpdates::unknown(); + } + + /** + * Covered by the generic kernel check: pacman removes the running + * kernel's modules when it upgrades the kernel. + */ + #[\Override] + public function getRebootReasons(): array { + return []; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + return false; + } + + #[\Override] + public function getUpgradeCommands(): array { + return ['all' => 'sudo pacman -Syu', 'security' => null]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return '/var/lib/pacman/local'; + } +} diff --git a/lib/OsStatus/Packages/PendingUpdate.php b/lib/OsStatus/Packages/PendingUpdate.php index 0ab3ee08..c7156fa6 100644 --- a/lib/OsStatus/Packages/PendingUpdate.php +++ b/lib/OsStatus/Packages/PendingUpdate.php @@ -16,6 +16,10 @@ * A package that has a newer version waiting in the repositories. */ final class PendingUpdate { + public const KIND_PACKAGE = 'package'; + /** A SUSE patch: one advisory covering one or more packages */ + public const KIND_PATCH = 'patch'; + /** * @param list $advisories advisory or CVE identifiers, e.g. "USN-7012-1", "RHSA-2026:1234" */ @@ -31,11 +35,15 @@ public function __construct( public readonly ?string $severity = null, /** When this update was first seen pending, as a unix timestamp */ public readonly ?int $since = null, + /** @var self::KIND_* */ + public readonly string $kind = self::KIND_PACKAGE, + /** What a patch fixes, e.g. "Security update for glibc" */ + public readonly ?string $summary = null, ) { } /** - * @return array{name: string, installed: ?string, candidate: string, security: bool, origin: ?string, advisories: list, severity: ?string, since: ?int, stack: bool} + * @return array{name: string, installed: ?string, candidate: string, security: bool, origin: ?string, advisories: list, severity: ?string, since: ?int, kind: string, stack: bool, summary: ?string} */ public function toArray(): array { return [ @@ -47,7 +55,9 @@ public function toArray(): array { 'advisories' => $this->advisories, 'severity' => $this->severity, 'since' => $this->since, + 'kind' => $this->kind, 'stack' => $this->isStack(), + 'summary' => $this->summary, ]; } @@ -65,14 +75,40 @@ public static function fromArray(mixed $data): ?self { Values::strings($data['advisories'] ?? null), Values::string($data['severity'] ?? null), Values::int($data['since'] ?? null), + ($data['kind'] ?? null) === self::KIND_PATCH ? self::KIND_PATCH : self::KIND_PACKAGE, + Values::string($data['summary'] ?? null), ); } /** - * Whether this updates something Nextcloud runs on. + * The more severe of two advisory severities, as distributions name them. + */ + public static function higherSeverity(?string $a, ?string $b): ?string { + $rank = ['low' => 1, 'moderate' => 2, 'important' => 3, 'critical' => 4]; + if ($a === null) { + return $b; + } + if ($b === null) { + return $a; + } + + return ($rank[strtolower($b)] ?? 0) > ($rank[strtolower($a)] ?? 0) ? $b : $a; + } + + /** + * Whether this updates something Nextcloud runs on. A patch names the + * package it fixes in its summary, e.g. "Security update for openssl-3". */ public function isStack(): bool { - return NextcloudStack::isStackPackage($this->name); + if (NextcloudStack::isStackPackage($this->name)) { + return true; + } + + if ($this->kind !== self::KIND_PATCH || preg_match('/update for ([A-Za-z0-9._+-]+)/i', $this->summary ?? '', $matches) !== 1) { + return false; + } + + return NextcloudStack::isStackPackage($matches[1]); } /** @@ -84,6 +120,6 @@ public function key(): string { } public function withSince(?int $since): self { - return new self($this->name, $this->installedVersion, $this->candidateVersion, $this->security, $this->origin, $this->advisories, $this->severity, $since); + return new self($this->name, $this->installedVersion, $this->candidateVersion, $this->security, $this->origin, $this->advisories, $this->severity, $since, $this->kind, $this->summary); } } diff --git a/lib/OsStatus/Packages/Rpm.php b/lib/OsStatus/Packages/Rpm.php new file mode 100644 index 00000000..fab4c78e --- /dev/null +++ b/lib/OsStatus/Packages/Rpm.php @@ -0,0 +1,95 @@ +|null */ + private ?array $installed = null; + + public function __construct( + private IHost $host, + ) { + } + + public function getDatabasePath(): ?string { + foreach (self::DATABASES as $path) { + if ($this->host->fileExists($path)) { + return $path; + } + } + + return null; + } + + /** + * Installed version (with epoch when it has one) by package name. + * + * @return array + */ + public function getInstalledVersions(): array { + if ($this->installed !== null) { + return $this->installed; + } + + $this->installed = []; + $result = $this->host->run('rpm', ['-qa', '--qf', '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE}\n'], 60); + if ($result === null || !$result->succeeded()) { + return $this->installed; + } + + foreach (preg_split('/\R/', $result->stdout) ?: [] as $line) { + if (preg_match('/^(\S+) (\d+):(\S+)$/', trim($line), $matches) === 1) { + // Several versions of a name (kernels) keep the last listed. + $this->installed[$matches[1]] = $matches[2] === '0' ? $matches[3] : $matches[2] . ':' . $matches[3]; + } + } + + return $this->installed; + } + + public function getLastInstallTime(): ?int { + $result = $this->host->run('rpm', ['-qa', '--qf', '%{INSTALLTIME}\n'], 60); + if ($result === null || !$result->succeeded()) { + return null; + } + + $times = array_map('intval', array_filter(preg_split('/\R/', $result->stdout) ?: [], 'ctype_digit')); + + return $times === [] ? null : max($times); + } + + /** + * Splits "name-[epoch:]version-release.arch" into name and version. + * + * @return array{0: string, 1: string}|null + */ + public static function splitNevra(string $nevra): ?array { + $withoutArch = preg_replace('/\.(x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64|src)$/', '', $nevra) ?? $nevra; + if (preg_match('/^(.+)-([^-]+-[^-]+)$/', $withoutArch, $matches) !== 1) { + return null; + } + + return [$matches[1], $matches[2]]; + } +} diff --git a/lib/OsStatus/Packages/Zypper.php b/lib/OsStatus/Packages/Zypper.php new file mode 100644 index 00000000..71d1f11a --- /dev/null +++ b/lib/OsStatus/Packages/Zypper.php @@ -0,0 +1,422 @@ +host->hasBinary('zypper'); + } + + /** + * Tumbleweed and MicroOS publish no patches, only new package versions, + * so there is nothing to tell security fixes by. + */ + #[\Override] + public function classifiesSecurity(): bool { + return !$this->isRolling(); + } + + private function isRolling(): bool { + $release = $this->host->readFile('/etc/os-release', 65536) ?? ''; + + return preg_match('/^ID="?opensuse-(tumbleweed|microos|slowroll)"?$/m', $release) === 1; + } + + #[\Override] + public function getPendingUpdates(): array { + if ($this->isRolling()) { + return self::parsePackageUpdates($this->zypperXml(['list-updates'])); + } + + $patches = self::parsePatches($this->zypperXml(['list-patches', '--category', 'security'])); + $packages = self::parsePackageUpdates($this->zypperXml(['list-updates'])); + + return self::mergePatches($patches, $packages, $this->patchPackages($patches)); + } + + /** + * Which packages each patch updates, from the "Conflicts" of + * `zypper info -t patch`, which lists every package older than the fix. + * + * @param list $patches + * @return array> patch name => package names + */ + private function patchPackages(array $patches): array { + $names = array_values(array_filter( + array_map(static fn (PendingUpdate $patch): string => $patch->name, $patches), + static fn (string $name): bool => preg_match('/^[A-Za-z0-9._:+-]+$/', $name) === 1, + )); + if ($names === []) { + return []; + } + + $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', 'info', '-t', 'patch', ...array_slice($names, 0, self::MAX_PATCH_LOOKUP)], 180); + if ($result === null || !$result->succeeded()) { + return []; + } + + return self::parsePatchConflicts($result->stdout); + } + + /** + * @return array> patch name => package names, without architecture + */ + public static function parsePatchConflicts(string $output): array { + $packages = []; + $patch = null; + $inConflicts = false; + foreach (preg_split('/\R/', $output) ?: [] as $line) { + if (preg_match('/^Information for patch (\S+):$/', $line, $matches) === 1) { + $patch = $matches[1]; + $packages[$patch] = []; + $inConflicts = false; + continue; + } + if ($patch === null) { + continue; + } + if (preg_match('/^Conflicts\s*:/', $line) === 1) { + $inConflicts = true; + continue; + } + if ($inConflicts && preg_match('/^\s+([A-Za-z0-9._+-]+?)(?:\.(?:x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64|src))?\s*[<=>]/', $line, $matches) === 1) { + $packages[$patch][$matches[1]] = true; + continue; + } + $inConflicts = $inConflicts && trim($line) !== '' && preg_match('/^\s/', $line) === 1; + } + + return array_map(static fn (array $names): array => array_keys($names), $packages); + } + + /** + * Marks the package updates a security patch covers as security updates + * with its advisories; patches covering no listed package stay rows of + * their own, so no security fix goes unreported. + * + * @param list $patches + * @param list $packages + * @param array> $coverage patch name => package names + * @return list + */ + public static function mergePatches(array $patches, array $packages, array $coverage): array { + $byPackage = []; + foreach ($patches as $patch) { + foreach ($coverage[$patch->name] ?? [] as $package) { + $byPackage[$package][] = $patch; + } + } + + $covered = []; + $merged = []; + foreach ($packages as $package) { + $fixes = $byPackage[$package->name] ?? []; + if ($fixes === []) { + $merged[] = $package; + continue; + } + + $advisories = []; + $severity = null; + foreach ($fixes as $patch) { + $covered[$patch->name] = true; + $advisories = array_merge($advisories, $patch->advisories); + $severity = PendingUpdate::higherSeverity($severity, $patch->severity); + } + $merged[] = new PendingUpdate( + $package->name, + $package->installedVersion, + $package->candidateVersion, + true, + $package->origin, + array_values(array_unique($advisories)), + $severity, + ); + } + + foreach ($patches as $patch) { + if (!isset($covered[$patch->name])) { + $merged[] = $patch; + } + } + + return $merged; + } + + /** + * @return list + */ + public static function parsePatches(string $xml): array { + $updates = []; + foreach (self::updateElements($xml) as $update) { + if ((string)$update['kind'] !== 'patch' || (string)$update['category'] !== 'security') { + continue; + } + // Patches already applied, or not applicable to this system, are listed too. + if (!in_array((string)$update['status'], ['', 'needed'], true)) { + continue; + } + + $name = (string)$update['name']; + $severity = (string)$update['severity']; + $advisories = [$name]; + foreach ($update->xpath('issue-list/issue[@type="cve"]') ?: [] as $issue) { + $id = (string)$issue['id']; + if (preg_match('/^CVE-\d{4}-\d+$/', $id) === 1 && !in_array($id, $advisories, true)) { + $advisories[] = $id; + } + } + $summary = trim((string)$update->summary); + + $updates[] = new PendingUpdate( + $name, + null, + (string)$update['edition'], + true, + null, + $advisories, + $severity !== '' && $severity !== 'unspecified' ? ucfirst($severity) : null, + null, + PendingUpdate::KIND_PATCH, + $summary !== '' ? mb_substr($summary, 0, 200) : null, + ); + } + + return $updates; + } + + /** + * @return list + */ + public static function parsePackageUpdates(string $xml): array { + $updates = []; + foreach (self::updateElements($xml) as $update) { + if ((string)$update['kind'] !== 'package') { + continue; + } + + $old = (string)$update['edition-old']; + $source = $update->source['alias'] ?? $update->source['url'] ?? null; + $updates[] = new PendingUpdate( + (string)$update['name'], + $old !== '' ? $old : null, + (string)$update['edition'], + false, + $source !== null ? (string)$source : null, + ); + } + + return $updates; + } + + /** + * @return list<\SimpleXMLElement> + */ + private static function updateElements(string $xml): array { + $document = self::parseXml($xml); + if ($document === null) { + return []; + } + + $elements = $document->xpath('//update-list/update') ?: []; + + return array_values(array_filter($elements, static fn (mixed $e): bool => $e instanceof \SimpleXMLElement)); + } + + /** + * Parses zypper's XML without network access or entity expansion. + */ + private static function parseXml(string $xml): ?\SimpleXMLElement { + $start = strpos($xml, ' $arguments + */ + private function zypperXml(array $arguments): string { + $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', '--xmlout', ...$arguments], 180); + if ($result === null) { + throw new ProbeException('zypper could not be run.'); + } + // zypper reports trouble inside the XML stream as . + $xmlError = preg_match('#([^<]*)#', $result->stdout, $matches) === 1 + ? html_entity_decode($matches[1], ENT_QUOTES | ENT_XML1) + : null; + if (!$result->succeeded() || $xmlError !== null) { + $message = $xmlError ?? trim($result->stderr ?: $result->stdout); + throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . mb_substr($message, 0, 200)); + } + + return $result->stdout; + } + + #[\Override] + public function getHeldPackages(): ?array { + $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'locks'], 60); + if ($result === null || !$result->succeeded()) { + return null; + } + + return self::parseLocks($result->stdout); + } + + /** + * @return list + */ + public static function parseLocks(string $xml): array { + $document = self::parseXml($xml); + if ($document === null) { + return []; + } + + $held = []; + foreach ($document->xpath('//locks/lock') ?: [] as $lock) { + $name = trim((string)$lock->name); + if ($name !== '') { + $held[$name] = new HeldPackage($name, 'zypper addlock'); + } + } + + return array_values($held); + } + + /** + * zypper writes a cookie next to the raw metadata of each repository on + * every refresh. + */ + #[\Override] + public function getListsRefreshedAt(): ?int { + $times = []; + foreach (['/var/cache/zypp/raw/*/cookie', '/var/cache/zypp/solv/*/cookie', '/var/cache/zypp/raw/*/repodata/repomd.xml'] as $pattern) { + foreach ($this->host->glob($pattern) as $file) { + $time = $this->host->modifiedAt($file); + if ($time !== null) { + $times[] = $time; + } + } + } + + return $times === [] ? null : max($times); + } + + #[\Override] + public function getLastUpgradeAt(): ?int { + return $this->rpm->getLastInstallTime(); + } + + #[\Override] + public function getAutomaticUpdates(): AutomaticUpdates { + $transactional = $this->systemd->getUnitFileState('transactional-update.timer'); + if ($transactional === 'enabled') { + return new AutomaticUpdates(AutomaticUpdates::STATE_ENABLED, 'transactional-update', AutomaticUpdates::SCOPE_ALL); + } + + $yast = $this->host->readFile('/etc/sysconfig/automatic_online_update', 65536); + if ($yast !== null && preg_match('/^AOU_ENABLE_CRONJOB="?(true|yes)"?/mi', $yast) === 1) { + $securityOnly = preg_match('/^AOU_PATCH_CATEGORIES="?security"?/mi', $yast) === 1; + + return new AutomaticUpdates( + AutomaticUpdates::STATE_ENABLED, + 'yast2-online-update', + $securityOnly ? AutomaticUpdates::SCOPE_SECURITY : AutomaticUpdates::SCOPE_ALL, + ); + } + + return $this->systemd->isRunning() ? new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED) : AutomaticUpdates::unknown(); + } + + #[\Override] + public function getRebootReasons(): array { + $result = $this->host->run('zypper', ['--non-interactive', 'needs-rebooting'], 60); + if ($result !== null && $result->exitCode === self::EXIT_REBOOT_NEEDED) { + return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, [], $this->host->modifiedAt('/run/reboot-needed'))]; + } + + if ($result === null && $this->host->fileExists('/run/reboot-needed')) { + return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, [], $this->host->modifiedAt('/run/reboot-needed'))]; + } + + return []; + } + + #[\Override] + public function hasOwnRebootCheck(): bool { + $result = $this->host->run('zypper', ['--non-interactive', 'needs-rebooting'], 60); + + return $result !== null && ($result->exitCode === 0 || $result->exitCode === self::EXIT_REBOOT_NEEDED); + } + + #[\Override] + public function getUpgradeCommands(): array { + if ($this->isRolling()) { + return [ + 'all' => $this->host->hasBinary('transactional-update') ? 'sudo transactional-update dup' : 'sudo zypper refresh && sudo zypper dup', + 'security' => null, + ]; + } + + return [ + 'all' => 'sudo zypper refresh && sudo zypper patch', + 'security' => 'sudo zypper refresh && sudo zypper patch --category security', + ]; + } + + #[\Override] + public function getPackageDatabasePath(): ?string { + return $this->rpm->getDatabasePath(); + } +} diff --git a/tests/data/os/alma-9-dnf-automatic.conf b/tests/data/os/alma-9-dnf-automatic.conf new file mode 100644 index 00000000..7b3b1616 --- /dev/null +++ b/tests/data/os/alma-9-dnf-automatic.conf @@ -0,0 +1,97 @@ +### /etc/dnf/automatic.conf +[commands] +# What kind of upgrade to perform: +# default = all available upgrades +# security = only the security upgrades +upgrade_type = default +random_sleep = 0 + +# Maximum time in seconds to wait until the system is on-line and able to +# connect to remote repositories. +network_online_timeout = 60 + +# To just receive updates use dnf-automatic-notifyonly.timer + +# Whether updates should be downloaded when they are available, by +# dnf-automatic.timer. notifyonly.timer, download.timer and +# install.timer override this setting. +download_updates = yes + +# Whether updates should be applied when they are available, by +# dnf-automatic.timer. notifyonly.timer, download.timer and +# install.timer override this setting. +apply_updates = no + +# When the system should reboot following upgrades: +# never = don't reboot after upgrades +# when-changed = reboot after any changes +# when-needed = reboot when necessary to apply changes +reboot = never + +# The command that is run to trigger a system reboot. +reboot_command = "shutdown -r +5 'Rebooting after applying package updates'" + + +[emitters] +# Name to use for this system in messages that are emitted. Default is the +# hostname. +# system_name = my-host + +# How to send messages. Valid options are stdio, email and motd. If +# emit_via includes stdio, messages will be sent to stdout; this is useful +# to have cron send the messages. If emit_via includes email, this +# program will send email itself according to the configured options. +# If emit_via includes motd, /etc/motd file will have the messages. if +# emit_via includes command_email, then messages will be send via a shell +# command compatible with sendmail. +# Default is email,stdio. +# If emit_via is None or left blank, no messages will be sent. +emit_via = stdio + + +[email] +# The address to send email messages from. +email_from = root@example.com + +# List of addresses to send messages to. +email_to = root + +# Name of the host to connect to to send email messages. +email_host = localhost + + +[command] +# The shell command to execute. This is a Python format string, as used in +# str.format(). The format function will pass a shell-quoted argument called +# `body`. +# command_format = "cat" + +# The contents of stdin to pass to the command. It is a format string with the +# same arguments as `command_format`. +# stdin_format = "{body}" + + +[command_email] +# The shell command to use to send email. This is a Python format string, +# as used in str.format(). The format function will pass shell-quoted arguments +# called body, subject, email_from, email_to. +# command_format = "mail -Ssendwait -s {subject} -r {email_from} {email_to}" + +# The contents of stdin to pass to the command. It is a format string with the +# same arguments as `command_format`. +# stdin_format = "{body}" + +# The address to send email messages from. +email_from = root@example.com + +# List of addresses to send messages to. +email_to = root + + +[base] +# This section overrides dnf.conf + +# Use this to filter DNF core messages +debuglevel = 1 +### /usr/share/dnf5/dnf5-plugins/automatic.conf +cat: /usr/share/dnf5/dnf5-plugins/automatic.conf: No such file or directory diff --git a/tests/data/os/alma-9-dnf-needs-restarting-r b/tests/data/os/alma-9-dnf-needs-restarting-r new file mode 100644 index 00000000..7fc1aaa0 --- /dev/null +++ b/tests/data/os/alma-9-dnf-needs-restarting-r @@ -0,0 +1,5 @@ +Core libraries or services have been updated since boot-up: + * glibc + +Reboot is required to fully utilize these updates. +More information: https://access.redhat.com/solutions/27943 diff --git a/tests/data/os/alma-9-dnf-versionlock b/tests/data/os/alma-9-dnf-versionlock new file mode 100644 index 00000000..57c031a2 --- /dev/null +++ b/tests/data/os/alma-9-dnf-versionlock @@ -0,0 +1,2 @@ +Last metadata expiration check: 0:11:05 ago on Mon Oct 19 18:12:41 2026. +sudo-0:1.9.17p2-3.el9_8.1.* diff --git a/tests/data/os/alpine-apk-list-upgradable b/tests/data/os/alpine-apk-list-upgradable new file mode 100644 index 00000000..50702263 --- /dev/null +++ b/tests/data/os/alpine-apk-list-upgradable @@ -0,0 +1,3 @@ +libcrypto3-3.5.9-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.5.8-r0] +libssl3-3.5.9-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.5.8-r0] +zlib-1.3.2-r1 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.2-r0] diff --git a/tests/data/os/alpine-apk-world-pinned b/tests/data/os/alpine-apk-world-pinned new file mode 100644 index 00000000..1f180b42 --- /dev/null +++ b/tests/data/os/alpine-apk-world-pinned @@ -0,0 +1,7 @@ +alpine-baselayout +alpine-keys +alpine-release +apk-tools +busybox +musl-utils +zlib=1.3.2-r0 diff --git a/tests/data/os/arch-audit-u-pending b/tests/data/os/arch-audit-u-pending new file mode 100644 index 00000000..c3fbc880 --- /dev/null +++ b/tests/data/os/arch-audit-u-pending @@ -0,0 +1 @@ +python-urllib3 is affected by certificate verification bypass, denial of service. High risk! Update to at least 1.26.5-1! diff --git a/tests/data/os/arch-checkupdates-pending b/tests/data/os/arch-checkupdates-pending new file mode 100644 index 00000000..d27febbc --- /dev/null +++ b/tests/data/os/arch-checkupdates-pending @@ -0,0 +1 @@ +python-urllib3 1.26.3-1 -> 2.8.0-1 diff --git a/tests/data/os/arch-pacman-conf-ignorepkg b/tests/data/os/arch-pacman-conf-ignorepkg new file mode 100644 index 00000000..328bef6a --- /dev/null +++ b/tests/data/os/arch-pacman-conf-ignorepkg @@ -0,0 +1 @@ +IgnorePkg = python-requests diff --git a/tests/data/os/arch-pacman-qu-ignored b/tests/data/os/arch-pacman-qu-ignored new file mode 100644 index 00000000..aed801e5 --- /dev/null +++ b/tests/data/os/arch-pacman-qu-ignored @@ -0,0 +1 @@ +python-requests 2.31.0-3 -> 2.34.2-1 [ignored] diff --git a/tests/data/os/fedora-44-dnf-unwritable-home.stderr b/tests/data/os/fedora-44-dnf-unwritable-home.stderr new file mode 100644 index 00000000..9350a89d --- /dev/null +++ b/tests/data/os/fedora-44-dnf-unwritable-home.stderr @@ -0,0 +1 @@ +filesystem error: cannot create directories: Permission denied [/var/www/.local/state] diff --git a/tests/data/os/fedora-44-dnf-versionlock b/tests/data/os/fedora-44-dnf-versionlock new file mode 100644 index 00000000..c0e9d175 --- /dev/null +++ b/tests/data/os/fedora-44-dnf-versionlock @@ -0,0 +1,3 @@ +# Added by 'versionlock add' command on 2026-10-19 18:33:45 +Package name: sudo +evr = 1.9.17-7.p2.fc44 diff --git a/tests/data/os/fedora-44-versionlock.toml b/tests/data/os/fedora-44-versionlock.toml new file mode 100644 index 00000000..4e08791b --- /dev/null +++ b/tests/data/os/fedora-44-versionlock.toml @@ -0,0 +1,19 @@ +version = "1.0" + +[[packages]] +name = "sudo" +comment = "Added by 'versionlock add' command on 2026-10-19 19:33:30" + +[[packages.conditions]] +key = "evr" +comparator = "=" +value = "1.9.17-10.p2.fc44" + +[[packages]] +name = "curl" +comment = "Added by 'versionlock add' command on 2026-10-19 19:33:30" + +[[packages.conditions]] +key = "evr" +comparator = "=" +value = "8.18.0-14.fc44" diff --git a/tests/data/os/leap-16-zypper-info-patch b/tests/data/os/leap-16-zypper-info-patch new file mode 100644 index 00000000..55c0b87a --- /dev/null +++ b/tests/data/os/leap-16-zypper-info-patch @@ -0,0 +1,127 @@ +Loading repository data... +Reading installed packages... + + +Information for patch openSUSE-Leap-16.0-1157: +---------------------------------------------- +Repository : repo-oss (16.0) +Name : openSUSE-Leap-16.0-1157 +Version : 1 +Arch : noarch +Vendor : maintenance@opensuse.org +Status : applied +Category : security +Severity : moderate +Created On : Fri Jul 3 13:19:16 2026 +Interactive : --- +Summary : Security update for glibc +Description : + This update for glibc fixes the following issues + + - CVE-2026-5435: unchecked buffer writing in TSIG handling can lead to an out-of-bounds write (bsc#1263656). + - CVE-2026-6238: insufficient RDATA length validation can lead to application crashes or uninitialized memory disclosure + (bsc#1263658). +Provides : patch:openSUSE-Leap-16.0-1157 = 1 +Conflicts : [53] + glibc.aarch64 < 2.40-160000.6.1 + glibc.noarch < 2.40-160000.6.1 + glibc-devel.aarch64 < 2.40-160000.6.1 + glibc-devel.noarch < 2.40-160000.6.1 + glibc-devel-static.aarch64 < 2.40-160000.6.1 + glibc-devel-static.noarch < 2.40-160000.6.1 + glibc-extra.aarch64 < 2.40-160000.6.1 + glibc-extra.noarch < 2.40-160000.6.1 + glibc-gconv-modules-extra.aarch64 < 2.40-160000.6.1 + glibc-gconv-modules-extra.noarch < 2.40-160000.6.1 + glibc-html < 2.40-160000.6.1 + glibc-i18ndata < 2.40-160000.6.1 + glibc-info < 2.40-160000.6.1 + glibc-lang < 2.40-160000.6.1 + glibc-locale.aarch64 < 2.40-160000.6.1 + glibc-locale.noarch < 2.40-160000.6.1 + glibc-locale-base.aarch64 < 2.40-160000.6.1 + glibc-locale-base.noarch < 2.40-160000.6.1 + glibc-profile.aarch64 < 2.40-160000.6.1 + glibc-profile.noarch < 2.40-160000.6.1 + cross-ppc64le-glibc-devel < 2.40-160000.6.1 + cross-riscv64-glibc-devel < 2.40-160000.6.1 + cross-s390x-glibc-devel < 2.40-160000.6.1 + glibc-utils.aarch64 < 2.40-160000.6.1 + glibc-utils.noarch < 2.40-160000.6.1 + glibc.ppc64le < 2.40-160000.6.1 + glibc-devel.ppc64le < 2.40-160000.6.1 + glibc-devel-static.ppc64le < 2.40-160000.6.1 + glibc-extra.ppc64le < 2.40-160000.6.1 + glibc-gconv-modules-extra.ppc64le < 2.40-160000.6.1 + glibc-locale.ppc64le < 2.40-160000.6.1 + glibc-locale-base.ppc64le < 2.40-160000.6.1 + glibc-profile.ppc64le < 2.40-160000.6.1 + cross-aarch64-glibc-devel < 2.40-160000.6.1 + glibc-utils.ppc64le < 2.40-160000.6.1 + glibc.s390x < 2.40-160000.6.1 + glibc-devel.s390x < 2.40-160000.6.1 + glibc-devel-static.s390x < 2.40-160000.6.1 + glibc-extra.s390x < 2.40-160000.6.1 + glibc-gconv-modules-extra.s390x < 2.40-160000.6.1 + glibc-locale.s390x < 2.40-160000.6.1 + glibc-locale-base.s390x < 2.40-160000.6.1 + glibc-profile.s390x < 2.40-160000.6.1 + glibc-utils.s390x < 2.40-160000.6.1 + glibc.x86_64 < 2.40-160000.6.1 + glibc-devel.x86_64 < 2.40-160000.6.1 + glibc-devel-static.x86_64 < 2.40-160000.6.1 + glibc-extra.x86_64 < 2.40-160000.6.1 + glibc-gconv-modules-extra.x86_64 < 2.40-160000.6.1 + glibc-locale.x86_64 < 2.40-160000.6.1 + glibc-locale-base.x86_64 < 2.40-160000.6.1 + glibc-profile.x86_64 < 2.40-160000.6.1 + glibc-utils.x86_64 < 2.40-160000.6.1 + + +Information for patch openSUSE-Leap-16.0-1187: +---------------------------------------------- +Repository : repo-oss (16.0) +Name : openSUSE-Leap-16.0-1187 +Version : 1 +Arch : noarch +Vendor : maintenance@opensuse.org +Status : applied +Category : security +Severity : important +Created On : Wed Jul 8 13:46:40 2026 +Interactive : --- +Summary : Security update for curl +Description : + This update for curl fixes the following issues + + - CVE-2026-8286: wrong STARTTLS connection reuse (bsc#1268402). + - CVE-2026-8458: wrong reuse for different services (bsc#1268407). + - CVE-2026-8924: traling dot domain super cookie (bsc#1268409). + - CVE-2026-8927: env-set cross-proxy Digest auth state leak (bsc#1268413). + - CVE-2026-9079: stale proxy password leak (bsc#1268415). + - CVE-2026-9080: UAF after pause in socket callback (bsc#1268416). + - CVE-2026-9545: exposing HTTP/3 early data (bsc#1268417). + - CVE-2026-9547: SSH improper host validation (bsc#1268420). + - CVE-2026-10536: HTTP/2 stream-dependency tree UAF (bsc#1268422). + - CVE-2026-12064: proto-default skips SSH verification (bsc#1268427). +Provides : patch:openSUSE-Leap-16.0-1187 = 1 +Conflicts : [18] + curl.aarch64 < 8.14.1-160000.8.1 + curl.noarch < 8.14.1-160000.8.1 + curl-fish-completion < 8.14.1-160000.8.1 + curl-zsh-completion < 8.14.1-160000.8.1 + libcurl-devel.aarch64 < 8.14.1-160000.8.1 + libcurl-devel.noarch < 8.14.1-160000.8.1 + libcurl-devel-doc < 8.14.1-160000.8.1 + libcurl4.aarch64 < 8.14.1-160000.8.1 + libcurl4.noarch < 8.14.1-160000.8.1 + curl.ppc64le < 8.14.1-160000.8.1 + libcurl-devel.ppc64le < 8.14.1-160000.8.1 + libcurl4.ppc64le < 8.14.1-160000.8.1 + curl.s390x < 8.14.1-160000.8.1 + libcurl-devel.s390x < 8.14.1-160000.8.1 + libcurl4.s390x < 8.14.1-160000.8.1 + curl.x86_64 < 8.14.1-160000.8.1 + libcurl-devel.x86_64 < 8.14.1-160000.8.1 + libcurl4.x86_64 < 8.14.1-160000.8.1 + diff --git a/tests/data/os/leap-16-zypper-locks.xml b/tests/data/os/leap-16-zypper-locks.xml new file mode 100644 index 00000000..03f7d740 --- /dev/null +++ b/tests/data/os/leap-16-zypper-locks.xml @@ -0,0 +1,4 @@ + + +curlpackage + diff --git a/tests/data/os/leap-16-zypper-patches-security.xml b/tests/data/os/leap-16-zypper-patches-security.xml new file mode 100644 index 00000000..82ebe745 --- /dev/null +++ b/tests/data/os/leap-16-zypper-patches-security.xml @@ -0,0 +1,90 @@ + + +Loading repository data... +Reading installed packages... + + +Security update for glibcThis update for glibc fixes the following issues + +- CVE-2026-5435: unchecked buffer writing in TSIG handling can lead to an out-of-bounds write (bsc#1263656). +- CVE-2026-6238: insufficient RDATA length validation can lead to application crashes or uninitialized memory disclosure + (bsc#1263658). +VUL-0: CVE-2026-5435: glibc: unchecked buffer writing in TSIG handling can lead to an out-of-bounds writehttps://bugzilla.suse.com/show_bug.cgi?id=1263656VUL-0: CVE-2026-6238: glibc: insufficient RDATA length validation can lead to application crashes or uninitialized memory disclosurehttps://bugzilla.suse.com/show_bug.cgi?id=1263658http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5435http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5435http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6238http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6238Security update for curlThis update for curl fixes the following issues + +- CVE-2026-8286: wrong STARTTLS connection reuse (bsc#1268402). +- CVE-2026-8458: wrong reuse for different services (bsc#1268407). +- CVE-2026-8924: traling dot domain super cookie (bsc#1268409). +- CVE-2026-8927: env-set cross-proxy Digest auth state leak (bsc#1268413). +- CVE-2026-9079: stale proxy password leak (bsc#1268415). +- CVE-2026-9080: UAF after pause in socket callback (bsc#1268416). +- CVE-2026-9545: exposing HTTP/3 early data (bsc#1268417). +- CVE-2026-9547: SSH improper host validation (bsc#1268420). +- CVE-2026-10536: HTTP/2 stream-dependency tree UAF (bsc#1268422). +- CVE-2026-12064: proto-default skips SSH verification (bsc#1268427). +VUL-0: CVE-2026-8286: curl: wrong STARTTLS connection reusehttps://bugzilla.suse.com/show_bug.cgi?id=1268402VUL-0: CVE-2026-8458: curl: wrong reuse for different serviceshttps://bugzilla.suse.com/show_bug.cgi?id=1268407VUL-0: CVE-2026-8924: curl: traling dot domain super cookiehttps://bugzilla.suse.com/show_bug.cgi?id=1268409VUL-0: CVE-2026-8927: curl: env-set cross-proxy Digest auth state leakhttps://bugzilla.suse.com/show_bug.cgi?id=1268413VUL-0: CVE-2026-9079: curl: stale proxy password leakhttps://bugzilla.suse.com/show_bug.cgi?id=1268415VUL-0: CVE-2026-9080: curl: UAF after pause in socket callbackhttps://bugzilla.suse.com/show_bug.cgi?id=1268416VUL-0: CVE-2026-9545: curl: exposing HTTP/3 early datahttps://bugzilla.suse.com/show_bug.cgi?id=1268417VUL-0: CVE-2026-9547: curl: SSH improper host validationhttps://bugzilla.suse.com/show_bug.cgi?id=1268420VUL-0: CVE-2026-10536: curl: HTTP/2 stream-dependency tree UAFhttps://bugzilla.suse.com/show_bug.cgi?id=1268422VUL-0: CVE-2026-12064: curl: proto-default skips SSH verificationhttps://bugzilla.suse.com/show_bug.cgi?id=1268427http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8286http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8286http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8458http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8458http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8924http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8924http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8927http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8927http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9079http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9079http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9080http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9080http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9545http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9545http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9547http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-9547http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-10536http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-10536http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-12064http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-12064Security update for openssl-3This update for openssl-3 fixes the following issues: + +August 2026 release. + +- CVE-2026-14456: unbounded memory growth in QUIC server incoming channel queue (bsc#1274791). +- CVE-2026-14457: RPK server signature algorithm selection can dereference a missing certificate (bsc#1274792). +- CVE-2026-18798: QUIC server may trigger double free when processing INITIAL packet (bsc#1274777). +- CVE-2026-34181: PKCS#12 files with PBMAC1 are accepted with short HMAC keys (bsc#1266343). +- CVE-2026-54874: excessive memory use buffering DTLS records for a future epoch (bsc#1274795). +- CVE-2026-63072: heap buffer overflow in CMS key unwrapping (bsc#1274788). +- CVE-2026-63073: untrusted sender DN used as format string in CMP response validation (bsc#1274796). +- CVE-2026-63074: CMP indefinite cache growth of `ExtraCerts` (bsc#1274797). +- CVE-2026-63075: QUIC ACK-only packet retention can cause memory exhaustion (bsc#1274798). +- CVE-2026-63076: invalid pointer dereference in CMP server via crafted `protectionAlg` (bsc#1274790). +- CVE-2026-75803: AEAD forgeries with empty ciphertext when using `EVP_Cipher()` (bsc#1275837). +VUL-0: CVE-2026-34181: openssl, openssl-3: PKCS#12 Files with PBMAC1 Are Accepted with Short HMAC Keyshttps://bugzilla.suse.com/show_bug.cgi?id=1266343VUL-0: EMBARGOED: openssl: August 2026 releasehttps://bugzilla.suse.com/show_bug.cgi?id=1274774VUL-0: CVE-2026-18798: openssl: QUIC Server May Trigger Double Free When Processing INITIAL Packethttps://bugzilla.suse.com/show_bug.cgi?id=1274777VUL-0: CVE-2026-63072: openssl: Heap Buffer Overflow in CMS Key Unwrappinghttps://bugzilla.suse.com/show_bug.cgi?id=1274788VUL-0: CVE-2026-63076: openssl: Invalid Pointer Dereference in CMP Server via Crafted protectionAlghttps://bugzilla.suse.com/show_bug.cgi?id=1274790VUL-0: CVE-2026-14456: openssl: Unbounded Memory Growth in QUIC Server Incoming Channel Queuehttps://bugzilla.suse.com/show_bug.cgi?id=1274791VUL-0: CVE-2026-14457: openssl: RPK Server Signature Algorithm Selection Can Dereference a Missing Certificatehttps://bugzilla.suse.com/show_bug.cgi?id=1274792VUL-0: CVE-2026-54874: openssl: Excessive Memory Use Buffering DTLS Records for a Future Epochhttps://bugzilla.suse.com/show_bug.cgi?id=1274795VUL-0: CVE-2026-63073: openssl: Untrusted Sender DN Used as Format String in CMP Response Validationhttps://bugzilla.suse.com/show_bug.cgi?id=1274796VUL-0: CVE-2026-63074: openssl: CMP Indefinite Cache Growth of ExtraCertshttps://bugzilla.suse.com/show_bug.cgi?id=1274797VUL-0: CVE-2026-63075: openssl: QUIC ACK-only Packet Retention Can Cause Memory Exhaustionhttps://bugzilla.suse.com/show_bug.cgi?id=1274798VUL-0: CVE-2026-75803: openssl: AEAD Forgeries with Empty Ciphertext When Using EVP_Cipher()https://bugzilla.suse.com/show_bug.cgi?id=1275837http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14456http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14456http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14457http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-14457http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18798http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18798http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-34181http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-34181http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54874http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54874http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63072http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63072http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63073http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63073http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63074http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63074http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63075http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63075http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63076http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-63076http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75803http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75803Security update for curlThis update for curl fixes the following issues: + +- CVE-2026-5773: wrong reuse of SMB connection (bsc#1262633). +- CVE-2026-7168: cross-proxy Digest auth state leak (bsc#1263440). +- CVE-2026-8926: password leak with netrc and user in URL (bsc#1268412). +- CVE-2026-13608: OpenLDAP SASL authentication bypass (bsc#1277476). +- CVE-2026-80229: OpenSSL provider use-after-free (bsc#1277479). +- CVE-2026-80230: OpenSSL pinning bypass (bsc#1277480). +VUL-0: CVE-2026-5773: curl: wrong reuse of SMB connectionhttps://bugzilla.suse.com/show_bug.cgi?id=1262633VUL-0: CVE-2026-7168: curl: cross-proxy Digest auth state leakhttps://bugzilla.suse.com/show_bug.cgi?id=1263440VUL-0: CVE-2026-8926: curl: password leak with netrc and user in URLhttps://bugzilla.suse.com/show_bug.cgi?id=1268412VUL-0: CVE-2026-13608: curl: OpenLDAP SASL authentication bypasshttps://bugzilla.suse.com/show_bug.cgi?id=1277476VUL-0: CVE-2026-80229: curl: OpenSSL provider use-after-freehttps://bugzilla.suse.com/show_bug.cgi?id=1277479VUL-0: CVE-2026-80230: curl: OpenSSL pinning bypasshttps://bugzilla.suse.com/show_bug.cgi?id=1277480http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5773http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-5773http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-7168http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-7168http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8926http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8926http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-13608http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-13608http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80229http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80229http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80230http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80230Security update for glibcThis update for glibc fixes the following issues: + +- CVE-2026-6368: invalid free via wordexp WRDE_APPEND rollback (bsc#1274726). +- CVE-2026-6791: stack overflow in wordexp tilde expansion (bsc#1274723). +- CVE-2026-8674: reachable assert in `resolv/resolv_conf.c:update_from_conf` allows an attacker in a local network to + trigger a process crash (bsc#1281297). +- CVE-2026-18374: heap buffer overflow in the fopen ccs extension (bsc#1277262). +- CVE-2026-19499: buffer overflow in strfmon right-justification padding (bsc#1276892). +- CVE-2026-19542: out-of-bounds array write in tdelete (bsc#1276946). +- CVE-2026-77117: SHIFT_JISX0213 decoding lacks pending character reset (bsc#1277921). +- CVE-2026-80489: EUC_JISX0213 decoding lacks pending character reset (bsc#1277922). +- CVE-2026-86805: TOCTOU race condition in the dynamic loader allows local attackers to escalate privileges + (bsc#1282509). + +Other changes: + +- Add -flive-patching=inline-clone to avoid untraceable inter-procedural optimizations (bsc#1277247). +VUL-0: CVE-2026-6791: glibc: wordexp: unbounded alloca (strndupa) in parse_tildehttps://bugzilla.suse.com/show_bug.cgi?id=1274723VUL-0: CVE-2026-6368: glibc: wordexp WRDE_APPEND rollback restores stale we_wordv, leading to invalid free in wordfreehttps://bugzilla.suse.com/show_bug.cgi?id=1274726VUL-0: CVE-2026-19499: glibc: glibc: Buffer Overflow in strfmon right-justification paddinghttps://bugzilla.suse.com/show_bug.cgi?id=1276892VUL-0: CVE-2026-19542: glibc: glibc: Fix out-of-bounds array write in tdeletehttps://bugzilla.suse.com/show_bug.cgi?id=1276946openssl and glibc are being compiled with propagating optimizations we can't track for livepatchinghttps://bugzilla.suse.com/show_bug.cgi?id=1277247VUL-0: CVE-2026-18374: glibc: overflow in the fopen ccs extensionhttps://bugzilla.suse.com/show_bug.cgi?id=1277262VUL-0: CVE-2026-77117: glibc: non-progress DoS in SHIFT_JISX0213 -&gt; UCS-4 conversion state handlinghttps://bugzilla.suse.com/show_bug.cgi?id=1277921VUL-0: CVE-2026-80489: glibc: glibc: non-progress DoS in SHIFT_JISX0213 -&gt; UCS-4 conversion state handlinghttps://bugzilla.suse.com/show_bug.cgi?id=1277922VUL-0: CVE-2026-8674: glibc: reachable assert in `resolv/resolv_conf.c:update_from_conf` allows an attacker in a local network to trigger a process crashhttps://bugzilla.suse.com/show_bug.cgi?id=1281297VUL-0: CVE-2026-86805: glibc: TOCTOU race condition in the dynamic loader allows local attackers to escalate privilegeshttps://bugzilla.suse.com/show_bug.cgi?id=1282509http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6368http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6368http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6791http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-6791http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8674http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-8674http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18374http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-18374http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19499http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19499http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19542http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-19542http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77117http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77117http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80489http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-80489http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-86805http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-86805Recommended update for timezoneThis update for timezone fixes the following issues: + +Changes in timezone: + +- Update to 2026e: + * Manitoba moves to permanent -05 on 2026-10-31 + +- Update to 2026d: + * Canada’s Northwest Territories moved to permanent -06 on 2026-08-21 + * Obsolescent settings like TZ="EST5EDT" now conform better to POSIX + * Fix security, performance and porting bugs in zic and localtime +Security update for openssl-3This update for openssl-3 fixes the following issues: + +- CVE-2026-35189: Excessive Memory Allocation in Relative CRLDP Processing (bsc#1280589). +- CVE-2026-35191: QUIC Unvalidated Amplification Credit may be Over Accounted (bsc#1280590). +- CVE-2026-54872: Timing Side-Channel in Scalar Multiplication for Non-NIST EC Curves (bsc#1280591). +- CVE-2026-54875: Non-Constant-Time SM2 Scalar Multiplication on ARM64 and RISC-V (bsc#1280592). +- CVE-2026-72897: Out-of-Bounds Access After SSL_set_SSL_CTX() During a Handshake (bsc#1280594). +- CVE-2026-75804: QUIC Connection-Level Flow Control is Not Enforced for Streams (bsc#1280595). +- CVE-2026-75805: NULL Pointer Dereference in CMP Client Revocation Response Handling (bsc#1280596). +- CVE-2026-75806: Unauthenticated and Undersized DTLS 1.2 AEAD Record Causes DoS (bsc#1280597). +- CVE-2026-77696: Timing Side-Channel in SM2 Signature Generation (bsc#1280598). +- CVE-2026-84782: DTLS Retransmits Handshake Messages From a Stale Buffer Offset (bsc#1280587). +- CVE-2026-84784: QUIC: Unbounded RETIRE_CONNECTION_ID Backlog (bsc#1280599). +VUL-0: CVE-2026-84782: openssl-3: DTLS Retransmits Handshake Messages From a Stale Buffer Offsethttps://bugzilla.suse.com/show_bug.cgi?id=1280587VUL-0: CVE-2026-35189: openssl-3: Excessive Memory Allocation in Relative CRLDP Processinghttps://bugzilla.suse.com/show_bug.cgi?id=1280589VUL-0: CVE-2026-35191: openssl-3: QUIC Unvalidated Amplification Credit may be Over Accountedhttps://bugzilla.suse.com/show_bug.cgi?id=1280590VUL-0: CVE-2026-54872: openssl-3: Timing Side-Channel in Scalar Multiplication for Non-NIST EC Curveshttps://bugzilla.suse.com/show_bug.cgi?id=1280591VUL-0: CVE-2026-54875: openssl-3: Non-Constant-Time SM2 Scalar Multiplication on ARM64 and RISC-Vhttps://bugzilla.suse.com/show_bug.cgi?id=1280592VUL-0: CVE-2026-72897: openssl-3: Out-of-Bounds Access After SSL_set_SSL_CTX() During a Handshakehttps://bugzilla.suse.com/show_bug.cgi?id=1280594VUL-0: CVE-2026-75804: openssl-3: QUIC Connection-Level Flow Control is Not Enforced for Streamshttps://bugzilla.suse.com/show_bug.cgi?id=1280595VUL-0: CVE-2026-75805: openssl-3: NULL Pointer Dereference in CMP Client Revocation Response Handlinghttps://bugzilla.suse.com/show_bug.cgi?id=1280596VUL-0: CVE-2026-75806: openssl-3: Unauthenticated and Undersized DTLS 1.2 AEAD Record Causes DoShttps://bugzilla.suse.com/show_bug.cgi?id=1280597VUL-0: CVE-2026-77696: openssl-3: Timing Side-Channel in SM2 Signature Generationhttps://bugzilla.suse.com/show_bug.cgi?id=1280598VUL-0: CVE-2026-84784: openssl-3: QUIC: Unbounded RETIRE_CONNECTION_ID Backloghttps://bugzilla.suse.com/show_bug.cgi?id=1280599http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35189http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35189http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35191http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-35191http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54872http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54872http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54875http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-54875http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-72897http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-72897http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75804http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75804http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75805http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75805http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75806http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-75806http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77696http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-77696http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84782http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84782http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84784http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-84784 + + diff --git a/tests/data/os/leap-16-zypper-updates.xml b/tests/data/os/leap-16-zypper-updates.xml new file mode 100644 index 00000000..224aa3b8 --- /dev/null +++ b/tests/data/os/leap-16-zypper-updates.xml @@ -0,0 +1,21 @@ + + +Loading repository data... +Reading installed packages... + + +Standard Shared Libraries (from the GNU C Library)The GNU C Library provides the most important standard libraries used +by nearly all programs: the standard C library, the standard math +library, and the POSIX thread library. A system is not functional +without these libraries.en_US Locale Data for Localized ProgramsLocale data for the internationalisation features of the GNU C library. +This package contains only the U.S. English locale.Library for transferring data from URLsThe cURL shared library for accessing data using different +network protocols.OpenSSL FIPS providerThis package contains the OpenSSL FIPS provider.Secure Sockets and Transport Layer SecurityOpenSSL is a software library to be used in applications that need to +secure communications over computer networks against eavesdropping or +need to ascertain the identity of the party at the other end. +OpenSSL contains an implementation of the SSL and TLS protocols.Secure Sockets and Transport Layer SecurityOpenSSL is a software library to be used in applications that need to +secure communications over computer networks against eavesdropping or +need to ascertain the identity of the party at the other end. +OpenSSL contains an implementation of the SSL and TLS protocols.Time Zone DescriptionsThese are configuration files that describe available time zones. You +can select an appropriate time zone for your system with YaST. + + diff --git a/tests/data/os/tumbleweed-os-release b/tests/data/os/tumbleweed-os-release new file mode 100644 index 00000000..82a2a969 --- /dev/null +++ b/tests/data/os/tumbleweed-os-release @@ -0,0 +1,16 @@ +NAME="openSUSE Tumbleweed" +# VERSION="20261011" +ID="opensuse-tumbleweed" +ID_LIKE="opensuse suse" +VERSION_ID="20261011" +PRETTY_NAME="openSUSE Tumbleweed" +ANSI_COLOR="0;32" +# CPE 2.3 format, boo#1217921 +CPE_NAME="cpe:2.3:o:opensuse:tumbleweed:20261011:*:*:*:*:*:*:*" +#CPE 2.2 format +#CPE_NAME="cpe:/o:opensuse:tumbleweed:20261011" +BUG_REPORT_URL="https://bugzilla.opensuse.org" +SUPPORT_URL="https://bugs.opensuse.org" +HOME_URL="https://www.opensuse.org" +DOCUMENTATION_URL="https://en.opensuse.org/Portal:Tumbleweed" +LOGO="distributor-logo-Tumbleweed" diff --git a/tests/data/os/tumbleweed-zypper-updates.xml b/tests/data/os/tumbleweed-zypper-updates.xml new file mode 100644 index 00000000..a497bffc --- /dev/null +++ b/tests/data/os/tumbleweed-zypper-updates.xml @@ -0,0 +1,18 @@ + + +Loading repository data... +Reading installed packages... + + +The public gpg keys for rpm package signature verificationThis package contains the gpg keys that are used to sign the +openSUSE rpm packages. The keys installed here are not actually +used by anything. rpm/zypper use the keys in the rpm db instead.Test update that requires a system rebootPackage for testing the update stack during product development. + +An update for this package should inform the user that a system +reboot is necessary. This is used, for example, in kernel updates.Test update that includes security fixesPackage for testing the update stack during product development. + +An update for this package should have its category set to +"security", indicating that one or more security vulnerabilities +are being fixed. + + diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php new file mode 100644 index 00000000..2ca11fb2 --- /dev/null +++ b/tests/lib/OsStatus/DnfTest.php @@ -0,0 +1,186 @@ +host = new FakeHost(); + $this->host->binaries[] = 'dnf'; + $this->dnf = new Dnf($this->host, new Rpm($this->host), new SystemdProbe($this->host)); + } + + public function testParsesCheckUpdate(): void { + $updates = Dnf::parseCheckUpdate(implode("\n", [ + '', + 'openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos', + 'kernel.x86_64 5.14.0-503.15.1.el9_5 baseos', + 'tzdata.noarch 2025a-1.el9 appstream', + 'Obsoleting Packages', + 'grub2-tools.x86_64 1:2.06-92.el9 baseos', + ])); + + $this->assertSame([ + 'openssl-libs' => ['1:3.2.2-6.el9_5.1', 'baseos'], + 'kernel' => ['5.14.0-503.15.1.el9_5', 'baseos'], + 'tzdata' => ['2025a-1.el9', 'appstream'], + ], $updates); + } + + public function testParsesDnf4Advisories(): void { + $advisories = Dnf::parseAdvisories(implode("\n", [ + 'RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64', + 'RHSA-2026:1240 Moderate/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64', + 'ALSA-2026:0099 Critical/Sec. kernel-5.14.0-503.15.1.el9_5.x86_64', + 'not an advisory line', + ])); + + $this->assertSame(['RHSA-2026:1234', 'RHSA-2026:1240'], $advisories['openssl-libs']['ids']); + $this->assertSame('Important', $advisories['openssl-libs']['severity']); + $this->assertSame('Critical', $advisories['kernel']['severity']); + } + + public function testParsesDnf5Advisories(): void { + $advisories = Dnf::parseAdvisories(implode("\n", [ + 'Name Type Severity Package Issued', + 'FEDORA-2026-1a2b3c4d security Moderate curl-8.11.1-4.fc43.x86_64 2026-10-01 00:00:00', + 'FEDORA-2026-99887766 security None libxml2-2.12.9-1.fc43.x86_64 2026-10-02 00:00:00', + ])); + + $this->assertSame(['FEDORA-2026-1a2b3c4d'], $advisories['curl']['ids']); + $this->assertSame('Moderate', $advisories['curl']['severity']); + $this->assertNull($advisories['libxml2']['severity']); + } + + public function testPendingUpdatesAreMatchedWithAdvisories(): void { + $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100) + ->withCommand('dnf', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], "RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64\n") + ->withCommand('rpm', ['-qa', '--qf', '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE}\n'], "openssl-libs 1:3.2.2-6.el9_5\ntzdata 0:2024b-2.el9\n"); + + $updates = $this->dnf->getPendingUpdates(); + + $this->assertCount(2, $updates); + $this->assertTrue($updates[0]->security); + $this->assertSame(['RHSA-2026:1234'], $updates[0]->advisories); + $this->assertSame('1:3.2.2-6.el9_5', $updates[0]->installedVersion); + $this->assertFalse($updates[1]->security); + $this->assertSame('2024b-2.el9', $updates[1]->installedVersion); + } + + public function testNoUpdatesSkipsTheAdvisories(): void { + $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], '', 0); + + $this->assertSame([], $this->dnf->getPendingUpdates()); + $this->assertNotContains('dnf -q --cacheonly updateinfo list --security --available', $this->host->ran); + } + + public function testCheckUpdateFailure(): void { + $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], '', 1, "Error: Cache-only enabled but no cache for 'baseos'\n"); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage("no cache for 'baseos'"); + $this->dnf->getPendingUpdates(); + } + + public function testVersionlock(): void { + $dnf4 = Dnf::parseVersionlock("Last metadata expiration check: 0:10:00 ago.\nmariadb-server-3:10.11.10-1.el9.*\n"); + $this->assertSame('mariadb-server', $dnf4[0]->name); + $this->assertSame('3:10.11.10-1.el9', $dnf4[0]->constraint); + + $dnf5 = Dnf::parseVersionlock("# Added by 'versionlock add' command on 2026-10-01 10:00:00\nPackage name: nginx\nevr = 1.26.2-1.fc43\n"); + $this->assertSame('nginx', $dnf5[0]->name); + } + + public function testAutomaticInstallTimer(): void { + $this->host->withCommand('systemctl', ['is-enabled', 'dnf-automatic-install.timer'], "enabled\n") + ->withFile('/etc/dnf/automatic.conf', "[commands]\nupgrade_type = security\napply_updates = no\n"); + + $automatic = $this->dnf->getAutomaticUpdates(); + + $this->assertSame(AutomaticUpdates::STATE_ENABLED, $automatic->state); + $this->assertSame(AutomaticUpdates::SCOPE_SECURITY, $automatic->scope); + } + + public function testAutomaticTimerThatOnlyDownloads(): void { + $this->host->withCommand('systemctl', ['is-enabled', 'dnf-automatic-install.timer'], "disabled\n", 1) + ->withCommand('systemctl', ['is-enabled', 'dnf-automatic.timer'], "enabled\n") + ->withFile('/etc/dnf/automatic.conf', "[commands]\napply_updates = no\n"); + + $automatic = $this->dnf->getAutomaticUpdates(); + + $this->assertSame(AutomaticUpdates::STATE_DISABLED, $automatic->state); + $this->assertSame(AutomaticUpdates::SCOPE_DOWNLOAD, $automatic->scope); + } + + public function testNeedsRestartingReboot(): void { + $this->host->withCommand('dnf', ['-q', '--cacheonly', 'needs-restarting', '-r'], "Core libraries or services have been updated since boot-up:\n * glibc\n * kernel\n\nReboot is required to fully utilize these updates.\n", 1); + + $reasons = $this->dnf->getRebootReasons(); + + $this->assertSame(['glibc', 'kernel'], $reasons[0]->packages); + } + + public function testNeedsRestartingNoReboot(): void { + $this->host->withCommand('dnf', ['-q', '--cacheonly', 'needs-restarting', '-r'], "No core libraries or services have been updated since boot-up.\n", 0); + + $this->assertSame([], $this->dnf->getRebootReasons()); + } + + public function testDnf5IsNeverRunAsTheWebServer(): void { + $this->host->binaries[] = 'dnf5'; + $this->host->withFile('/etc/dnf/versionlock.toml', FakeHost::fixture('fedora-44-versionlock.toml')); + + try { + $this->dnf->getPendingUpdates(); + $this->fail('dnf 5 must not be asked'); + } catch (ProbeException $e) { + $this->assertStringContainsString('root helper', $e->getMessage()); + } + $held = $this->dnf->getHeldPackages(); + + $this->assertSame(['sudo' => '1.9.17-10.p2.fc44', 'curl' => '8.18.0-14.fc44'], array_combine( + array_map(static fn ($h) => $h->name, $held ?? []), + array_map(static fn ($h) => $h->constraint, $held ?? []), + )); + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertFalse($this->dnf->hasOwnRebootCheck()); + $this->assertSame([], $this->host->ran, 'Not a single dnf process'); + } + + public function testDnf5WithoutLocks(): void { + $this->host->binaries[] = 'dnf5'; + $this->host->modified['/etc/dnf'] = 1; + + $this->assertSame([], $this->dnf->getHeldPackages()); + } + + public function testListsRefreshTimeFromMetalinks(): void { + $this->host->globs['/var/cache/libdnf5/*/metalink.xml'] = ['/var/cache/libdnf5/fedora-1/metalink.xml', '/var/cache/libdnf5/updates-2/metalink.xml']; + $this->host->modified['/var/cache/libdnf5/fedora-1/metalink.xml'] = 1792438330; + $this->host->modified['/var/cache/libdnf5/updates-2/metalink.xml'] = 1792438400; + + $this->assertSame(1792438400, $this->dnf->getListsRefreshedAt()); + } + + public function testSplitNevra(): void { + $this->assertSame(['openssl-libs', '1:3.2.2-6.el9_5.1'], Rpm::splitNevra('openssl-libs-1:3.2.2-6.el9_5.1.x86_64')); + $this->assertSame(['python3-dnf-plugin-versionlock', '4.3.0-16.el9'], Rpm::splitNevra('python3-dnf-plugin-versionlock-4.3.0-16.el9.noarch')); + $this->assertNull(Rpm::splitNevra('nonsense')); + } +} diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 41c4b0f5..326a2f41 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -20,8 +20,13 @@ use OCA\ServerInfo\OsStatus\OsStatus; use OCA\ServerInfo\OsStatus\OsStatusCollector; use OCA\ServerInfo\OsStatus\OsStatusStore; +use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Apt; +use OCA\ServerInfo\OsStatus\Packages\Dnf; use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory; +use OCA\ServerInfo\OsStatus\Packages\Pacman; +use OCA\ServerInfo\OsStatus\Packages\Rpm; +use OCA\ServerInfo\OsStatus\Packages\Zypper; use OCA\ServerInfo\OsStatus\Reboot\CoreLibraryProbe; use OCA\ServerInfo\OsStatus\Reboot\KernelProbe; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; @@ -74,6 +79,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { $host = $this->host; $systemd = new SystemdProbe($host); + $rpm = new Rpm($host); return new OsStatusCollector( $host, @@ -83,7 +89,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { new EnvironmentDetector($host), new SupportTable(), new SubscriptionProbe($host), - new PackageManagerFactory(new Apt($host, $systemd)), + new PackageManagerFactory(new Apt($host, $systemd), new Dnf($host, $rpm, $systemd), new Zypper($host, $rpm, $systemd), new Pacman($host), new Apk($host)), new KernelProbe($host), new CoreLibraryProbe($host), $systemd, diff --git a/tests/lib/OsStatus/PacmanApkTest.php b/tests/lib/OsStatus/PacmanApkTest.php new file mode 100644 index 00000000..e2ffe000 --- /dev/null +++ b/tests/lib/OsStatus/PacmanApkTest.php @@ -0,0 +1,84 @@ + 6.11.3.arch1-1\nopenssl 3.3.2-1 -> 3.4.0-1\nnginx 1.26.2-1 -> 1.26.2-2 [ignored]\nnot an update\n"); + + $this->assertSame(['linux', 'openssl'], array_map(static fn ($u) => $u->name, $updates), 'An ignored package is held, not pending'); + $this->assertSame('6.11.2.arch1-1', $updates[0]->installedVersion); + $this->assertSame('6.11.3.arch1-1', $updates[0]->candidateVersion); + } + + public function testArchAuditMarksSecurityUpdates(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('checkupdates', [], "openssl 3.3.2-1 -> 3.4.0-1\nnano 8.1-1 -> 8.2-1\n") + ->withCommand('arch-audit', ['--upgradable', '--quiet'], "openssl\n"); + + $pacman = new Pacman($host); + $updates = $pacman->getPendingUpdates(); + + $this->assertTrue($pacman->classifiesSecurity()); + $this->assertTrue($updates[0]->security); + $this->assertFalse($updates[1]->security); + } + + public function testCheckupdatesWithNothingToDo(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 2); + + $pacman = new Pacman($host); + + $this->assertFalse($pacman->classifiesSecurity()); + $this->assertSame([], $pacman->getPendingUpdates()); + } + + public function testIgnoredPackages(): void { + $held = Pacman::parseIgnored("[options]\nIgnorePkg = linux nginx # pinned\n#IgnorePkg = commented\nIgnorePkg=php\n"); + + $this->assertSame(['linux', 'nginx', 'php'], array_map(static fn ($h) => $h->name, $held)); + } + + public function testPacmanLog(): void { + $host = new FakeHost(); + $host->withFile('/var/log/pacman.log', "[2026-10-12T06:41:02+0200] [ALPM] upgraded openssl (3.3.2-1 -> 3.4.0-1)\n[2026-10-12T07:00:00+0200] [ALPM] removed foo (1-1)\n"); + + $this->assertSame((int)strtotime('2026-10-12 04:41:02 UTC'), (new Pacman($host))->getLastUpgradeAt()); + } + + public function testApkUpgradable(): void { + $updates = Apk::parseUpgradable(implode("\n", [ + 'busybox-1.37.0-r13 x86_64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.37.0-r12]', + 'py3-cryptography-44.0.0-r0 x86_64 {py3-cryptography} (Apache-2.0 OR BSD-3-Clause) [upgradable from: py3-cryptography-43.0.3-r0]', + 'nonsense', + ])); + + $this->assertSame('busybox', $updates[0]->name); + $this->assertSame('1.37.0-r12', $updates[0]->installedVersion); + $this->assertSame('py3-cryptography', $updates[1]->name); + $this->assertSame('44.0.0-r0', $updates[1]->candidateVersion); + } + + public function testApkWorldPins(): void { + $host = new FakeHost(); + $host->withFile('/etc/apk/world', "alpine-base\nnginx=1.26.2-r0\nphp83<8.4\ncurl@edge\n"); + + $held = (new Apk($host))->getHeldPackages(); + + $this->assertSame(['nginx', 'php83', 'curl'], array_map(static fn ($h) => $h->name, $held ?? [])); + $this->assertSame('=1.26.2-r0', $held[0]->constraint); + } +} diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index 85b6a681..cd841c83 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -9,14 +9,20 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\OsRelease; +use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Apt; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; +use OCA\ServerInfo\OsStatus\Packages\Dnf; +use OCA\ServerInfo\OsStatus\Packages\Pacman; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; +use OCA\ServerInfo\OsStatus\Packages\Rpm; +use OCA\ServerInfo\OsStatus\Packages\Zypper; /** - * Output captured from real systems (arm64 containers, October 2026), run as - * an unprivileged user unless named otherwise. + * Output captured from real systems (arm64 containers of each distribution, + * October 2026), run as an unprivileged user unless named otherwise. */ class RealOutputTest extends \Test\TestCase { /** @@ -50,4 +56,145 @@ public function testUbuntu2404(): void { 'tzdata' => false, 'curl' => true, 'libcurl4t64' => true, ], self::names($updates)); } + + public function testAlmaLinux9WithDnf4(): void { + $host = new FakeHost(); + $host->binaries[] = 'dnf'; + $host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) + ->withCommand('dnf', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], FakeHost::fixture('alma-9-dnf-updateinfo-security')) + ->withCommand('dnf', ['-q', '--cacheonly', 'versionlock', 'list'], FakeHost::fixture('alma-9-dnf-versionlock')) + ->withCommand('dnf', ['-q', '--cacheonly', 'needs-restarting', '-r'], FakeHost::fixture('alma-9-dnf-needs-restarting-r'), 1) + ->withFile('/etc/dnf/automatic.conf', FakeHost::fixture('alma-9-dnf-automatic.conf')); + $dnf = new Dnf($host, new Rpm($host), new SystemdProbe($host)); + + $updates = $dnf->getPendingUpdates(); + + $this->assertSame([ + 'curl-minimal' => true, 'glibc' => true, 'glibc-common' => true, 'glibc-minimal-langpack' => true, + 'libcurl-minimal' => true, 'openssl' => true, 'openssl-fips-provider' => false, 'openssl-libs' => true, + 'tzdata' => false, + ], self::names($updates), 'sudo has an advisory but is versionlocked, so it is not pending'); + $this->assertSame(['ALSA-2026:77396'], $updates[5]->advisories); + $this->assertSame('Important', $updates[5]->severity); + $this->assertSame('1:3.5.8-2.el9_8', $updates[5]->candidateVersion); + + $this->assertSame(['sudo'], array_map(static fn ($h) => $h->name, $dnf->getHeldPackages() ?? [])); + $this->assertSame(['glibc'], $dnf->getRebootReasons()[0]->packages); + } + + public function testFedora44WithDnf5(): void { + $updates = Dnf::parseCheckUpdate(FakeHost::fixture('fedora-44-dnf-check-update-pending')); + $advisories = Dnf::parseAdvisories(FakeHost::fixture('fedora-44-dnf-advisory-security')); + + $this->assertSame(['curl', 'glibc', 'glibc-common', 'glibc-minimal-langpack', 'libcurl', 'openssl-libs', 'tzdata'], array_keys($updates)); + $this->assertSame(['1:3.5.9-1.fc44', 'updates'], $updates['openssl-libs']); + $this->assertSame(['FEDORA-2026-12f3f3d569'], $advisories['openssl-libs']['ids']); + $this->assertSame('Moderate', $advisories['curl']['severity']); + $this->assertArrayHasKey('sudo', $advisories, 'Advisories cover locked packages, which check-update leaves out'); + + $held = Dnf::parseVersionlock(FakeHost::fixture('fedora-44-dnf-versionlock')); + $this->assertSame(['sudo'], array_map(static fn ($h) => $h->name, $held)); + } + + public function testOpenSuseLeap16(): void { + $patches = Zypper::parsePatches(FakeHost::fixture('leap-16-zypper-patches-security.xml')); + $packages = Zypper::parsePackageUpdates(FakeHost::fixture('leap-16-zypper-updates.xml')); + + $this->assertCount(6, $patches); + $this->assertSame('openSUSE-Leap-16.0-1157', $patches[0]->name); + $this->assertSame('Security update for glibc', $patches[0]->summary); + $this->assertSame(['openSUSE-Leap-16.0-1157', 'CVE-2026-5435', 'CVE-2026-6238'], $patches[0]->advisories); + $this->assertSame('Moderate', $patches[0]->severity); + $this->assertSame(['Moderate', 'Important', 'Important', 'Moderate', 'Important', 'Important'], array_map(static fn ($p) => $p->severity, $patches)); + + $this->assertSame(['glibc', 'glibc-locale-base', 'libcurl4', 'libopenssl-3-fips-provider', 'libopenssl3', 'openssl-3', 'timezone'], array_keys(self::names($packages))); + $this->assertSame('2.40-160000.5.1', $packages[0]->installedVersion); + $this->assertSame(['curl'], array_map(static fn ($h) => $h->name, Zypper::parseLocks(FakeHost::fixture('leap-16-zypper-locks.xml')))); + } + + public function testOpenSuseLeap16PatchesBecomePackageSecurityUpdates(): void { + $coverage = Zypper::parsePatchConflicts(FakeHost::fixture('leap-16-zypper-info-patch')); + + $this->assertSame(['openSUSE-Leap-16.0-1157', 'openSUSE-Leap-16.0-1187'], array_keys($coverage)); + $this->assertContains('glibc-locale-base', $coverage['openSUSE-Leap-16.0-1157']); + $this->assertContains('cross-ppc64le-glibc-devel', $coverage['openSUSE-Leap-16.0-1157']); + $this->assertContains('libcurl4', $coverage['openSUSE-Leap-16.0-1187']); + $this->assertNotContains('glibc.aarch64', $coverage['openSUSE-Leap-16.0-1157'], 'Architectures are stripped'); + + $merged = Zypper::mergePatches( + Zypper::parsePatches(FakeHost::fixture('leap-16-zypper-patches-security.xml')), + Zypper::parsePackageUpdates(FakeHost::fixture('leap-16-zypper-updates.xml')), + $coverage, + ); + $byName = []; + foreach ($merged as $update) { + $byName[$update->name] = $update; + } + + $this->assertTrue($byName['glibc']->security); + $this->assertSame(['openSUSE-Leap-16.0-1157', 'CVE-2026-5435', 'CVE-2026-6238'], $byName['glibc']->advisories); + $this->assertSame('Moderate', $byName['glibc']->severity); + $this->assertTrue($byName['libcurl4']->security, 'curl itself is locked, its library is not'); + $this->assertSame('Important', $byName['libcurl4']->severity); + $this->assertFalse($byName['timezone']->security); + $this->assertArrayNotHasKey('openSUSE-Leap-16.0-1157', $byName, 'A patch whose packages are listed is not repeated'); + $this->assertSame(PendingUpdate::KIND_PATCH, $byName['openSUSE-Leap-16.0-1592']->kind, 'Patches without looked-up packages stay visible'); + } + + public function testZypperFallsBackToPatchRowsWhenInfoFails(): void { + $host = new FakeHost(); + $host->binaries[] = 'zypper'; + $host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], FakeHost::fixture('leap-16-zypper-patches-security.xml')) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], FakeHost::fixture('leap-16-zypper-updates.xml')); + + $updates = (new Zypper($host, new Rpm($host), new SystemdProbe($host)))->getPendingUpdates(); + + $this->assertCount(13, $updates); + $this->assertCount(6, array_filter($updates, static fn (PendingUpdate $u): bool => $u->kind === PendingUpdate::KIND_PATCH)); + } + + public function testOpenSuseTumbleweed(): void { + $host = new FakeHost(); + $host->binaries[] = 'zypper'; + $host->withFile('/etc/os-release', FakeHost::fixture('tumbleweed-os-release')) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], FakeHost::fixture('tumbleweed-zypper-updates.xml')); + $zypper = new Zypper($host, new Rpm($host), new SystemdProbe($host)); + + $this->assertFalse($zypper->classifiesSecurity()); + $this->assertSame(['openSUSE-build-key', 'update-test-reboot-needed', 'update-test-security'], array_keys(self::names($zypper->getPendingUpdates()))); + } + + public function testArchLinux(): void { + $this->assertSame(['python-urllib3' => false], self::names(Pacman::parseUpdates(FakeHost::fixture('arch-checkupdates-pending')))); + $this->assertSame([], Pacman::parseUpdates(FakeHost::fixture('arch-pacman-qu-ignored')), 'Ignored packages are held, not pending'); + $this->assertSame(['python-requests'], array_map(static fn ($h) => $h->name, Pacman::parseIgnored(FakeHost::fixture('arch-pacman-conf-ignorepkg')))); + + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('checkupdates', [], FakeHost::fixture('arch-checkupdates-pending')) + ->withCommand('arch-audit', ['--upgradable', '--quiet'], FakeHost::fixture('arch-audit-u-pending')); + + $this->assertSame(['python-urllib3' => true], self::names((new Pacman($host))->getPendingUpdates())); + } + + public function testArchFallsBackWhenCheckupdatesCannotRun(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 1, "==> ERROR: Cannot find the fakeroot binary.\n") + ->withCommand('pacman', ['-Qu'], "python-urllib3 1.26.3-1 -> 2.8.0-1\n"); + + $this->assertSame(['python-urllib3' => false], self::names((new Pacman($host))->getPendingUpdates())); + } + + public function testAlpine(): void { + $host = new FakeHost(); + $host->binaries[] = 'apk'; + $host->withCommand('apk', ['list', '--upgradable'], FakeHost::fixture('alpine-apk-list-upgradable')) + ->withFile('/etc/apk/world', FakeHost::fixture('alpine-apk-world-pinned')); + + $updates = (new Apk($host))->getPendingUpdates(); + + $this->assertSame(['libcrypto3' => false, 'libssl3' => false], self::names($updates), 'zlib is pinned to its installed version'); + $this->assertSame('3.5.8-r0', $updates[0]->installedVersion); + } } diff --git a/tests/lib/OsStatus/ZypperTest.php b/tests/lib/OsStatus/ZypperTest.php new file mode 100644 index 00000000..ff4e02db --- /dev/null +++ b/tests/lib/OsStatus/ZypperTest.php @@ -0,0 +1,122 @@ + + + Loading repository data... + + + Security update for openssl-3 + old + bugfix + + + + XML; + + private const UPDATES = <<<'XML' + + + + + Secure Sockets + + + + XML; + + private FakeHost $host; + private Zypper $zypper; + + protected function setUp(): void { + parent::setUp(); + $this->host = new FakeHost(); + $this->host->binaries[] = 'zypper'; + $this->zypper = new Zypper($this->host, new Rpm($this->host), new SystemdProbe($this->host)); + } + + public function testOnlyNeededSecurityPatchesCount(): void { + $patches = Zypper::parsePatches(self::PATCHES); + + $this->assertCount(1, $patches); + $this->assertSame('openSUSE-SLE-15.6-2026-3456', $patches[0]->name); + $this->assertSame(PendingUpdate::KIND_PATCH, $patches[0]->kind); + $this->assertSame('Important', $patches[0]->severity); + $this->assertTrue($patches[0]->security); + } + + public function testPackageUpdates(): void { + $updates = Zypper::parsePackageUpdates(self::UPDATES); + + $this->assertSame('libopenssl3', $updates[0]->name); + $this->assertSame('3.1.4-150600.5.24.1', $updates[0]->installedVersion); + $this->assertSame('repo-sle-update', $updates[0]->origin); + } + + public function testEntitiesAreNotExpanded(): void { + $xml = ']>' + . ''; + + foreach (Zypper::parsePackageUpdates($xml) as $update) { + $this->assertStringNotContainsString('root:', $update->name); + } + $this->addToAssertionCount(1); + } + + public function testCombinesPatchesAndPackages(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], self::PATCHES) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); + + $this->assertCount(2, $this->zypper->getPendingUpdates()); + $this->assertTrue($this->zypper->classifiesSecurity()); + } + + public function testErrorsInsideTheXmlStream(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], + "Repository 'main' is invalid."); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage("Repository 'main' is invalid."); + $this->zypper->getPendingUpdates(); + } + + public function testTumbleweedHasNoPatches(): void { + $this->host->withFile('/etc/os-release', "NAME=\"openSUSE Tumbleweed\"\nID=\"opensuse-tumbleweed\"\n") + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); + + $this->assertFalse($this->zypper->classifiesSecurity()); + $this->assertCount(1, $this->zypper->getPendingUpdates()); + $this->assertStringContainsString('dup', $this->zypper->getUpgradeCommands()['all']); + } + + public function testLocks(): void { + $locks = Zypper::parseLocks("kernel-defaultpackage"); + + $this->assertSame('kernel-default', $locks[0]->name); + } + + public function testNeedsRebooting(): void { + $this->host->withCommand('zypper', ['--non-interactive', 'needs-rebooting'], "Core libraries or services have been updated.\n", 102); + $this->assertCount(1, $this->zypper->getRebootReasons()); + + $this->host->withCommand('zypper', ['--non-interactive', 'needs-rebooting'], "No core libraries or services have been updated since the last system boot.\n", 0); + $this->assertSame([], $this->zypper->getRebootReasons()); + $this->assertTrue($this->zypper->hasOwnRebootCheck()); + } +} From b648e886b49f929be431faeada436f23e0981367 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:39:53 +0200 Subject: [PATCH 02/14] refactor(os): share the file-age and error-line helpers, query rpm once dnf, zypper and apk each had their own loop taking the newest modification time of a set of globbed files, and their own way of cutting command output down for a diagnostic. Both now live in ProbeHelper. dnf and zypper ran `rpm -qa` twice per collection: once for the installed versions and once for the newest install time. One query now returns both. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Apk.php | 12 ++----- lib/OsStatus/Packages/Dnf.php | 26 ++++++---------- lib/OsStatus/Packages/Pacman.php | 2 +- lib/OsStatus/Packages/ProbeHelper.php | 44 ++++++++++++++++++++++++++ lib/OsStatus/Packages/Rpm.php | 45 ++++++++++++++++----------- lib/OsStatus/Packages/Zypper.php | 15 ++------- tests/lib/OsStatus/DnfTest.php | 12 ++++++- 7 files changed, 95 insertions(+), 61 deletions(-) create mode 100644 lib/OsStatus/Packages/ProbeHelper.php diff --git a/lib/OsStatus/Packages/Apk.php b/lib/OsStatus/Packages/Apk.php index 6fd815d6..356c0d50 100644 --- a/lib/OsStatus/Packages/Apk.php +++ b/lib/OsStatus/Packages/Apk.php @@ -45,7 +45,7 @@ public function getPendingUpdates(): array { throw new ProbeException('apk could not be run.'); } if (!$result->succeeded()) { - throw new ProbeException('apk list --upgradable failed: ' . mb_substr(trim($result->stderr), 0, 200)); + throw new ProbeException('apk list --upgradable failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout)); } // apk lists packages pinned to an exact version too, though it never @@ -106,15 +106,7 @@ public function getHeldPackages(): ?array { #[\Override] public function getListsRefreshedAt(): ?int { - $times = []; - foreach ($this->host->glob('/var/cache/apk/APKINDEX.*.tar.gz') as $index) { - $time = $this->host->modifiedAt($index); - if ($time !== null) { - $times[] = $time; - } - } - - return $times === [] ? null : max($times); + return ProbeHelper::newestModified($this->host, ['/var/cache/apk/APKINDEX.*.tar.gz']); } #[\Override] diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index c30ec6f8..b8cc6249 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -72,7 +72,7 @@ public function getPendingUpdates(): array { throw new ProbeException('dnf could not be run.'); } if ($result->exitCode !== 0 && $result->exitCode !== self::EXIT_UPDATES) { - throw new ProbeException('dnf check-update failed: ' . self::firstLine($result->stderr ?: $result->stdout)); + throw new ProbeException('dnf check-update failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout)); } $available = self::parseCheckUpdate($result->stdout); @@ -128,7 +128,7 @@ public static function parseCheckUpdate(string $output): array { private function securityAdvisories(): array { $result = $this->dnf(['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], 180); if ($result === null || !$result->succeeded()) { - throw new ProbeException('dnf could not list security advisories: ' . self::firstLine($result?->stderr ?? '')); + throw new ProbeException('dnf could not list security advisories: ' . ProbeHelper::firstLine($result?->stderr ?? '')); } return self::parseAdvisories($result->stdout); @@ -255,17 +255,13 @@ public static function parseVersionlockToml(string $toml): array { */ #[\Override] public function getListsRefreshedAt(): ?int { - $times = []; - foreach (['/var/cache/dnf/*/metalink.xml', '/var/cache/libdnf5/*/metalink.xml', '/var/cache/dnf/*/repodata/repomd.xml', '/var/cache/libdnf5/*/repodata/repomd.xml', '/var/cache/dnf/last_makecache'] as $pattern) { - foreach (str_contains($pattern, '*') ? $this->host->glob($pattern) : [$pattern] as $file) { - $time = $this->host->modifiedAt($file); - if ($time !== null) { - $times[] = $time; - } - } - } - - return $times === [] ? null : max($times); + return ProbeHelper::newestModified($this->host, [ + '/var/cache/dnf/*/metalink.xml', + '/var/cache/libdnf5/*/metalink.xml', + '/var/cache/dnf/*/repodata/repomd.xml', + '/var/cache/libdnf5/*/repodata/repomd.xml', + '/var/cache/dnf/last_makecache', + ]); } #[\Override] @@ -373,8 +369,4 @@ private function isDnf5(): bool { private function dnf(array $arguments, int $timeout): ?CommandResult { return $this->host->run($this->host->hasBinary('dnf') ? 'dnf' : 'dnf5', $arguments, $timeout); } - - private static function firstLine(string $text): string { - return mb_substr(trim((string)strtok(trim($text), "\n")), 0, 200); - } } diff --git a/lib/OsStatus/Packages/Pacman.php b/lib/OsStatus/Packages/Pacman.php index fc76c4e7..3a54becd 100644 --- a/lib/OsStatus/Packages/Pacman.php +++ b/lib/OsStatus/Packages/Pacman.php @@ -62,7 +62,7 @@ public function getPendingUpdates(): array { } // pacman -Qu exits 1 when nothing is upgradable. if (!$result->succeeded() && trim($result->stdout) !== '') { - throw new ProbeException('pacman -Qu failed: ' . mb_substr(trim($result->stderr), 0, 200)); + throw new ProbeException('pacman -Qu failed: ' . ProbeHelper::firstLine($result->stderr)); } return $this->withSecurity(self::parseUpdates($result->stdout)); diff --git a/lib/OsStatus/Packages/ProbeHelper.php b/lib/OsStatus/Packages/ProbeHelper.php new file mode 100644 index 00000000..a4009823 --- /dev/null +++ b/lib/OsStatus/Packages/ProbeHelper.php @@ -0,0 +1,44 @@ + $patterns + */ + public static function newestModified(IHost $host, array $patterns): ?int { + $newest = null; + foreach ($patterns as $pattern) { + foreach (str_contains($pattern, '*') ? $host->glob($pattern) : [$pattern] as $file) { + $time = $host->modifiedAt($file); + if ($time !== null && ($newest === null || $time > $newest)) { + $newest = $time; + } + } + } + + return $newest; + } + + /** + * The first line of a command's output, short enough for a diagnostic. + */ + public static function firstLine(string $text): string { + return mb_substr(trim((string)strtok(trim($text), "\n")), 0, 200); + } +} diff --git a/lib/OsStatus/Packages/Rpm.php b/lib/OsStatus/Packages/Rpm.php index fab4c78e..c8d4affd 100644 --- a/lib/OsStatus/Packages/Rpm.php +++ b/lib/OsStatus/Packages/Rpm.php @@ -24,8 +24,11 @@ class Rpm { '/var/lib/rpm/Packages', ]; + public const QUERY_FORMAT = '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE} %{INSTALLTIME}\n'; + /** @var array|null */ private ?array $installed = null; + private ?int $lastInstallTime = null; public function __construct( private IHost $host, @@ -48,35 +51,39 @@ public function getDatabasePath(): ?string { * @return array */ public function getInstalledVersions(): array { + $this->query(); + + return $this->installed ?? []; + } + + public function getLastInstallTime(): ?int { + $this->query(); + + return $this->lastInstallTime; + } + + /** + * Reads every installed package with its install time in one rpm run. + */ + private function query(): void { if ($this->installed !== null) { - return $this->installed; + return; } $this->installed = []; - $result = $this->host->run('rpm', ['-qa', '--qf', '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE}\n'], 60); + $result = $this->host->run('rpm', ['-qa', '--qf', self::QUERY_FORMAT], 60); if ($result === null || !$result->succeeded()) { - return $this->installed; + return; } foreach (preg_split('/\R/', $result->stdout) ?: [] as $line) { - if (preg_match('/^(\S+) (\d+):(\S+)$/', trim($line), $matches) === 1) { - // Several versions of a name (kernels) keep the last listed. - $this->installed[$matches[1]] = $matches[2] === '0' ? $matches[3] : $matches[2] . ':' . $matches[3]; + if (preg_match('/^(\S+) (\d+):(\S+) (\d+)$/', trim($line), $matches) !== 1) { + continue; } + // Several versions of a name (kernels) keep the last listed. + $this->installed[$matches[1]] = $matches[2] === '0' ? $matches[3] : $matches[2] . ':' . $matches[3]; + $this->lastInstallTime = max($this->lastInstallTime ?? 0, (int)$matches[4]); } - - return $this->installed; - } - - public function getLastInstallTime(): ?int { - $result = $this->host->run('rpm', ['-qa', '--qf', '%{INSTALLTIME}\n'], 60); - if ($result === null || !$result->succeeded()) { - return null; - } - - $times = array_map('intval', array_filter(preg_split('/\R/', $result->stdout) ?: [], 'ctype_digit')); - - return $times === [] ? null : max($times); } /** diff --git a/lib/OsStatus/Packages/Zypper.php b/lib/OsStatus/Packages/Zypper.php index 71d1f11a..8aa8c282 100644 --- a/lib/OsStatus/Packages/Zypper.php +++ b/lib/OsStatus/Packages/Zypper.php @@ -297,8 +297,7 @@ private function zypperXml(array $arguments): string { ? html_entity_decode($matches[1], ENT_QUOTES | ENT_XML1) : null; if (!$result->succeeded() || $xmlError !== null) { - $message = $xmlError ?? trim($result->stderr ?: $result->stdout); - throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . mb_substr($message, 0, 200)); + throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . ProbeHelper::firstLine($xmlError ?? ($result->stderr ?: $result->stdout))); } return $result->stdout; @@ -340,17 +339,7 @@ public static function parseLocks(string $xml): array { */ #[\Override] public function getListsRefreshedAt(): ?int { - $times = []; - foreach (['/var/cache/zypp/raw/*/cookie', '/var/cache/zypp/solv/*/cookie', '/var/cache/zypp/raw/*/repodata/repomd.xml'] as $pattern) { - foreach ($this->host->glob($pattern) as $file) { - $time = $this->host->modifiedAt($file); - if ($time !== null) { - $times[] = $time; - } - } - } - - return $times === [] ? null : max($times); + return ProbeHelper::newestModified($this->host, ['/var/cache/zypp/raw/*/cookie', '/var/cache/zypp/solv/*/cookie', '/var/cache/zypp/raw/*/repodata/repomd.xml']); } #[\Override] diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php index 2ca11fb2..51296cc8 100644 --- a/tests/lib/OsStatus/DnfTest.php +++ b/tests/lib/OsStatus/DnfTest.php @@ -71,7 +71,7 @@ public function testParsesDnf5Advisories(): void { public function testPendingUpdatesAreMatchedWithAdvisories(): void { $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100) ->withCommand('dnf', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], "RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64\n") - ->withCommand('rpm', ['-qa', '--qf', '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE}\n'], "openssl-libs 1:3.2.2-6.el9_5\ntzdata 0:2024b-2.el9\n"); + ->withCommand('rpm', ['-qa', '--qf', Rpm::QUERY_FORMAT], "openssl-libs 1:3.2.2-6.el9_5 1792000000\ntzdata 0:2024b-2.el9 1792100000\n"); $updates = $this->dnf->getPendingUpdates(); @@ -81,6 +81,8 @@ public function testPendingUpdatesAreMatchedWithAdvisories(): void { $this->assertSame('1:3.2.2-6.el9_5', $updates[0]->installedVersion); $this->assertFalse($updates[1]->security); $this->assertSame('2024b-2.el9', $updates[1]->installedVersion); + $this->assertSame(1792100000, $this->dnf->getLastUpgradeAt()); + $this->assertCount(1, array_filter($this->host->ran, static fn (string $command): bool => str_starts_with($command, 'rpm ')), 'rpm -qa runs once'); } public function testNoUpdatesSkipsTheAdvisories(): void { @@ -170,6 +172,14 @@ public function testDnf5WithoutLocks(): void { $this->assertSame([], $this->dnf->getHeldPackages()); } + public function testListsRefreshTimeIncludesSingleFiles(): void { + $this->host->globs['/var/cache/dnf/*/metalink.xml'] = ['/var/cache/dnf/baseos-1/metalink.xml']; + $this->host->modified['/var/cache/dnf/baseos-1/metalink.xml'] = 1792438330; + $this->host->modified['/var/cache/dnf/last_makecache'] = 1792438500; + + $this->assertSame(1792438500, $this->dnf->getListsRefreshedAt()); + } + public function testListsRefreshTimeFromMetalinks(): void { $this->host->globs['/var/cache/libdnf5/*/metalink.xml'] = ['/var/cache/libdnf5/fedora-1/metalink.xml', '/var/cache/libdnf5/updates-2/metalink.xml']; $this->host->modified['/var/cache/libdnf5/fedora-1/metalink.xml'] = 1792438330; From 729cc38fc5a4201c4dbca1e117fca808b825e0d3 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:40:28 +0200 Subject: [PATCH 03/14] fix(os): accept every rpm architecture The parsers for dnf check-update, advisory NEVRAs and zypper patch conflicts only knew a fixed list of architectures. On AlmaLinux 10 for x86_64_v2, and on ppc64 or s390 systems, every line failed to match and the system reported no pending updates at all. Any token after the last dot now counts as an architecture when it starts with a letter, so versions such as the ".22" of "go1.22" stay part of the name. The pattern is one constant, Rpm::ARCH. dnf 4's versionlock patterns carry no architecture, and a release such as "1.el9" ends like one, so they are split without stripping one. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Dnf.php | 4 ++-- lib/OsStatus/Packages/Rpm.php | 13 +++++++++++-- lib/OsStatus/Packages/Zypper.php | 2 +- tests/lib/OsStatus/DnfTest.php | 16 +++++++++++++++- tests/lib/OsStatus/ZypperTest.php | 13 +++++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index b8cc6249..6c2acf37 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -112,7 +112,7 @@ public static function parseCheckUpdate(string $output): array { if (preg_match('/^Obsoleting|^Security:/i', $line) === 1) { break; } - if (preg_match('/^(\S+)\.(x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64)\s+(\S+)\s+(\S+)\s*$/', $line, $matches) === 1) { + if (preg_match('/^(\S+)\.(' . Rpm::ARCH . ')\s+(\S+)\s+(\S+)\s*$/', $line, $matches) === 1) { $updates[$matches[1]] = [$matches[3], $matches[4]]; } } @@ -216,7 +216,7 @@ public static function parseVersionlock(string $output): array { continue; } $pattern = preg_replace('/\.\*$/', '', $line) ?? $line; - $split = Rpm::splitNevra($pattern); + $split = Rpm::splitNevra($pattern, false); $name = $split[0] ?? $pattern; if (preg_match('/^[A-Za-z0-9._+-]+$/', $name) === 1) { $held[$name] = new HeldPackage($name, 'dnf versionlock', $split[1] ?? null); diff --git a/lib/OsStatus/Packages/Rpm.php b/lib/OsStatus/Packages/Rpm.php index c8d4affd..67ad3ba3 100644 --- a/lib/OsStatus/Packages/Rpm.php +++ b/lib/OsStatus/Packages/Rpm.php @@ -24,6 +24,13 @@ class Rpm { '/var/lib/rpm/Packages', ]; + /** + * An architecture: x86_64, x86_64_v2, aarch64, ppc64, s390, noarch, src … + * Any token works, as long as it starts with a letter, so that a version + * such as the "22" of "go1.22" is not taken for one. + */ + public const ARCH = '[a-z][a-z0-9_]*'; + public const QUERY_FORMAT = '%{NAME} %{EPOCHNUM}:%{VERSION}-%{RELEASE} %{INSTALLTIME}\n'; /** @var array|null */ @@ -89,10 +96,12 @@ private function query(): void { /** * Splits "name-[epoch:]version-release.arch" into name and version. * + * @param bool $withArch whether the string ends in an architecture; a + * release such as "1.el9" ends like one * @return array{0: string, 1: string}|null */ - public static function splitNevra(string $nevra): ?array { - $withoutArch = preg_replace('/\.(x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64|src)$/', '', $nevra) ?? $nevra; + public static function splitNevra(string $nevra, bool $withArch = true): ?array { + $withoutArch = $withArch ? (preg_replace('/\.' . self::ARCH . '$/', '', $nevra) ?? $nevra) : $nevra; if (preg_match('/^(.+)-([^-]+-[^-]+)$/', $withoutArch, $matches) !== 1) { return null; } diff --git a/lib/OsStatus/Packages/Zypper.php b/lib/OsStatus/Packages/Zypper.php index 8aa8c282..3490b0af 100644 --- a/lib/OsStatus/Packages/Zypper.php +++ b/lib/OsStatus/Packages/Zypper.php @@ -120,7 +120,7 @@ public static function parsePatchConflicts(string $output): array { $inConflicts = true; continue; } - if ($inConflicts && preg_match('/^\s+([A-Za-z0-9._+-]+?)(?:\.(?:x86_64|aarch64|i[3-6]86|noarch|ppc64le|s390x|armv7hl|riscv64|src))?\s*[<=>]/', $line, $matches) === 1) { + if ($inConflicts && preg_match('/^\s+([A-Za-z0-9._+-]+?)(?:\.' . Rpm::ARCH . ')?\s*[<=>]/', $line, $matches) === 1) { $packages[$patch][$matches[1]] = true; continue; } diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php index 51296cc8..a252ed91 100644 --- a/tests/lib/OsStatus/DnfTest.php +++ b/tests/lib/OsStatus/DnfTest.php @@ -43,6 +43,17 @@ public function testParsesCheckUpdate(): void { ], $updates); } + public function testAcceptsEveryArchitecture(): void { + $updates = Dnf::parseCheckUpdate(implode("\n", [ + 'openssl-libs.x86_64_v2 1:3.2.2-6.el10_0.1 baseos', + 'glibc.ppc64 2.39-37.el10 baseos', + 'zlib-ng.s390 2.2.3-1.el10 baseos', + 'python3.12-pip.noarch 23.2.1-4.el10 appstream', + ])); + + $this->assertSame(['openssl-libs', 'glibc', 'zlib-ng', 'python3.12-pip'], array_keys($updates)); + } + public function testParsesDnf4Advisories(): void { $advisories = Dnf::parseAdvisories(implode("\n", [ 'RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64', @@ -103,7 +114,7 @@ public function testCheckUpdateFailure(): void { public function testVersionlock(): void { $dnf4 = Dnf::parseVersionlock("Last metadata expiration check: 0:10:00 ago.\nmariadb-server-3:10.11.10-1.el9.*\n"); $this->assertSame('mariadb-server', $dnf4[0]->name); - $this->assertSame('3:10.11.10-1.el9', $dnf4[0]->constraint); + $this->assertSame('3:10.11.10-1.el9', $dnf4[0]->constraint, 'The release is not taken for an architecture'); $dnf5 = Dnf::parseVersionlock("# Added by 'versionlock add' command on 2026-10-01 10:00:00\nPackage name: nginx\nevr = 1.26.2-1.fc43\n"); $this->assertSame('nginx', $dnf5[0]->name); @@ -191,6 +202,9 @@ public function testListsRefreshTimeFromMetalinks(): void { public function testSplitNevra(): void { $this->assertSame(['openssl-libs', '1:3.2.2-6.el9_5.1'], Rpm::splitNevra('openssl-libs-1:3.2.2-6.el9_5.1.x86_64')); $this->assertSame(['python3-dnf-plugin-versionlock', '4.3.0-16.el9'], Rpm::splitNevra('python3-dnf-plugin-versionlock-4.3.0-16.el9.noarch')); + $this->assertSame(['openssl-libs', '1:3.2.2-6.el10_0.1'], Rpm::splitNevra('openssl-libs-1:3.2.2-6.el10_0.1.x86_64_v2')); + $this->assertSame(['glibc', '2.39-37.el10'], Rpm::splitNevra('glibc-2.39-37.el10.s390')); + $this->assertSame(['sudo', '0:1.9.17p2-3.el9_8.1'], Rpm::splitNevra('sudo-0:1.9.17p2-3.el9_8.1', false)); $this->assertNull(Rpm::splitNevra('nonsense')); } } diff --git a/tests/lib/OsStatus/ZypperTest.php b/tests/lib/OsStatus/ZypperTest.php index ff4e02db..9b88c53f 100644 --- a/tests/lib/OsStatus/ZypperTest.php +++ b/tests/lib/OsStatus/ZypperTest.php @@ -105,6 +105,19 @@ public function testTumbleweedHasNoPatches(): void { $this->assertStringContainsString('dup', $this->zypper->getUpgradeCommands()['all']); } + public function testPatchConflictsWithAnyArchitecture(): void { + $coverage = Zypper::parsePatchConflicts(implode("\n", [ + 'Information for patch SUSE-SLE-Module-Basesystem-15-SP6-2026-1:', + 'Conflicts : [4]', + ' libopenssl3.s390x < 3.1.4-150600.5.27.1', + ' libopenssl3.x86_64_v3 < 3.1.4-150600.5.27.1', + ' go1.22 < 1.22.5-150000.1.3.1', + ' glibc.ppc64 < 2.38-150600.14.1', + ])); + + $this->assertSame(['libopenssl3', 'go1.22', 'glibc'], $coverage['SUSE-SLE-Module-Basesystem-15-SP6-2026-1']); + } + public function testLocks(): void { $locks = Zypper::parseLocks("kernel-defaultpackage"); From 00ec597dff9ffc02148d9d85d04bb320fc8ecef6 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:41:47 +0200 Subject: [PATCH 04/14] fix(os): run dnf 4 only as dnf-3 and leave any other dnf to the helper dnf 5 must never run as the web server: it copies root's whole cache into the web server's home first. dnf 5 was recognised only by its dnf5 binary, and everything else ran plain `dnf`. A distribution that ships dnf 5 only as `dnf` would have had it run as the web server. dnf 4's real binary is dnf-3 on RHEL 8 to 10, their rebuilds and Amazon Linux 2023; on AlmaLinux 9 `dnf`, `dnf4` and `yum` are links to it. dnf is now run only as dnf-3, and only when dnf5 is not installed next to it, because then dnf 5 keeps the system cache and dnf 4's would be stale. Any other dnf is left to the root helper, like dnf 5. The "only root can ask" diagnostic is dropped when the helper report supplies the pending updates, since nothing is missing then. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/OsStatusCollector.php | 7 +++ lib/OsStatus/Packages/Dnf.php | 34 ++++++++----- lib/OsStatus/Packages/NeedsRootException.php | 17 +++++++ tests/lib/OsStatus/DnfTest.php | 51 +++++++++++++++++--- tests/lib/OsStatus/OsStatusCollectorTest.php | 24 +++++++++ tests/lib/OsStatus/RealOutputTest.php | 10 ++-- 6 files changed, 118 insertions(+), 25 deletions(-) create mode 100644 lib/OsStatus/Packages/NeedsRootException.php diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index f062a7bf..3831dad3 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -19,6 +19,7 @@ use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\IPackageManager; +use OCA\ServerInfo\OsStatus\Packages\NeedsRootException; use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Packages\ProbeException; @@ -117,10 +118,13 @@ public function collect(): OsStatus { $updates = null; $updatesSource = null; + $needsRoot = null; if ($packageManager !== null) { try { $updates = $packageManager->getPendingUpdates(); $updatesSource = OsStatus::SOURCE_SYSTEM; + } catch (NeedsRootException $e) { + $needsRoot = $e->getMessage(); } catch (ProbeException $e) { $diagnostics[] = $e->getMessage(); } catch (\Throwable $e) { @@ -133,6 +137,9 @@ public function collect(): OsStatus { $updates = $reportedUpdates; $updatesSource = OsStatus::SOURCE_HELPER; } + if ($needsRoot !== null && $updatesSource !== OsStatus::SOURCE_HELPER) { + $diagnostics[] = $needsRoot; + } $listsRefreshedAt = $this->latest( $packageManager !== null ? $this->probe('The package list age', fn (): ?int => $packageManager->getListsRefreshedAt(), null, $diagnostics) : null, diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index 6c2acf37..457fdc2e 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -23,6 +23,10 @@ * answering, so it is never run as the web server. On dnf 5 systems pending * updates come from the root helper, and locks are read from their file. * + * dnf 4 is run as `dnf-3`, its real binary on RHEL 8 to 10, their rebuilds + * and Amazon Linux 2023, because `dnf` may be dnf 5. A system without dnf-3, + * or with dnf 5 next to it, is left to the root helper. + * * @psalm-api */ class Dnf implements IPackageManager { @@ -37,7 +41,10 @@ class Dnf implements IPackageManager { 'dnf-automatic-notifyonly.timer' => false, ]; - private ?bool $isDnf5 = null; + /** dnf 4's own binary; `dnf` is only a link to it, or to dnf 5 */ + private const DNF4 = 'dnf-3'; + + private ?bool $isDnf4 = null; public function __construct( private IHost $host, @@ -53,7 +60,7 @@ public function getId(): string { #[\Override] public function isAvailable(): bool { - return $this->host->hasBinary('dnf') || $this->host->hasBinary('dnf5'); + return $this->host->hasBinary(self::DNF4) || $this->host->hasBinary('dnf5') || $this->host->hasBinary('dnf'); } #[\Override] @@ -63,8 +70,10 @@ public function classifiesSecurity(): bool { #[\Override] public function getPendingUpdates(): array { - if ($this->isDnf5()) { - throw new ProbeException('dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.'); + if (!$this->isDnf4()) { + throw new NeedsRootException($this->host->hasBinary('dnf5') + ? 'dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.' + : 'dnf could not be identified as dnf 4, so it is not run as the web server; the root helper reports pending updates on this system.'); } $result = $this->dnf(['-q', '--cacheonly', 'check-update'], 180); @@ -178,7 +187,7 @@ public static function parseAdvisories(string $output): array { #[\Override] public function getHeldPackages(): ?array { - if ($this->isDnf5()) { + if (!$this->isDnf4()) { $locks = $this->host->readFile('/etc/dnf/versionlock.toml', 262144); return $locks === null ? ($this->host->fileExists('/etc/dnf') ? [] : null) : self::parseVersionlockToml($locks); @@ -310,7 +319,7 @@ public function getAutomaticUpdates(): AutomaticUpdates { */ #[\Override] public function getRebootReasons(): array { - if ($this->isDnf5()) { + if (!$this->isDnf4()) { return []; } @@ -333,7 +342,7 @@ public function getRebootReasons(): array { */ #[\Override] public function hasOwnRebootCheck(): bool { - if ($this->isDnf5()) { + if (!$this->isDnf4()) { return false; } @@ -356,17 +365,18 @@ public function getPackageDatabasePath(): ?string { } /** - * Told apart by the binary rather than by running dnf, because running - * dnf 5 as the web server is what has to be avoided. + * Told apart by the binaries rather than by running dnf, because running + * dnf 5 as the web server is what has to be avoided. Where both are + * installed, dnf 5 keeps the system cache and dnf 4's would be stale. */ - private function isDnf5(): bool { - return $this->isDnf5 ??= $this->host->hasBinary('dnf5'); + private function isDnf4(): bool { + return $this->isDnf4 ??= $this->host->hasBinary(self::DNF4) && !$this->host->hasBinary('dnf5'); } /** * @param list $arguments */ private function dnf(array $arguments, int $timeout): ?CommandResult { - return $this->host->run($this->host->hasBinary('dnf') ? 'dnf' : 'dnf5', $arguments, $timeout); + return $this->host->run(self::DNF4, $arguments, $timeout); } } diff --git a/lib/OsStatus/Packages/NeedsRootException.php b/lib/OsStatus/Packages/NeedsRootException.php new file mode 100644 index 00000000..8edc65e8 --- /dev/null +++ b/lib/OsStatus/Packages/NeedsRootException.php @@ -0,0 +1,17 @@ +host = new FakeHost(); - $this->host->binaries[] = 'dnf'; + $this->host->binaries[] = 'dnf-3'; $this->dnf = new Dnf($this->host, new Rpm($this->host), new SystemdProbe($this->host)); } @@ -80,8 +81,8 @@ public function testParsesDnf5Advisories(): void { } public function testPendingUpdatesAreMatchedWithAdvisories(): void { - $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100) - ->withCommand('dnf', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], "RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64\n") + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], "RHSA-2026:1234 Important/Sec. openssl-libs-1:3.2.2-6.el9_5.1.x86_64\n") ->withCommand('rpm', ['-qa', '--qf', Rpm::QUERY_FORMAT], "openssl-libs 1:3.2.2-6.el9_5 1792000000\ntzdata 0:2024b-2.el9 1792100000\n"); $updates = $this->dnf->getPendingUpdates(); @@ -97,14 +98,14 @@ public function testPendingUpdatesAreMatchedWithAdvisories(): void { } public function testNoUpdatesSkipsTheAdvisories(): void { - $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], '', 0); + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); $this->assertSame([], $this->dnf->getPendingUpdates()); - $this->assertNotContains('dnf -q --cacheonly updateinfo list --security --available', $this->host->ran); + $this->assertNotContains('dnf-3 -q --cacheonly updateinfo list --security --available', $this->host->ran); } public function testCheckUpdateFailure(): void { - $this->host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], '', 1, "Error: Cache-only enabled but no cache for 'baseos'\n"); + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 1, "Error: Cache-only enabled but no cache for 'baseos'\n"); $this->expectException(ProbeException::class); $this->expectExceptionMessage("no cache for 'baseos'"); @@ -142,7 +143,7 @@ public function testAutomaticTimerThatOnlyDownloads(): void { } public function testNeedsRestartingReboot(): void { - $this->host->withCommand('dnf', ['-q', '--cacheonly', 'needs-restarting', '-r'], "Core libraries or services have been updated since boot-up:\n * glibc\n * kernel\n\nReboot is required to fully utilize these updates.\n", 1); + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], "Core libraries or services have been updated since boot-up:\n * glibc\n * kernel\n\nReboot is required to fully utilize these updates.\n", 1); $reasons = $this->dnf->getRebootReasons(); @@ -150,7 +151,7 @@ public function testNeedsRestartingReboot(): void { } public function testNeedsRestartingNoReboot(): void { - $this->host->withCommand('dnf', ['-q', '--cacheonly', 'needs-restarting', '-r'], "No core libraries or services have been updated since boot-up.\n", 0); + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], "No core libraries or services have been updated since boot-up.\n", 0); $this->assertSame([], $this->dnf->getRebootReasons()); } @@ -176,6 +177,40 @@ public function testDnf5IsNeverRunAsTheWebServer(): void { $this->assertSame([], $this->host->ran, 'Not a single dnf process'); } + public function testDnf4RunsAsDnf3EvenWhenDnfIsThere(): void { + $this->host->binaries[] = 'dnf'; + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); + + $this->assertTrue($this->dnf->isAvailable()); + $this->assertSame([], $this->dnf->getPendingUpdates()); + $this->assertSame(['dnf-3 -q --cacheonly check-update'], $this->host->ran); + } + + public function testDnfThatIsNotDnf4IsNeverRun(): void { + $host = new FakeHost(); + $host->binaries = ['dnf', 'rpm']; + $dnf = new Dnf($host, new Rpm($host), new SystemdProbe($host)); + + $this->assertTrue($dnf->isAvailable()); + try { + $dnf->getPendingUpdates(); + $this->fail('An unidentified dnf must not be asked'); + } catch (NeedsRootException $e) { + $this->assertStringContainsString('root helper', $e->getMessage()); + } + $this->assertSame([], $dnf->getRebootReasons()); + $this->assertFalse($dnf->hasOwnRebootCheck()); + $this->assertNull($dnf->getHeldPackages()); + $this->assertSame([], $host->ran); + } + + public function testDnf5NextToDnf4IsLeftToTheHelper(): void { + $this->host->binaries[] = 'dnf5'; + + $this->expectException(NeedsRootException::class); + $this->dnf->getPendingUpdates(); + } + public function testDnf5WithoutLocks(): void { $this->host->binaries[] = 'dnf5'; $this->host->modified['/etc/dnf'] = 1; diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 326a2f41..b1b91aff 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -267,6 +267,30 @@ public function testUpdatesFromTheHelperWhenTheWebServerCannotAsk(): void { $this->assertContains('No supported package manager was found for Ubuntu 24.04.3 LTS.', $status->diagnostics); } + public function testDnf5LeavesTheUpdatesToTheHelperWithoutComplaint(): void { + $this->host->withFile('/etc/os-release', "NAME=\"Fedora Linux\"\nID=fedora\nVERSION_ID=44\nPRETTY_NAME=\"Fedora Linux 44\"\n"); + $this->host->binaries = ['dnf', 'dnf5']; + + $without = $this->collector()->collect(); + + $this->assertSame('dnf', $without->packageManager); + $this->assertNull($without->updates); + $this->assertContains('dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.', $without->diagnostics); + + $this->withRootsHelperReportPath(); + $this->host->withFile(HelperReportReader::DEFAULT_PATH, (string)json_encode([ + 'schema' => 1, 'helperVersion' => '1.0.0', 'generatedAt' => self::NOW - 600, 'hostname' => 'cloud.example.org', + 'bootTime' => self::BOOT, 'listsRefreshedAt' => self::NOW - 600, + 'pendingUpdates' => [['name' => 'openssl-libs', 'installed' => '1', 'candidate' => '2', 'security' => true]], + ])); + + $with = $this->collector()->collect(); + + $this->assertSame(OsStatus::SOURCE_HELPER, $with->updatesSource); + $this->assertSame([], array_values(array_filter($with->diagnostics, static fn (string $d): bool => str_contains($d, 'dnf 5')))); + $this->assertSame([], array_values(array_filter($this->host->ran, static fn (string $c): bool => str_starts_with($c, 'dnf'))), 'dnf 5 never runs'); + } + public function testFailuresBecomeDiagnostics(): void { $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], '', 100, "E: The package lists are damaged\n"); diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index cd841c83..61f76e2b 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -59,11 +59,11 @@ public function testUbuntu2404(): void { public function testAlmaLinux9WithDnf4(): void { $host = new FakeHost(); - $host->binaries[] = 'dnf'; - $host->withCommand('dnf', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) - ->withCommand('dnf', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], FakeHost::fixture('alma-9-dnf-updateinfo-security')) - ->withCommand('dnf', ['-q', '--cacheonly', 'versionlock', 'list'], FakeHost::fixture('alma-9-dnf-versionlock')) - ->withCommand('dnf', ['-q', '--cacheonly', 'needs-restarting', '-r'], FakeHost::fixture('alma-9-dnf-needs-restarting-r'), 1) + $host->binaries[] = 'dnf-3'; + $host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], FakeHost::fixture('alma-9-dnf-updateinfo-security')) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'versionlock', 'list'], FakeHost::fixture('alma-9-dnf-versionlock')) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], FakeHost::fixture('alma-9-dnf-needs-restarting-r'), 1) ->withFile('/etc/dnf/automatic.conf', FakeHost::fixture('alma-9-dnf-automatic.conf')); $dnf = new Dnf($host, new Rpm($host), new SystemdProbe($host)); From a6edf3f6b30789eb3ad4bed3d94e36d8214a99e3 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:42:32 +0200 Subject: [PATCH 05/14] fix(os): count needs-restarting as a reboot only when it says so `dnf needs-restarting -r` exits 1 when a reboot is needed, but dnf exits 1 for its own errors as well. On an AlmaLinux 9 container without the plugin module, it printed "No such command: needs-restarting" to stderr and exited 1, which read as a permanent reboot reason. A reboot now also needs "Reboot is required" in the output, which LC_ALL=C keeps in English. When needs-restarting gives no answer, hasOwnRebootCheck() is false, so the generic core library check runs instead. hasOwnRebootCheck() reuses the answer of getRebootReasons() rather than running dnf a second time. The stderr fixtures were captured on almalinux:9 (dnf 4.14) as an unprivileged user. There, needs-restarting with --cacheonly and no cache at all still answered with exit 0; the no-cache error is tested anyway. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Dnf.php | 32 +++++++++++++------ .../alma-9-dnf-check-update-no-cache.stderr | 1 + ...ma-9-dnf-needs-restarting-no-plugin.stderr | 2 ++ tests/lib/OsStatus/DnfTest.php | 24 ++++++++++++++ 4 files changed, 50 insertions(+), 9 deletions(-) create mode 100644 tests/data/os/alma-9-dnf-check-update-no-cache.stderr create mode 100644 tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index 457fdc2e..c04fd2d7 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -45,6 +45,8 @@ class Dnf implements IPackageManager { private const DNF4 = 'dnf-3'; private ?bool $isDnf4 = null; + /** Whether the last getRebootReasons() got an answer from needs-restarting */ + private ?bool $rebootChecked = null; public function __construct( private IHost $host, @@ -315,19 +317,32 @@ public function getAutomaticUpdates(): AutomaticUpdates { } /** - * `needs-restarting -r` exits 1 when core packages were updated since boot. + * `needs-restarting -r` exits 1 and says "Reboot is required" when core + * packages were updated since boot. dnf exits 1 for its own errors too, + * e.g. "No such command" where the plugin is not installed, so the exit + * code alone means nothing. */ #[\Override] public function getRebootReasons(): array { + $this->rebootChecked = false; if (!$this->isDnf4()) { return []; } $result = $this->dnf(['-q', '--cacheonly', 'needs-restarting', '-r'], 120); - if ($result === null || $result->exitCode !== 1 || $result->timedOut) { + if ($result === null || $result->timedOut || $result->truncated) { return []; } + if ($result->exitCode === 0) { + $this->rebootChecked = true; + return []; + } + if ($result->exitCode !== 1 || preg_match('/^Reboot is required/m', $result->stdout) !== 1) { + return []; + } + + $this->rebootChecked = true; $packages = []; if (preg_match_all('/^\s*\*\s*(\S+)/m', $result->stdout, $matches) > 0) { $packages = array_values(array_unique($matches[1])); @@ -337,18 +352,17 @@ public function getRebootReasons(): array { } /** - * dnf 5's needs-restarting cannot run unprivileged; there the generic - * kernel and core library checks answer instead. + * Whether needs-restarting gave an answer. dnf 5's cannot run + * unprivileged; there, and wherever it failed, the generic kernel and core + * library checks answer instead. */ #[\Override] public function hasOwnRebootCheck(): bool { - if (!$this->isDnf4()) { - return false; + if ($this->rebootChecked === null) { + $this->getRebootReasons(); } - $result = $this->dnf(['-q', 'needs-restarting', '--help'], 30); - - return $result !== null && $result->succeeded(); + return $this->rebootChecked === true; } #[\Override] diff --git a/tests/data/os/alma-9-dnf-check-update-no-cache.stderr b/tests/data/os/alma-9-dnf-check-update-no-cache.stderr new file mode 100644 index 00000000..009d7bb3 --- /dev/null +++ b/tests/data/os/alma-9-dnf-check-update-no-cache.stderr @@ -0,0 +1 @@ +Error: Cache-only enabled but no cache for 'appstream' diff --git a/tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr b/tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr new file mode 100644 index 00000000..e8c2f845 --- /dev/null +++ b/tests/data/os/alma-9-dnf-needs-restarting-no-plugin.stderr @@ -0,0 +1,2 @@ +No such command: needs-restarting. Please use /usr/bin/dnf --help +It could be a DNF plugin command, try: "dnf install 'dnf-command(needs-restarting)'" diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php index 6664ce6b..fd052a8b 100644 --- a/tests/lib/OsStatus/DnfTest.php +++ b/tests/lib/OsStatus/DnfTest.php @@ -10,6 +10,7 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\Dnf; use OCA\ServerInfo\OsStatus\Packages\NeedsRootException; @@ -154,6 +155,29 @@ public function testNeedsRestartingNoReboot(): void { $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], "No core libraries or services have been updated since boot-up.\n", 0); $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertTrue($this->dnf->hasOwnRebootCheck()); + $this->assertCount(1, $this->host->ran, 'needs-restarting runs once'); + } + + public function testNeedsRestartingWithoutThePlugin(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], '', 1, FakeHost::fixture('alma-9-dnf-needs-restarting-no-plugin.stderr')); + + $this->assertSame([], $this->dnf->getRebootReasons(), 'A missing plugin is no reason to reboot'); + $this->assertFalse($this->dnf->hasOwnRebootCheck(), 'The generic core library check answers instead'); + } + + public function testNeedsRestartingWithoutCache(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'needs-restarting', '-r'], '', 1, FakeHost::fixture('alma-9-dnf-check-update-no-cache.stderr')); + + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertFalse($this->dnf->hasOwnRebootCheck()); + } + + public function testNeedsRestartingTimeout(): void { + $this->host->commands['dnf-3 -q --cacheonly needs-restarting -r'] = new CommandResult(1, "Reboot is required to fully utilize these updates.\n", '', true); + + $this->assertSame([], $this->dnf->getRebootReasons()); + $this->assertFalse($this->dnf->hasOwnRebootCheck()); } public function testDnf5IsNeverRunAsTheWebServer(): void { From ad90fc875c3f01912380288e6ad1aa2cecb97a48 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:43:02 +0200 Subject: [PATCH 06/14] fix(os): join the wrapped lines of dnf 4 check-update Without a terminal, dnf 4 lays the check-update columns out in 80 characters. When a column is wider than its share, as with long package names next to RHEL repository ids such as rhel-9-for-x86_64-appstream-rpms, dnf ends the line after that column and continues the row on the next line, indented to the next column (fmtColumns in dnf/cli/output.py). Only complete one-line rows were parsed, so those updates were silently lost. Indented lines now continue the row before them until it has its three columns. The test follows dnf's layout rules; capturing real output with a long repository id on AlmaLinux 9 failed because the mirrors could not be reached from the test host. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Dnf.php | 25 ++++++++++++++++++++++--- tests/lib/OsStatus/DnfTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index c04fd2d7..99ab1e14 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -115,16 +115,35 @@ public function getPendingUpdates(): array { * Parses `dnf check-update`: "name.arch version-release repository", * up to the "Obsoleting Packages" section. * + * Without a terminal dnf 4 lays the columns out in 80 characters and + * moves whatever follows a column that is too wide onto the next line, + * indented. Such continuation lines are joined to the line they continue. + * * @return array name => [version, repository] */ public static function parseCheckUpdate(string $output): array { - $updates = []; + $rows = []; foreach (preg_split('/\R/', $output) ?: [] as $line) { if (preg_match('/^Obsoleting|^Security:/i', $line) === 1) { break; } - if (preg_match('/^(\S+)\.(' . Rpm::ARCH . ')\s+(\S+)\s+(\S+)\s*$/', $line, $matches) === 1) { - $updates[$matches[1]] = [$matches[3], $matches[4]]; + if (trim($line) === '') { + continue; + } + + $columns = preg_split('/\s+/', trim($line)) ?: []; + $last = array_key_last($rows); + if ($last !== null && count($rows[$last]) < 3 && preg_match('/^\s/', $line) === 1) { + $rows[$last] = array_merge($rows[$last], $columns); + } else { + $rows[] = $columns; + } + } + + $updates = []; + foreach ($rows as $columns) { + if (count($columns) === 3 && preg_match('/^(\S+)\.' . Rpm::ARCH . '$/', $columns[0], $matches) === 1) { + $updates[$matches[1]] = [$columns[1], $columns[2]]; } } diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php index fd052a8b..39ddac8c 100644 --- a/tests/lib/OsStatus/DnfTest.php +++ b/tests/lib/OsStatus/DnfTest.php @@ -45,6 +45,32 @@ public function testParsesCheckUpdate(): void { ], $updates); } + public function testJoinsWrappedCheckUpdateLines(): void { + // dnf 4 fits the columns into 80 characters and continues a row on the + // next line after a column that does not fit (dnf/cli/output.py). + $updates = Dnf::parseCheckUpdate(implode("\n", [ + '', + 'glibc.aarch64 2.34-283.el9_8 rhel-9-for-aarch64-baseos-rpms', + 'glibc-minimal-langpack.aarch64', + ' 2.34-283.el9_8 rhel-9-for-aarch64-baseos-rpms', + 'python3-cryptography-vectors.noarch', + ' 38.0.4-1.el9_4.20260101.1', + ' rhel-9-for-aarch64-appstream-rpms', + 'tzdata.noarch 2026e-1.el9_8 rhel-9-for-aarch64-baseos-rpms', + 'not-a-package-line', + 'Obsoleting Packages', + 'grub2-tools.aarch64', + ' 1:2.06-92.el9 rhel-9-for-aarch64-baseos-rpms', + ])); + + $this->assertSame([ + 'glibc' => ['2.34-283.el9_8', 'rhel-9-for-aarch64-baseos-rpms'], + 'glibc-minimal-langpack' => ['2.34-283.el9_8', 'rhel-9-for-aarch64-baseos-rpms'], + 'python3-cryptography-vectors' => ['38.0.4-1.el9_4.20260101.1', 'rhel-9-for-aarch64-appstream-rpms'], + 'tzdata' => ['2026e-1.el9_8', 'rhel-9-for-aarch64-baseos-rpms'], + ], $updates); + } + public function testAcceptsEveryArchitecture(): void { $updates = Dnf::parseCheckUpdate(implode("\n", [ 'openssl-libs.x86_64_v2 1:3.2.2-6.el10_0.1 baseos', From df51f31ffe34dea05ccee4aed0a7a4ab124df7f9 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:57:50 +0200 Subject: [PATCH 07/14] fix(os): leave dnf updates unclassified without usable updateinfo dnf always claimed to tell security fixes apart. CentOS Stream publishes no updateinfo, and neither do many third-party repositories, so an openssl CVE fix there showed as an ordinary update and the verdict stayed green. And when `dnf updateinfo` failed, the whole update list was thrown away. classifiesSecurity() is now false for CentOS (ID=centos), when no cached repository of dnf 4 or dnf 5 has updateinfo metadata, and when the advisories could not be listed in this collection. The updates are then kept with security=false and an unclassified status, which the verdict already treats as "every update counts", so it never claims there are no security fixes. A failed advisory listing also adds a diagnostic. Package managers report such partial answers through the new IReportsDiagnostics, which the collector appends. The factory hands dnf the os-release it already parsed. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/OsStatusCollector.php | 5 ++ lib/OsStatus/Packages/Dnf.php | 66 +++++++++++++++++-- lib/OsStatus/Packages/IReportsDiagnostics.php | 23 +++++++ .../Packages/PackageManagerFactory.php | 2 +- tests/lib/OsStatus/DnfTest.php | 46 +++++++++++++ tests/lib/OsStatus/OsStatusCollectorTest.php | 15 +++++ tests/lib/OsStatus/RealOutputTest.php | 1 + 7 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 lib/OsStatus/Packages/IReportsDiagnostics.php diff --git a/lib/OsStatus/OsStatusCollector.php b/lib/OsStatus/OsStatusCollector.php index 3831dad3..da64efc2 100644 --- a/lib/OsStatus/OsStatusCollector.php +++ b/lib/OsStatus/OsStatusCollector.php @@ -19,6 +19,7 @@ use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\IPackageManager; +use OCA\ServerInfo\OsStatus\Packages\IReportsDiagnostics; use OCA\ServerInfo\OsStatus\Packages\NeedsRootException; use OCA\ServerInfo\OsStatus\Packages\PackageManagerFactory; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; @@ -172,6 +173,10 @@ public function collect(): OsStatus { $updates = array_slice($updates, 0, self::MAX_LISTED_UPDATES); } + if ($packageManager instanceof IReportsDiagnostics) { + array_push($diagnostics, ...$packageManager->getDiagnostics()); + } + $timeSync = $this->probe('The time synchronisation', fn (): array => $this->timeSync->probe(), ['synchronized' => null, 'source' => null], $diagnostics); return new OsStatus( diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index 99ab1e14..08a0fbb7 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -12,6 +12,7 @@ use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\OsStatus\Host\IHost; +use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; /** @@ -29,10 +30,18 @@ * * @psalm-api */ -class Dnf implements IPackageManager { +class Dnf implements IPackageManager, IReportsDiagnostics { /** check-update's exit code when updates are available */ private const EXIT_UPDATES = 100; + /** Where dnf 4 and dnf 5 keep each repository's updateinfo */ + private const UPDATEINFO = [ + '/var/cache/dnf/*-updateinfo.solvx', + '/var/cache/dnf/*/repodata/*updateinfo*', + '/var/cache/libdnf5/*/solv/*-updateinfo.solvx', + '/var/cache/libdnf5/*/repodata/*updateinfo*', + ]; + private const AUTOMATIC_TIMERS = [ 'dnf-automatic-install.timer' => true, 'dnf5-automatic.timer' => null, @@ -47,6 +56,11 @@ class Dnf implements IPackageManager { private ?bool $isDnf4 = null; /** Whether the last getRebootReasons() got an answer from needs-restarting */ private ?bool $rebootChecked = null; + /** Whether the last getPendingUpdates() could not read the advisories */ + private bool $advisoriesFailed = false; + /** @var list */ + private array $diagnostics = []; + private ?OsRelease $release = null; public function __construct( private IHost $host, @@ -65,13 +79,48 @@ public function isAvailable(): bool { return $this->host->hasBinary(self::DNF4) || $this->host->hasBinary('dnf5') || $this->host->hasBinary('dnf'); } + /** + * The same package manager, for the given distribution. + */ + public function withRelease(OsRelease $release): self { + $clone = clone $this; + $clone->release = $release; + + return $clone; + } + + /** + * Security fixes are told apart by the repositories' updateinfo metadata. + * CentOS Stream publishes none, and neither do many third-party + * repositories; without it every update would look like a non-security one. + */ #[\Override] public function classifiesSecurity(): bool { - return true; + return $this->release?->id !== 'centos' && !$this->advisoriesFailed && $this->hasUpdateinfo(); + } + + /** + * Whether any repository in the system cache came with updateinfo. + */ + private function hasUpdateinfo(): bool { + foreach (self::UPDATEINFO as $pattern) { + if ($this->host->glob($pattern) !== []) { + return true; + } + } + + return false; + } + + #[\Override] + public function getDiagnostics(): array { + return $this->diagnostics; } #[\Override] public function getPendingUpdates(): array { + $this->advisoriesFailed = false; + $this->diagnostics = []; if (!$this->isDnf4()) { throw new NeedsRootException($this->host->hasBinary('dnf5') ? 'dnf 5 can only be asked about pending updates as root, so the root helper reports them on this system.' @@ -82,7 +131,7 @@ public function getPendingUpdates(): array { if ($result === null) { throw new ProbeException('dnf could not be run.'); } - if ($result->exitCode !== 0 && $result->exitCode !== self::EXIT_UPDATES) { + if (($result->exitCode !== 0 && $result->exitCode !== self::EXIT_UPDATES) || $result->timedOut || $result->truncated) { throw new ProbeException('dnf check-update failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout)); } @@ -91,7 +140,7 @@ public function getPendingUpdates(): array { return []; } - $advisories = $this->securityAdvisories(); + $advisories = $this->classifiesSecurity() ? $this->securityAdvisories() : []; $installed = $this->rpm->getInstalledVersions(); $updates = []; @@ -151,14 +200,19 @@ public static function parseCheckUpdate(string $output): array { } /** - * Security advisories for the available updates, by package name. + * Security advisories for the available updates, by package name. When + * they cannot be listed, the updates stay listed but unclassified. * * @return array, severity: ?string}> */ private function securityAdvisories(): array { $result = $this->dnf(['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], 180); if ($result === null || !$result->succeeded()) { - throw new ProbeException('dnf could not list security advisories: ' . ProbeHelper::firstLine($result?->stderr ?? '')); + $this->advisoriesFailed = true; + $this->diagnostics[] = 'dnf could not list security advisories, so pending updates are not told apart into security fixes and others: ' + . ProbeHelper::firstLine($result?->stderr ?: ($result?->timedOut ? 'timed out' : '')); + + return []; } return self::parseAdvisories($result->stdout); diff --git a/lib/OsStatus/Packages/IReportsDiagnostics.php b/lib/OsStatus/Packages/IReportsDiagnostics.php new file mode 100644 index 00000000..dfd455a2 --- /dev/null +++ b/lib/OsStatus/Packages/IReportsDiagnostics.php @@ -0,0 +1,23 @@ + + */ + public function getDiagnostics(): array; +} diff --git a/lib/OsStatus/Packages/PackageManagerFactory.php b/lib/OsStatus/Packages/PackageManagerFactory.php index 70450b9d..dd3db4d2 100644 --- a/lib/OsStatus/Packages/PackageManagerFactory.php +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -31,7 +31,7 @@ public function forRelease(OsRelease $release): ?IPackageManager { $candidate = match (true) { $release->isLike('debian') || $release->isLike('ubuntu') => $this->apt->withRelease($release), $release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles' || $release->id === 'sled' => $this->zypper, - $release->isLike('fedora') || $release->isLike('rhel') || $release->isLike('centos') || in_array($release->id, ['amzn', 'ol', 'almalinux', 'rocky'], true) => $this->dnf, + $release->isLike('fedora') || $release->isLike('rhel') || $release->isLike('centos') || in_array($release->id, ['amzn', 'ol', 'almalinux', 'rocky'], true) => $this->dnf->withRelease($release), $release->isLike('arch') => $this->pacman, $release->id === 'alpine' => $this->apk, default => null, diff --git a/tests/lib/OsStatus/DnfTest.php b/tests/lib/OsStatus/DnfTest.php index 39ddac8c..48bc049f 100644 --- a/tests/lib/OsStatus/DnfTest.php +++ b/tests/lib/OsStatus/DnfTest.php @@ -11,6 +11,7 @@ use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\Host\CommandResult; +use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\Packages\AutomaticUpdates; use OCA\ServerInfo\OsStatus\Packages\Dnf; use OCA\ServerInfo\OsStatus\Packages\NeedsRootException; @@ -25,6 +26,7 @@ protected function setUp(): void { parent::setUp(); $this->host = new FakeHost(); $this->host->binaries[] = 'dnf-3'; + $this->host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/baseos-updateinfo.solvx']; $this->dnf = new Dnf($this->host, new Rpm($this->host), new SystemdProbe($this->host)); } @@ -124,6 +126,50 @@ public function testPendingUpdatesAreMatchedWithAdvisories(): void { $this->assertCount(1, array_filter($this->host->ran, static fn (string $command): bool => str_starts_with($command, 'rpm ')), 'rpm -qa runs once'); } + private function withPendingUpdates(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], "openssl-libs.x86_64 1:3.2.2-6.el9_5.1 baseos\ntzdata.noarch 2025a-1.el9 appstream\n", 100); + } + + public function testFailedAdvisoriesLeaveTheUpdatesUnclassified(): void { + $this->withPendingUpdates(); + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], '', 1, "Error: Cache-only enabled but no cache for 'appstream'\n"); + + $updates = $this->dnf->getPendingUpdates(); + + $this->assertSame(['openssl-libs', 'tzdata'], array_map(static fn ($u) => $u->name, $updates)); + $this->assertFalse($updates[0]->security); + $this->assertFalse($this->dnf->classifiesSecurity(), 'No security fix is ruled out'); + $this->assertSame(["dnf could not list security advisories, so pending updates are not told apart into security fixes and others: Error: Cache-only enabled but no cache for 'appstream'"], $this->dnf->getDiagnostics()); + } + + public function testRepositoriesWithoutUpdateinfoDoNotClassify(): void { + unset($this->host->globs['/var/cache/dnf/*-updateinfo.solvx']); + $this->withPendingUpdates(); + + $updates = $this->dnf->getPendingUpdates(); + + $this->assertCount(2, $updates); + $this->assertFalse($this->dnf->classifiesSecurity()); + $this->assertNotContains('dnf-3 -q --cacheonly updateinfo list --security --available', $this->host->ran); + $this->assertSame([], $this->dnf->getDiagnostics()); + } + + public function testUpdateinfoOfEitherDnfCounts(): void { + unset($this->host->globs['/var/cache/dnf/*-updateinfo.solvx']); + $this->host->globs['/var/cache/libdnf5/*/repodata/*updateinfo*'] = ['/var/cache/libdnf5/updates-7608c6d2bd2ece7d/repodata/1a2b-updateinfo.xml.zst']; + + $this->assertTrue($this->dnf->classifiesSecurity()); + } + + public function testCentOsStreamDoesNotClassify(): void { + $stream = OsRelease::parse("NAME=\"CentOS Stream\"\nID=\"centos\"\nID_LIKE=\"rhel fedora\"\nVERSION_ID=\"10\"\n"); + $this->assertNotNull($stream); + $dnf = $this->dnf->withRelease($stream); + + $this->assertFalse($dnf->classifiesSecurity()); + $this->assertTrue($this->dnf->classifiesSecurity(), 'withRelease() leaves the original alone'); + } + public function testNoUpdatesSkipsTheAdvisories(): void { $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index b1b91aff..8048e02b 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -291,6 +291,21 @@ public function testDnf5LeavesTheUpdatesToTheHelperWithoutComplaint(): void { $this->assertSame([], array_values(array_filter($this->host->ran, static fn (string $c): bool => str_starts_with($c, 'dnf'))), 'dnf 5 never runs'); } + public function testUnclassifiedDnfUpdatesAreKeptWithADiagnostic(): void { + $this->host->withFile('/etc/os-release', "NAME=\"AlmaLinux\"\nID=\"almalinux\"\nID_LIKE=\"rhel centos fedora\"\nVERSION_ID=\"9.8\"\nPRETTY_NAME=\"AlmaLinux 9.8\"\n"); + $this->host->binaries = ['dnf', 'dnf-3']; + $this->host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/baseos-updateinfo.solvx']; + $this->host->modified['/var/cache/dnf/last_makecache'] = self::NOW - 600; + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) + ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], '', 1, "Error: something broke\n"); + + $status = $this->collector()->collect(); + + $this->assertCount(9, $status->updates ?? []); + $this->assertFalse($status->securityClassified); + $this->assertContains('dnf could not list security advisories, so pending updates are not told apart into security fixes and others: Error: something broke', $status->diagnostics); + } + public function testFailuresBecomeDiagnostics(): void { $this->host->withCommand('apt-get', ['-s', '-o', 'Debug::NoLocking=1', 'dist-upgrade'], '', 100, "E: The package lists are damaged\n"); diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index 61f76e2b..e7e295eb 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -60,6 +60,7 @@ public function testUbuntu2404(): void { public function testAlmaLinux9WithDnf4(): void { $host = new FakeHost(); $host->binaries[] = 'dnf-3'; + $host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/appstream-updateinfo.solvx', '/var/cache/dnf/baseos-updateinfo.solvx', '/var/cache/dnf/crb-updateinfo.solvx']; $host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], FakeHost::fixture('alma-9-dnf-check-update-pending'), 100) ->withCommand('dnf-3', ['-q', '--cacheonly', 'updateinfo', 'list', '--security', '--available'], FakeHost::fixture('alma-9-dnf-updateinfo-security')) ->withCommand('dnf-3', ['-q', '--cacheonly', 'versionlock', 'list'], FakeHost::fixture('alma-9-dnf-versionlock')) From 3236885166b2fe0d55e49aa1ef0a036b6c736ecd Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 05:59:24 +0200 Subject: [PATCH 08/14] fix(os): accept zypper's informational exit codes and report a lock as busy zypper exits with 100 to 106 to say something beyond success: updates or a reboot are needed, or repositories were skipped. Every non-zero exit was treated as a failure, so a single never-refreshed SLES module (exit 106) would have cost the whole update list. Exit codes 0, 100 to 104 and 106 are now accepted when zypper finished and its XML parses; 106 adds a diagnostic that the list may be incomplete, with zypper's own message. 105 means zypper was interrupted and stays a failure. Exit 7, the package manager lock held by another process, now says zypper is busy instead of failing with the lock message. `zypper needs-rebooting` exiting 0 turned the core library check off, but it only knows what /etc/zypp/needreboot lists, which is nothing on the Leap 16 image. hasOwnRebootCheck() is now false, so the core library check runs, and needs-rebooting runs once per collection instead of twice. Patch names passed to `zypper info` must start with a letter or digit, so none can be read as an option. isRolling() uses the os-release the factory already parsed instead of reading the file three times. Exit 106 with --no-refresh on a never-refreshed repository could not be reproduced: the test host could not reach the mirrors. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- .../Packages/PackageManagerFactory.php | 2 +- lib/OsStatus/Packages/Zypper.php | 83 +++++++++++++++---- tests/lib/OsStatus/RealOutputTest.php | 7 +- tests/lib/OsStatus/ZypperTest.php | 78 +++++++++++++++-- 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/lib/OsStatus/Packages/PackageManagerFactory.php b/lib/OsStatus/Packages/PackageManagerFactory.php index dd3db4d2..86266cc1 100644 --- a/lib/OsStatus/Packages/PackageManagerFactory.php +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -30,7 +30,7 @@ public function __construct( public function forRelease(OsRelease $release): ?IPackageManager { $candidate = match (true) { $release->isLike('debian') || $release->isLike('ubuntu') => $this->apt->withRelease($release), - $release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles' || $release->id === 'sled' => $this->zypper, + $release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles' || $release->id === 'sled' => $this->zypper->withRelease($release), $release->isLike('fedora') || $release->isLike('rhel') || $release->isLike('centos') || in_array($release->id, ['amzn', 'ol', 'almalinux', 'rocky'], true) => $this->dnf->withRelease($release), $release->isLike('arch') => $this->pacman, $release->id === 'alpine' => $this->apk, diff --git a/lib/OsStatus/Packages/Zypper.php b/lib/OsStatus/Packages/Zypper.php index 3490b0af..aa8c34e9 100644 --- a/lib/OsStatus/Packages/Zypper.php +++ b/lib/OsStatus/Packages/Zypper.php @@ -10,7 +10,9 @@ namespace OCA\ServerInfo\OsStatus\Packages; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\OsStatus\Host\IHost; +use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\Reboot\RebootReason; /** @@ -24,10 +26,30 @@ * * @psalm-api */ -class Zypper implements IPackageManager { +class Zypper implements IPackageManager, IReportsDiagnostics { /** `zypper needs-rebooting` exits with this when a reboot is needed */ private const EXIT_REBOOT_NEEDED = 102; + /** + * Informational exit codes, after which the output is complete: updates + * or security updates needed (100, 101), reboot or restart needed (102, + * 103), a name not found (104) and repositories skipped (106). 105 means + * zypper was interrupted. + */ + private const EXIT_INFORMATIONAL = [100, 101, 102, 103, 104, 106]; + + /** Some repositories could not be read and were left out */ + private const EXIT_REPOS_SKIPPED = 106; + + /** Another process holds the package manager lock */ + private const EXIT_LOCKED = 7; + + private const ROLLING = ['opensuse-tumbleweed', 'opensuse-microos', 'opensuse-slowroll']; + + private ?OsRelease $release = null; + /** @var list */ + private array $diagnostics = []; + /** Patches looked up at once; more stay patch rows of their own. */ private const MAX_PATCH_LOOKUP = 100; @@ -48,6 +70,16 @@ public function isAvailable(): bool { return $this->host->hasBinary('zypper'); } + /** + * The same package manager, for the given distribution. + */ + public function withRelease(OsRelease $release): self { + $clone = clone $this; + $clone->release = $release; + + return $clone; + } + /** * Tumbleweed and MicroOS publish no patches, only new package versions, * so there is nothing to tell security fixes by. @@ -58,13 +90,17 @@ public function classifiesSecurity(): bool { } private function isRolling(): bool { - $release = $this->host->readFile('/etc/os-release', 65536) ?? ''; + return in_array($this->release?->id, self::ROLLING, true); + } - return preg_match('/^ID="?opensuse-(tumbleweed|microos|slowroll)"?$/m', $release) === 1; + #[\Override] + public function getDiagnostics(): array { + return array_values(array_unique($this->diagnostics)); } #[\Override] public function getPendingUpdates(): array { + $this->diagnostics = []; if ($this->isRolling()) { return self::parsePackageUpdates($this->zypperXml(['list-updates'])); } @@ -85,14 +121,14 @@ public function getPendingUpdates(): array { private function patchPackages(array $patches): array { $names = array_values(array_filter( array_map(static fn (PendingUpdate $patch): string => $patch->name, $patches), - static fn (string $name): bool => preg_match('/^[A-Za-z0-9._:+-]+$/', $name) === 1, + static fn (string $name): bool => preg_match('/^[A-Za-z0-9][A-Za-z0-9._:+-]*$/', $name) === 1, )); if ($names === []) { return []; } $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', 'info', '-t', 'patch', ...array_slice($names, 0, self::MAX_PATCH_LOOKUP)], 180); - if ($result === null || !$result->succeeded()) { + if ($result === null || !self::isComplete($result)) { return []; } @@ -292,21 +328,38 @@ private function zypperXml(array $arguments): string { if ($result === null) { throw new ProbeException('zypper could not be run.'); } + if ($result->exitCode === self::EXIT_LOCKED) { + throw new ProbeException('zypper is busy: another program holds the package manager lock, so pending updates could not be listed this time.'); + } + // zypper reports trouble inside the XML stream as . $xmlError = preg_match('#([^<]*)#', $result->stdout, $matches) === 1 ? html_entity_decode($matches[1], ENT_QUOTES | ENT_XML1) : null; - if (!$result->succeeded() || $xmlError !== null) { + $skipped = $result->exitCode === self::EXIT_REPOS_SKIPPED; + if (!self::isComplete($result) || self::parseXml($result->stdout) === null || ($xmlError !== null && !$skipped)) { throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . ProbeHelper::firstLine($xmlError ?? ($result->stderr ?: $result->stdout))); } + if ($skipped) { + $this->diagnostics[] = 'zypper skipped repositories it could not read, so pending updates may be incomplete' + . ($xmlError !== null ? ': ' . ProbeHelper::firstLine($xmlError) : '.'); + } return $result->stdout; } + /** + * Whether zypper finished with its whole output. + */ + private static function isComplete(CommandResult $result): bool { + return ($result->exitCode === 0 || in_array($result->exitCode, self::EXIT_INFORMATIONAL, true)) + && !$result->timedOut && !$result->truncated; + } + #[\Override] public function getHeldPackages(): ?array { $result = $this->host->run('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'locks'], 60); - if ($result === null || !$result->succeeded()) { + if ($result === null || !self::isComplete($result)) { return null; } @@ -368,14 +421,16 @@ public function getAutomaticUpdates(): AutomaticUpdates { return $this->systemd->isRunning() ? new AutomaticUpdates(AutomaticUpdates::STATE_DISABLED) : AutomaticUpdates::unknown(); } + /** + * `zypper needs-rebooting` only knows what /etc/zypp/needreboot lists, + * which is nothing at all on some images, so its "no" is not a complete + * answer and the generic core library check still runs. + */ #[\Override] public function getRebootReasons(): array { $result = $this->host->run('zypper', ['--non-interactive', 'needs-rebooting'], 60); - if ($result !== null && $result->exitCode === self::EXIT_REBOOT_NEEDED) { - return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, [], $this->host->modifiedAt('/run/reboot-needed'))]; - } - - if ($result === null && $this->host->fileExists('/run/reboot-needed')) { + if (($result !== null && $result->exitCode === self::EXIT_REBOOT_NEEDED) + || ($result === null && $this->host->fileExists('/run/reboot-needed'))) { return [new RebootReason(RebootReason::KIND_DISTRIBUTION, null, [], $this->host->modifiedAt('/run/reboot-needed'))]; } @@ -384,9 +439,7 @@ public function getRebootReasons(): array { #[\Override] public function hasOwnRebootCheck(): bool { - $result = $this->host->run('zypper', ['--non-interactive', 'needs-rebooting'], 60); - - return $result !== null && ($result->exitCode === 0 || $result->exitCode === self::EXIT_REBOOT_NEEDED); + return false; } #[\Override] diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index e7e295eb..2cbf4dc9 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -157,9 +157,10 @@ public function testZypperFallsBackToPatchRowsWhenInfoFails(): void { public function testOpenSuseTumbleweed(): void { $host = new FakeHost(); $host->binaries[] = 'zypper'; - $host->withFile('/etc/os-release', FakeHost::fixture('tumbleweed-os-release')) - ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], FakeHost::fixture('tumbleweed-zypper-updates.xml')); - $zypper = new Zypper($host, new Rpm($host), new SystemdProbe($host)); + $host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], FakeHost::fixture('tumbleweed-zypper-updates.xml')); + $release = OsRelease::parse(FakeHost::fixture('tumbleweed-os-release')); + $this->assertNotNull($release); + $zypper = (new Zypper($host, new Rpm($host), new SystemdProbe($host)))->withRelease($release); $this->assertFalse($zypper->classifiesSecurity()); $this->assertSame(['openSUSE-build-key', 'update-test-reboot-needed', 'update-test-security'], array_keys(self::names($zypper->getPendingUpdates()))); diff --git a/tests/lib/OsStatus/ZypperTest.php b/tests/lib/OsStatus/ZypperTest.php index 9b88c53f..05108270 100644 --- a/tests/lib/OsStatus/ZypperTest.php +++ b/tests/lib/OsStatus/ZypperTest.php @@ -10,6 +10,8 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; +use OCA\ServerInfo\OsStatus\Host\CommandResult; +use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Packages\ProbeException; use OCA\ServerInfo\OsStatus\Packages\Rpm; @@ -97,12 +99,77 @@ public function testErrorsInsideTheXmlStream(): void { } public function testTumbleweedHasNoPatches(): void { - $this->host->withFile('/etc/os-release', "NAME=\"openSUSE Tumbleweed\"\nID=\"opensuse-tumbleweed\"\n") + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); + $release = OsRelease::parse("NAME=\"openSUSE Tumbleweed\"\nID=\"opensuse-tumbleweed\"\n"); + $this->assertNotNull($release); + $zypper = $this->zypper->withRelease($release); + + $this->assertFalse($zypper->classifiesSecurity()); + $this->assertCount(1, $zypper->getPendingUpdates()); + $this->assertStringContainsString('dup', $zypper->getUpgradeCommands()['all']); + } + + public function testSkippedRepositoriesStillGiveUpdates(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], self::PATCHES, 106) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], + str_replace('Repository 'SLE-Module-Python3' is out-of-date.\nassertCount(2, $this->zypper->getPendingUpdates()); + $this->assertSame(["zypper skipped repositories it could not read, so pending updates may be incomplete: Repository 'SLE-Module-Python3' is out-of-date."], array_values(array_filter( + $this->zypper->getDiagnostics(), + static fn (string $d): bool => str_contains($d, 'Python3'), + ))); + $this->assertContains('zypper skipped repositories it could not read, so pending updates may be incomplete.', $this->zypper->getDiagnostics()); + } + + public function testInformationalExitCodesAreNotFailures(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], self::PATCHES, 101) + ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES, 100); + + $this->assertCount(2, $this->zypper->getPendingUpdates()); + $this->assertSame([], $this->zypper->getDiagnostics()); + } + + public function testLockedZypperIsBusy(): void { + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], '', 7, "System management is locked by the application with pid 4242 (zypper).\n"); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('zypper is busy'); + $this->zypper->getPendingUpdates(); + } + + /** + * @return array + */ + public static function failedRuns(): array { + return [ + 'zypp problem' => [4, self::PATCHES, false], + 'interrupted' => [105, self::PATCHES, false], + 'no XML' => [106, 'Segmentation fault', false], + 'timed out' => [0, self::PATCHES, true], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('failedRuns')] + public function testFailedRuns(int $exitCode, string $stdout, bool $timedOut): void { + $this->host->commands['zypper --non-interactive --no-refresh --xmlout list-patches --category security'] = new CommandResult($exitCode, $stdout, 'Problem', $timedOut); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('zypper list-patches failed'); + $this->zypper->getPendingUpdates(); + } + + public function testPatchNamesMustStartWithALetterOrDigit(): void { + $patches = '' + . '' + . '' + . ''; + $this->host->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-patches', '--category', 'security'], $patches) ->withCommand('zypper', ['--non-interactive', '--no-refresh', '--xmlout', 'list-updates'], self::UPDATES); - $this->assertFalse($this->zypper->classifiesSecurity()); - $this->assertCount(1, $this->zypper->getPendingUpdates()); - $this->assertStringContainsString('dup', $this->zypper->getUpgradeCommands()['all']); + $this->zypper->getPendingUpdates(); + + $this->assertContains('zypper --non-interactive --no-refresh info -t patch openSUSE-Leap-16.0-1157', $this->host->ran); } public function testPatchConflictsWithAnyArchitecture(): void { @@ -130,6 +197,7 @@ public function testNeedsRebooting(): void { $this->host->withCommand('zypper', ['--non-interactive', 'needs-rebooting'], "No core libraries or services have been updated since the last system boot.\n", 0); $this->assertSame([], $this->zypper->getRebootReasons()); - $this->assertTrue($this->zypper->hasOwnRebootCheck()); + $this->assertFalse($this->zypper->hasOwnRebootCheck(), 'needs-rebooting only knows what /etc/zypp/needreboot lists'); + $this->assertCount(2, $this->host->ran, 'hasOwnRebootCheck() runs nothing'); } } From 52ef7ef93df03f47b70409b6ab70d94723637c81 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 06:00:37 +0200 Subject: [PATCH 09/14] fix(os): no all-clear from failed pacman queries or months-old databases Three ways an Arch system read as up to date when it was not: - `pacman -Qu` errors that only went to stderr, such as a failed alpm initialisation or a held database lock, returned an empty list. "Nothing to update" now needs exit 1 with no output at all; every other failure is a diagnostic and the updates stay unknown. - When checkupdates could not sync, the fallback answered from root's sync databases without saying how old they are. The lists age is now the newest of the sync databases and their directory, pacman counts for the lists-age rules, and a diagnostic says the fallback was used. After a successful checkupdates the lists age is the time it synced. - A failed arch-audit threw away every update. The updates are now kept, unclassified, with a diagnostic. Adding pacman to OsVerdict::LISTS_AGE_MATTERS means its package lists concern is unknown, not ok, when no age can be found. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Pacman.php | 53 ++++++++--- lib/OsStatus/Verdict/OsVerdict.php | 2 +- tests/lib/OsStatus/OsStatusCollectorTest.php | 2 +- tests/lib/OsStatus/OsVerdictTest.php | 7 +- tests/lib/OsStatus/PacmanApkTest.php | 92 +++++++++++++++++++- tests/lib/OsStatus/RealOutputTest.php | 14 ++- 6 files changed, 150 insertions(+), 20 deletions(-) diff --git a/lib/OsStatus/Packages/Pacman.php b/lib/OsStatus/Packages/Pacman.php index 3a54becd..64d82ec0 100644 --- a/lib/OsStatus/Packages/Pacman.php +++ b/lib/OsStatus/Packages/Pacman.php @@ -10,24 +10,34 @@ namespace OCA\ServerInfo\OsStatus\Packages; use OCA\ServerInfo\OsStatus\Host\IHost; +use OCP\AppFramework\Utility\ITimeFactory; /** * Arch Linux and its derivatives. * * `checkupdates` syncs a private copy of the package databases, so it is * current without root and without the partial-upgrade hazard of - * `pacman -Sy`. Security fixes are only known when arch-audit is installed. + * `pacman -Sy`. Where it cannot run, `pacman -Qu` answers from the + * databases root last synced. Security fixes are only known when arch-audit + * is installed. * * @psalm-api */ -class Pacman implements IPackageManager { +class Pacman implements IPackageManager, IReportsDiagnostics { /** checkupdates exits with 2 when there is nothing to update */ private const EXIT_NO_UPDATES = 2; private const LOG = '/var/log/pacman.log'; + /** When checkupdates last synced its own databases, in this collection */ + private ?int $syncedAt = null; + private bool $auditFailed = false; + /** @var list */ + private array $diagnostics = []; + public function __construct( private IHost $host, + private ITimeFactory $timeFactory, ) { } @@ -43,26 +53,40 @@ public function isAvailable(): bool { #[\Override] public function classifiesSecurity(): bool { - return $this->host->hasBinary('arch-audit'); + return !$this->auditFailed && $this->host->hasBinary('arch-audit'); + } + + #[\Override] + public function getDiagnostics(): array { + return $this->diagnostics; } #[\Override] public function getPendingUpdates(): array { + $this->syncedAt = null; + $this->auditFailed = false; + $this->diagnostics = []; + if ($this->host->hasBinary('checkupdates')) { $result = $this->host->run('checkupdates', [], 180); - if ($result !== null && ($result->succeeded() || $result->exitCode === self::EXIT_NO_UPDATES)) { + if ($result !== null && !$result->timedOut && !$result->truncated + && ($result->exitCode === 0 || $result->exitCode === self::EXIT_NO_UPDATES)) { + $this->syncedAt = $this->timeFactory->getTime(); + return $this->withSecurity(self::parseUpdates($result->stdout)); } + $this->diagnostics[] = 'checkupdates could not sync the package databases, so pending updates come from the databases root last synced' + . ($result !== null && trim($result->stderr) !== '' ? ': ' . ProbeHelper::firstLine($result->stderr) : '.'); } - // Falls back to the databases root last synced. $result = $this->host->run('pacman', ['-Qu'], 60); if ($result === null) { throw new ProbeException('pacman could not be run.'); } - // pacman -Qu exits 1 when nothing is upgradable. - if (!$result->succeeded() && trim($result->stdout) !== '') { - throw new ProbeException('pacman -Qu failed: ' . ProbeHelper::firstLine($result->stderr)); + // pacman -Qu exits 1 without a word when nothing is upgradable. + $nothing = $result->exitCode === 1 && trim($result->stdout) === '' && trim($result->stderr) === ''; + if (!$nothing && !$result->succeeded()) { + throw new ProbeException('pacman -Qu failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout ?: 'exit code ' . $result->exitCode)); } return $this->withSecurity(self::parseUpdates($result->stdout)); @@ -99,7 +123,11 @@ private function withSecurity(array $updates): array { $result = $this->host->run('arch-audit', ['--upgradable', '--quiet'], 120); if ($result === null || !$result->succeeded()) { - throw new ProbeException('arch-audit could not be run.'); + $this->auditFailed = true; + $this->diagnostics[] = 'arch-audit could not be run, so pending updates are not told apart into security fixes and others' + . ($result !== null && trim($result->stderr) !== '' ? ': ' . ProbeHelper::firstLine($result->stderr) : '.'); + + return $updates; } // Each line starts with the package name, followed by a description @@ -148,11 +176,14 @@ public static function parseIgnored(string $config): array { } /** - * checkupdates fetches fresh databases itself, so there are no lists to age. + * checkupdates fetches fresh databases itself. Otherwise the answer came + * from root's databases, which pacman replaces in the sync directory when + * they changed upstream; the files keep the mirror's time, the directory + * the time of the download. */ #[\Override] public function getListsRefreshedAt(): ?int { - return null; + return $this->syncedAt ?? ProbeHelper::newestModified($this->host, ['/var/lib/pacman/sync/*.db', '/var/lib/pacman/sync']); } #[\Override] diff --git a/lib/OsStatus/Verdict/OsVerdict.php b/lib/OsStatus/Verdict/OsVerdict.php index 519a229b..cb9a03a3 100644 --- a/lib/OsStatus/Verdict/OsVerdict.php +++ b/lib/OsStatus/Verdict/OsVerdict.php @@ -27,7 +27,7 @@ */ class OsVerdict { /** Package managers whose metadata is refreshed in place and can go stale */ - public const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk']; + public const LISTS_AGE_MATTERS = ['apt', 'dnf', 'zypper', 'apk', 'pacman']; /** A status older than this many collection intervals is stale, */ public const STALE_AFTER_INTERVALS = 3; diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index 8048e02b..ebb3dcdd 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -89,7 +89,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { new EnvironmentDetector($host), new SupportTable(), new SubscriptionProbe($host), - new PackageManagerFactory(new Apt($host, $systemd), new Dnf($host, $rpm, $systemd), new Zypper($host, $rpm, $systemd), new Pacman($host), new Apk($host)), + new PackageManagerFactory(new Apt($host, $systemd), new Dnf($host, $rpm, $systemd), new Zypper($host, $rpm, $systemd), new Pacman($host, $time), new Apk($host)), new KernelProbe($host), new CoreLibraryProbe($host), $systemd, diff --git a/tests/lib/OsStatus/OsVerdictTest.php b/tests/lib/OsStatus/OsVerdictTest.php index 953d1dbc..9d951e17 100644 --- a/tests/lib/OsStatus/OsVerdictTest.php +++ b/tests/lib/OsStatus/OsVerdictTest.php @@ -104,10 +104,12 @@ public function testUpdatesFromOldPackageListsAreMarked(): void { $fresh = $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 6 * self::DAY])); $stale = $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 7 * self::DAY])); $pacman = $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW - 90 * self::DAY])); + $pacmanSynced = $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW])); $this->assertFalse($fresh->get(Concern::UPDATES)?->facts['listsStale']); $this->assertTrue($stale->get(Concern::UPDATES)?->facts['listsStale']); - $this->assertFalse($pacman->get(Concern::UPDATES)?->facts['listsStale']); + $this->assertTrue($pacman->get(Concern::UPDATES)?->facts['listsStale'], 'pacman -Qu answers from root\'s databases'); + $this->assertFalse($pacmanSynced->get(Concern::UPDATES)?->facts['listsStale']); } public function testUnknownUpdatesLeaveNoAllClear(): void { @@ -174,7 +176,8 @@ public function testOldPackageLists(): void { $this->assertSame(Concern::LEVEL_OK, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 6 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); $this->assertSame(Concern::LEVEL_WARNING, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 7 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); $this->assertSame(Concern::LEVEL_ERROR, $this->evaluate($this->osStatus(['listsRefreshedAt' => self::NOW - 31 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); - $this->assertSame(Concern::LEVEL_OK, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => null]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_UNKNOWN, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => null]))->get(Concern::PACKAGE_LISTS)?->level); + $this->assertSame(Concern::LEVEL_ERROR, $this->evaluate($this->osStatus(['packageManager' => 'pacman', 'listsRefreshedAt' => self::NOW - 31 * self::DAY]))->get(Concern::PACKAGE_LISTS)?->level); } public function testHealth(): void { diff --git a/tests/lib/OsStatus/PacmanApkTest.php b/tests/lib/OsStatus/PacmanApkTest.php index e2ffe000..db7e01fb 100644 --- a/tests/lib/OsStatus/PacmanApkTest.php +++ b/tests/lib/OsStatus/PacmanApkTest.php @@ -11,8 +11,19 @@ use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Pacman; +use OCA\ServerInfo\OsStatus\Packages\ProbeException; +use OCP\AppFramework\Utility\ITimeFactory; class PacmanApkTest extends \Test\TestCase { + private const NOW = 1792411200; + + private function pacman(FakeHost $host): Pacman { + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn(self::NOW); + + return new Pacman($host, $time); + } + public function testCheckupdatesOutput(): void { $updates = Pacman::parseUpdates("linux 6.11.2.arch1-1 -> 6.11.3.arch1-1\nopenssl 3.3.2-1 -> 3.4.0-1\nnginx 1.26.2-1 -> 1.26.2-2 [ignored]\nnot an update\n"); @@ -27,7 +38,7 @@ public function testArchAuditMarksSecurityUpdates(): void { $host->withCommand('checkupdates', [], "openssl 3.3.2-1 -> 3.4.0-1\nnano 8.1-1 -> 8.2-1\n") ->withCommand('arch-audit', ['--upgradable', '--quiet'], "openssl\n"); - $pacman = new Pacman($host); + $pacman = $this->pacman($host); $updates = $pacman->getPendingUpdates(); $this->assertTrue($pacman->classifiesSecurity()); @@ -40,12 +51,87 @@ public function testCheckupdatesWithNothingToDo(): void { $host->binaries = ['pacman', 'checkupdates']; $host->withCommand('checkupdates', [], '', 2); - $pacman = new Pacman($host); + $pacman = $this->pacman($host); $this->assertFalse($pacman->classifiesSecurity()); $this->assertSame([], $pacman->getPendingUpdates()); } + public function testCheckupdatesSyncIsTheListsAge(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 2); + $host->modified['/var/lib/pacman/sync'] = self::NOW - 90 * 86400; + + $pacman = $this->pacman($host); + $pacman->getPendingUpdates(); + + $this->assertSame(self::NOW, $pacman->getListsRefreshedAt()); + $this->assertSame([], $pacman->getDiagnostics()); + } + + public function testFallbackReportsTheAgeOfRootsDatabases(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates']; + $host->withCommand('checkupdates', [], '', 1, "==> ERROR: Cannot fetch updates\n") + ->withCommand('pacman', ['-Qu'], "openssl 3.3.2-1 -> 3.4.0-1\n"); + $host->globs['/var/lib/pacman/sync/*.db'] = ['/var/lib/pacman/sync/core.db', '/var/lib/pacman/sync/extra.db']; + $host->modified['/var/lib/pacman/sync/core.db'] = self::NOW - 40 * 86400; + $host->modified['/var/lib/pacman/sync/extra.db'] = self::NOW - 35 * 86400; + $host->modified['/var/lib/pacman/sync'] = self::NOW - 34 * 86400; + + $pacman = $this->pacman($host); + + $this->assertCount(1, $pacman->getPendingUpdates()); + $this->assertSame(self::NOW - 34 * 86400, $pacman->getListsRefreshedAt()); + $this->assertSame(['checkupdates could not sync the package databases, so pending updates come from the databases root last synced: ==> ERROR: Cannot fetch updates'], $pacman->getDiagnostics()); + } + + public function testPacmanQuWithNothingToDo(): void { + $host = new FakeHost(); + $host->binaries = ['pacman']; + $host->withCommand('pacman', ['-Qu'], '', 1); + + $this->assertSame([], $this->pacman($host)->getPendingUpdates()); + } + + /** + * @return array + */ + public static function failedQueries(): array { + return [ + 'error on stderr only' => [1, '', "error: failed to initialize alpm library:\n(root: /, dbpath: /var/lib/pacman/)\ncould not find or read directory\n"], + 'database lock' => [1, '', "error: could not lock database: File exists\n"], + 'other exit code' => [2, '', ''], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('failedQueries')] + public function testPacmanQuErrorsAreNotUpToDate(int $exitCode, string $stdout, string $stderr): void { + $host = new FakeHost(); + $host->binaries = ['pacman']; + $host->withCommand('pacman', ['-Qu'], $stdout, $exitCode, $stderr); + + $this->expectException(ProbeException::class); + $this->expectExceptionMessage('pacman -Qu failed'); + $this->pacman($host)->getPendingUpdates(); + } + + public function testArchAuditFailureKeepsTheUpdatesUnclassified(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('checkupdates', [], "openssl 3.3.2-1 -> 3.4.0-1\nnano 8.1-1 -> 8.2-1\n") + ->withCommand('arch-audit', ['--upgradable', '--quiet'], '', 1, "Error: Failed to fetch https://security.archlinux.org/issues/all.json\n"); + + $pacman = $this->pacman($host); + $updates = $pacman->getPendingUpdates(); + + $this->assertSame(['openssl', 'nano'], array_map(static fn ($u) => $u->name, $updates)); + $this->assertFalse($updates[0]->security); + $this->assertFalse($pacman->classifiesSecurity()); + $this->assertSame(['arch-audit could not be run, so pending updates are not told apart into security fixes and others: Error: Failed to fetch https://security.archlinux.org/issues/all.json'], $pacman->getDiagnostics()); + } + public function testIgnoredPackages(): void { $held = Pacman::parseIgnored("[options]\nIgnorePkg = linux nginx # pinned\n#IgnorePkg = commented\nIgnorePkg=php\n"); @@ -56,7 +142,7 @@ public function testPacmanLog(): void { $host = new FakeHost(); $host->withFile('/var/log/pacman.log', "[2026-10-12T06:41:02+0200] [ALPM] upgraded openssl (3.3.2-1 -> 3.4.0-1)\n[2026-10-12T07:00:00+0200] [ALPM] removed foo (1-1)\n"); - $this->assertSame((int)strtotime('2026-10-12 04:41:02 UTC'), (new Pacman($host))->getLastUpgradeAt()); + $this->assertSame((int)strtotime('2026-10-12 04:41:02 UTC'), ($this->pacman($host))->getLastUpgradeAt()); } public function testApkUpgradable(): void { diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index 2cbf4dc9..d826c556 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -19,12 +19,22 @@ use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Packages\Rpm; use OCA\ServerInfo\OsStatus\Packages\Zypper; +use OCP\AppFramework\Utility\ITimeFactory; /** * Output captured from real systems (arm64 containers of each distribution, * October 2026), run as an unprivileged user unless named otherwise. */ class RealOutputTest extends \Test\TestCase { + private const NOW = 1792411200; + + private function pacman(FakeHost $host): Pacman { + $time = $this->createMock(ITimeFactory::class); + $time->method('getTime')->willReturn(self::NOW); + + return new Pacman($host, $time); + } + /** * @param list $updates * @return array name => security @@ -176,7 +186,7 @@ public function testArchLinux(): void { $host->withCommand('checkupdates', [], FakeHost::fixture('arch-checkupdates-pending')) ->withCommand('arch-audit', ['--upgradable', '--quiet'], FakeHost::fixture('arch-audit-u-pending')); - $this->assertSame(['python-urllib3' => true], self::names((new Pacman($host))->getPendingUpdates())); + $this->assertSame(['python-urllib3' => true], self::names($this->pacman($host)->getPendingUpdates())); } public function testArchFallsBackWhenCheckupdatesCannotRun(): void { @@ -185,7 +195,7 @@ public function testArchFallsBackWhenCheckupdatesCannotRun(): void { $host->withCommand('checkupdates', [], '', 1, "==> ERROR: Cannot find the fakeroot binary.\n") ->withCommand('pacman', ['-Qu'], "python-urllib3 1.26.3-1 -> 2.8.0-1\n"); - $this->assertSame(['python-urllib3' => false], self::names((new Pacman($host))->getPendingUpdates())); + $this->assertSame(['python-urllib3' => false], self::names($this->pacman($host)->getPendingUpdates())); } public function testAlpine(): void { From d509954e1b7351261a111b781da48b32fa6b8913 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 06:01:26 +0200 Subject: [PATCH 10/14] feat(os): let admins keep the Arch queries offline Every other query of the operating system status reads only what is on the machine, but on Arch checkupdates syncs its own copy of the package databases and arch-audit fetches the advisories from security.archlinux.org, both as the web server user, on every run. The README now says so, and the new os_allow_network app config value (on by default, as before) turns both off. Pending updates then come from root's sync databases, whose age counts as the package list age, and they are not classified. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 9 ++++++++ lib/Config/ConfigLexicon.php | 8 +++++++ lib/OsStatus/Packages/Pacman.php | 15 +++++++++--- tests/lib/OsStatus/OsStatusCollectorTest.php | 3 ++- tests/lib/OsStatus/PacmanApkTest.php | 24 ++++++++++++++++++-- tests/lib/OsStatus/RealOutputTest.php | 8 +++++-- 6 files changed, 59 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 422040c6..9b70b25c 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,15 @@ installed kernel modules. It runs the package manager's query commands (updates only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and `systemd-detect-virt`. Anything it may not read or run is reported as unknown. +These queries stay offline, with one exception: on Arch, `checkupdates` +downloads the package databases into a temporary directory of the web server +user, and `arch-audit` fetches the security advisories from +security.archlinux.org. To keep Arch offline as well, run +`occ config:app:set serverinfo os_allow_network --type=boolean --value=false`. +Pending updates then come from the databases root last synced, the age of +those databases counts as the package list age, and security fixes are not +told apart. + The status is collected by a background job once an hour, when background jobs run from system cron; under AJAX or webcron the commands would hold a web request for minutes. A status older than three job intervals (at least six diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 465dee5f..5fb0a583 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -41,6 +41,7 @@ class ConfigLexicon implements ILexicon { public const OS_EOL_WARNING_DAYS = 'os_eol_warning_days'; public const OS_LISTS_WARNING_DAYS = 'os_lists_warning_days'; public const OS_LISTS_ERROR_DAYS = 'os_lists_error_days'; + public const OS_ALLOW_NETWORK = 'os_allow_network'; #[\Override] public function getStrictness(): Strictness { @@ -127,6 +128,13 @@ public function getAppConfigs(): array { new Entry(self::OS_EOL_WARNING_DAYS, ValueType::INT, 90, 'Days before the end of security support that a warning starts', lazy: true), new Entry(self::OS_LISTS_WARNING_DAYS, ValueType::INT, 7, 'Days without a package list refresh before a warning', lazy: true), new Entry(self::OS_LISTS_ERROR_DAYS, ValueType::INT, 30, 'Days without a package list refresh before an error', lazy: true), + new Entry( + self::OS_ALLOW_NETWORK, + ValueType::BOOL, + true, + 'Whether the operating system status may use the network as the web server user: on Arch, checkupdates syncs its own package databases and arch-audit fetches the security advisories', + lazy: true, + ), ]; } diff --git a/lib/OsStatus/Packages/Pacman.php b/lib/OsStatus/Packages/Pacman.php index 64d82ec0..7909087a 100644 --- a/lib/OsStatus/Packages/Pacman.php +++ b/lib/OsStatus/Packages/Pacman.php @@ -9,7 +9,9 @@ namespace OCA\ServerInfo\OsStatus\Packages; +use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\OsStatus\Host\IHost; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; /** @@ -19,7 +21,9 @@ * current without root and without the partial-upgrade hazard of * `pacman -Sy`. Where it cannot run, `pacman -Qu` answers from the * databases root last synced. Security fixes are only known when arch-audit - * is installed. + * is installed. Both use the network, unlike every other query of the + * operating system status, so the os_allow_network app config value can + * turn them off. * * @psalm-api */ @@ -38,6 +42,7 @@ class Pacman implements IPackageManager, IReportsDiagnostics { public function __construct( private IHost $host, private ITimeFactory $timeFactory, + private IAppConfig $appConfig, ) { } @@ -53,7 +58,11 @@ public function isAvailable(): bool { #[\Override] public function classifiesSecurity(): bool { - return !$this->auditFailed && $this->host->hasBinary('arch-audit'); + return !$this->auditFailed && $this->networkAllowed() && $this->host->hasBinary('arch-audit'); + } + + private function networkAllowed(): bool { + return $this->appConfig->getAppValueBool(ConfigLexicon::OS_ALLOW_NETWORK, true, true); } #[\Override] @@ -67,7 +76,7 @@ public function getPendingUpdates(): array { $this->auditFailed = false; $this->diagnostics = []; - if ($this->host->hasBinary('checkupdates')) { + if ($this->networkAllowed() && $this->host->hasBinary('checkupdates')) { $result = $this->host->run('checkupdates', [], 180); if ($result !== null && !$result->timedOut && !$result->truncated && ($result->exitCode === 0 || $result->exitCode === self::EXIT_NO_UPDATES)) { diff --git a/tests/lib/OsStatus/OsStatusCollectorTest.php b/tests/lib/OsStatus/OsStatusCollectorTest.php index ebb3dcdd..6921c3a7 100644 --- a/tests/lib/OsStatus/OsStatusCollectorTest.php +++ b/tests/lib/OsStatus/OsStatusCollectorTest.php @@ -62,6 +62,7 @@ protected function setUp(): void { private function collector(int $now = self::NOW): OsStatusCollector { $appConfig = $this->createMock(IAppConfig::class); $appConfig->method('getAppValueString')->willReturnCallback(fn (string $key, string $default = ''): string => (string)($this->config[$key] ?? $default)); + $appConfig->method('getAppValueBool')->willReturnCallback(fn (string $key, bool $default = false): bool => (bool)($this->config[$key] ?? $default)); $appConfig->method('getAppValueArray')->willReturnCallback(fn (string $key): array => $this->config[$key] ?? []); $appConfig->method('setAppValueArray')->willReturnCallback(function (string $key, array $value): bool { $this->config[$key] = $value; @@ -89,7 +90,7 @@ private function collector(int $now = self::NOW): OsStatusCollector { new EnvironmentDetector($host), new SupportTable(), new SubscriptionProbe($host), - new PackageManagerFactory(new Apt($host, $systemd), new Dnf($host, $rpm, $systemd), new Zypper($host, $rpm, $systemd), new Pacman($host, $time), new Apk($host)), + new PackageManagerFactory(new Apt($host, $systemd), new Dnf($host, $rpm, $systemd), new Zypper($host, $rpm, $systemd), new Pacman($host, $time, $appConfig), new Apk($host)), new KernelProbe($host), new CoreLibraryProbe($host), $systemd, diff --git a/tests/lib/OsStatus/PacmanApkTest.php b/tests/lib/OsStatus/PacmanApkTest.php index db7e01fb..b7da9aef 100644 --- a/tests/lib/OsStatus/PacmanApkTest.php +++ b/tests/lib/OsStatus/PacmanApkTest.php @@ -9,19 +9,23 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Pacman; use OCA\ServerInfo\OsStatus\Packages\ProbeException; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; class PacmanApkTest extends \Test\TestCase { private const NOW = 1792411200; - private function pacman(FakeHost $host): Pacman { + private function pacman(FakeHost $host, bool $network = true): Pacman { $time = $this->createMock(ITimeFactory::class); $time->method('getTime')->willReturn(self::NOW); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueBool')->willReturnMap([[ConfigLexicon::OS_ALLOW_NETWORK, true, true, $network]]); - return new Pacman($host, $time); + return new Pacman($host, $time, $appConfig); } public function testCheckupdatesOutput(): void { @@ -87,6 +91,22 @@ public function testFallbackReportsTheAgeOfRootsDatabases(): void { $this->assertSame(['checkupdates could not sync the package databases, so pending updates come from the databases root last synced: ==> ERROR: Cannot fetch updates'], $pacman->getDiagnostics()); } + public function testWithoutNetworkOnlyLocalDatabasesAreRead(): void { + $host = new FakeHost(); + $host->binaries = ['pacman', 'checkupdates', 'arch-audit']; + $host->withCommand('pacman', ['-Qu'], "openssl 3.3.2-1 -> 3.4.0-1\n"); + $host->modified['/var/lib/pacman/sync'] = self::NOW - 3600; + + $pacman = $this->pacman($host, false); + $updates = $pacman->getPendingUpdates(); + + $this->assertFalse($updates[0]->security); + $this->assertFalse($pacman->classifiesSecurity()); + $this->assertSame(self::NOW - 3600, $pacman->getListsRefreshedAt()); + $this->assertSame(['pacman -Qu'], $host->ran, 'Neither checkupdates nor arch-audit runs'); + $this->assertSame([], $pacman->getDiagnostics()); + } + public function testPacmanQuWithNothingToDo(): void { $host = new FakeHost(); $host->binaries = ['pacman']; diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index d826c556..332c761f 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -9,6 +9,7 @@ namespace OCA\ServerInfo\Tests\OsStatus; +use OCA\ServerInfo\Config\ConfigLexicon; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\Packages\Apk; @@ -19,6 +20,7 @@ use OCA\ServerInfo\OsStatus\Packages\PendingUpdate; use OCA\ServerInfo\OsStatus\Packages\Rpm; use OCA\ServerInfo\OsStatus\Packages\Zypper; +use OCP\AppFramework\Services\IAppConfig; use OCP\AppFramework\Utility\ITimeFactory; /** @@ -28,11 +30,13 @@ class RealOutputTest extends \Test\TestCase { private const NOW = 1792411200; - private function pacman(FakeHost $host): Pacman { + private function pacman(FakeHost $host, bool $network = true): Pacman { $time = $this->createMock(ITimeFactory::class); $time->method('getTime')->willReturn(self::NOW); + $appConfig = $this->createMock(IAppConfig::class); + $appConfig->method('getAppValueBool')->willReturnMap([[ConfigLexicon::OS_ALLOW_NETWORK, true, true, $network]]); - return new Pacman($host, $time); + return new Pacman($host, $time, $appConfig); } /** From 7a318098f4abb182509c964f26b6ef9a79c3e561 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 06:02:59 +0200 Subject: [PATCH 11/14] fix(os): apk on postmarketOS, every holding world constraint, offline Alpine was recognised by ID=alpine only; postmarketOS and other derivatives declare ID_LIKE=alpine and got no package manager at all. `apk list --upgradable` lists every newer version apk knows of, also those /etc/apk/world keeps it from installing. Only "=" pins were taken out. The world entries are now parsed as name[@tag][operator version], and an update is left out when its candidate does not meet the constraint: "=", "<", "<=" and the fuzzy "~" can hold one back. A repository tag alone ("curl@edge") is not a hold: it lets the package also come from the tagged repository, and apk upgrades it from there, so such an update stays pending and the package is no longer listed as held. Versions compare like apk's, closely enough for these constraints: upstream version, then the -rN release. apk runs with --no-network. apk-tools 3.0.8 on Alpine 3.24 accepts it before or after the applet and still lists from its cached indexes. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Apk.php | 117 +++++++++++++++--- .../Packages/PackageManagerFactory.php | 2 +- tests/lib/OsStatus/PacmanApkTest.php | 38 +++++- tests/lib/OsStatus/RealOutputTest.php | 2 +- 4 files changed, 139 insertions(+), 20 deletions(-) diff --git a/lib/OsStatus/Packages/Apk.php b/lib/OsStatus/Packages/Apk.php index 356c0d50..5e2d8e48 100644 --- a/lib/OsStatus/Packages/Apk.php +++ b/lib/OsStatus/Packages/Apk.php @@ -12,12 +12,16 @@ use OCA\ServerInfo\OsStatus\Host\IHost; /** - * Alpine Linux. Most Nextcloud installs on Alpine are containers, where the - * container check comes first; this covers Alpine hosts and LXC guests. + * Alpine Linux and its derivatives such as postmarketOS. Most Nextcloud + * installs on Alpine are containers, where the container check comes first; + * this covers Alpine hosts and LXC guests. apk only reads its cached indexes + * here and never goes online. * * @psalm-api */ class Apk implements IPackageManager { + private const WORLD = '/etc/apk/world'; + public function __construct( private IHost $host, ) { @@ -40,7 +44,7 @@ public function classifiesSecurity(): bool { #[\Override] public function getPendingUpdates(): array { - $result = $this->host->run('apk', ['list', '--upgradable'], 120); + $result = $this->host->run('apk', ['--no-network', 'list', '--upgradable'], 120); if ($result === null) { throw new ProbeException('apk could not be run.'); } @@ -48,18 +52,26 @@ public function getPendingUpdates(): array { throw new ProbeException('apk list --upgradable failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout)); } - // apk lists packages pinned to an exact version too, though it never - // upgrades them. - $pinned = []; - foreach ($this->getHeldPackages() ?? [] as $held) { - if (str_starts_with($held->constraint ?? '', '=')) { - $pinned[$held->name] = true; + // apk lists every newer version it knows of, including those the + // constraints in /etc/apk/world keep it from installing. + $constraints = []; + foreach (self::parseWorld($this->host->readFile(self::WORLD, 262144) ?? '') as [$name, $operator, $version]) { + if ($operator !== null) { + $constraints[$name][] = [$operator, $version]; } } return array_values(array_filter( self::parseUpgradable($result->stdout), - static fn (PendingUpdate $update): bool => !isset($pinned[$update->name]), + static function (PendingUpdate $update) use ($constraints): bool { + foreach ($constraints[$update->name] ?? [] as [$operator, $version]) { + if (!self::satisfies($update->candidateVersion, $operator, $version)) { + return false; + } + } + + return true; + }, )); } @@ -85,25 +97,100 @@ public static function parseUpgradable(string $output): array { } /** - * Packages pinned to a version or tag in /etc/apk/world. + * Packages whose version /etc/apk/world restricts in a way that can hold + * back an upgrade: "=", "<", "<=" and the fuzzy "~". A repository tag + * alone ("curl@edge") only lets the package come from the tagged + * repository as well, and upgrades go on from there. */ #[\Override] public function getHeldPackages(): ?array { - $world = $this->host->readFile('/etc/apk/world', 262144); + $world = $this->host->readFile(self::WORLD, 262144); if ($world === null) { return null; } $held = []; - foreach (preg_split('/\s+/', trim($world)) ?: [] as $entry) { - if (preg_match('/^([A-Za-z0-9._+-]+?)((?:[=<>~]|@)\S+)$/', $entry, $matches) === 1) { - $held[] = new HeldPackage($matches[1], 'apk world', $matches[2]); + foreach (self::parseWorld($world) as [$name, $operator, $version, $tag]) { + if ($operator !== null && !in_array($operator, ['>', '>='], true)) { + $held[] = new HeldPackage($name, 'apk world', ($tag ?? '') . $operator . $version); } } return $held; } + /** + * Parses the dependencies in /etc/apk/world: name[@tag][operator version]. + * Conflicts ("!name") are left out. + * + * @return list [name, operator, version, tag] + */ + public static function parseWorld(string $world): array { + $entries = []; + foreach (preg_split('/\s+/', trim($world)) ?: [] as $entry) { + if (preg_match('/^([A-Za-z0-9][A-Za-z0-9._+-]*?)(@[A-Za-z0-9._-]+)?(?:(<=|>=|~=|=~|[<>=~])(\S+))?$/', $entry, $matches) !== 1) { + continue; + } + $operator = ($matches[3] ?? '') !== '' ? $matches[3] : null; + $entries[] = [ + $matches[1], + $operator === '~=' || $operator === '=~' ? '~' : $operator, + $matches[4] ?? '', + ($matches[2] ?? '') !== '' ? $matches[2] : null, + ]; + } + + return $entries; + } + + /** + * Whether a version meets a world constraint. "~1.2" matches 1.2 and + * every 1.2.x or 1.2-rN. + */ + public static function satisfies(string $version, string $operator, string $constraint): bool { + if ($operator === '~') { + return $version === $constraint + || (str_starts_with($version, $constraint) && in_array($version[strlen($constraint)], ['.', '-', '_'], true)); + } + + $order = self::compareVersions($version, $constraint); + + return match ($operator) { + '=' => $order === 0, + '<' => $order < 0, + '<=' => $order <= 0, + '>' => $order > 0, + '>=' => $order >= 0, + default => true, + }; + } + + /** + * Compares apk versions such as "3.5.8-r0" or "1.2_rc1-r3": the + * upstream version as PHP's version_compare() orders it, then the -rN + * package release. + */ + public static function compareVersions(string $a, string $b): int { + [$baseA, $releaseA] = self::splitRelease($a); + [$baseB, $releaseB] = self::splitRelease($b); + + return version_compare($baseA, $baseB) ?: $releaseA <=> $releaseB; + } + + /** + * @return array{0: string, 1: int} + */ + private static function splitRelease(string $version): array { + $release = 0; + if (preg_match('/^(.*)-r(\d+)$/', $version, $matches) === 1) { + $version = $matches[1]; + $release = (int)$matches[2]; + } + + // apk's _pre sorts like a release candidate; version_compare() does not know it. + return [str_replace(['_pre', '_'], ['.rc', '.'], $version), $release]; + } + #[\Override] public function getListsRefreshedAt(): ?int { return ProbeHelper::newestModified($this->host, ['/var/cache/apk/APKINDEX.*.tar.gz']); diff --git a/lib/OsStatus/Packages/PackageManagerFactory.php b/lib/OsStatus/Packages/PackageManagerFactory.php index 86266cc1..0219caaf 100644 --- a/lib/OsStatus/Packages/PackageManagerFactory.php +++ b/lib/OsStatus/Packages/PackageManagerFactory.php @@ -33,7 +33,7 @@ public function forRelease(OsRelease $release): ?IPackageManager { $release->isLike('suse') || str_starts_with($release->id, 'opensuse') || $release->id === 'sles' || $release->id === 'sled' => $this->zypper->withRelease($release), $release->isLike('fedora') || $release->isLike('rhel') || $release->isLike('centos') || in_array($release->id, ['amzn', 'ol', 'almalinux', 'rocky'], true) => $this->dnf->withRelease($release), $release->isLike('arch') => $this->pacman, - $release->id === 'alpine' => $this->apk, + $release->isLike('alpine') => $this->apk, default => null, }; diff --git a/tests/lib/OsStatus/PacmanApkTest.php b/tests/lib/OsStatus/PacmanApkTest.php index b7da9aef..97bd1068 100644 --- a/tests/lib/OsStatus/PacmanApkTest.php +++ b/tests/lib/OsStatus/PacmanApkTest.php @@ -180,11 +180,43 @@ public function testApkUpgradable(): void { public function testApkWorldPins(): void { $host = new FakeHost(); - $host->withFile('/etc/apk/world', "alpine-base\nnginx=1.26.2-r0\nphp83<8.4\ncurl@edge\n"); + $host->withFile('/etc/apk/world', "alpine-base\nnginx=1.26.2-r0\nphp83<8.4\ncurl@edge\nzlib~1.3\nmusl>=1.2\nopenssl@edge<=3.5.8-r0\n!busybox-extras\n-x=1\n"); $held = (new Apk($host))->getHeldPackages(); - $this->assertSame(['nginx', 'php83', 'curl'], array_map(static fn ($h) => $h->name, $held ?? [])); - $this->assertSame('=1.26.2-r0', $held[0]->constraint); + $this->assertSame( + ['nginx' => '=1.26.2-r0', 'php83' => '<8.4', 'zlib' => '~1.3', 'openssl' => '@edge<=3.5.8-r0'], + array_combine(array_map(static fn ($h) => $h->name, $held ?? []), array_map(static fn ($h) => $h->constraint, $held ?? [])), + 'A repository tag alone or a lower bound holds nothing back', + ); + } + + public function testApkWorldConstraintsFilterTheUpgradable(): void { + $host = new FakeHost(); + $host->binaries[] = 'apk'; + $host->withFile('/etc/apk/world', "php83<8.3.20\nzlib~1.3\ncurl@edge\nnginx<1.28\nmusl>=1.2\n") + ->withCommand('apk', ['--no-network', 'list', '--upgradable'], implode("\n", [ + 'php83-8.3.21-r0 aarch64 {php83} (PHP-3.01 AND BSD-3-Clause) [upgradable from: php83-8.3.19-r0]', + 'zlib-1.4.0-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.2-r1]', + 'curl-8.18.0-r0 aarch64 {curl} (curl) [upgradable from: curl-8.17.0-r0]', + 'nginx-1.26.3-r0 aarch64 {nginx} (BSD-2-Clause) [upgradable from: nginx-1.26.2-r0]', + 'musl-1.2.6-r0 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r9]', + ])); + + $updates = (new Apk($host))->getPendingUpdates(); + + $this->assertSame(['curl', 'nginx', 'musl'], array_map(static fn ($u) => $u->name, $updates)); + } + + public function testApkVersions(): void { + $this->assertSame(-1, Apk::compareVersions('3.5.8-r0', '3.5.8-r1')); + $this->assertSame(1, Apk::compareVersions('3.5.10-r0', '3.5.9-r3')); + $this->assertSame(-1, Apk::compareVersions('1.2_rc1-r0', '1.2-r0')); + $this->assertSame(-1, Apk::compareVersions('1.2_pre1', '1.2')); + $this->assertSame(1, Apk::compareVersions('1.2_p1-r0', '1.2-r5')); + $this->assertTrue(Apk::satisfies('1.3.2-r1', '~', '1.3')); + $this->assertFalse(Apk::satisfies('1.30-r0', '~', '1.3')); + $this->assertTrue(Apk::satisfies('8.3.19-r0', '<', '8.4')); + $this->assertFalse(Apk::satisfies('1.3.2-r2', '=', '1.3.2-r1')); } } diff --git a/tests/lib/OsStatus/RealOutputTest.php b/tests/lib/OsStatus/RealOutputTest.php index 332c761f..9284447c 100644 --- a/tests/lib/OsStatus/RealOutputTest.php +++ b/tests/lib/OsStatus/RealOutputTest.php @@ -205,7 +205,7 @@ public function testArchFallsBackWhenCheckupdatesCannotRun(): void { public function testAlpine(): void { $host = new FakeHost(); $host->binaries[] = 'apk'; - $host->withCommand('apk', ['list', '--upgradable'], FakeHost::fixture('alpine-apk-list-upgradable')) + $host->withCommand('apk', ['--no-network', 'list', '--upgradable'], FakeHost::fixture('alpine-apk-list-upgradable')) ->withFile('/etc/apk/world', FakeHost::fixture('alpine-apk-world-pinned')); $updates = (new Apk($host))->getPendingUpdates(); From ef32f70b1243c3a45bd7f3d5e15d2ff2a9f1a3d2 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 06:03:35 +0200 Subject: [PATCH 12/14] test(os): cover which package manager each distribution gets The factory picks the package manager from os-release and then checks that the tool is installed. Nothing tested that choice. The new test runs it for Debian, RHEL 9 with dnf 4, Rocky, Oracle, Amazon Linux 2023 and Amazon Linux 2 (yum only, so none), CentOS Stream, Fedora with dnf 5, Leap, Tumbleweed, SLES, Arch, Manjaro, Alpine, postmarketOS and an unsupported distribution, together with whether security fixes can be told apart. It also checks that RHEL 9 runs dnf-3 and Fedora with dnf 5 runs nothing. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- .../OsStatus/PackageManagerFactoryTest.php | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 tests/lib/OsStatus/PackageManagerFactoryTest.php diff --git a/tests/lib/OsStatus/PackageManagerFactoryTest.php b/tests/lib/OsStatus/PackageManagerFactoryTest.php new file mode 100644 index 00000000..2cba6baf --- /dev/null +++ b/tests/lib/OsStatus/PackageManagerFactoryTest.php @@ -0,0 +1,110 @@ +host = new FakeHost(); + $this->host->globs['/var/cache/dnf/*-updateinfo.solvx'] = ['/var/cache/dnf/baseos-updateinfo.solvx']; + } + + /** + * @param list $binaries + */ + private function managerFor(string $osRelease, array $binaries): ?IPackageManager { + $this->host->binaries = $binaries; + $release = OsRelease::parse($osRelease); + $this->assertNotNull($release); + $host = $this->host; + $systemd = new SystemdProbe($host); + $rpm = new Rpm($host); + $factory = new PackageManagerFactory( + new Apt($host, $systemd), + new Dnf($host, $rpm, $systemd), + new Zypper($host, $rpm, $systemd), + new Pacman($host, $this->createMock(ITimeFactory::class), $this->createMock(IAppConfig::class)), + new Apk($host), + ); + + return $factory->forRelease($release); + } + + /** + * @return array, ?string, ?bool}> + */ + public static function systems(): array { + return [ + 'Debian 12' => ["ID=debian\nVERSION_ID=\"12\"\n", ['apt-get'], 'apt', true], + 'RHEL 9 with dnf 4' => ["ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3', 'yum'], 'dnf', true], + 'Rocky Linux 9' => ["ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3'], 'dnf', true], + 'Oracle Linux 9' => ["ID=\"ol\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3'], 'dnf', true], + 'Amazon Linux 2023' => ["ID=\"amzn\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"2023\"\n", ['dnf', 'dnf-3'], 'dnf', true], + 'Amazon Linux 2 has only yum' => ["ID=\"amzn\"\nID_LIKE=\"centos rhel fedora\"\nVERSION_ID=\"2\"\n", ['yum'], null, null], + 'CentOS Stream 10' => ["ID=\"centos\"\nID_LIKE=\"rhel fedora\"\nVERSION_ID=\"10\"\n", ['dnf', 'dnf-3'], 'dnf', false], + 'Fedora with dnf 5' => ["ID=fedora\nVERSION_ID=44\n", ['dnf', 'dnf5'], 'dnf', true], + 'openSUSE Leap 16' => ["ID=\"opensuse-leap\"\nID_LIKE=\"suse opensuse\"\nVERSION_ID=\"16.0\"\n", ['zypper'], 'zypper', true], + 'openSUSE Tumbleweed' => ["ID=\"opensuse-tumbleweed\"\nID_LIKE=\"opensuse suse\"\n", ['zypper'], 'zypper', false], + 'SLES 15' => ["ID=\"sles\"\nID_LIKE=\"suse\"\nVERSION_ID=\"15.6\"\n", ['zypper'], 'zypper', true], + 'Arch Linux' => ["ID=arch\n", ['pacman'], 'pacman', false], + 'Manjaro' => ["ID=manjaro\nID_LIKE=arch\n", ['pacman'], 'pacman', false], + 'Alpine' => ["ID=alpine\nVERSION_ID=3.24.2\n", ['apk'], 'apk', false], + 'postmarketOS' => ["ID=\"postmarketos\"\nID_LIKE=\"alpine\"\nVERSION_ID=\"v25.06\"\n", ['apk'], 'apk', false], + 'Gentoo' => ["ID=gentoo\n", ['emerge'], null, null], + 'Debian without apt-get' => ["ID=debian\n", [], null, null], + ]; + } + + /** + * @param list $binaries + */ + #[\PHPUnit\Framework\Attributes\DataProvider('systems')] + public function testDispatch(string $osRelease, array $binaries, ?string $expected, ?bool $classifies): void { + $manager = $this->managerFor($osRelease, $binaries); + + $this->assertSame($expected, $manager?->getId()); + $this->assertSame($classifies, $manager?->classifiesSecurity()); + } + + public function testRhel9RunsDnf4AsDnf3(): void { + $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); + $manager = $this->managerFor("ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3']); + + $this->assertSame([], $manager?->getPendingUpdates()); + $this->assertSame(['dnf-3 -q --cacheonly check-update'], $this->host->ran); + } + + public function testFedoraWithDnf5RunsNothing(): void { + $manager = $this->managerFor("ID=fedora\nVERSION_ID=44\n", ['dnf', 'dnf5']); + + try { + $manager?->getPendingUpdates(); + $this->fail('dnf 5 must not be asked'); + } catch (NeedsRootException) { + } + $this->assertSame([], $this->host->ran); + } +} From 112b8a6e998023e62d2f130a3028fbf59c8c9daa Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 06:06:22 +0200 Subject: [PATCH 13/14] fix(os): name timeouts and try hidden package managers on every distribution apt already says "timed out after N s" or "output too large" when a command fails for those reasons, and it is tried when open_basedir hides apt-get. dnf, zypper, pacman and apk still quoted the first output line, which is often a progress line after a timeout, and were not tried at all when open_basedir hid their binary. They now use CommandResult::failure() for the failure text and treat an unknown binary state as "try it", like apt. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- lib/OsStatus/Packages/Apk.php | 4 +-- lib/OsStatus/Packages/Dnf.php | 4 +-- lib/OsStatus/Packages/Pacman.php | 4 +-- lib/OsStatus/Packages/Zypper.php | 4 +-- .../OsStatus/PackageManagerFactoryTest.php | 27 +++++++++++++++++++ 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/lib/OsStatus/Packages/Apk.php b/lib/OsStatus/Packages/Apk.php index 5e2d8e48..7f4382b0 100644 --- a/lib/OsStatus/Packages/Apk.php +++ b/lib/OsStatus/Packages/Apk.php @@ -34,7 +34,7 @@ public function getId(): string { #[\Override] public function isAvailable(): bool { - return $this->host->hasBinary('apk'); + return $this->host->binaryState('apk') !== false; } #[\Override] @@ -49,7 +49,7 @@ public function getPendingUpdates(): array { throw new ProbeException('apk could not be run.'); } if (!$result->succeeded()) { - throw new ProbeException('apk list --upgradable failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout)); + throw new ProbeException('apk list --upgradable failed: ' . $result->failure(120)); } // apk lists every newer version it knows of, including those the diff --git a/lib/OsStatus/Packages/Dnf.php b/lib/OsStatus/Packages/Dnf.php index 08a0fbb7..147af5c4 100644 --- a/lib/OsStatus/Packages/Dnf.php +++ b/lib/OsStatus/Packages/Dnf.php @@ -132,7 +132,7 @@ public function getPendingUpdates(): array { throw new ProbeException('dnf could not be run.'); } if (($result->exitCode !== 0 && $result->exitCode !== self::EXIT_UPDATES) || $result->timedOut || $result->truncated) { - throw new ProbeException('dnf check-update failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout)); + throw new ProbeException('dnf check-update failed: ' . $result->failure(180)); } $available = self::parseCheckUpdate($result->stdout); @@ -210,7 +210,7 @@ private function securityAdvisories(): array { if ($result === null || !$result->succeeded()) { $this->advisoriesFailed = true; $this->diagnostics[] = 'dnf could not list security advisories, so pending updates are not told apart into security fixes and others: ' - . ProbeHelper::firstLine($result?->stderr ?: ($result?->timedOut ? 'timed out' : '')); + . ($result?->failure(180) ?? 'dnf could not be run'); return []; } diff --git a/lib/OsStatus/Packages/Pacman.php b/lib/OsStatus/Packages/Pacman.php index 7909087a..89edfb83 100644 --- a/lib/OsStatus/Packages/Pacman.php +++ b/lib/OsStatus/Packages/Pacman.php @@ -53,7 +53,7 @@ public function getId(): string { #[\Override] public function isAvailable(): bool { - return $this->host->hasBinary('pacman'); + return $this->host->binaryState('pacman') !== false; } #[\Override] @@ -95,7 +95,7 @@ public function getPendingUpdates(): array { // pacman -Qu exits 1 without a word when nothing is upgradable. $nothing = $result->exitCode === 1 && trim($result->stdout) === '' && trim($result->stderr) === ''; if (!$nothing && !$result->succeeded()) { - throw new ProbeException('pacman -Qu failed: ' . ProbeHelper::firstLine($result->stderr ?: $result->stdout ?: 'exit code ' . $result->exitCode)); + throw new ProbeException('pacman -Qu failed: ' . $result->failure(60)); } return $this->withSecurity(self::parseUpdates($result->stdout)); diff --git a/lib/OsStatus/Packages/Zypper.php b/lib/OsStatus/Packages/Zypper.php index aa8c34e9..bdbe9a20 100644 --- a/lib/OsStatus/Packages/Zypper.php +++ b/lib/OsStatus/Packages/Zypper.php @@ -67,7 +67,7 @@ public function getId(): string { #[\Override] public function isAvailable(): bool { - return $this->host->hasBinary('zypper'); + return $this->host->binaryState('zypper') !== false; } /** @@ -338,7 +338,7 @@ private function zypperXml(array $arguments): string { : null; $skipped = $result->exitCode === self::EXIT_REPOS_SKIPPED; if (!self::isComplete($result) || self::parseXml($result->stdout) === null || ($xmlError !== null && !$skipped)) { - throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . ProbeHelper::firstLine($xmlError ?? ($result->stderr ?: $result->stdout))); + throw new ProbeException('zypper ' . $arguments[0] . ' failed: ' . ($xmlError !== null ? ProbeHelper::firstLine($xmlError) : $result->failure(180))); } if ($skipped) { $this->diagnostics[] = 'zypper skipped repositories it could not read, so pending updates may be incomplete' diff --git a/tests/lib/OsStatus/PackageManagerFactoryTest.php b/tests/lib/OsStatus/PackageManagerFactoryTest.php index 2cba6baf..5cf1b49c 100644 --- a/tests/lib/OsStatus/PackageManagerFactoryTest.php +++ b/tests/lib/OsStatus/PackageManagerFactoryTest.php @@ -10,6 +10,7 @@ namespace OCA\ServerInfo\Tests\OsStatus; use OCA\ServerInfo\OsStatus\Health\SystemdProbe; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\OsStatus\OsRelease; use OCA\ServerInfo\OsStatus\Packages\Apk; use OCA\ServerInfo\OsStatus\Packages\Apt; @@ -89,6 +90,32 @@ public function testDispatch(string $osRelease, array $binaries, ?string $expect $this->assertSame($classifies, $manager?->classifiesSecurity()); } + /** + * @return array + */ + public static function hiddenByOpenBasedir(): array { + return [ + 'openSUSE Leap 16' => ["ID=\"opensuse-leap\"\nID_LIKE=\"suse opensuse\"\nVERSION_ID=\"16.0\"\n", 'zypper', 'zypper'], + 'Arch Linux' => ["ID=arch\n", 'pacman', 'pacman'], + 'Alpine' => ["ID=alpine\nVERSION_ID=3.24.2\n", 'apk', 'apk'], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider('hiddenByOpenBasedir')] + public function testTriesAPackageManagerOpenBasedirHides(string $osRelease, string $binary, string $expected): void { + $this->host->hiddenBinaries = [$binary]; + + $this->assertSame($expected, $this->managerFor($osRelease, [])?->getId()); + } + + public function testDnfNamesATimeout(): void { + $manager = $this->managerFor("ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3']); + $this->host->commands['dnf-3 -q --cacheonly check-update'] = new CommandResult(-1, "Last metadata expiration check\n", '', true); + + $this->expectExceptionMessage('dnf check-update failed: timed out after 180 s'); + $manager?->getPendingUpdates(); + } + public function testRhel9RunsDnf4AsDnf3(): void { $this->host->withCommand('dnf-3', ['-q', '--cacheonly', 'check-update'], '', 0); $manager = $this->managerFor("ID=\"rhel\"\nID_LIKE=\"fedora\"\nVERSION_ID=\"9.6\"\n", ['dnf', 'dnf-3']); From e9df3f36eb2395a9be7b060d6bec5f1a13d55e92 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Fri, 23 Oct 2026 09:53:34 +0200 Subject: [PATCH 14/14] docs(os): name where the Arch queries connect to The README and the os_allow_network setting said that checkupdates and arch-audit use the network on Arch, but not where to. They now name the pacman mirrors and security.archlinux.org, and the README says that these are the only outbound connections of the operating system status, so an admin can allow exactly those or turn them off. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 7 ++++--- lib/Config/ConfigLexicon.php | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9b70b25c..3adc3949 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,10 @@ only in simulation mode), `systemctl`, `timedatectl`, `chronyc` and `systemd-detect-virt`. Anything it may not read or run is reported as unknown. These queries stay offline, with one exception: on Arch, `checkupdates` -downloads the package databases into a temporary directory of the web server -user, and `arch-audit` fetches the security advisories from -security.archlinux.org. To keep Arch offline as well, run +downloads the package databases from the mirrors in +`/etc/pacman.d/mirrorlist` into a temporary directory of the web server user, +and `arch-audit` fetches the security advisories from security.archlinux.org. +These are the only outbound connections the operating system status makes. To keep Arch offline as well, run `occ config:app:set serverinfo os_allow_network --type=boolean --value=false`. Pending updates then come from the databases root last synced, the age of those databases counts as the package list age, and security fixes are not diff --git a/lib/Config/ConfigLexicon.php b/lib/Config/ConfigLexicon.php index 5fb0a583..7c018135 100644 --- a/lib/Config/ConfigLexicon.php +++ b/lib/Config/ConfigLexicon.php @@ -132,7 +132,7 @@ public function getAppConfigs(): array { self::OS_ALLOW_NETWORK, ValueType::BOOL, true, - 'Whether the operating system status may use the network as the web server user: on Arch, checkupdates syncs its own package databases and arch-audit fetches the security advisories', + 'Whether the operating system status may use the network as the web server user: on Arch, checkupdates syncs its own package databases from the pacman mirrors and arch-audit fetches the security advisories from security.archlinux.org', lazy: true, ), ];