diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 5880d45..8c491a8 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -14,7 +14,7 @@ | `android` | Android | implemented | | `global` | Global Caches | implemented | | `llm` | LLM Model Stores | implemented | -| `docker` | Docker | planned | +| `docker` | Docker | implemented (scan only; prune deferred) | **Dedup attribution**: project ecosystems (node, rust, ruby, python, go, flutter, android) share a single-pass scan — a directory matching artifact rules of several active ecosystems is reported once, attributed to the first in scanner order (node → rust → ruby → python → go → flutter → android), so `--eco` subsets can shift attribution (a shared `coverage/` goes to node in a full scan, to ruby under `--eco ruby`). @@ -184,6 +184,23 @@ - `Archives` is `caution` because losing an archive means losing the ability to symbolicate crash reports for that release. - `CoreSimulator/Devices` is `caution` because it contains app installs, settings, and user data inside simulators currently in use. +## Docker + +**Detection**: a fixed path — the default Docker Desktop VM disk image `~/Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw`. A stat-based scanner (like Xcode / Global Caches), not a project-tree walk. A non-default Docker data root is not tracked; only the default location is scanned. + +**Scope rule**: reported only when the path is the same as, or a descendant of, the scan root (default `~`). Narrowing the root excludes it. + +**Artifacts**: + +| Path (relative to home) | Category | Safety | Description | +|-------------------------|----------|--------|-------------| +| `Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw` | runtime | protected | Docker Desktop VM disk image — holds every image, container and volume | + +**Notes**: +- **Protected, never path-deleted.** The image is a single sparse file holding all Docker state; deleting it destroys every image, container and volume. It is reported as `protected` so the cleaner refuses to remove it (`Protected: true`). +- **Sparse-aware sizing.** `Size` is the real on-disk usage (allocated blocks, `st_blocks×512`); `ApparentSize` is the image's declared size. A 460G-declared image reads as its real ~8G on disk — the shared sparse-aware `Measure` handles this with no Docker-specific code. +- **Reclaiming space is deferred.** Space inside the image is reclaimed by Docker's own prune (`docker system prune`), which is destructive (removes images/containers/volumes/build cache). That vendor cleanup is gated behind a future `--include-destructive` flag and is **not** wired up yet — this scanner reports the image and its real footprint only. + ## Global Caches Shared, home-rooted developer caches that are not tied to any single project. Unlike the per-project scanners, paths are fixed (home-relative) and span package managers and dev tools across ecosystems. Paths owned by a dedicated scanner (Xcode's DerivedData, DeviceSupport, Archives, CoreSimulator) are intentionally excluded to avoid double-counting. diff --git a/internal/scanner/docker.go b/internal/scanner/docker.go new file mode 100644 index 0000000..198719e --- /dev/null +++ b/internal/scanner/docker.go @@ -0,0 +1,103 @@ +package scanner + +import ( + "context" + "os" + "path/filepath" + + "github.com/ohing504/devclean/internal/model" +) + +// dockerArtifact is one fixed, home-relative Docker path plus how to classify +// it. Docker keeps every image, container and volume inside a single sparse +// disk image, so the file itself is never a deletion target — reclaiming space +// means pruning from inside Docker, not removing the file. +type dockerArtifact struct { + relPath string + category model.Category + safety model.SafetyLevel + label string + reason string + rec string +} + +// dockerArtifacts are the Docker Desktop disk images scanned on this machine. +// The path is the default Docker Desktop location; a non-default DOCKER data +// root is not tracked (only the default is scanned), mirroring how the Flutter +// scanner tracks only the default ~/.pub-cache. +var dockerArtifacts = []dockerArtifact{ + { + relPath: "Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw", + category: model.CatRuntime, + safety: model.SafetyProtected, + label: "Docker Desktop VM disk image", + reason: "holds every Docker image, container and volume in one sparse file — deleting it destroys all Docker state", + rec: "reclaim from inside Docker (`docker system prune`), not by deleting this file; disk shows real usage, apparent shows the sparse image's declared size", + }, +} + +// DockerScanner reports Docker Desktop disk images. It is a stat-based scanner +// (fixed paths, no project tree walk) like xcode and global. The disk image is +// reported as protected: it must never be path-deleted, and its space is +// reclaimed by Docker's own prune commands (a future --include-destructive +// vendor cleanup), not by this tool removing the file. +type DockerScanner struct{} + +// NewDockerScanner constructs a Docker scanner. +func NewDockerScanner() *DockerScanner { return &DockerScanner{} } + +func (s *DockerScanner) Name() string { return "docker" } +func (s *DockerScanner) Ecosystem() model.Ecosystem { return model.EcoDocker } + +// Scan reports each Docker disk image that exists and falls under the scan +// root. Unlike the global scanner these targets are files, not directories, so +// there is no IsDir gate — presence alone qualifies. Sizing is deferred to +// sizePending, which fills sparse-aware Size (allocated blocks) and ApparentSize +// (the image's declared size) so a 460G-declared image reads as its real ~8G. +func (s *DockerScanner) Scan(ctx context.Context, root string) ([]model.ScanResult, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, nil + } + + absRoot, err := filepath.Abs(root) + if err != nil { + absRoot = root + } + + var results []model.ScanResult + for _, a := range dockerArtifacts { + select { + case <-ctx.Done(): + return results, ctx.Err() + default: + } + + full := filepath.Join(home, a.relPath) + if !isUnderRoot(full, absRoot) { + continue + } + if _, err := os.Stat(full); err != nil { + continue // Docker not installed, or a non-default data root + } + + results = append(results, model.ScanResult{ + Path: full, + Ecosystem: model.EcoDocker, + Category: a.category, + LastMod: ModTime(full), + Safety: a.safety, + Protected: a.safety == model.SafetyProtected, + Reason: a.reason, + ProjectRoot: filepath.Dir(full), + Label: a.label, + Recommendation: a.rec, + }) + ReportProgress(ctx, len(results)) + } + + if err := sizePending(ctx, results); err != nil { + return results, err + } + return results, nil +} diff --git a/internal/scanner/docker_test.go b/internal/scanner/docker_test.go new file mode 100644 index 0000000..56410c5 --- /dev/null +++ b/internal/scanner/docker_test.go @@ -0,0 +1,141 @@ +package scanner_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/ohing504/devclean/internal/model" + "github.com/ohing504/devclean/internal/scanner" +) + +const dockerRawRel = "Library/Containers/com.docker.docker/Data/vms/0/data/Docker.raw" + +func TestDockerScanner_ReportsRawImageAsProtected(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + raw := filepath.Join(home, dockerRawRel) + mustMkdir(t, filepath.Dir(raw)) + mustWriteFile(t, raw, make([]byte, 4096)) + + results, err := scanner.NewDockerScanner().Scan(context.Background(), home) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result (Docker.raw), got %d", len(results)) + } + + r := results[0] + if r.Ecosystem != model.EcoDocker { + t.Errorf("expected ecosystem=docker, got %s", r.Ecosystem) + } + if r.Category != model.CatRuntime { + t.Errorf("expected category=runtime, got %s", r.Category) + } + if r.Safety != model.SafetyProtected { + t.Errorf("expected safety=protected, got %s", r.Safety) + } + if !r.Protected { + t.Error("Docker.raw must be marked Protected so the cleaner refuses to delete it") + } + if r.Reason == "" { + t.Error("protected result should carry a Reason") + } +} + +func TestDockerScanner_IgnoresMissingImage(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + results, err := scanner.NewDockerScanner().Scan(context.Background(), home) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results without Docker installed, got %d", len(results)) + } +} + +// TestDockerScanner_ScopedRootExcludesImage pins the scope rule: a --path scan +// of a home subdirectory that does not cover the Docker image must not surface +// it (isUnderRoot), matching the global scanner's home-cache behavior. +func TestDockerScanner_ScopedRootExcludesImage(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + raw := filepath.Join(home, dockerRawRel) + mustMkdir(t, filepath.Dir(raw)) + mustWriteFile(t, raw, make([]byte, 4096)) + + // Scan an unrelated subdirectory of home. + scoped := filepath.Join(home, "workspace") + mustMkdir(t, scoped) + + results, err := scanner.NewDockerScanner().Scan(context.Background(), scoped) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(results) != 0 { + t.Errorf("expected 0 results for a scoped root excluding the image, got %d", len(results)) + } +} + +// TestDockerScanner_SparseAwareSize pins that the scanner routes sizing through +// the sparse-aware measurer: a sparse image's on-disk Size stays below its +// declared ApparentSize. This is the whole reason Docker.raw reads as ~8G, not +// its 460G declared size. +func TestDockerScanner_SparseAwareSize(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + raw := filepath.Join(home, dockerRawRel) + mustMkdir(t, filepath.Dir(raw)) + + // Create a sparse file: a large declared size with almost no allocated + // blocks (truncate writes no data). If the host filesystem does not support + // sparse files, disk and apparent may match — assert non-strict so the test + // is portable, but require apparent to reflect the declared size. + const declared = 512 * 1024 * 1024 // 512 MiB + f, err := os.Create(raw) + if err != nil { + t.Fatalf("create: %v", err) + } + if err := f.Truncate(declared); err != nil { + t.Fatalf("truncate: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + results, err := scanner.NewDockerScanner().Scan(context.Background(), home) + if err != nil { + t.Fatalf("Scan error: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + + r := results[0] + if r.ApparentSize != declared { + t.Errorf("expected apparent size %d (declared), got %d", declared, r.ApparentSize) + } + if r.Size > r.ApparentSize { + t.Errorf("disk size %d must not exceed apparent size %d", r.Size, r.ApparentSize) + } +} + +func TestDockerScanner_NameAndEcosystem(t *testing.T) { + for _, s := range scanner.DefaultRegistry().All() { + if s.Name() != "docker" { + continue + } + if s.Ecosystem() != model.EcoDocker { + t.Errorf("expected ecosystem=docker, got %s", s.Ecosystem()) + } + return + } + t.Error(`expected a registered scanner named "docker"`) +} diff --git a/internal/scanner/registry_default.go b/internal/scanner/registry_default.go index f3296f4..a049fef 100644 --- a/internal/scanner/registry_default.go +++ b/internal/scanner/registry_default.go @@ -11,6 +11,7 @@ func DefaultRegistry() *Registry { reg.Register(newWalkScanner(flutterWalkEcosystem)) reg.Register(newWalkScanner(androidWalkEcosystem)) reg.Register(NewXcodeScanner()) + reg.Register(NewDockerScanner()) reg.Register(NewGlobalScanner()) reg.Register(NewLLMScanner()) return reg