package main import ( "fmt" "io/fs" "os" "strings" "testing" "time" ) const mb = 1 << 20 // megabytes writes a file of n MiB under the fake root. func (f *fake) megabytes(path string, n int) { f.file(path, strings.Repeat("x", n*mb)) } func aFullDisk(t *testing.T) *fake { f := newFake(t) f.megabytes("var/lib/docker/overlay2/a/layer", 8) f.megabytes("var/lib/docker/overlay2/b/layer", 4) f.megabytes("var/log/big.log", 3) f.megabytes("home/jo/video.mkv", 6) f.megabytes("home/jo/deep/er/est/file", 1) f.dir("empty") return f } func folders(t *testing.T, m *Machine, args map[string]any) FolderReport { t.Helper() out, err := tool(t, m, "disk_folders").Run(args) if err != nil { t.Fatal(err) } return out.(FolderReport) } func paths(r FolderReport) string { var p []string for _, f := range r.Largest { p = append(p, f.Path) } return strings.Join(p, ",") } // 1804/AC1: given a disk with folders of known sizes, when disk_folders runs with the defaults, then it answers // the largest folders to depth 2, biggest first, with bytes and GiB, and the total of the path. func TestFoldersAreRankedBiggestFirstToDepthTwoByDefault(t *testing.T) { r := folders(t, aFullDisk(t).machine(), nil) if got := paths(r); !strings.HasPrefix(got, "/var,/var/lib,") || !strings.Contains(got, "/home,/home/jo") { t.Fatalf("ranked %s", got) } if r.Path != "/" || r.Partial || r.Unreadable != 0 { t.Errorf("%+v", r) } top := r.Largest[0] if top.Path != "/var" || top.Depth != 1 || top.Bytes < 15*mb || top.Bytes > 16*mb || top.GiB <= 0 { t.Errorf("top %+v", top) } for _, f := range r.Largest { if f.Depth > 2 || f.Depth < 1 { t.Errorf("%+v is outside depth 2", f) } if strings.Contains(f.Path, "overlay2") { t.Errorf("%s is deeper than 2", f.Path) } } if r.Bytes < 22*mb || r.Bytes > 24*mb { t.Errorf("total %d", r.Bytes) } } // 1804/AC2: given a path, a depth and a limit, then only that folder is measured, depth is held at 3 and limit at 100. func TestFoldersTakeAPathADepthAndALimitHeldAtThreeAndAHundred(t *testing.T) { f := aFullDisk(t) r := folders(t, f.machine(), map[string]any{"path": "/var/lib/docker", "depth": 1.0}) if r.Path != "/var/lib/docker" || paths(r) != "/var/lib/docker/overlay2" { t.Errorf("%s: %s", r.Path, paths(r)) } r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0, "limit": 2.0}) if len(r.Largest) != 2 { t.Errorf("limit 2 gave %d", len(r.Largest)) } r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0}) if got := paths(r); !strings.Contains(got, "/home/jo/deep/er") || strings.Contains(got, "/home/jo/deep/er/est") { t.Errorf("depth 3 under /home is /home/jo/deep/er: %s", got) } tl := tool(t, f.machine(), "disk_folders") for _, bad := range []map[string]any{{"depth": 0.0}, {"limit": 0.0}, {"depth": "deep"}, {"path": "relative/dir"}, {"path": "/nope"}, {"path": "/var/log/big.log"}} { if _, err := tl.Run(bad); err == nil { t.Errorf("%v was accepted", bad) } } for i := 0; i < 120; i++ { f.file(fmt.Sprintf("many/d%03d/f", i), "x") } if r := folders(t, f.machine(), map[string]any{"path": "/many", "limit": 500.0}); len(r.Largest) != 100 { t.Errorf("limit held at %d, not 100", len(r.Largest)) } } // 1804/AC3: given a mount below the path, then it is never entered or counted, and is named. func TestFoldersNeverCrossIntoAnotherFilesystem(t *testing.T) { f := aFullDisk(t) m := f.machine() m.DevOf = func(path string, info fs.FileInfo) uint64 { if path == "/var/lib/docker" { return 7 } return 1 } r := folders(t, m, nil) if strings.Contains(paths(r), "docker") || len(r.Skipped) != 1 || r.Skipped[0] != "/var/lib/docker" { t.Errorf("%s, skipped %v", paths(r), r.Skipped) } if r.Bytes > 14*mb { t.Errorf("total %d includes the mount", r.Bytes) } } // 1804/AC4: given folders it cannot read, then they are counted and named and the rest is still answered. func TestUnreadableFoldersAreCountedAndNamed(t *testing.T) { f := aFullDisk(t) m := f.machine() real := os.ReadDir m.ReadDir = func(p string) ([]os.DirEntry, error) { if strings.HasSuffix(p, "/var/log") || strings.HasSuffix(p, "/overlay2/b") { return nil, fmt.Errorf("open %s: permission denied", p) } return real(p) } r := folders(t, m, map[string]any{"depth": 3.0}) if r.Unreadable != 2 || strings.Join(r.UnreadableFolders, ",") != "/var/lib/docker/overlay2/b,/var/log" { t.Errorf("%d: %v", r.Unreadable, r.UnreadableFolders) } if r.Partial || !strings.Contains(strings.Join(r.Said, "\n"), "2 folders could not be read") { t.Errorf("%+v", r) } } // 1804/AC5: given a walk that outlasts its bound, then it answers what it measured, partial, with where it stopped. func TestALongWalkAnswersPartialAndSaysWhereItStopped(t *testing.T) { f := aFullDisk(t) m := f.machine() now := time.Unix(0, 0) m.Clock = func() time.Time { now = now.Add(10 * time.Second); return now } r := folders(t, m, nil) if !r.Partial || r.StoppedAt == "" || len(r.Said) < 2 || !strings.HasPrefix(r.Said[0], "PARTIAL") || !strings.Contains(r.Said[0], r.StoppedAt) { t.Errorf("%+v", r) } } // 1804/AC6: a hard link is counted once, and the tool changes nothing. func TestAHardLinkIsCountedOnce(t *testing.T) { f := newFake(t) f.megabytes("a/big", 5) if err := os.Link(f.root+"/a/big", f.root+"/a/again"); err != nil { t.Skip(err) } r := folders(t, f.machine(), nil) if r.Bytes > 6*mb { t.Errorf("counted twice: %d", r.Bytes) } } // 1804/AC7: the bound sits below the 30 s the tool runner gives a call, so a partial answer reaches its caller. func TestTheWalkBoundIsBelowTheToolRunnersCallLimit(t *testing.T) { if FolderBudget >= 30*time.Second { t.Errorf("the budget %s is not below the 30 s a call gets", FolderBudget) } }