From 2e67b94795f0fb2ab1de3ce69b5e1b204893123f Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 10:04:18 +0000 Subject: [PATCH 1/2] feat: add directory-level disk usage stats This adds three new methods to Statter: - DiskUsage: Returns actual disk usage of a directory tree (like du -sh) using stat.Blocks for accurate block-level accounting on Unix. Handles hard links by tracking inodes to avoid double-counting. - DiskUsageWithTotal: Same as DiskUsage but includes the filesystem total capacity from statfs/GetDiskFreeSpaceEx. - DiskUsageSimple: A simpler/faster version that uses file sizes instead of blocks. Useful when accuracy for sparse files is not needed. These methods are useful in containerized environments where you need to track usage of specific directories (e.g., /home, /var/lib/docker) rather than the entire filesystem. Fixes #32 --- disk.go | 27 -------- disk_unix.go | 155 +++++++++++++++++++++++++++++++++++++++++++++ disk_usage_test.go | 131 ++++++++++++++++++++++++++++++++++++++ disk_windows.go | 93 +++++++++++++++++++++++++++ 4 files changed, 379 insertions(+), 27 deletions(-) delete mode 100644 disk.go create mode 100644 disk_unix.go create mode 100644 disk_usage_test.go diff --git a/disk.go b/disk.go deleted file mode 100644 index de79fe8..0000000 --- a/disk.go +++ /dev/null @@ -1,27 +0,0 @@ -//go:build !windows - -package clistat - -import ( - "syscall" - - "tailscale.com/types/ptr" -) - -// Disk returns the disk usage of the given path. -// If path is empty, it returns the usage of the root directory. -func (*Statter) Disk(p Prefix, path string) (*Result, error) { - if path == "" { - path = "/" - } - var stat syscall.Statfs_t - if err := syscall.Statfs(path, &stat); err != nil { - return nil, err - } - var r Result - r.Total = ptr.To(float64(stat.Blocks * uint64(stat.Bsize))) - r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) - r.Unit = "B" - r.Prefix = p - return &r, nil -} diff --git a/disk_unix.go b/disk_unix.go new file mode 100644 index 0000000..ea9ccb5 --- /dev/null +++ b/disk_unix.go @@ -0,0 +1,155 @@ +//go:build !windows + +package clistat + +import ( + "io/fs" + "path/filepath" + "syscall" + + "tailscale.com/types/ptr" +) + +// Disk returns the disk usage of the given path at the filesystem level. +// If path is empty, it returns the usage of the root directory. +func (*Statter) Disk(p Prefix, path string) (*Result, error) { + if path == "" { + path = "/" + } + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return nil, err + } + var r Result + r.Total = ptr.To(float64(stat.Blocks * uint64(stat.Bsize))) + r.Used = float64(stat.Blocks-stat.Bfree) * float64(stat.Bsize) + r.Unit = "B" + r.Prefix = p + return &r, nil +} + +// DiskUsage returns the actual disk usage of a directory tree, +// similar to "du -sh". This is useful in containerized environments +// where you want to track usage of specific directories rather than +// the entire filesystem. +// +// Unlike Disk(), which uses statfs to get filesystem-level usage, +// DiskUsage walks the directory tree and sums up file sizes. +// +// Note: This operation can be expensive for large directory trees +// with many small files. Consider using appropriate refresh intervals. +func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { + if path == "" { + path = "/" + } + + var totalSize int64 + // Track visited inodes to avoid double-counting hard links + visited := make(map[uint64]struct{}) + + err := filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { + if err != nil { + // Skip files/directories we can't access + return nil + } + + // Skip directories themselves, we only count file sizes + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + + // Get the underlying syscall.Stat_t to check for hard links + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + // Skip if we've already counted this inode (hard link) + if _, seen := visited[stat.Ino]; seen { + return nil + } + visited[stat.Ino] = struct{}{} + // Use actual disk blocks allocated (accounts for sparse files) + totalSize += stat.Blocks * 512 // Blocks are always 512-byte units + } else { + // Fallback to reported size if we can't get block info + totalSize += info.Size() + } + + return nil + }) + if err != nil { + return nil, err + } + + return &Result{ + Used: float64(totalSize), + Total: nil, // Directory usage doesn't have a "total" concept + Unit: "B", + Prefix: p, + }, nil +} + +// DiskUsageWithTotal returns the actual disk usage of a directory tree +// along with the total filesystem capacity. This combines DiskUsage +// with filesystem-level total from Disk. +func (s *Statter) DiskUsageWithTotal(p Prefix, path string) (*Result, error) { + if path == "" { + path = "/" + } + + usage, err := s.DiskUsage(p, path) + if err != nil { + return nil, err + } + + // Get the filesystem total for the path + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + // Return usage without total if we can't get fs stats + return usage, nil + } + + usage.Total = ptr.To(float64(stat.Blocks * uint64(stat.Bsize))) + return usage, nil +} + +// DiskUsageSimple returns the actual disk usage of a directory tree +// using only file sizes (not disk blocks). This is faster but less +// accurate for sparse files. +func (*Statter) DiskUsageSimple(p Prefix, path string) (*Result, error) { + if path == "" { + path = "/" + } + + var totalSize int64 + + err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + + totalSize += info.Size() + return nil + }) + if err != nil { + return nil, err + } + + return &Result{ + Used: float64(totalSize), + Total: nil, + Unit: "B", + Prefix: p, + }, nil +} diff --git a/disk_usage_test.go b/disk_usage_test.go new file mode 100644 index 0000000..9eff244 --- /dev/null +++ b/disk_usage_test.go @@ -0,0 +1,131 @@ +package clistat_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/coder/clistat" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiskUsage(t *testing.T) { + t.Parallel() + + s, err := clistat.New() + require.NoError(t, err) + + t.Run("EmptyDirectory", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + result, err := s.DiskUsage(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, float64(0), result.Used) + assert.Nil(t, result.Total) + assert.Equal(t, "B", result.Unit) + }) + + t.Run("DirectoryWithFiles", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + + // Create some test files with known sizes + file1 := filepath.Join(tmpDir, "file1.txt") + file2 := filepath.Join(tmpDir, "file2.txt") + subDir := filepath.Join(tmpDir, "subdir") + file3 := filepath.Join(subDir, "file3.txt") + + require.NoError(t, os.WriteFile(file1, make([]byte, 1024), 0o644)) + require.NoError(t, os.WriteFile(file2, make([]byte, 2048), 0o644)) + require.NoError(t, os.MkdirAll(subDir, 0o755)) + require.NoError(t, os.WriteFile(file3, make([]byte, 4096), 0o644)) + + result, err := s.DiskUsage(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + // The exact size might vary due to block allocation, + // but it should be at least the sum of file sizes + assert.GreaterOrEqual(t, result.Used, float64(1024+2048+4096)) + assert.Nil(t, result.Total) + assert.Equal(t, "B", result.Unit) + }) + + t.Run("WithPrefix", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(file, make([]byte, 1024), 0o644)) + + result, err := s.DiskUsage(clistat.PrefixKibi, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, clistat.Prefix(clistat.PrefixKibi), result.Prefix) + // String representation should use KiB + str := result.String() + assert.Contains(t, str, "KiB") + }) + + t.Run("NonExistentPath", func(t *testing.T) { + t.Parallel() + + result, err := s.DiskUsage(clistat.PrefixDefault, "/nonexistent/path/that/does/not/exist") + // WalkDir returns an error if the root path doesn't exist + // The behavior depends on the OS - it may return error or empty result + if err == nil { + // If no error, used should be 0 for non-existent path + assert.Equal(t, float64(0), result.Used) + } + }) +} + +func TestDiskUsageWithTotal(t *testing.T) { + t.Parallel() + + s, err := clistat.New() + require.NoError(t, err) + + t.Run("IncludesFilesystemTotal", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(file, make([]byte, 1024), 0o644)) + + result, err := s.DiskUsageWithTotal(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + assert.GreaterOrEqual(t, result.Used, float64(1024)) + // Should have a Total from the filesystem + assert.NotNil(t, result.Total) + assert.Greater(t, *result.Total, float64(0)) + assert.Equal(t, "B", result.Unit) + }) +} + +func TestDiskUsageSimple(t *testing.T) { + t.Parallel() + + s, err := clistat.New() + require.NoError(t, err) + + t.Run("UsesFileSizes", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + file := filepath.Join(tmpDir, "file.txt") + // Write exactly 1000 bytes to test that Simple uses file size, not blocks + require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o644)) + + result, err := s.DiskUsageSimple(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + // Simple version should report exactly the file size + assert.Equal(t, float64(1000), result.Used) + }) +} diff --git a/disk_windows.go b/disk_windows.go index fb7a64d..a17eb51 100644 --- a/disk_windows.go +++ b/disk_windows.go @@ -1,6 +1,9 @@ package clistat import ( + "io/fs" + "path/filepath" + "golang.org/x/sys/windows" "tailscale.com/types/ptr" ) @@ -34,3 +37,93 @@ func (*Statter) Disk(p Prefix, path string) (*Result, error) { r.Prefix = p return &r, nil } + +// DiskUsage returns the actual disk usage of a directory tree, +// similar to "du -sh". This is useful in containerized environments +// where you want to track usage of specific directories rather than +// the entire filesystem. +// +// Unlike Disk(), which uses GetDiskFreeSpaceEx to get filesystem-level usage, +// DiskUsage walks the directory tree and sums up file sizes. +// +// Note: This operation can be expensive for large directory trees +// with many small files. Consider using appropriate refresh intervals. +func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { + if path == "" { + path = `C:\` + } + + var totalSize int64 + + err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { + if err != nil { + // Skip files/directories we can't access + return nil + } + + // Skip directories themselves, we only count file sizes + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + + totalSize += info.Size() + return nil + }) + if err != nil { + return nil, err + } + + return &Result{ + Used: float64(totalSize), + Total: nil, // Directory usage doesn't have a "total" concept + Unit: "B", + Prefix: p, + }, nil +} + +// DiskUsageWithTotal returns the actual disk usage of a directory tree +// along with the total filesystem capacity. This combines DiskUsage +// with filesystem-level total from Disk. +func (s *Statter) DiskUsageWithTotal(p Prefix, path string) (*Result, error) { + if path == "" { + path = `C:\` + } + + usage, err := s.DiskUsage(p, path) + if err != nil { + return nil, err + } + + // Get the filesystem total for the path + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return usage, nil + } + + var freeBytes, totalBytes, availBytes uint64 + if err := windows.GetDiskFreeSpaceEx( + pathPtr, + &freeBytes, + &totalBytes, + &availBytes, + ); err != nil { + // Return usage without total if we can't get fs stats + return usage, nil + } + + usage.Total = ptr.To(float64(totalBytes)) + return usage, nil +} + +// DiskUsageSimple is identical to DiskUsage on Windows. +// On Unix systems, DiskUsage uses disk blocks for accuracy +// while DiskUsageSimple uses file sizes. On Windows, both +// use file sizes. +func (s *Statter) DiskUsageSimple(p Prefix, path string) (*Result, error) { + return s.DiskUsage(p, path) +} From 54e31507b9a3841b5500dd7592df283be53499b3 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 11:19:51 +0000 Subject: [PATCH 2/2] fix: address review feedback - Skip symlinks to avoid following links outside directory or loops - Use (Dev, Ino) tuple for hard link deduplication across filesystems - Add comprehensive tests for symlinks, hard links, and permission errors - Improve documentation with details on edge case handling --- disk_unix.go | 46 +++++++++++-- disk_usage_test.go | 168 ++++++++++++++++++++++++++++++++++++++++++++- disk_windows.go | 13 ++++ 3 files changed, 217 insertions(+), 10 deletions(-) diff --git a/disk_unix.go b/disk_unix.go index ea9ccb5..b5c55ca 100644 --- a/disk_unix.go +++ b/disk_unix.go @@ -28,6 +28,15 @@ func (*Statter) Disk(p Prefix, path string) (*Result, error) { return &r, nil } +// devIno uniquely identifies a file across filesystems. +// Inodes are only unique within a single filesystem, so we need +// to include the device ID to handle directory trees that span +// multiple mount points. +type devIno struct { + Dev uint64 + Ino uint64 +} + // DiskUsage returns the actual disk usage of a directory tree, // similar to "du -sh". This is useful in containerized environments // where you want to track usage of specific directories rather than @@ -36,18 +45,27 @@ func (*Statter) Disk(p Prefix, path string) (*Result, error) { // Unlike Disk(), which uses statfs to get filesystem-level usage, // DiskUsage walks the directory tree and sums up file sizes. // +// Symlinks are not followed to avoid counting files outside the +// target directory and to prevent infinite loops from symlink cycles. +// +// Hard links are handled by tracking (device, inode) pairs to avoid +// double-counting files that have multiple directory entries. +// // Note: This operation can be expensive for large directory trees // with many small files. Consider using appropriate refresh intervals. +// Files that cannot be accessed (permission errors, etc.) are skipped +// silently. func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { if path == "" { path = "/" } var totalSize int64 - // Track visited inodes to avoid double-counting hard links - visited := make(map[uint64]struct{}) + // Track visited (device, inode) pairs to avoid double-counting hard links. + // We use both device and inode because inodes are only unique per-filesystem. + visited := make(map[devIno]struct{}) - err := filepath.WalkDir(path, func(filePath string, d fs.DirEntry, err error) error { + err := filepath.WalkDir(path, func(_ string, d fs.DirEntry, err error) error { if err != nil { // Skip files/directories we can't access return nil @@ -58,6 +76,14 @@ func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { return nil } + // Skip symlinks to avoid: + // 1. Counting files outside the target directory + // 2. Infinite loops from symlink cycles + // 3. Double-counting if symlink target is also in the tree + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + info, err := d.Info() if err != nil { return nil @@ -65,11 +91,12 @@ func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { // Get the underlying syscall.Stat_t to check for hard links if stat, ok := info.Sys().(*syscall.Stat_t); ok { - // Skip if we've already counted this inode (hard link) - if _, seen := visited[stat.Ino]; seen { + // Skip if we've already counted this (device, inode) pair (hard link) + key := devIno{Dev: uint64(stat.Dev), Ino: stat.Ino} + if _, seen := visited[key]; seen { return nil } - visited[stat.Ino] = struct{}{} + visited[key] = struct{}{} // Use actual disk blocks allocated (accounts for sparse files) totalSize += stat.Blocks * 512 // Blocks are always 512-byte units } else { @@ -117,7 +144,7 @@ func (s *Statter) DiskUsageWithTotal(p Prefix, path string) (*Result, error) { // DiskUsageSimple returns the actual disk usage of a directory tree // using only file sizes (not disk blocks). This is faster but less -// accurate for sparse files. +// accurate for sparse files. Symlinks are skipped. func (*Statter) DiskUsageSimple(p Prefix, path string) (*Result, error) { if path == "" { path = "/" @@ -134,6 +161,11 @@ func (*Statter) DiskUsageSimple(p Prefix, path string) (*Result, error) { return nil } + // Skip symlinks + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + info, err := d.Info() if err != nil { return nil diff --git a/disk_usage_test.go b/disk_usage_test.go index 9eff244..851c674 100644 --- a/disk_usage_test.go +++ b/disk_usage_test.go @@ -3,6 +3,7 @@ package clistat_test import ( "os" "path/filepath" + "runtime" "testing" "github.com/coder/clistat" @@ -75,13 +76,144 @@ func TestDiskUsage(t *testing.T) { t.Parallel() result, err := s.DiskUsage(clistat.PrefixDefault, "/nonexistent/path/that/does/not/exist") - // WalkDir returns an error if the root path doesn't exist - // The behavior depends on the OS - it may return error or empty result + // WalkDir may or may not return an error for non-existent root paths + // depending on OS. Either an error or zero usage is acceptable. if err == nil { - // If no error, used should be 0 for non-existent path + assert.NotNil(t, result) assert.Equal(t, float64(0), result.Used) } }) + + t.Run("SymlinksAreSkipped", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Symlink creation may require elevated privileges on Windows") + } + + tmpDir := t.TempDir() + + // Create a real file + realFile := filepath.Join(tmpDir, "real.txt") + require.NoError(t, os.WriteFile(realFile, make([]byte, 1024), 0o644)) + + // Create an external directory with a file + externalDir := t.TempDir() + externalFile := filepath.Join(externalDir, "external.txt") + require.NoError(t, os.WriteFile(externalFile, make([]byte, 5000), 0o644)) + + // Create a symlink to the external file (should be skipped) + symlink := filepath.Join(tmpDir, "link_to_external.txt") + require.NoError(t, os.Symlink(externalFile, symlink)) + + // Create a symlink to the real file (should also be skipped) + symlinkInternal := filepath.Join(tmpDir, "link_to_real.txt") + require.NoError(t, os.Symlink(realFile, symlinkInternal)) + + result, err := s.DiskUsage(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + + // Should only count the real file, not the symlinks or their targets + // The used space should be approximately 1024 bytes (the real file), + // not 1024 + 5000 + 1024 (if symlinks were followed) + // Using block size, so check it's less than what it would be with symlinks + assert.Less(t, result.Used, float64(5000), "Symlinks should not be followed") + }) + + t.Run("SymlinkLoopsDoNotCauseInfiniteLoop", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Symlink creation may require elevated privileges on Windows") + } + + tmpDir := t.TempDir() + + // Create a file + realFile := filepath.Join(tmpDir, "real.txt") + require.NoError(t, os.WriteFile(realFile, make([]byte, 512), 0o644)) + + // Create a symlink loop: dir/loop -> dir + loopLink := filepath.Join(tmpDir, "loop") + require.NoError(t, os.Symlink(tmpDir, loopLink)) + + // This should complete without hanging + result, err := s.DiskUsage(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Greater(t, result.Used, float64(0)) + }) + + t.Run("HardLinksCountedOnce", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Hard link behavior differs on Windows") + } + + tmpDir := t.TempDir() + + // Create a file + originalFile := filepath.Join(tmpDir, "original.txt") + require.NoError(t, os.WriteFile(originalFile, make([]byte, 4096), 0o644)) + + // Create hard links to the same file + hardLink1 := filepath.Join(tmpDir, "hardlink1.txt") + hardLink2 := filepath.Join(tmpDir, "hardlink2.txt") + require.NoError(t, os.Link(originalFile, hardLink1)) + require.NoError(t, os.Link(originalFile, hardLink2)) + + result, err := s.DiskUsage(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + + // With hard link deduplication, should count the file only once. + // The result should be around 4096 bytes (one block), not 12288 (3x). + // Account for block size variance + assert.Less(t, result.Used, float64(8192), "Hard links should be deduplicated") + }) + + t.Run("PermissionDeniedSkipped", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Permission handling differs on Windows") + } + + if os.Getuid() == 0 { + t.Skip("Test cannot run as root (root can read anything)") + } + + tmpDir := t.TempDir() + + // Create an accessible file + accessibleFile := filepath.Join(tmpDir, "accessible.txt") + require.NoError(t, os.WriteFile(accessibleFile, make([]byte, 1024), 0o644)) + + // Create an inaccessible subdirectory + inaccessibleDir := filepath.Join(tmpDir, "noaccess") + require.NoError(t, os.MkdirAll(inaccessibleDir, 0o755)) + inaccessibleFile := filepath.Join(inaccessibleDir, "secret.txt") + require.NoError(t, os.WriteFile(inaccessibleFile, make([]byte, 5000), 0o644)) + + // Remove read permission on the directory + require.NoError(t, os.Chmod(inaccessibleDir, 0o000)) + t.Cleanup(func() { + // Restore permissions for cleanup + _ = os.Chmod(inaccessibleDir, 0o755) + }) + + // Should not error, just skip the inaccessible directory + result, err := s.DiskUsage(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + + // Should have counted the accessible file but not the inaccessible one + assert.Greater(t, result.Used, float64(0)) + // If it counted everything, it would be at least 6000 bytes + assert.Less(t, result.Used, float64(5000), "Inaccessible files should be skipped") + }) } func TestDiskUsageWithTotal(t *testing.T) { @@ -128,4 +260,34 @@ func TestDiskUsageSimple(t *testing.T) { // Simple version should report exactly the file size assert.Equal(t, float64(1000), result.Used) }) + + t.Run("SymlinksAreSkipped", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Symlink creation may require elevated privileges on Windows") + } + + tmpDir := t.TempDir() + + // Create a real file + realFile := filepath.Join(tmpDir, "real.txt") + require.NoError(t, os.WriteFile(realFile, make([]byte, 500), 0o644)) + + // Create an external file + externalDir := t.TempDir() + externalFile := filepath.Join(externalDir, "external.txt") + require.NoError(t, os.WriteFile(externalFile, make([]byte, 10000), 0o644)) + + // Symlink to external file + symlink := filepath.Join(tmpDir, "link.txt") + require.NoError(t, os.Symlink(externalFile, symlink)) + + result, err := s.DiskUsageSimple(clistat.PrefixDefault, tmpDir) + require.NoError(t, err) + assert.NotNil(t, result) + + // Should only count the real file (500 bytes) + assert.Equal(t, float64(500), result.Used) + }) } diff --git a/disk_windows.go b/disk_windows.go index a17eb51..17856e2 100644 --- a/disk_windows.go +++ b/disk_windows.go @@ -46,8 +46,13 @@ func (*Statter) Disk(p Prefix, path string) (*Result, error) { // Unlike Disk(), which uses GetDiskFreeSpaceEx to get filesystem-level usage, // DiskUsage walks the directory tree and sums up file sizes. // +// Symlinks are not followed to avoid counting files outside the +// target directory and to prevent infinite loops from symlink cycles. +// // Note: This operation can be expensive for large directory trees // with many small files. Consider using appropriate refresh intervals. +// Files that cannot be accessed (permission errors, etc.) are skipped +// silently. func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { if path == "" { path = `C:\` @@ -66,6 +71,14 @@ func (*Statter) DiskUsage(p Prefix, path string) (*Result, error) { return nil } + // Skip symlinks to avoid: + // 1. Counting files outside the target directory + // 2. Infinite loops from symlink cycles + // 3. Double-counting if symlink target is also in the tree + if d.Type()&fs.ModeSymlink != 0 { + return nil + } + info, err := d.Info() if err != nil { return nil