From 888587df7686cbd2af83fb4fda494aa0d12ebc7d Mon Sep 17 00:00:00 2001 From: Mina Sameh Date: Tue, 4 Aug 2026 14:26:04 +0300 Subject: [PATCH 1/3] fix(scripts): correct powersched vendor source path after repo move dscan is now a sibling of dotai instead of nested under it, shifting the relative path to the upstream powersched CLI source. --- desktop/scripts/sync-powersched.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/desktop/scripts/sync-powersched.sh b/desktop/scripts/sync-powersched.sh index 48965cf..b2515c2 100755 --- a/desktop/scripts/sync-powersched.sh +++ b/desktop/scripts/sync-powersched.sh @@ -3,13 +3,14 @@ # # The Schedule tab drives the powersched CLI, which is bundled at build time from # desktop/vendor/powersched. That vendored copy must be kept in sync with the -# canonical source in the dotai monorepo (../../../scripts/powersched). Run this -# whenever the CLI changes upstream. +# canonical source in the dotai monorepo (../../../dotai/scripts/powersched), +# a sibling repo since dscan moved out of dotai. Run this whenever the CLI +# changes upstream. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" dest="${here}/../vendor/powersched" -src="${POWERSCHED_SRC:-${here}/../../../scripts/powersched}" +src="${POWERSCHED_SRC:-${here}/../../../dotai/scripts/powersched}" if [[ ! -f "${src}/powersched" ]]; then echo "error: powersched source not found at ${src}" >&2 From f537dd02c94fb8a34a8d146a89c37d7fc57c8b2f Mon Sep 17 00:00:00 2001 From: Mina Sameh Date: Tue, 18 Aug 2026 18:44:25 +0300 Subject: [PATCH 2/3] feat(rules): refine coarse cache sweep into curated tiered catalog Remove the broad ~/Library/Caches and ~/.cache SAFE rows; replace with curated SAFE dev-cache subdirs and REVIEW (Trash, recoverable) app-cache rows. Add Entry.GuardProcess + guarded() so high-churn caches lock while their tool runs. Browser rows target cache subdirs only, never a profile root or cookies/history. --- internal/rules/catalog.go | 88 ++++++++++++++++++++++++-- internal/rules/catalog_breadth_test.go | 69 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 internal/rules/catalog_breadth_test.go diff --git a/internal/rules/catalog.go b/internal/rules/catalog.go index aa0356e..7c144e9 100644 --- a/internal/rules/catalog.go +++ b/internal/rules/catalog.go @@ -13,6 +13,7 @@ type Entry struct { Tier Tier Method CleanMethod Command []string + GuardProcess string // if non-empty and this process is running, entry is forced to Keep } // Expand resolves "~" to home. @@ -30,6 +31,12 @@ func entry(tmpl, label string, cat Category, tier Tier) Entry { return Entry{PathTemplate: tmpl, Label: label, Category: cat, Tier: tier} } +// guarded is entry() plus a GuardProcess: when that process is running the +// engine downgrades this entry to Keep (locked) for the duration. +func guarded(tmpl, label string, cat Category, tier Tier, proc string) Entry { + return Entry{PathTemplate: tmpl, Label: label, Category: cat, Tier: tier, GuardProcess: proc} +} + func cmd(tmpl, label string, cat Category, c ...string) Entry { return Entry{PathTemplate: tmpl, Label: label, Category: cat, Tier: Safe, Method: Command, Command: c} } @@ -44,7 +51,6 @@ func sharedEntries() []Entry { entry("~/.m2/repository", "Maven repo", PackageStores, Safe), entry("~/.cargo/registry", "Cargo registry", PackageStores, Safe), entry("~/.cache/go-build", "Go build cache", Caches, Safe), - entry("~/.cache", "Generic ~/.cache", Caches, Safe), entry("~/.bun/install/cache", "Bun cache", PackageStores, Safe), entry("~/.ssh", "SSH keys", AppData, Keep), } @@ -56,9 +62,8 @@ func Catalog(goos, home string) []Entry { switch goos { case "darwin": out = append(out, - entry("~/Library/Caches", "~/Library/Caches", Caches, Safe), entry("~/Library/Logs", "~/Library/Logs", Caches, Safe), - entry("~/Library/Developer/Xcode/DerivedData", "Xcode DerivedData", BuildArtifacts, Safe), + guarded("~/Library/Developer/Xcode/DerivedData", "Xcode DerivedData", BuildArtifacts, Safe, "Xcode"), entry("~/Library/Developer/Xcode/iOS DeviceSupport", "iOS DeviceSupport", BuildArtifacts, Safe), entry("~/Library/Developer/CoreSimulator/Caches", "Simulator caches", Simulators, Safe), cmd("simctl:unavailable", "Delete unavailable simulators", Simulators, "xcrun", "simctl", "delete", "unavailable"), @@ -68,16 +73,91 @@ func Catalog(goos, home string) []Entry { ) case "linux": out = append(out, - entry("~/.config/google-chrome/Default/Cache", "Chrome cache", Caches, Safe), + entry("~/.cache/google-chrome/Default/Cache", "Chrome cache", Caches, Review), cmd("brew:cleanup", "Homebrew cleanup", PackageStores, "brew", "cleanup", "-s"), entry("~/.local/share/keyrings", "Keyrings", AppData, Keep), ) } + out = append(out, devCaches(goos)...) + out = append(out, appCaches(goos)...) // home is accepted for symmetry/future use; entries are home-relative via Expand. _ = home return out } +// devCaches are regenerable developer tool caches (SAFE, hard-delete). Path rows +// only — no permanent command purges. High-churn caches carry a GuardProcess. +// These are specific subdirs; the coarse ~/Library/Caches and ~/.cache sweeps +// were removed so nothing here double-counts under a broad parent row. +func devCaches(goos string) []Entry { + switch goos { + case "darwin": + return []Entry{ + entry("~/Library/Developer/Xcode/watchOS DeviceSupport", "watchOS DeviceSupport", BuildArtifacts, Safe), + entry("~/Library/Developer/Xcode/tvOS DeviceSupport", "tvOS DeviceSupport", BuildArtifacts, Safe), + entry("~/Library/Developer/Xcode/Archives", "Xcode Archives", BuildArtifacts, Review), + entry("~/Library/Caches/go-build", "Go build cache", Caches, Safe), + entry("~/Library/Caches/Homebrew", "Homebrew cache", PackageStores, Safe), + entry("~/Library/Caches/Yarn", "Yarn cache", PackageStores, Safe), + entry("~/Library/Caches/CocoaPods", "CocoaPods cache", PackageStores, Safe), + entry("~/Library/Caches/org.swift.swiftpm", "Swift PM cache", PackageStores, Safe), + entry("~/Library/Caches/ms-playwright", "Playwright browsers", Caches, Safe), + entry("~/Library/Caches/pip", "pip cache", PackageStores, Safe), + entry("~/Library/Caches/uv", "uv cache", PackageStores, Safe), + entry("~/.node-gyp", "node-gyp headers", Caches, Safe), + entry("~/.cargo/git", "Cargo git cache", PackageStores, Safe), + } + case "linux": + return []Entry{ + entry("~/.cache/ms-playwright", "Playwright browsers", Caches, Safe), + entry("~/.cache/pip", "pip cache", PackageStores, Safe), + entry("~/.cache/uv", "uv cache", PackageStores, Safe), + entry("~/.node-gyp", "node-gyp headers", Caches, Safe), + entry("~/.cargo/git", "Cargo git cache", PackageStores, Safe), + } + } + return nil +} + +// appCaches are third-party app caches (REVIEW, Trash — recoverable). Browser +// entries target cache sub-dirs only, never a profile root or cookies/history. +func appCaches(goos string) []Entry { + switch goos { + case "darwin": + c := func(id, label string) Entry { + return entry("~/Library/Caches/"+id, label, Caches, Review) + } + chrome := "~/Library/Application Support/Google/Chrome/Default/" + return []Entry{ + c("com.spotify.client", "Spotify cache"), + c("com.tinyspeck.slackmacgap", "Slack cache"), + c("com.microsoft.teams2", "Teams cache"), + c("us.zoom.xos", "Zoom cache"), + c("com.hnc.Discord", "Discord cache"), + c("com.figma.Desktop", "Figma cache"), + c("md.obsidian", "Obsidian cache"), + c("notion.id", "Notion cache"), + c("com.anthropic.claudefordesktop", "Claude cache"), + c("com.openai.chat", "ChatGPT cache"), + entry("~/Library/Caches/Google/Chrome", "Chrome disk cache", Caches, Review), + entry(chrome+"Code Cache", "Chrome code cache", Caches, Review), + entry(chrome+"GPUCache", "Chrome GPU cache", Caches, Review), + entry(chrome+"Service Worker/CacheStorage", "Chrome service-worker cache", Caches, Review), + } + case "linux": + c := func(name, label string) Entry { + return entry("~/.cache/"+name, label, Caches, Review) + } + return []Entry{ + c("spotify", "Spotify cache"), + c("Slack", "Slack cache"), + c("discord", "Discord cache"), + c("obsidian", "Obsidian cache"), + } + } + return nil +} + // ClassifyHeuristic classifies a path discovered by the heuristic walk that the // catalog did not already cover. Default: user data, treated as a large file. func ClassifyHeuristic(path string) Item { diff --git a/internal/rules/catalog_breadth_test.go b/internal/rules/catalog_breadth_test.go new file mode 100644 index 0000000..87a5d9a --- /dev/null +++ b/internal/rules/catalog_breadth_test.go @@ -0,0 +1,69 @@ +package rules + +import ( + "strings" + "testing" +) + +func TestGuardedEntry(t *testing.T) { + e := guarded("~/Library/Developer/Xcode/DerivedData", "Xcode DerivedData", BuildArtifacts, Safe, "Xcode") + if e.GuardProcess != "Xcode" { + t.Errorf("GuardProcess = %q, want Xcode", e.GuardProcess) + } + if e.Tier != Safe || e.Category != BuildArtifacts || e.PathTemplate == "" { + t.Errorf("bad entry: %+v", e) + } + if (Entry{}).GuardProcess != "" { + t.Error("zero Entry must have empty GuardProcess") + } +} + +func TestCatalogBreadth(t *testing.T) { + for _, goos := range []string{"darwin", "linux"} { + cat := Catalog(goos, "/Users/me") + byLabel := map[string]Entry{} + for _, e := range cat { + byLabel[e.Label] = e + } + + // Guarded DerivedData present on darwin, SAFE + guard. + if goos == "darwin" { + dd, ok := byLabel["Xcode DerivedData"] + if !ok || dd.Tier != Safe || dd.GuardProcess != "Xcode" { + t.Errorf("[%s] DerivedData: %+v ok=%v", goos, dd, ok) + } + if sp, ok := byLabel["Spotify cache"]; !ok || sp.Tier != Review { + t.Errorf("[%s] Spotify cache: %+v ok=%v", goos, sp, ok) + } + } + + // Coarse parent sweeps removed. + for _, e := range cat { + if e.PathTemplate == "~/Library/Caches" || e.PathTemplate == "~/.cache" { + t.Errorf("[%s] coarse parent row %q should be removed", goos, e.PathTemplate) + } + } + + // Denylist: no row targets a browser profile-root sensitive file. + deny := []string{"Cookies", "Login Data", "History", "Bookmarks", "Local Storage", "Local State"} + for _, e := range cat { + for _, d := range deny { + if strings.HasSuffix(e.PathTemplate, "/"+d) { + t.Errorf("[%s] row %q points at sensitive path %q", goos, e.Label, e.PathTemplate) + } + } + } + + // No duplicate PathTemplate (ignoring command tokens). + seen := map[string]bool{} + for _, e := range cat { + if e.Method == Command { + continue + } + if seen[e.PathTemplate] { + t.Errorf("[%s] duplicate path %q", goos, e.PathTemplate) + } + seen[e.PathTemplate] = true + } + } +} From ed9a9c07b3be5254ae57bcfd76e31638a6136f14 Mon Sep 17 00:00:00 2001 From: Mina Sameh Date: Tue, 18 Aug 2026 18:44:25 +0300 Subject: [PATCH 3/3] feat(engine): running-process guard + descendant suppression in ScanAll One ps snapshot per scan (fail-open); guarded entries downgrade to Keep while their process runs. Skip a catalog path nested under an already covered path so no row double-counts. --- internal/engine/engine.go | 8 ++-- internal/engine/procs.go | 69 ++++++++++++++++++++++++++++ internal/engine/procs_test.go | 85 +++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 internal/engine/procs.go create mode 100644 internal/engine/procs_test.go diff --git a/internal/engine/engine.go b/internal/engine/engine.go index b10f386..d307dac 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -18,6 +18,7 @@ import ( func ScanAll(goos, home string, system bool, onItem func(rules.Item), cancel <-chan struct{}, excludes []string) []rules.Item { var items []rules.Item covered := map[string]bool{} + procs := procSnapshot() for _, e := range rules.Catalog(goos, home) { if canceled(cancel) { break @@ -34,7 +35,7 @@ func ScanAll(goos, home string, system bool, onItem func(rules.Item), cancel <-c continue } path := e.Expand(home) - if scan.IsExcluded(path, excludes) { + if scan.IsExcluded(path, excludes) || isDescendant(path, covered) { continue } size, _ := scan.DirSizeCancel(path, cancel) @@ -42,9 +43,10 @@ func ScanAll(goos, home string, system bool, onItem func(rules.Item), cancel <-c continue } covered[path] = true + tier, label := guardedTier(e, procs) it := rules.Item{ - Path: path, Label: e.Label, Bytes: size, - Category: e.Category, Tier: e.Tier, Method: e.Method, Source: rules.CatalogSource, + Path: path, Label: label, Bytes: size, + Category: e.Category, Tier: tier, Method: e.Method, Source: rules.CatalogSource, } items = append(items, it) if onItem != nil { diff --git a/internal/engine/procs.go b/internal/engine/procs.go new file mode 100644 index 0000000..5ca3029 --- /dev/null +++ b/internal/engine/procs.go @@ -0,0 +1,69 @@ +package engine + +import ( + "os/exec" + "path/filepath" + "strings" + + "github.com/gor3a/disk-scan/internal/rules" +) + +// procSnapshot is a seam: tests override it to inject a fake process set. +var procSnapshot = runningProcs + +// runningProcs returns a set of lowercased process basenames from one `ps` +// snapshot. On any error it returns an empty map (fail-open: the guard is a +// convenience, never blocks a scan). `ps -Ao comm=` works on macOS and Linux. +func runningProcs() map[string]bool { + out, err := exec.Command("ps", "-Ao", "comm=").Output() + if err != nil { + return map[string]bool{} + } + return parseProcs(out) +} + +// parseProcs turns `ps comm=` output into a set of lowercased basenames. +func parseProcs(out []byte) map[string]bool { + procs := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + procs[strings.ToLower(filepath.Base(line))] = true + } + return procs +} + +// isGuarded reports whether any running process name contains proc (lowercased +// substring, so "docker" matches "com.docker.backend"). +func isGuarded(proc string, procs map[string]bool) bool { + needle := strings.ToLower(proc) + for name := range procs { + if strings.Contains(name, needle) { + return true + } + } + return false +} + +// guardedTier resolves an entry's effective tier and label given a process +// snapshot: a guarded entry whose process is running is downgraded to Keep with +// a "( running — skipped)" suffix. Unguarded/idle entries are unchanged. +func guardedTier(e rules.Entry, procs map[string]bool) (rules.Tier, string) { + if e.GuardProcess != "" && isGuarded(e.GuardProcess, procs) { + return rules.Keep, e.Label + " (" + e.GuardProcess + " running — skipped)" + } + return e.Tier, e.Label +} + +// isDescendant reports whether path lives under an already-covered catalog path, +// so a specific child row is never double-counted beneath a broader row. +func isDescendant(path string, covered map[string]bool) bool { + for c := range covered { + if strings.HasPrefix(path, c+"/") { + return true + } + } + return false +} diff --git a/internal/engine/procs_test.go b/internal/engine/procs_test.go new file mode 100644 index 0000000..0e5f8bb --- /dev/null +++ b/internal/engine/procs_test.go @@ -0,0 +1,85 @@ +package engine + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/gor3a/disk-scan/internal/rules" +) + +func TestParseProcs(t *testing.T) { + out := []byte("/usr/bin/Xcode\ncom.docker.backend\n/System/Library/Frameworks/bar\n") + p := parseProcs(out) + if !p["xcode"] || !p["com.docker.backend"] || !p["bar"] { + t.Fatalf("parseProcs = %v", p) + } +} + +func TestIsGuarded(t *testing.T) { + procs := map[string]bool{"com.docker.backend": true, "xcode": true} + if !isGuarded("docker", procs) { + t.Error("docker should match com.docker.backend") + } + if !isGuarded("Xcode", procs) { + t.Error("Xcode should match (case-insensitive)") + } + if isGuarded("node", procs) { + t.Error("node should not match") + } +} + +func TestGuardedTier(t *testing.T) { + e := rules.Entry{Label: "Xcode DerivedData", Tier: rules.Safe, GuardProcess: "Xcode"} + tier, label := guardedTier(e, map[string]bool{"xcode": true}) + if tier != rules.Keep || label != "Xcode DerivedData (Xcode running — skipped)" { + t.Errorf("running: tier=%v label=%q", tier, label) + } + tier, label = guardedTier(e, map[string]bool{}) + if tier != rules.Safe || label != "Xcode DerivedData" { + t.Errorf("idle: tier=%v label=%q", tier, label) + } + e2 := rules.Entry{Label: "npm cache", Tier: rules.Safe} + tier, label = guardedTier(e2, map[string]bool{"xcode": true}) + if tier != rules.Safe || label != "npm cache" { + t.Errorf("unguarded changed: tier=%v label=%q", tier, label) + } +} + +func TestIsDescendant(t *testing.T) { + covered := map[string]bool{"/a/b": true} + if !isDescendant("/a/b/c", covered) { + t.Error("/a/b/c is under /a/b") + } + if isDescendant("/a/bc", covered) { + t.Error("/a/bc is NOT under /a/b") + } +} + +func TestScanAllGuardDowngrades(t *testing.T) { + home := t.TempDir() + dd := filepath.Join(home, "Library", "Developer", "Xcode", "DerivedData", "x") + if err := writeTree(t, dd, 4096); err != nil { + t.Fatal(err) + } + old := procSnapshot + procSnapshot = func() map[string]bool { return map[string]bool{"xcode": true} } + defer func() { procSnapshot = old }() + + items := ScanAll("darwin", home, false, nil, nil, nil) + var found bool + for _, it := range items { + if strings.Contains(it.Label, "Xcode DerivedData") { + found = true + if it.Tier != rules.Keep { + t.Errorf("guarded DerivedData tier=%v want Keep", it.Tier) + } + if !strings.Contains(it.Label, "Xcode running") { + t.Errorf("label missing suffix: %q", it.Label) + } + } + } + if !found { + t.Fatal("DerivedData item not found") + } +}