|
| 1 | +//go:build !windows |
| 2 | + |
| 3 | +package main |
| 4 | + |
| 5 | +import ( |
| 6 | + "errors" |
| 7 | + "os" |
| 8 | + "strings" |
| 9 | + "testing" |
| 10 | +) |
| 11 | + |
| 12 | +// Real `btrfs filesystem usage --raw` output captured from David's |
| 13 | +// ZimaOS box (Brookdale NAS, /dev/md0). Kept inline rather than read |
| 14 | +// from disk so the test is hermetic. Mirrors |
| 15 | +// docs/internal/research/test-fixtures/zimaos-btrfs-usage.txt. |
| 16 | +const fixtureBtrfsUsageRaw = `Overall: |
| 17 | + Device size: 2000263643136 |
| 18 | + Device allocated: 1968050339840 |
| 19 | + Device unallocated: 32213303296 |
| 20 | + Device missing: 0 |
| 21 | + Device slack: 0 |
| 22 | + Used: 1225996673024 |
| 23 | + Free (estimated): 769020321792 (min: 752913670144) |
| 24 | + Free (statfs, df): 769019273216 |
| 25 | + Data ratio: 1.00 |
| 26 | + Metadata ratio: 2.00 |
| 27 | + Global reserve: 536870912 (used: 0) |
| 28 | + Multiple profiles: no |
| 29 | +
|
| 30 | +Data,single: Size:1959599865856, Used:1222792847360 (62.40%) |
| 31 | + /dev/md0 1959599865856 |
| 32 | +
|
| 33 | +Metadata,DUP: Size:4216848384, Used:1601634304 (37.98%) |
| 34 | + /dev/md0 8433696768 |
| 35 | +
|
| 36 | +System,DUP: Size:8388608, Used:278528 (3.32%) |
| 37 | + /dev/md0 16777216 |
| 38 | +
|
| 39 | +Unallocated: |
| 40 | + /dev/md0 32213303296 |
| 41 | +` |
| 42 | + |
| 43 | +func TestParseBtrfsUsageRaw_RealFixture(t *testing.T) { |
| 44 | + usage, err := parseBtrfsUsageRaw([]byte(fixtureBtrfsUsageRaw)) |
| 45 | + if err != nil { |
| 46 | + t.Fatalf("expected success, got: %v", err) |
| 47 | + } |
| 48 | + |
| 49 | + const ( |
| 50 | + wantTotal = uint64(2000263643136) |
| 51 | + wantUsed = uint64(1225996673024) |
| 52 | + wantAvailable = uint64(769020321792) |
| 53 | + ) |
| 54 | + if usage.Total != wantTotal { |
| 55 | + t.Errorf("Total = %d, want %d", usage.Total, wantTotal) |
| 56 | + } |
| 57 | + if usage.Used != wantUsed { |
| 58 | + t.Errorf("Used = %d, want %d", usage.Used, wantUsed) |
| 59 | + } |
| 60 | + if usage.Available != wantAvailable { |
| 61 | + t.Errorf("Available = %d, want %d", usage.Available, wantAvailable) |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +// Regression: the per-block-group lines have "Used:" mid-line (e.g. |
| 66 | +// "Data,single: Size:N, Used:N (62.40%)"). The Overall: parser must |
| 67 | +// only match the bare-line Used:, not these. |
| 68 | +func TestParseBtrfsUsageRaw_InlineUsedRegression(t *testing.T) { |
| 69 | + // Strip the Overall: block to verify the parser does NOT pick up |
| 70 | + // the inline Used field as a substitute. |
| 71 | + overallEnd := strings.Index(fixtureBtrfsUsageRaw, "\nData,single:") |
| 72 | + if overallEnd < 0 { |
| 73 | + t.Fatal("test fixture malformed: missing Data,single section marker") |
| 74 | + } |
| 75 | + withoutOverall := fixtureBtrfsUsageRaw[overallEnd:] |
| 76 | + |
| 77 | + _, err := parseBtrfsUsageRaw([]byte(withoutOverall)) |
| 78 | + if err == nil { |
| 79 | + t.Fatal("expected parse error when Overall: block is missing, got nil") |
| 80 | + } |
| 81 | + if !errors.Is(err, errBtrfsParse) { |
| 82 | + t.Errorf("expected errBtrfsParse, got %v", err) |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +func TestParseBtrfsUsageRaw_MissingDeviceSize(t *testing.T) { |
| 87 | + input := `Overall: |
| 88 | + Used: 1225996673024 |
| 89 | +` |
| 90 | + _, err := parseBtrfsUsageRaw([]byte(input)) |
| 91 | + if !errors.Is(err, errBtrfsParse) { |
| 92 | + t.Errorf("expected errBtrfsParse, got %v", err) |
| 93 | + } |
| 94 | +} |
| 95 | + |
| 96 | +func TestParseBtrfsUsageRaw_MissingUsed(t *testing.T) { |
| 97 | + input := `Overall: |
| 98 | + Device size: 2000263643136 |
| 99 | +` |
| 100 | + _, err := parseBtrfsUsageRaw([]byte(input)) |
| 101 | + if !errors.Is(err, errBtrfsParse) { |
| 102 | + t.Errorf("expected errBtrfsParse, got %v", err) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +func TestParseBtrfsUsageRaw_EmptyInput(t *testing.T) { |
| 107 | + _, err := parseBtrfsUsageRaw([]byte("")) |
| 108 | + if !errors.Is(err, errBtrfsParse) { |
| 109 | + t.Errorf("expected errBtrfsParse, got %v", err) |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +func TestParseBtrfsUsageRaw_NonNumericTotal(t *testing.T) { |
| 114 | + input := `Overall: |
| 115 | + Device size: NOTANUMBER |
| 116 | + Used: 1225996673024 |
| 117 | +` |
| 118 | + // Regex requires \d+, so a non-numeric value won't even match the |
| 119 | + // capture group -- this tests that path through the error. |
| 120 | + _, err := parseBtrfsUsageRaw([]byte(input)) |
| 121 | + if !errors.Is(err, errBtrfsParse) { |
| 122 | + t.Errorf("expected errBtrfsParse, got %v", err) |
| 123 | + } |
| 124 | +} |
| 125 | + |
| 126 | +// Available falls back to Total - Used when Free (estimated) is missing. |
| 127 | +func TestParseBtrfsUsageRaw_AvailableFallback(t *testing.T) { |
| 128 | + input := `Overall: |
| 129 | + Device size: 1000 |
| 130 | + Used: 300 |
| 131 | +` |
| 132 | + usage, err := parseBtrfsUsageRaw([]byte(input)) |
| 133 | + if err != nil { |
| 134 | + t.Fatalf("unexpected error: %v", err) |
| 135 | + } |
| 136 | + if usage.Total != 1000 { |
| 137 | + t.Errorf("Total = %d, want 1000", usage.Total) |
| 138 | + } |
| 139 | + if usage.Used != 300 { |
| 140 | + t.Errorf("Used = %d, want 300", usage.Used) |
| 141 | + } |
| 142 | + if usage.Available != 700 { |
| 143 | + t.Errorf("Available = %d, want 700 (Total - Used fallback)", usage.Available) |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +// errBtrfsProgsMissing is returned when btrfs is not on PATH. We |
| 148 | +// simulate this by setting PATH to a directory we know doesn't have |
| 149 | +// btrfs. Skip if the test environment doesn't allow PATH manipulation |
| 150 | +// (very rare but possible). |
| 151 | +func TestTryBtrfsFallback_BinaryMissing(t *testing.T) { |
| 152 | + origPath := os.Getenv("PATH") |
| 153 | + t.Cleanup(func() { os.Setenv("PATH", origPath) }) |
| 154 | + |
| 155 | + // Empty PATH guarantees exec.LookPath fails for "btrfs". We don't |
| 156 | + // need /tmp to be free of a btrfs binary -- empty PATH is enough. |
| 157 | + if err := os.Setenv("PATH", ""); err != nil { |
| 158 | + t.Skipf("cannot set PATH for test: %v", err) |
| 159 | + } |
| 160 | + |
| 161 | + _, err := tryBtrfsFallback("/") |
| 162 | + if !errors.Is(err, errBtrfsProgsMissing) { |
| 163 | + t.Errorf("expected errBtrfsProgsMissing, got %v", err) |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +// Note on timeout testing: the timeout path requires a btrfs binary |
| 168 | +// that hangs longer than 5s. Constructing this hermetically would |
| 169 | +// require a test double that injects a fake runner via a package-level |
| 170 | +// hook. The current implementation uses exec.LookPath + exec.CommandContext |
| 171 | +// directly for clarity; if timeout flakiness is reported in production |
| 172 | +// we can refactor to inject a runner. For now the timeout sentinel is |
| 173 | +// covered by code review of the ctx.Err() == context.DeadlineExceeded |
| 174 | +// branch in tryBtrfsFallback. |
0 commit comments