From b89f28f0d02d9a772953e49869bf1b6088cdb4a6 Mon Sep 17 00:00:00 2001 From: Frank Karlitschek Date: Sat, 1 Aug 2026 19:58:34 +0200 Subject: [PATCH] fix(disk): report a filesystem with no capacity as 0% used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The used percentage is only computed when `df` reports a block count, so a filesystem with zero total blocks is listed at 0% instead of raising DivisionByZeroError. That is an Error, not a RuntimeException, so nothing in Os::getDiskData() caught it and the whole monitoring page and /api/v1/diskdata answered 500. busybox df lists such filesystems — an nsfs mount from snapd is enough — coreutils only with -a. Used is clamped at zero as well, so a filesystem reporting more available than total no longer produces a negative size and percentage. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Frank Karlitschek --- lib/OperatingSystems/FreeBSD.php | 14 ++++++---- lib/OperatingSystems/Linux.php | 15 +++++++---- tests/data/freebsd_df_tp_edge_cases | 4 +++ tests/data/linux_df_tp_edge_cases | 4 +++ tests/lib/FreeBSDTest.php | 34 ++++++++++++++++++++++++ tests/lib/LinuxTest.php | 40 ++++++++++++++++++++++++++--- tests/psalm-baseline.xml | 2 -- 7 files changed, 98 insertions(+), 15 deletions(-) create mode 100644 tests/data/freebsd_df_tp_edge_cases create mode 100644 tests/data/linux_df_tp_edge_cases diff --git a/lib/OperatingSystems/FreeBSD.php b/lib/OperatingSystems/FreeBSD.php index 624e3758..3c0f18fc 100644 --- a/lib/OperatingSystems/FreeBSD.php +++ b/lib/OperatingSystems/FreeBSD.php @@ -189,8 +189,8 @@ public function getDiskInfo(): array { $matches = []; // `df -P` prints one record per line with the mount point last, so the mount // point is everything after the capacity column. Used and Capacity are loose - // because nothing reads them and `df` prints "-" for filesystems without - // usage numbers. + // because nothing reads them and `df` prints "-" in those two columns when + // it has no usage numbers. $pattern = '/^(?\S+)[ \t]+(?\S+)[ \t]+(?\d+)[ \t]+(?\S+)[ \t]+(?\d+)[ \t]+(?\S+)[ \t]+(?\S.*?)[ \t\r]*$/m'; $result = preg_match_all($pattern, $disks, $matches); @@ -204,13 +204,17 @@ public function getDiskInfo(): array { continue; } + $blocks = (int)$matches['Blocks'][$i]; + $available = (int)$matches['Available'][$i]; + // Clamped: more available than total would give a negative size + $used = max(0, $blocks - $available); + $disk = new Disk(); $disk->setDevice($filesystem); $disk->setFs($matches['Type'][$i]); - $used = (int)((int)$matches['Blocks'][$i] - (int)$matches['Available'][$i]); $disk->setUsed((int)ceil($used / 1024)); - $disk->setAvailable((int)floor((int)$matches['Available'][$i] / 1024)); - $disk->setPercent(round(($used * 100 / (int)$matches['Blocks'][$i]), 2) . '%'); + $disk->setAvailable((int)floor($available / 1024)); + $disk->setPercent($blocks > 0 ? (string)round($used * 100 / $blocks, 2) . '%' : '0%'); $disk->setMount($matches['Mounted'][$i]); $data[] = $disk; diff --git a/lib/OperatingSystems/Linux.php b/lib/OperatingSystems/Linux.php index 16c4293a..d1a3c35b 100644 --- a/lib/OperatingSystems/Linux.php +++ b/lib/OperatingSystems/Linux.php @@ -208,8 +208,8 @@ public function getDiskInfo(): array { $matches = []; // `df -P` prints one record per line with the mount point last, so the mount // point is everything after the capacity column. Used and Capacity are loose - // because nothing reads them and `df` prints "-" for filesystems without - // usage numbers. + // because nothing reads them and `df` prints "-" in those two columns when + // it has no usage numbers. $pattern = '/^(?\S+)[ \t]+(?\S+)[ \t]+(?\d+)[ \t]+(?\S+)[ \t]+(?\d+)[ \t]+(?\S+)[ \t]+(?\S.*?)[ \t\r]*$/m'; $result = preg_match_all($pattern, $disks, $matches); @@ -224,13 +224,18 @@ public function getDiskInfo(): array { continue; } + $blocks = (int)$matches['Blocks'][$i]; + $available = (int)$matches['Available'][$i]; + // Clamped: more available than total would give a negative size + $used = max(0, $blocks - $available); + $disk = new Disk(); $disk->setDevice($filesystem); $disk->setFs($matches['Type'][$i]); - $used = (int)((int)$matches['Blocks'][$i] - (int)$matches['Available'][$i]); $disk->setUsed((int)ceil($used / 1024)); - $disk->setAvailable((int)floor((int)$matches['Available'][$i] / 1024)); - $disk->setPercent(round(($used * 100 / (int)$matches['Blocks'][$i]), 2) . '%'); + $disk->setAvailable((int)floor($available / 1024)); + // busybox df lists zero-block filesystems, coreutils does not + $disk->setPercent($blocks > 0 ? (string)round($used * 100 / $blocks, 2) . '%' : '0%'); $disk->setMount($matches['Mounted'][$i]); $data[] = $disk; diff --git a/tests/data/freebsd_df_tp_edge_cases b/tests/data/freebsd_df_tp_edge_cases new file mode 100644 index 00000000..d17364ee --- /dev/null +++ b/tests/data/freebsd_df_tp_edge_cases @@ -0,0 +1,4 @@ +Filesystem Type 1024-blocks Used Available Capacity Mounted on +/dev/gpt/rootfs ufs 56422560 6205468 47321220 12% / +empty nullfs 0 0 0 0% /mnt/empty +inverted zfs 1000 0 2000 0% /tank diff --git a/tests/data/linux_df_tp_edge_cases b/tests/data/linux_df_tp_edge_cases new file mode 100644 index 00000000..7708e1d6 --- /dev/null +++ b/tests/data/linux_df_tp_edge_cases @@ -0,0 +1,4 @@ +Filesystem Type 1024-blocks Used Available Capacity Mounted on +/dev/vda1 ext4 56422560 6205468 47321220 12% / +nsfs nsfs 0 0 0 0% /run/snapd/ns +inverted zfs 1000 0 2000 0% /tank diff --git a/tests/lib/FreeBSDTest.php b/tests/lib/FreeBSDTest.php index b89bdfe7..4ea26ab1 100644 --- a/tests/lib/FreeBSDTest.php +++ b/tests/lib/FreeBSDTest.php @@ -200,6 +200,40 @@ public function testGetNetworkInterfacesError(): void { $this->assertEquals($expected, $actual); } + public function testGetDiskInfoZeroCapacityAndInvertedUsage(): void { + $this->os->method('executeCommand') + ->with('df -TPk') + ->willReturn(file_get_contents(__DIR__ . '/../data/freebsd_df_tp_edge_cases')); + + $disk1 = new Disk(); + $disk1->setDevice('/dev/gpt/rootfs'); + $disk1->setFs('ufs'); + $disk1->setUsed(8889); + $disk1->setAvailable(46212); + $disk1->setPercent('16.13%'); + $disk1->setMount('/'); + + // Zero blocks: used to be a DivisionByZeroError + $disk2 = new Disk(); + $disk2->setDevice('empty'); + $disk2->setFs('nullfs'); + $disk2->setUsed(0); + $disk2->setAvailable(0); + $disk2->setPercent('0%'); + $disk2->setMount('/mnt/empty'); + + // More available than total: used is clamped + $disk3 = new Disk(); + $disk3->setDevice('inverted'); + $disk3->setFs('zfs'); + $disk3->setUsed(0); + $disk3->setAvailable(1); + $disk3->setPercent('0%'); + $disk3->setMount('/tank'); + + $this->assertEquals([$disk1, $disk2, $disk3], $this->os->getDiskInfo()); + } + public function testSupported(): void { $this->assertFalse($this->os->supported()); } diff --git a/tests/lib/LinuxTest.php b/tests/lib/LinuxTest.php index ae5f0b35..acfe1e5a 100644 --- a/tests/lib/LinuxTest.php +++ b/tests/lib/LinuxTest.php @@ -235,17 +235,51 @@ public function testGetDiskInfo(): void { $this->assertEquals([$disk1, $disk2, $disk3, $disk4, $disk5, $disk6, $disk7], $this->os->getDiskInfo()); } + public function testGetDiskInfoZeroCapacityAndInvertedUsage(): void { + $this->os->method('executeCommand') + ->with('df -TPk') + ->willReturn(file_get_contents(__DIR__ . '/../data/linux_df_tp_edge_cases')); + + $disk1 = new Disk(); + $disk1->setDevice('/dev/vda1'); + $disk1->setFs('ext4'); + $disk1->setUsed(8889); + $disk1->setAvailable(46212); + $disk1->setPercent('16.13%'); + $disk1->setMount('/'); + + // Zero blocks: used to be a DivisionByZeroError + $disk2 = new Disk(); + $disk2->setDevice('nsfs'); + $disk2->setFs('nsfs'); + $disk2->setUsed(0); + $disk2->setAvailable(0); + $disk2->setPercent('0%'); + $disk2->setMount('/run/snapd/ns'); + + // More available than total: used is clamped + $disk3 = new Disk(); + $disk3->setDevice('inverted'); + $disk3->setFs('zfs'); + $disk3->setUsed(0); + $disk3->setAvailable(1); + $disk3->setPercent('0%'); + $disk3->setMount('/tank'); + + $this->assertEquals([$disk1, $disk2, $disk3], $this->os->getDiskInfo()); + } + public function testGetDiskInfoNoCommandOutput(): void { $this->os->method('executeCommand') - ->with('df -TP') - ->willThrowException(new RuntimeException('No output for command "df -TP"')); + ->with('df -TPk') + ->willThrowException(new RuntimeException('No output for command "df -TPk"')); $this->assertEquals([], $this->os->getDiskInfo()); } public function testGetDiskInfoInvalidCommandOutput(): void { $this->os->method('executeCommand') - ->with('df -TP') + ->with('df -TPk') ->willReturn('invalid_data'); $this->assertEquals([], $this->os->getDiskInfo()); diff --git a/tests/psalm-baseline.xml b/tests/psalm-baseline.xml index 726ebdb3..442eafb1 100644 --- a/tests/psalm-baseline.xml +++ b/tests/psalm-baseline.xml @@ -52,7 +52,6 @@ - @@ -62,7 +61,6 @@ -