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 @@ - - - - - - -