From 9802b976ca8855d2f3a8833d3ef601d50052bc30 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Sun, 25 Oct 2026 09:46:25 +0100 Subject: [PATCH] perf(os): run the system commands without a shell and with time limits The Linux and FreeBSD backends ran df, free, nproc, sysctl, swapinfo, netstat, ifconfig, date and a cat of /proc/self/stat through shell_exec with no time limit, and the gateway through the pipeline `ip route | awk`. A stale NFS or CIFS mount makes df hang, so every Status page load or diskdata call then held a PHP worker until FPM killed it. On FreeBSD each poll also forked date(1) and sysctl twice for values PHP already knows. All commands now go through the OS status CommandRunner via IHost, in a small CommandOutput helper: argument vectors, no shell, a fixed PATH, the runner's output cap, 2 s per command and 5 s for df. Output of a command that exits with an error is still used, as before, since df does so when one mount point is unreadable. When df is stopped at its limit, the complete lines it printed are still reported and one warning is logged; other failures are logged at debug level. - Linux: `ip route show default` is parsed in PHP; several default gateways are joined with ", " as on FreeBSD, and the value is always a string (shell_exec returned null without output). - FreeBSD: the time is formatted in PHP in the zone /etc/localtime links to or /var/db/zoneinfo names; the boot time moves to sampleUptime(), so Os caches it as it does on Linux; hw.model and kern.smp.cpus come from one sysctl; ifconfig gets the interface as an argument and only names that are valid interface names; the load average comes from sys_getloadavg(). The tests run the backends against FakeHost instead of mocking executeCommand, and cover timeouts, failed commands and the route parser. Assisted-by: ClaudeCode:claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Frank Karlitschek --- README.md | 2 +- lib/OperatingSystems/CommandOutput.php | 64 ++++++++++ lib/OperatingSystems/FreeBSD.php | 159 +++++++++++------------ lib/OperatingSystems/Linux.php | 53 ++++---- lib/Os.php | 10 +- tests/data/linux_ip_route_default | 4 + tests/lib/FreeBSDTest.php | 168 +++++++++++++++++-------- tests/lib/LinuxTest.php | 136 ++++++++++++++------ tests/lib/OsStatus/OsNameTest.php | 3 +- tests/psalm-baseline.xml | 7 -- 10 files changed, 391 insertions(+), 215 deletions(-) create mode 100644 lib/OperatingSystems/CommandOutput.php create mode 100644 tests/data/linux_ip_route_default diff --git a/README.md b/README.md index bd3b1faf..c2ea7ab7 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ php occ serverinfo:update-storage-statistics -v --output=json_pretty ##### Restricted mode (>= Nextcloud 28) -To obtain information about your server, the serverinfo app reads files outside the application directory (e.g. /proc on Linux) or executes shell commands (e.g. df on Linux). +To obtain information about your server, the serverinfo app reads files outside the application directory (e.g. /proc on Linux) or runs system commands (e.g. df on Linux) without a shell and with a time limit of a few seconds each. If you don't want that (for example, to avoid open_basedir warnings) enable the restricted mode. diff --git a/lib/OperatingSystems/CommandOutput.php b/lib/OperatingSystems/CommandOutput.php new file mode 100644 index 00000000..d3909585 --- /dev/null +++ b/lib/OperatingSystems/CommandOutput.php @@ -0,0 +1,64 @@ + $arguments + * @param bool $partial whether the complete lines printed before the command + * was stopped are still of use + * @return string|null null when there is no output to use + */ + public function read(string $binary, array $arguments, int $timeout = self::TIMEOUT, bool $partial = false): ?string { + $result = $this->host->run($binary, $arguments, $timeout); + if ($result === null) { + $this->logger->debug('Could not run {command}', ['app' => 'serverinfo', 'command' => $binary]); + return null; + } + + $output = $result->stdout; + if ($result->timedOut || $result->truncated || $result->readFailed) { + $this->logger->warning('{command} did not finish: {reason}', [ + 'app' => 'serverinfo', + 'command' => trim($binary . ' ' . implode(' ', $arguments)), + 'reason' => $result->failure($timeout), + ]); + + $end = strrpos($output, "\n"); + $output = $partial && $end !== false ? substr($output, 0, $end + 1) : ''; + } + + return $output !== '' ? $output : null; + } +} diff --git a/lib/OperatingSystems/FreeBSD.php b/lib/OperatingSystems/FreeBSD.php index 5ae3d6c8..dcdd7676 100644 --- a/lib/OperatingSystems/FreeBSD.php +++ b/lib/OperatingSystems/FreeBSD.php @@ -8,16 +8,27 @@ namespace OCA\ServerInfo\OperatingSystems; +use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\Resources\CPU; use OCA\ServerInfo\Resources\Disk; use OCA\ServerInfo\Resources\Memory; use OCA\ServerInfo\Resources\NetInterface; +use Psr\Log\LoggerInterface; use RuntimeException; class FreeBSD implements IOperatingSystem { private const AF_INET = 2; private const AF_INET6 = 28; + private CommandOutput $commands; + + public function __construct( + private IHost $host, + LoggerInterface $logger, + ) { + $this->commands = new CommandOutput($host, $logger); + } + #[\Override] public function supported(): bool { return false; @@ -25,36 +36,47 @@ public function supported(): bool { #[\Override] public function getCPU(): CPU { - try { - $name = $this->executeCommand('/sbin/sysctl -n hw.model'); - $cores = (int)$this->executeCommand('/sbin/sysctl -n kern.smp.cpus'); - } catch (RuntimeException) { - $name = 'Unknown Processor'; + $lines = explode("\n", trim($this->commands->read('sysctl', ['-n', 'hw.model', 'kern.smp.cpus']) ?? '')); + if (count($lines) !== 2 || trim($lines[0]) === '' || preg_match('/^\d+$/', trim($lines[1])) !== 1) { // -1 marks the count as unknown rather than claiming a single core. - $cores = -1; + return new CPU('Unknown Processor', -1); } - return new CPU($name, $cores); + + return new CPU(trim($lines[0]), (int)$lines[1]); } #[\Override] public function getAverageLoad(): array|false { - return false; + $load = function_exists('sys_getloadavg') ? sys_getloadavg() : false; + if ($load === false || count($load) < 3) { + return false; + } + + return [(float)$load[0], (float)$load[1], (float)$load[2]]; } + /** + * The boot time comes from sysctl, which costs a fork, so + * {@see \OCA\ServerInfo\Os::getUptime()} only asks occasionally and + * remembers the answer. + */ #[\Override] public function sampleUptime(): int { - return -1; + $boottime = $this->commands->read('sysctl', ['-n', 'kern.boottime']); + if ($boottime === null || preg_match('/\bsec\s*=\s*(\d+)/', $boottime, $matches) !== 1) { + return -1; + } + + $bootedAt = (int)$matches[1]; + + return $bootedAt > 0 ? max(0, time() - $bootedAt) : -1; } #[\Override] public function getMemory(): Memory { $data = new Memory(); - try { - $swapinfo = $this->executeCommand('/usr/sbin/swapinfo -k'); - } catch (RuntimeException $e) { - $swapinfo = ''; - } + $swapinfo = $this->commands->read('swapinfo', ['-k']) ?? ''; $matches = []; $pattern = '/(?>\/dev\/\S+)\s+(?>\d+)\s+(?\d+)\s+(?\d+)\s+(?\d+)/'; @@ -67,11 +89,7 @@ public function getMemory(): Memory { unset($matches, $result); - try { - $meminfo = $this->executeCommand('/sbin/sysctl -n hw.realmem hw.pagesize vm.stats.vm.v_inactive_count vm.stats.vm.v_cache_count vm.stats.vm.v_free_count'); - } catch (RuntimeException $e) { - $meminfo = ''; - } + $meminfo = $this->commands->read('sysctl', ['-n', 'hw.realmem', 'hw.pagesize', 'vm.stats.vm.v_inactive_count', 'vm.stats.vm.v_cache_count', 'vm.stats.vm.v_free_count']) ?? ''; $lines = array_map('intval', explode("\n", $meminfo)); if (count($lines) > 4) { @@ -86,26 +104,37 @@ public function getMemory(): Memory { #[\Override] public function getTime(): string { - try { - return $this->executeCommand('date'); - } catch (RuntimeException $e) { - return ''; + return (new \DateTimeImmutable('now', $this->getSystemTimeZone()))->format('D M j H:i:s T Y'); + } + + /** + * Nextcloud runs PHP in UTC, so the zone date(1) would use is read from + * the system: tzsetup either links /etc/localtime into the zoneinfo + * database or copies it there and records the name in /var/db/zoneinfo. + */ + private function getSystemTimeZone(): \DateTimeZone { + $link = $this->host->readLink('/etc/localtime'); + $name = $link !== null && preg_match('#/zoneinfo/(.+)$#', $link, $matches) === 1 + ? $matches[1] + : trim($this->host->readFile('/var/db/zoneinfo', 128) ?? ''); + + if ($name !== '' && preg_match('#^[A-Za-z0-9/_+-]+$#', $name) === 1) { + try { + return new \DateTimeZone($name); + } catch (\Exception) { + // fall through to the configured default + } } + + return new \DateTimeZone(date_default_timezone_get()); } + /** + * Only {@see sampleUptime()} can tell, at the cost of a fork. + */ #[\Override] public function getUptime(): int { - $uptime = -1; - - try { - $shell_boot = $this->executeCommand('/sbin/sysctl -n kern.boottime'); - preg_match("/[\d]+/", $shell_boot, $boottime); - $time = $this->executeCommand('date +%s'); - $uptime = (int)$time - (int)$boottime[0]; - } catch (RuntimeException $e) { - return $uptime; - } - return $uptime; + return -1; } #[\Override] @@ -116,18 +145,17 @@ public function getNetworkInfo(): array { 'dns' => '', ]; - try { - $netstat = $this->executeCommand('netstat -rn'); + $netstat = $this->commands->read('netstat', ['-rn']); + if ($netstat !== null) { preg_match_all("/(?<=^default)\s*[0-9a-fA-f\.:]+/m", $netstat, $gw); if (count($gw[0]) > 0) { $result['gateway'] = implode(', ', array_map('trim', $gw[0])); } - $resolvConf = $this->readContent('/etc/resolv.conf'); - if (preg_match_all('/^\s*nameserver\s+(\S+)/m', $resolvConf, $matches)) { - $result['dns'] = implode(', ', array_unique($matches[1])); - } - } catch (RuntimeException) { - // okay + } + + $resolvConf = $this->host->readFile('/etc/resolv.conf', 65536); + if ($resolvConf !== null && preg_match_all('/^\s*nameserver\s+(\S+)/m', $resolvConf, $matches)) { + $result['dns'] = implode(', ', array_unique($matches[1])); } return $result; @@ -160,9 +188,14 @@ public function getNetworkInterfaces(): array { continue; } - try { - $details = $this->executeCommand('/sbin/ifconfig ' . $interfaceName); - } catch (RuntimeException $e) { + // Interface names are at most 15 characters and never start with a + // dash, which keeps the name from being read as an option. + if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,14}$/', (string)$interfaceName) !== 1) { + continue; + } + + $details = $this->commands->read('ifconfig', [(string)$interfaceName]); + if ($details === null) { continue; } @@ -195,9 +228,8 @@ public function getNetworkInterfaces(): array { public function getDiskInfo(): array { $data = []; - try { - $disks = $this->executeCommand('df -TPk'); - } catch (RuntimeException $e) { + $disks = $this->commands->read('df', ['-TPk'], CommandOutput::DF_TIMEOUT, true); + if ($disks === null) { return $data; } @@ -243,37 +275,6 @@ public function getThermalZones(): array { return []; } - /** - * @throws RuntimeException - */ - protected function readContent(string $filename): string { - $data = @file_get_contents($filename); - if ($data === false || $data === '') { - throw new RuntimeException('Unable to read: "' . $filename . '"'); - } - return trim($data); - } - - /** - * Execute a command with shell_exec. - * - * The command will be escaped with escapeshellcmd. - * - * @throws RuntimeException if shell_exec is unavailable, the command failed or an empty response. - */ - protected function executeCommand(string $command): string { - if (function_exists('shell_exec') === false) { - throw new RuntimeException('shell_exec unavailable'); - } - - $output = shell_exec(escapeshellcmd($command)); - if ($output === false || $output === null || $output === '') { - throw new RuntimeException('No output for command: "' . $command . '"'); - } - - return $output; - } - /** * Wrapper for net_get_interfaces * diff --git a/lib/OperatingSystems/Linux.php b/lib/OperatingSystems/Linux.php index 3ff14108..0ec600b8 100644 --- a/lib/OperatingSystems/Linux.php +++ b/lib/OperatingSystems/Linux.php @@ -9,11 +9,13 @@ namespace OCA\ServerInfo\OperatingSystems; +use OCA\ServerInfo\OsStatus\Host\IHost; use OCA\ServerInfo\Resources\CPU; use OCA\ServerInfo\Resources\Disk; use OCA\ServerInfo\Resources\Memory; use OCA\ServerInfo\Resources\NetInterface; use OCA\ServerInfo\Resources\ThermalZone; +use Psr\Log\LoggerInterface; use RuntimeException; class Linux implements IOperatingSystem { @@ -23,6 +25,12 @@ class Linux implements IOperatingSystem { /** Mount point of both the cgroup v2 unified hierarchy and the v1 controllers. */ private const CGROUP_ROOT = '/sys/fs/cgroup'; + private CommandOutput $commands; + + public function __construct(IHost $host, LoggerInterface $logger) { + $this->commands = new CommandOutput($host, $logger); + } + #[\Override] public function supported(): bool { return true; @@ -95,9 +103,8 @@ public function getAverageLoad(): array|false { private function getMemoryFromFree(): Memory { $data = new Memory(); - try { - $output = $this->executeCommand('free -k'); - } catch (RuntimeException) { + $output = $this->commands->read('free', ['-k']); + if ($output === null) { return $data; } @@ -186,11 +193,7 @@ private function readNodeMemValue(string $content, string $key): int { * @return int the number of processors, or -1 when it cannot be determined */ private function countProcessors(): int { - try { - $output = trim($this->executeCommand('nproc')); - } catch (RuntimeException) { - return -1; - } + $output = trim($this->commands->read('nproc', []) ?? ''); return preg_match('/^\d+$/', $output) === 1 ? (int)$output : -1; } @@ -431,9 +434,8 @@ public function getUptime(): int { */ #[\Override] public function sampleUptime(): int { - try { - $stat = $this->executeCommand('cat /proc/self/stat'); - } catch (RuntimeException) { + $stat = $this->commands->read('cat', ['/proc/self/stat']); + if ($stat === null) { return -1; } @@ -460,8 +462,9 @@ public function getNetworkInfo(): array { 'dns' => '', ]; - if (function_exists('shell_exec')) { - $result['gateway'] = shell_exec('ip route | awk \'/default/ { print $3 }\''); + $routes = $this->commands->read('ip', ['route', 'show', 'default']); + if ($routes !== null) { + $result['gateway'] = implode(', ', self::parseDefaultGateways($routes)); } try { @@ -530,9 +533,8 @@ public function getNetworkInterfaces(): array { public function getDiskInfo(): array { $data = []; - try { - $disks = $this->executeCommand('df -TPk'); - } catch (RuntimeException $e) { + $disks = $this->commands->read('df', ['-TPk'], CommandOutput::DF_TIMEOUT, true); + if ($disks === null) { return $data; } @@ -646,23 +648,14 @@ protected function readContent(string $filename): string { } /** - * Execute a command with shell_exec. + * The gateways of the default routes in `ip route show default` output. * - * The command will be escaped with escapeshellcmd. - * - * @throws RuntimeException if shell_exec is unavailable, the command failed or an empty response. + * @return list */ - protected function executeCommand(string $command): string { - if (function_exists('shell_exec') === false) { - throw new RuntimeException('shell_exec unavailable'); - } - - $output = shell_exec(escapeshellcmd($command)); - if ($output === false || $output === null || $output === '') { - throw new RuntimeException('No output for command: "' . $command . '"'); - } + public static function parseDefaultGateways(string $routes): array { + preg_match_all('/^default\s.*?\bvia\s+(\S+)/m', $routes, $matches); - return $output; + return array_values(array_unique($matches[1])); } /** diff --git a/lib/Os.php b/lib/Os.php index 18ba2cc4..9b55d708 100644 --- a/lib/Os.php +++ b/lib/Os.php @@ -19,6 +19,7 @@ use OCA\ServerInfo\Resources\Memory; use OCP\AppFramework\Services\IAppConfig; use OCP\IConfig; +use Psr\Log\LoggerInterface; class Os implements IOperatingSystem { /** How long a measured boot time is trusted before it is checked again. */ @@ -31,9 +32,10 @@ public function __construct( IConfig $config, private IAppConfig $appConfig, private IHost $host, + LoggerInterface $logger, ) { $this->restrictedMode = $config->getAppValue('serverinfo', 'restricted_mode', 'no') === 'yes'; - $this->backend = $this->getBackend($this->restrictedMode ? 'Dummy' : PHP_OS); + $this->backend = $this->getBackend($this->restrictedMode ? 'Dummy' : PHP_OS, $logger); } /** @@ -185,10 +187,10 @@ public function getThermalZones(): array { return $this->backend->getThermalZones(); } - private function getBackend(string $os): IOperatingSystem { + private function getBackend(string $os, LoggerInterface $logger): IOperatingSystem { return match ($os) { - 'Linux' => new Linux(), - 'FreeBSD' => new FreeBSD(), + 'Linux' => new Linux($this->host, $logger), + 'FreeBSD' => new FreeBSD($this->host, $logger), default => new Dummy(), }; } diff --git a/tests/data/linux_ip_route_default b/tests/data/linux_ip_route_default new file mode 100644 index 00000000..ce02cf2d --- /dev/null +++ b/tests/data/linux_ip_route_default @@ -0,0 +1,4 @@ +default via 192.0.2.1 dev eth0 proto dhcp src 192.0.2.10 metric 100 +default via 198.51.100.1 dev wlan0 proto dhcp metric 600 +default dev wg0 scope link +default via 192.0.2.1 dev eth0 proto static metric 1024 diff --git a/tests/lib/FreeBSDTest.php b/tests/lib/FreeBSDTest.php index fd28c151..616591e7 100644 --- a/tests/lib/FreeBSDTest.php +++ b/tests/lib/FreeBSDTest.php @@ -10,10 +10,13 @@ namespace OCA\ServerInfo\Tests; use OCA\ServerInfo\OperatingSystems\FreeBSD; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\Resources\Disk; use OCA\ServerInfo\Resources\Memory; use OCA\ServerInfo\Resources\NetInterface; +use OCA\ServerInfo\Tests\OsStatus\FakeHost; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use RuntimeException; use Test\TestCase; @@ -25,22 +28,41 @@ class FreeBSDTest extends TestCase { /** @var FreeBSD&MockObject */ protected $os; + private FakeHost $host; + /** @var LoggerInterface&MockObject */ + private LoggerInterface $logger; protected function setUp(): void { parent::setUp(); + $this->host = new FakeHost(); + $this->host->family = 'BSD'; + $this->logger = $this->createMock(LoggerInterface::class); $this->os = $this->getMockBuilder(FreeBSD::class) - ->disableOriginalConstructor() + ->setConstructorArgs([$this->host, $this->logger]) ->disableOriginalClone() ->disableArgumentCloning() ->disallowMockingUnknownTypes() - ->onlyMethods(['readContent', 'executeCommand', 'getNetInterfaces']) + ->onlyMethods(['getNetInterfaces']) ->getMock(); } + private function fixture(string $name): string { + return (string)file_get_contents(__DIR__ . '/../data/' . $name); + } + + public function testGetCpu(): void { + $this->host->withCommand('sysctl', ['-n', 'hw.model', 'kern.smp.cpus'], "Intel(R) Xeon(R) CPU E5-2650 v4 @ 2.20GHz\n8\n"); + + $cpu = $this->os->getCPU(); + + $this->assertEquals('Intel(R) Xeon(R) CPU E5-2650 v4 @ 2.20GHz', $cpu->getName()); + $this->assertEquals(8, $cpu->getThreads()); + $this->assertSame(['sysctl -n hw.model kern.smp.cpus'], $this->host->ran); + } + public function testGetCpuNoData(): void { - $this->os->method('executeCommand') - ->willThrowException(new RuntimeException('No output for command')); + $this->host->commandsAllowed = false; $cpu = $this->os->getCPU(); @@ -51,11 +73,8 @@ public function testGetCpuNoData(): void { } public function testGetMemory(): void { - $this->os->method('executeCommand') - ->willReturnMap([ - ['/usr/sbin/swapinfo -k', file_get_contents(__DIR__ . '/../data/freebsd_swapinfo')], - ['/sbin/sysctl -n hw.realmem hw.pagesize vm.stats.vm.v_inactive_count vm.stats.vm.v_cache_count vm.stats.vm.v_free_count', file_get_contents(__DIR__ . '/../data/freebsd_meminfo')], - ]); + $this->host->withCommand('swapinfo', ['-k'], $this->fixture('freebsd_swapinfo')) + ->withCommand('sysctl', ['-n', 'hw.realmem', 'hw.pagesize', 'vm.stats.vm.v_inactive_count', 'vm.stats.vm.v_cache_count', 'vm.stats.vm.v_free_count'], $this->fixture('freebsd_meminfo')); $memory = $this->os->getMemory(); @@ -67,15 +86,8 @@ public function testGetMemory(): void { } public function testGetMemoryNoSwapinfo(): void { - $this->os->method('executeCommand') - ->willReturnCallback(static function ($command) { - if ($command === '/usr/sbin/swapinfo -k') { - throw new \RuntimeException('No output for command: /usr/sbin/swapinfo'); - } - if ($command === '/sbin/sysctl -n hw.realmem hw.pagesize vm.stats.vm.v_inactive_count vm.stats.vm.v_cache_count vm.stats.vm.v_free_count') { - return file_get_contents(__DIR__ . '/../data/freebsd_meminfo'); - } - }); + $this->host->withCommand('swapinfo', ['-k'], '', 1) + ->withCommand('sysctl', ['-n', 'hw.realmem', 'hw.pagesize', 'vm.stats.vm.v_inactive_count', 'vm.stats.vm.v_cache_count', 'vm.stats.vm.v_free_count'], $this->fixture('freebsd_meminfo')); $memory = $this->os->getMemory(); @@ -87,18 +99,14 @@ public function testGetMemoryNoSwapinfo(): void { } public function testGetMemoryNoData(): void { - $this->os->method('executeCommand') - ->willThrowException(new \RuntimeException('No output for command: xxx')); + $this->host->commandsAllowed = false; $this->assertEquals(new Memory(), $this->os->getMemory()); } public function testGetMemoryTruenasSwapinfo(): void { - $this->os->method('executeCommand') - ->willReturnMap([ - ['/usr/sbin/swapinfo -k', file_get_contents(__DIR__ . '/../data/truenas_core_swapinfo')], - ['/sbin/sysctl -n hw.realmem hw.pagesize vm.stats.vm.v_inactive_count vm.stats.vm.v_cache_count vm.stats.vm.v_free_count', file_get_contents(__DIR__ . '/../data/truenas_core_meminfo')], - ]); + $this->host->withCommand('swapinfo', ['-k'], $this->fixture('truenas_core_swapinfo')) + ->withCommand('sysctl', ['-n', 'hw.realmem', 'hw.pagesize', 'vm.stats.vm.v_inactive_count', 'vm.stats.vm.v_cache_count', 'vm.stats.vm.v_free_count'], $this->fixture('truenas_core_meminfo')); $memory = $this->os->getMemory(); @@ -112,16 +120,8 @@ public function testGetMemoryTruenasSwapinfo(): void { public function testGetNetworkInterfaces(): void { $this->os->method('getNetInterfaces') ->willReturn(json_decode(file_get_contents(__DIR__ . '/../data/freebsd_net_get_interfaces.json'), true, 512, JSON_THROW_ON_ERROR)); - $this->os->method('executeCommand') - ->willReturnCallback(static function ($command) { - if ($command === '/sbin/ifconfig pflog0') { - return file_get_contents(__DIR__ . '/../data/freebsd_interface_pflog0'); - } - if ($command === '/sbin/ifconfig epair0b') { - return file_get_contents(__DIR__ . '/../data/freebsd_interface_epair0b'); - } - throw new RuntimeException(); - }); + $this->host->withCommand('ifconfig', ['pflog0'], $this->fixture('freebsd_interface_pflog0')) + ->withCommand('ifconfig', ['epair0b'], $this->fixture('freebsd_interface_epair0b')); $net1 = new NetInterface('epair0b', true); $net1->addIPv4('10.0.2.15'); @@ -148,9 +148,7 @@ public function testGetNetworkInterfaces(): void { } public function testGetDiskInfoUnusualMountPoints(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn(file_get_contents(__DIR__ . '/../data/freebsd_df_tp_mount_points')); + $this->host->withCommand('df', ['-TPk'], $this->fixture('freebsd_df_tp_mount_points')); // getDiskInfo() had no coverage here at all, and this pattern was even // narrower than the Linux one: it allowed no dots, so a mount point like @@ -191,12 +189,7 @@ public function testGetDiskInfoUnusualMountPoints(): void { } public function testGetNetworkInfo(): void { - $this->os->method('executeCommand') - ->with('netstat -rn') - ->willReturn("Routing tables\n\ndefault 192.0.2.1 UGS\n"); - $this->os->method('readContent') - ->with('/etc/resolv.conf') - ->willThrowException(new RuntimeException('Unable to read: "/etc/resolv.conf"')); + $this->host->withCommand('netstat', ['-rn'], "Routing tables\n\ndefault 192.0.2.1 UGS\n"); $networkInfo = $this->os->getNetworkInfo(); @@ -206,12 +199,8 @@ public function testGetNetworkInfo(): void { } public function testGetNetworkInfoListsTheNameservers(): void { - $this->os->method('executeCommand') - ->with('netstat -rn') - ->willReturn("Routing tables\n\ndefault 192.0.2.1 UGS\n"); - $this->os->method('readContent') - ->with('/etc/resolv.conf') - ->willReturn("# Generated by resolvconf\nsearch example.org\nnameserver 192.0.2.53\n nameserver 2001:db8::53\nnameserver 192.0.2.53\n"); + $this->host->withCommand('netstat', ['-rn'], "Routing tables\n\ndefault 192.0.2.1 UGS\n"); + $this->host->withFile('/etc/resolv.conf', "# Generated by resolvconf\nsearch example.org\nnameserver 192.0.2.53\n nameserver 2001:db8::53\nnameserver 192.0.2.53\n"); $this->assertSame('192.0.2.53, 2001:db8::53', $this->os->getNetworkInfo()['dns']); } @@ -227,9 +216,7 @@ public function testGetNetworkInterfacesError(): void { } public function testGetDiskInfoZeroCapacityAndInvertedUsage(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn(file_get_contents(__DIR__ . '/../data/freebsd_df_tp_edge_cases')); + $this->host->withCommand('df', ['-TPk'], $this->fixture('freebsd_df_tp_edge_cases')); $disk1 = new Disk(); $disk1->setDevice('/dev/gpt/rootfs'); @@ -260,6 +247,81 @@ public function testGetDiskInfoZeroCapacityAndInvertedUsage(): void { $this->assertEquals([$disk1, $disk2, $disk3], $this->os->getDiskInfo()); } + public function testGetNetworkInterfacesSkipsNamesThatAreNoInterfaceName(): void { + $this->host->withCommand('ifconfig', ['-a'], 'every interface'); + $this->os->method('getNetInterfaces')->willReturn([ + '-a' => ['up' => true, 'unicast' => []], + 'em0 ; reboot' => ['up' => true, 'unicast' => []], + ]); + + $interfaces = $this->os->getNetworkInterfaces(); + + $this->assertCount(2, $interfaces); + $this->assertSame([], $this->host->ran); + } + + public function testGetAverageLoadComesFromPhp(): void { + $load = $this->os->getAverageLoad(); + if (!function_exists('sys_getloadavg') || sys_getloadavg() === false) { + $this->assertFalse($load); + return; + } + + $this->assertIsArray($load); + $this->assertCount(3, $load); + $this->assertContainsOnly('float', $load); + $this->assertSame([], $this->host->ran); + } + + public function testSampleUptimeFromBootTime(): void { + $bootedAt = time() - 3600; + $this->host->withCommand('sysctl', ['-n', 'kern.boottime'], '{ sec = ' . $bootedAt . ', usec = 503212 } Sat Oct 24 09:00:00 2026' . "\n"); + + $this->assertEqualsWithDelta(3600, $this->os->sampleUptime(), 2); + } + + public function testSampleUptimeWithoutSysctl(): void { + $this->host->withCommand('sysctl', ['-n', 'kern.boottime'], 'sysctl: unknown oid', 1); + + $this->assertSame(-1, $this->os->sampleUptime()); + } + + public function testUptimeIsLeftToTheSampler(): void { + $this->assertSame(-1, $this->os->getUptime()); + $this->assertSame([], $this->host->ran); + } + + public function testGetTimeInTheLinkedSystemZone(): void { + $this->host->links['/etc/localtime'] = '/usr/share/zoneinfo/Asia/Kolkata'; + + $this->assertStringContainsString(' IST ', $this->os->getTime()); + $this->assertSame([], $this->host->ran); + } + + public function testGetTimeInTheZoneTzsetupRecorded(): void { + $this->host->withFile('/var/db/zoneinfo', "Asia/Kolkata\n"); + + $this->assertStringContainsString(' IST ', $this->os->getTime()); + } + + public function testGetTimeFormat(): void { + $this->assertMatchesRegularExpression( + '/^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \S+ \d{4}$/', + $this->os->getTime(), + ); + } + + public function testGetDiskInfoReturnsTheMountsThatAnsweredBeforeATimeout(): void { + $this->host->binaries[] = 'df'; + $this->host->commands['df -TPk'] = new CommandResult(137, "/dev/gpt/rootfs ufs 10000000 2000000 8000000 20% /\nstorage:/vol nfs 1", '', true); + $this->logger->expects($this->once())->method('warning'); + + $disks = $this->os->getDiskInfo(); + + $this->assertCount(1, $disks); + $this->assertSame('/', $disks[0]->getMount()); + } + public function testSupported(): void { $this->assertFalse($this->os->supported()); } diff --git a/tests/lib/LinuxTest.php b/tests/lib/LinuxTest.php index ea7b54aa..8cdd3fb1 100644 --- a/tests/lib/LinuxTest.php +++ b/tests/lib/LinuxTest.php @@ -10,26 +10,34 @@ namespace OCA\ServerInfo\Tests; use OCA\ServerInfo\OperatingSystems\Linux; +use OCA\ServerInfo\OsStatus\Host\CommandResult; use OCA\ServerInfo\Resources\Disk; use OCA\ServerInfo\Resources\Memory; use OCA\ServerInfo\Resources\NetInterface; +use OCA\ServerInfo\Tests\OsStatus\FakeHost; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use RuntimeException; use Test\TestCase; class LinuxTest extends TestCase { /** @var Linux&MockObject */ protected $os; + private FakeHost $host; + /** @var LoggerInterface&MockObject */ + private LoggerInterface $logger; protected function setUp(): void { - parent::setUp(); // TODO: Change the autogenerated stub + parent::setUp(); + $this->host = new FakeHost(); + $this->logger = $this->createMock(LoggerInterface::class); $this->os = $this->getMockBuilder(Linux::class) - ->disableOriginalConstructor() + ->setConstructorArgs([$this->host, $this->logger]) ->disableOriginalClone() ->disableArgumentCloning() ->disallowMockingUnknownTypes() - ->onlyMethods(['readContent', 'executeCommand', 'getNetInterfaces', 'getMemoryNodes']) + ->onlyMethods(['readContent', 'getNetInterfaces', 'getMemoryNodes']) ->getMock(); } @@ -293,9 +301,7 @@ public function testGetCpuFallsBackToNprocWhenProcIsHidden(): void { $this->os->method('readContent') ->with('/proc/cpuinfo') ->willThrowException(new RuntimeException('Unable to read: "/proc/cpuinfo"')); - $this->os->method('executeCommand') - ->with('nproc') - ->willReturn("2\n"); + $this->host->withCommand('nproc', [], "2\n"); $cpu = $this->os->getCPU(); @@ -307,9 +313,7 @@ public function testGetMemoryFallsBackToFreeWhenProcIsHidden(): void { $this->os->method('readContent') ->with('/proc/meminfo') ->willThrowException(new RuntimeException('Unable to read: "/proc/meminfo"')); - $this->os->method('executeCommand') - ->with('free -k') - ->willReturn(file_get_contents(__DIR__ . '/../data/linux_free_k')); + $this->host->withCommand('free', ['-k'], $this->fixture('linux_free_k')); $memory = $this->os->getMemory(); @@ -331,7 +335,6 @@ public function testGetMemoryPrefersSysOverForkingFree(): void { }); // free(1) reads /proc/meminfo itself, so where that file is hidden it is // bound to fail: /sys settles the question without paying for the fork. - $this->os->expects($this->never())->method('executeCommand'); $this->os->method('getMemoryNodes') ->willReturn(['/sys/devices/system/node/node0/meminfo']); @@ -344,43 +347,36 @@ public function testGetMemoryPrefersSysOverForkingFree(): void { $this->assertEquals(1529, $memory->getMemAvailable()); // Nor does it publish swap totals. $this->assertEquals(-1, $memory->getSwapTotal()); + $this->assertSame([], $this->host->ran); } public function testGetMemoryStaysUnknownWhenBothSourcesFail(): void { $this->os->method('readContent') ->with('/proc/meminfo') ->willThrowException(new RuntimeException('Unable to read: "/proc/meminfo"')); - $this->os->method('executeCommand') - ->with('free -k') - ->willThrowException(new RuntimeException('shell_exec unavailable')); + $this->host->commandsAllowed = false; $this->assertEquals(new Memory(), $this->os->getMemory()); } public function testGetTimeDoesNotFork(): void { - // date(1) used to be executed here on every poll. - $this->os->expects($this->never())->method('executeCommand'); - $this->assertMatchesRegularExpression( '/^[A-Z][a-z]{2} [A-Z][a-z]{2} \d{1,2} \d{2}:\d{2}:\d{2} \S+ \d{4}$/', $this->os->getTime(), ); + $this->assertSame([], $this->host->ran); } public function testSampleUptimeReadsStarttimeOfAFreshProcess(): void { // Field 22 of /proc/self/stat, in clock ticks since boot. The command name // sits in brackets and can contain spaces, hence the odd-looking prefix. - $this->os->method('executeCommand') - ->with('cat /proc/self/stat') - ->willReturn('302652 (cat) R 302651 284137 284137 0 -1 4194304 396 0 0 0 0 0 0 0 20 0 1 0 32618365 3close'); + $this->host->withCommand('cat', ['/proc/self/stat'], '302652 (cat) R 302651 284137 284137 0 -1 4194304 396 0 0 0 0 0 0 0 20 0 1 0 32618365 3close'); $this->assertEquals(326183, $this->os->sampleUptime()); } public function testSampleUptimeWithoutShell(): void { - $this->os->method('executeCommand') - ->with('cat /proc/self/stat') - ->willThrowException(new RuntimeException('shell_exec unavailable')); + $this->host->commandsAllowed = false; $this->assertEquals(-1, $this->os->sampleUptime()); } @@ -402,9 +398,7 @@ public function testGetUptimeNoData(): void { } public function testGetDiskInfo(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn(file_get_contents(__DIR__ . '/../data/linux_df_tp')); + $this->host->withCommand('df', ['-TPk'], $this->fixture('linux_df_tp')); $disk1 = new Disk(); $disk1->setDevice('/dev/mapper/homestead--vg-root'); @@ -487,9 +481,7 @@ public function testGetDiskInfo(): void { } public function testGetDiskInfoZeroCapacityAndInvertedUsage(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn(file_get_contents(__DIR__ . '/../data/linux_df_tp_edge_cases')); + $this->host->withCommand('df', ['-TPk'], $this->fixture('linux_df_tp_edge_cases')); $disk1 = new Disk(); $disk1->setDevice('/dev/vda1'); @@ -521,25 +513,19 @@ public function testGetDiskInfoZeroCapacityAndInvertedUsage(): void { } public function testGetDiskInfoNoCommandOutput(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willThrowException(new RuntimeException('No output for command "df -TPk"')); + $this->host->withCommand('df', ['-TPk'], '', 1, 'df: cannot read table of mounted file systems'); $this->assertEquals([], $this->os->getDiskInfo()); } public function testGetDiskInfoInvalidCommandOutput(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn('invalid_data'); + $this->host->withCommand('df', ['-TPk'], 'invalid_data'); $this->assertEquals([], $this->os->getDiskInfo()); } public function testGetDiskInfoSkipsMalformedLine(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn("a b 1 c 2\n/dev/sda1 ext4 10000000 2000000 8000000 20% /mnt/data\n"); + $this->host->withCommand('df', ['-TPk'], "a b 1 c 2\n/dev/sda1 ext4 10000000 2000000 8000000 20% /mnt/data\n"); $disk = new Disk(); $disk->setDevice('/dev/sda1'); @@ -553,9 +539,7 @@ public function testGetDiskInfoSkipsMalformedLine(): void { } public function testGetDiskInfoUnusualMountPoints(): void { - $this->os->method('executeCommand') - ->with('df -TPk') - ->willReturn(file_get_contents(__DIR__ . '/../data/linux_df_tp_mount_points')); + $this->host->withCommand('df', ['-TPk'], $this->fixture('linux_df_tp_mount_points')); // Only the first of these five used to be reported. The other four were // dropped by the mount point pattern, which silently removed the whole @@ -608,6 +592,78 @@ public function testGetDiskInfoUnusualMountPoints(): void { $this->assertEquals([$disk1, $disk2, $disk3, $disk4, $disk5], $this->os->getDiskInfo()); } + public function testGetDiskInfoKeepsMountsListedDespiteAnError(): void { + // df exits with 1 when a single mount point cannot be read, but still + // lists all the others. + $this->host->withCommand('df', ['-TPk'], "/dev/sda1 ext4 10000000 2000000 8000000 20% /mnt/data\n", 1, 'df: /run/user/1000/doc: Operation not permitted'); + + $disks = $this->os->getDiskInfo(); + + $this->assertCount(1, $disks); + $this->assertSame('/mnt/data', $disks[0]->getMount()); + } + + public function testGetDiskInfoReturnsTheMountsThatAnsweredBeforeATimeout(): void { + // A stale network mount: df is killed at its time limit, part way + // through a line that must not be read as a mount point. + $this->host->binaries[] = 'df'; + $this->host->commands['df -TPk'] = new CommandResult(137, "/dev/sda1 ext4 10000000 2000000 8000000 20% /mnt/data\n/dev/sdb1 ext4 10000000 2000000 8000000 20% /mnt/da", '', true); + $this->logger->expects($this->once()) + ->method('warning') + ->with($this->anything(), $this->callback(static fn (array $context): bool => $context['command'] === 'df -TPk' && $context['reason'] === 'timed out after 5 s')); + + $disks = $this->os->getDiskInfo(); + + $this->assertCount(1, $disks); + $this->assertSame('/mnt/data', $disks[0]->getMount()); + } + + public function testGetDiskInfoIsEmptyWhenDfTimesOutSilently(): void { + $this->host->binaries[] = 'df'; + $this->host->commands['df -TPk'] = new CommandResult(137, '', '', true); + $this->logger->expects($this->once())->method('warning'); + + $this->assertSame([], $this->os->getDiskInfo()); + } + + public function testGetDiskInfoWithoutCommands(): void { + $this->host->commandsAllowed = false; + $this->logger->expects($this->never())->method('warning'); + + $this->assertSame([], $this->os->getDiskInfo()); + } + + public function testSlowQuickCommandIsIgnored(): void { + $this->mockFiles([]); + $this->host->binaries[] = 'nproc'; + $this->host->commands['nproc'] = new CommandResult(137, "4\n", '', true); + $this->logger->expects($this->once())->method('warning'); + + $this->assertEquals(-1, $this->os->getCPU()->getThreads()); + } + + public function testParseDefaultGateways(): void { + $this->assertSame( + ['192.0.2.1', '198.51.100.1'], + Linux::parseDefaultGateways($this->fixture('linux_ip_route_default')), + ); + $this->assertSame([], Linux::parseDefaultGateways("10.0.0.0/8 via 10.1.1.1 dev eth1\n")); + $this->assertSame([], Linux::parseDefaultGateways('')); + } + + public function testGetNetworkInfoReadsTheGatewayFromIpRoute(): void { + $this->mockFiles([]); + $this->host->withCommand('ip', ['route', 'show', 'default'], $this->fixture('linux_ip_route_default')); + + $this->assertSame('192.0.2.1, 198.51.100.1', $this->os->getNetworkInfo()['gateway']); + } + + public function testGetNetworkInfoWithoutIp(): void { + $this->mockFiles([]); + + $this->assertSame('', $this->os->getNetworkInfo()['gateway']); + } + public function testSupported(): void { $this->assertTrue($this->os->supported()); } diff --git a/tests/lib/OsStatus/OsNameTest.php b/tests/lib/OsStatus/OsNameTest.php index e7c53ba9..8ce8b482 100644 --- a/tests/lib/OsStatus/OsNameTest.php +++ b/tests/lib/OsStatus/OsNameTest.php @@ -12,13 +12,14 @@ use OCA\ServerInfo\Os; use OCP\AppFramework\Services\IAppConfig; use OCP\IConfig; +use Psr\Log\LoggerInterface; class OsNameTest extends \Test\TestCase { private function osName(FakeHost $host, bool $restricted = false): string { $config = $this->createMock(IConfig::class); $config->method('getAppValue')->willReturn($restricted ? 'yes' : 'no'); - return (new Os($config, $this->createMock(IAppConfig::class), $host))->getOSName(); + return (new Os($config, $this->createMock(IAppConfig::class), $host, $this->createMock(LoggerInterface::class)))->getOSName(); } public function testNamesTheDistributionThroughTheHost(): void { diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index d81812ba..d74f51e3 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -129,19 +129,12 @@ - - - - - - -