diff --git a/collector/filesystem_linux.go b/collector/filesystem_linux.go index 038ff7970b..5cb7aec617 100644 --- a/collector/filesystem_linux.go +++ b/collector/filesystem_linux.go @@ -144,14 +144,27 @@ func (c *filesystemCollector) processStat(labels filesystemLabels) filesystemSta return filesystemStats{ labels: labels, size: float64(buf.Blocks) * float64(buf.Bsize), - free: float64(buf.Bfree) * float64(buf.Bsize), - avail: float64(buf.Bavail) * float64(buf.Bsize), + free: blocksToBytes(buf.Bfree, buf.Bsize), + avail: blocksToBytes(buf.Bavail, buf.Bsize), files: float64(buf.Files), filesFree: float64(buf.Ffree), ro: ro, } } +// blocksToBytes converts a block count reported by statfs(2) to bytes. +// Some filesystems (e.g. ext3/ext4 when reserved blocks are exhausted) can +// report a negative free/available block count that overflows the kernel's +// unsigned f_bfree/f_bavail fields, wrapping to a value near 2^64. Treat any +// block count whose sign bit is set as a wrapped negative value and report +// zero instead of an enormous, misleading byte count. +func blocksToBytes(blocks uint64, bsize int64) float64 { + if int64(blocks) < 0 { + return 0 + } + return float64(blocks) * float64(bsize) +} + // stuckMountWatcher listens on the given success channel and if the channel closes // then the watcher does nothing. If instead the timeout is reached, the // mount point that is being watched is marked as stuck. diff --git a/collector/filesystem_linux_test.go b/collector/filesystem_linux_test.go index 9e8869016a..d44134fd8a 100644 --- a/collector/filesystem_linux_test.go +++ b/collector/filesystem_linux_test.go @@ -18,6 +18,7 @@ package collector import ( "io" "log/slog" + "math" "sort" "strings" "testing" @@ -288,6 +289,54 @@ func TestMountOptionsStringReadOnlyDetection(t *testing.T) { } } +func TestBlocksToBytes(t *testing.T) { + tests := []struct { + name string + blocks uint64 + bsize int64 + want float64 + }{ + { + name: "normal value", + blocks: 1000, + bsize: 4096, + want: 1000 * 4096, + }, + { + name: "zero blocks", + blocks: 0, + bsize: 4096, + want: 0, + }, + { + name: "negative block count wrapped into uint64 clamps to zero", + blocks: math.MaxUint64, // two's complement of -1, as reported when reserved blocks exceed free blocks + bsize: 4096, + want: 0, + }, + { + name: "smallest block count with sign bit set clamps to zero", + blocks: 1 << 63, + bsize: 4096, + want: 0, + }, + { + name: "largest block count without sign bit set is not clamped", + blocks: 1<<63 - 1, + bsize: 4096, + want: float64(1<<63-1) * 4096, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := blocksToBytes(tt.blocks, tt.bsize); got != tt.want { + t.Errorf("blocksToBytes(%d, %d) = %v, want %v", tt.blocks, tt.bsize, got, tt.want) + } + }) + } +} + func TestPathRootfs(t *testing.T) { if _, err := kingpin.CommandLine.Parse([]string{"--path.procfs", "./fixtures_bindmount/proc", "--path.rootfs", "/host"}); err != nil { t.Fatal(err)