diff --git a/Sources/DMonteCore/Uninstaller.swift b/Sources/DMonteCore/Uninstaller.swift index 3e384bd..2c3cfeb 100644 --- a/Sources/DMonteCore/Uninstaller.swift +++ b/Sources/DMonteCore/Uninstaller.swift @@ -118,6 +118,97 @@ enum UninstallerSelection { } } +/// What the last uninstall has to say for itself. +/// +/// The success line and the failure line are two halves of one outcome rather than alternatives. +/// A sweep that trashes three items out of `~/Library` and is refused on two out of `/Library` has +/// to report both: told only about the failure, the user goes hunting in `~/Library` for files +/// that are already in the Trash, or re-runs the sweep. +struct UninstallerFeedback: Equatable, Sendable { + /// What did move. Rendered in the secondary colour. + var status: String? + + /// What did not. Rendered in red, *underneath* the status line rather than instead of it. + var failure: String? + + static let empty = UninstallerFeedback() + + var isEmpty: Bool { + status == nil && failure == nil + } + + /// macOS refused the bundle itself, so the sweep never ran and nothing moved. + static func bundleRefused(reason: String) -> UninstallerFeedback { + UninstallerFeedback(failure: reason) + } + + /// The bundle moved and there was nothing else to do — either no leftovers were found or the + /// user unticked every row in the review sheet. + static func bundleTrashed(appName: String) -> UninstallerFeedback { + UninstallerFeedback(status: "Moved \(appName) to Trash.") + } + + /// The bundle moved and the sweep ran. Both lines are filled in when the sweep was partial, + /// which is the common case: the tool is not privileged, so the `/Library` rows usually fail + /// while the `~/Library` ones succeed. + static func sweepFinished(appName: String, result: LeftoverRemovalResult) -> UninstallerFeedback { + var feedback = UninstallerFeedback() + + // "and 0 support items (Zero KB)" is technically true and reads like a malfunction, so a + // sweep that moved nothing reports the bundle alone and lets the failure line explain why. + if result.trashedCount == 0 { + feedback.status = "Moved \(appName) to Trash." + } else { + let noun = result.trashedCount == 1 ? "item" : "items" + feedback.status = "Moved \(appName) and \(result.trashedCount) support \(noun) (\(result.trashedBytes.bytesString)) to Trash." + } + + if let firstFailure = result.failures.first { + feedback.failure = result.failures.count == 1 + ? "Could not remove \(firstFailure.path): \(firstFailure.reason)" + : "Could not remove \(result.failures.count) support items. First: \(firstFailure.path) — \(firstFailure.reason)" + } + + return feedback + } +} + +/// Everything the details pane draws, resolved in one pass. +/// +/// The feedback is a field of the pane rather than something the selected-app branch renders, and +/// that is the entire point of the type: uninstalling the last app matching the current search +/// leaves nothing selected, and that is precisely the case where the user most needs to hear that +/// half the sweep was refused. Nested inside the selected-app branch, that report was silently +/// dropped exactly when it mattered. +struct UninstallerDetailsPane: Equatable, Sendable { + /// The app whose details are shown, or nil when the list has nothing to show. + let app: InstalledApplication? + + /// The stand-in shown when `app` is nil. Non-nil exactly when `app` is nil. + let placeholder: String? + + /// The outcome of the last uninstall, shown whether or not an app is selected. + let feedback: UninstallerFeedback + + var showsFeedback: Bool { + !feedback.isEmpty + } + + static func resolve( + filteredApps: [InstalledApplication], + selectedID: InstalledApplication.ID?, + feedback: UninstallerFeedback + ) -> UninstallerDetailsPane { + let app = UninstallerSelection.selectedApp(in: filteredApps, selectedID: selectedID) + + return UninstallerDetailsPane( + app: app, + placeholder: app == nil ? (filteredApps.isEmpty ? "No apps found" : "Select an app") : nil, + feedback: feedback + ) + } +} + public struct UninstallerPopoverView: View { var onQuit: () -> Void @@ -127,7 +218,11 @@ public struct UninstallerPopoverView: View { @State private var isScanning = true @State private var isSizing = false @State private var scanToken = UUID() - @State private var errorMessage: String? + @State private var feedback = UninstallerFeedback.empty + @State private var review: LeftoverReview? + @State private var selectedLeftoverIDs: Set = [] + @State private var isPreparingRemoval = false + @State private var isRemoving = false public init(onQuit: @escaping () -> Void) { self.onQuit = onQuit @@ -166,6 +261,15 @@ public struct UninstallerPopoverView: View { .task { await reload() } + .sheet(item: $review) { pendingReview in + LeftoverReviewSheet( + app: pendingReview.app, + items: pendingReview.items, + selection: $selectedLeftoverIDs, + onCancel: { review = nil }, + onConfirm: { performUninstall(pendingReview) } + ) + } } private var header: some View { @@ -228,7 +332,7 @@ public struct UninstallerPopoverView: View { LazyVStack(spacing: 4) { ForEach(filteredApps) { app in AppRow(app: app, isSelected: app.id == selectedApp?.id) { - selectedAppID = app.id + select(app) } } } @@ -240,16 +344,26 @@ public struct UninstallerPopoverView: View { } private var details: some View { - VStack(alignment: .leading, spacing: 14) { - if let selectedApp { + // Resolved in one pass, up front, because the uninstall report belongs to the pane and not + // to the selected app: trashing the last app that matches the current search leaves nothing + // selected, and that is exactly the moment the user most needs to hear that the /Library + // half of the sweep was refused. + let pane = UninstallerDetailsPane.resolve( + filteredApps: filteredApps, + selectedID: selectedAppID, + feedback: feedback + ) + + return VStack(alignment: .leading, spacing: 14) { + if let app = pane.app { HStack(alignment: .top, spacing: 14) { - AppIcon(url: selectedApp.url, size: 64) + AppIcon(url: app.url, size: 64) VStack(alignment: .leading, spacing: 4) { - Text(selectedApp.name) + Text(app.name) .font(.system(size: 22, weight: .bold)) - Text(selectedApp.path) + Text(app.path) .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) .lineLimit(2) @@ -258,40 +372,42 @@ public struct UninstallerPopoverView: View { Spacer() - Text(selectedApp.sizeLabel) + Text(app.sizeLabel) .font(.system(size: 14, weight: .bold, design: .rounded)) .foregroundStyle(.secondary) } Divider() - Text("Move this app bundle to Trash. This does not search for related support files yet.") + Text("Moves the app bundle to Trash, then offers the support files it left in ~/Library and /Library. You review the exact list before anything moves.") .font(.system(size: 13, weight: .medium)) .foregroundStyle(.secondary) - if let errorMessage { - Text(errorMessage) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.red) - } + feedbackLines(pane.feedback, alignment: .leading) Spacer() HStack { Button { - NSWorkspace.shared.activateFileViewerSelecting([selectedApp.url]) + NSWorkspace.shared.activateFileViewerSelecting([app.url]) } label: { Label("Reveal", systemImage: "finder") } Spacer() + if isPreparingRemoval || isRemoving { + ProgressView() + .controlSize(.small) + } + Button(role: .destructive) { - uninstall(selectedApp) + beginUninstall(app) } label: { Label("Move to Trash", systemImage: "trash") } .keyboardShortcut(.delete, modifiers: [.command]) + .disabled(isPreparingRemoval || isRemoving) } } else { Spacer() @@ -300,9 +416,11 @@ public struct UninstallerPopoverView: View { .font(.system(size: 34, weight: .semibold)) .foregroundStyle(.secondary) - Text(filteredApps.isEmpty ? "No apps found" : "Select an app") + Text(pane.placeholder ?? "") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.secondary) + + feedbackLines(pane.feedback, alignment: .center) } .frame(maxWidth: .infinity) Spacer() @@ -311,6 +429,35 @@ public struct UninstallerPopoverView: View { .padding(22) } + /// Both halves of the last uninstall's outcome, stacked rather than chosen between. They are + /// not alternatives: a sweep that moved three items out of `~/Library` and was refused on two + /// out of `/Library` has to report both, or the user goes hunting in `~/Library` for files + /// that are already in the Trash. + private func feedbackLines(_ feedback: UninstallerFeedback, alignment: HorizontalAlignment) -> some View { + VStack(alignment: alignment, spacing: 4) { + if let status = feedback.status { + Text(status) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + } + + if let failure = feedback.failure { + Text(failure) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.red) + } + } + .multilineTextAlignment(alignment == .center ? .center : .leading) + } + + /// Moves the selection, and drops the previous uninstall's report along with it. The report + /// names the app it describes, so leaving it on screen under a different app's name, path and + /// size reads as a claim that *this* app was just trashed. + private func select(_ app: InstalledApplication) { + selectedAppID = app.id + feedback = .empty + } + private var filteredApps: [InstalledApplication] { UninstallerSelection.filteredApps(apps, query: searchText) } @@ -324,7 +471,7 @@ public struct UninstallerPopoverView: View { scanToken = token isScanning = true isSizing = false - errorMessage = nil + feedback = .empty let scannedApps = await Task.detached(priority: .userInitiated) { ApplicationScanner.scan() }.value @@ -375,7 +522,11 @@ public struct UninstallerPopoverView: View { } } - private func uninstall(_ app: InstalledApplication) { + /// Gathers everything that would move — the bundle and its leftovers — and hands it to the + /// review sheet. Nothing is removed on this path: the old single-alert confirmation could + /// only ever describe the bundle, and a leftover sweep the user has not seen item by item is + /// exactly the kind of "helpful" deletion this tool must not do. + private func beginUninstall(_ app: InstalledApplication) { if app.isRunning { let runningAlert = NSAlert() runningAlert.messageText = "\(app.name) is currently running" @@ -399,26 +550,229 @@ public struct UninstallerPopoverView: View { } } - let alert = NSAlert() - alert.messageText = "Move \(app.name) to Trash?" - alert.informativeText = app.path - alert.alertStyle = .warning - alert.addButton(withTitle: "Move to Trash") - alert.addButton(withTitle: "Cancel") + feedback = .empty + isPreparingRemoval = true + + // The names of the *other* installed apps are what lets the display-name fallback refuse + // an ambiguous guess, so they have to be sampled here where the app list lives. + let otherAppNames = apps.filter { $0.id != app.id }.map(\.name) + let bundleIdentifier = app.bundleIdentifier + let appName = app.name + + Task { + let items = await Task.detached(priority: .userInitiated) { + LeftoverScanner.scan( + bundleIdentifier: bundleIdentifier, + appName: appName, + otherAppNames: otherAppNames + ) + }.value - guard alert.runModal() == .alertFirstButtonReturn else { - return + isPreparingRemoval = false + selectedLeftoverIDs = UninstallerLeftovers.defaultSelection(for: items) + review = LeftoverReview(app: app, items: items) } + } + + /// Trashes the bundle first, then whatever the user left ticked. The bundle goes first on + /// purpose: if macOS refuses it (a locked bundle, a missing admin right) the support files + /// are still where the app expects them, so the user is left with a working install rather + /// than a half-gutted one. + private func performUninstall(_ pendingReview: LeftoverReview) { + let app = pendingReview.app + let chosen = pendingReview.items.filter { selectedLeftoverIDs.contains($0.id) } + + review = nil do { var resultingURL: NSURL? try FileManager.default.trashItem(at: app.url, resultingItemURL: &resultingURL) - apps.removeAll { $0.id == app.id } - selectedAppID = filteredApps.first?.id - errorMessage = nil } catch { - errorMessage = error.localizedDescription + feedback = .bundleRefused(reason: error.localizedDescription) + return + } + + apps.removeAll { $0.id == app.id } + selectedAppID = filteredApps.first?.id + feedback = .empty + + guard !chosen.isEmpty else { + feedback = .bundleTrashed(appName: app.name) + return } + + isRemoving = true + + Task { + let result = await Task.detached(priority: .userInitiated) { + LeftoverScanner.moveToTrash(chosen) + }.value + + isRemoving = false + feedback = .sweepFinished(appName: app.name, result: result) + } + } +} + +/// The bundle and its leftovers, frozen at the moment the user asked to uninstall so the sheet +/// cannot be re-scanned out from under the checkboxes the user just ticked. +private struct LeftoverReview: Identifiable { + let id = UUID() + let app: InstalledApplication + let items: [LeftoverItem] +} + +/// The "here is exactly what will move" sheet. Every leftover is listed with its real path and +/// size, and rows matched only by display name start unticked because that match is a heuristic. +private struct LeftoverReviewSheet: View { + var app: InstalledApplication + var items: [LeftoverItem] + @Binding var selection: Set + var onCancel: () -> Void + var onConfirm: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text("Move \(app.name) to Trash?") + .font(.system(size: 17, weight: .bold)) + + HStack(spacing: 12) { + AppIcon(url: app.url, size: 40) + + VStack(alignment: .leading, spacing: 2) { + Text(app.path) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + + Text("The app bundle itself. Always removed.") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.tertiary) + } + + Spacer(minLength: 0) + + Text(app.size?.bytesString ?? "—") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) + } + + Divider() + + if items.isEmpty { + Text("No support files were found for this app.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + HStack { + Text("Support files (\(items.count))") + .font(.system(size: 13, weight: .semibold)) + + Spacer() + + Button(allSelected ? "Deselect All" : "Select All") { + selection = allSelected ? [] : Set(items.map(\.id)) + } + .buttonStyle(.link) + } + + ScrollView { + VStack(alignment: .leading, spacing: 6) { + ForEach(items) { item in + row(for: item) + } + } + } + .frame(height: 220) + } + + Divider() + + HStack { + Text(totalLabel) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + + Spacer() + + Button("Cancel", action: onCancel) + .keyboardShortcut(.cancelAction) + + Button("Move to Trash", action: onConfirm) + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 560) + } + + private func row(for item: LeftoverItem) -> some View { + HStack(alignment: .top, spacing: 10) { + Toggle("", isOn: binding(for: item)) + .labelsHidden() + .toggleStyle(.checkbox) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(item.candidate.label) + .font(.system(size: 12, weight: .semibold)) + + if item.candidate.match == .appName { + Text("matched by name") + .font(.system(size: 10, weight: .bold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.orange.opacity(0.22)) + .clipShape(Capsule()) + } + + if item.candidate.scope == .system { + Text("system-wide") + .font(.system(size: 10, weight: .bold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Color.secondary.opacity(0.18)) + .clipShape(Capsule()) + } + } + + Text(item.candidate.displayPath) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + + Spacer(minLength: 0) + + Text(item.bytes.bytesString) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + + private func binding(for item: LeftoverItem) -> Binding { + Binding( + get: { selection.contains(item.id) }, + set: { isOn in + if isOn { + selection.insert(item.id) + } else { + selection.remove(item.id) + } + } + ) + } + + private var allSelected: Bool { + !items.isEmpty && selection.count == items.count + } + + private var totalLabel: String { + let total = (app.size ?? 0) + UninstallerLeftovers.selectedBytes(in: items, selection: selection) + return "\(total.bytesString) will move to Trash" } } diff --git a/Sources/DMonteCore/UninstallerLeftovers.swift b/Sources/DMonteCore/UninstallerLeftovers.swift new file mode 100644 index 0000000..4ee95c4 --- /dev/null +++ b/Sources/DMonteCore/UninstallerLeftovers.swift @@ -0,0 +1,517 @@ +import Foundation + +/// One of the directories macOS lets an application scatter per-app state into, described as a +/// *rule* ("a child of `/Caches` named after the app") rather than as a finished path. +/// Keeping it a rule is what lets the same eight entries cover both `~/Library` and `/Library` +/// without a second, drift-prone copy of the list. +public struct LeftoverLocation: Sendable, Hashable { + /// Path of the container directory relative to a Library root, e.g. `Application Support`. + public let subpath: String + + /// Appended to the identifier to form the leaf name. macOS spells two of these with an + /// extension (`…​.plist`, `…​.savedState`); the rest are plain directories. + public let suffix: String + + /// Human label for the review sheet. + public let label: String + + /// Whether a leaf here is ever named after the app's *display name* rather than its bundle + /// identifier. Only Application Support, Caches and Logs are — Containers, HTTPStorages, + /// WebKit, Saved Application State and Preferences are keyed by bundle identifier by macOS + /// itself, so a display-name guess in those could only ever land on another app's data. + public let supportsNameFallback: Bool + + public init(subpath: String, suffix: String = "", label: String, supportsNameFallback: Bool = false) { + self.subpath = subpath + self.suffix = suffix + self.label = label + self.supportsNameFallback = supportsNameFallback + } +} + +/// Which Library a candidate came from. Shown in the review sheet because removing something out +/// of `/Library` is a machine-wide change and usually needs an admin prompt, whereas `~/Library` +/// is the user's own data. +public enum LeftoverScope: String, Sendable, Hashable { + case user + case system + + public var label: String { + switch self { + case .user: "~/Library" + case .system: "/Library" + } + } +} + +/// How a candidate was matched. This is not cosmetic: it decides whether the row is ticked when +/// the review sheet opens. Bundle-identifier matches are exact and safe to pre-select; display +/// name matches are a heuristic and start unticked so nothing is ever removed on a guess the +/// user did not look at. +public enum LeftoverMatch: String, Sendable, Hashable { + case bundleIdentifier + case appName +} + +/// A path that *might* hold leftovers for the app being uninstalled. Derivation is pure — nothing +/// here has looked at the disk yet. +public struct LeftoverCandidate: Identifiable, Sendable, Hashable { + public let url: URL + public let label: String + public let scope: LeftoverScope + public let match: LeftoverMatch + + public var id: String { url.path } + + public init(url: URL, label: String, scope: LeftoverScope, match: LeftoverMatch) { + self.url = url + self.label = label + self.scope = scope + self.match = match + } + + /// Path as the user thinks of it, with their home directory written as `~`. + public var displayPath: String { + (url.path as NSString).abbreviatingWithTildeInPath + } +} + +/// A candidate that was found on disk, with its measured size. +public struct LeftoverItem: Identifiable, Sendable, Hashable { + public let candidate: LeftoverCandidate + public let bytes: UInt64 + + public var id: String { candidate.id } + public var url: URL { candidate.url } + + /// Ticked when the review sheet opens. See `LeftoverMatch`. + public var isSelectedByDefault: Bool { + candidate.match == .bundleIdentifier + } + + public init(candidate: LeftoverCandidate, bytes: UInt64) { + self.candidate = candidate + self.bytes = bytes + } +} + +/// Something that survived the safety check but that macOS still refused to move. +public struct LeftoverRemovalFailure: Sendable, Equatable { + public let path: String + public let reason: String + + public init(path: String, reason: String) { + self.path = path + self.reason = reason + } +} + +public struct LeftoverRemovalResult: Sendable, Equatable { + public var trashedCount: Int + public var trashedBytes: UInt64 + public var failures: [LeftoverRemovalFailure] + + public init(trashedCount: Int = 0, trashedBytes: UInt64 = 0, failures: [LeftoverRemovalFailure] = []) { + self.trashedCount = trashedCount + self.trashedBytes = trashedBytes + self.failures = failures + } +} + +/// Path derivation and the safety predicate for the Uninstaller's leftover sweep. +/// +/// This is the dangerous part of the tool, so it is deliberately pure and `nonisolated`: every +/// rule below is a function of its arguments and is exercised directly by the test target, with +/// no window, no app list and no privileged state involved. +/// +/// Two independent ideas keep it safe, and both must agree before anything is offered: +/// +/// 1. **Derivation is exact.** A leaf is only ever `/`, where +/// the identifier is the app's bundle identifier (or, in the three directories that use one, +/// its display name). There is no prefix, glob or substring matching anywhere, so +/// `com.example.app` can never pull in `com.example.app.helper`'s data. +/// 2. **Removal is fenced.** `isRemovable(_:allowedRoots:)` re-derives the verdict from the URL +/// alone immediately before the trash call. A path only passes if it is a *direct child* of a +/// known Library container **and** still resolves inside one after symlinks are followed, so a +/// traversal attempt or a planted symlink pointing at the user's documents is refused rather +/// than followed. +public enum UninstallerLeftovers { + + // MARK: - The known locations + + /// The eight directories swept, in the order they are shown. Anything not in this list is, + /// by construction, not reachable by this feature. + public static let locations: [LeftoverLocation] = [ + LeftoverLocation(subpath: "Application Support", label: "Application Support", supportsNameFallback: true), + LeftoverLocation(subpath: "Caches", label: "Caches", supportsNameFallback: true), + LeftoverLocation(subpath: "Preferences", suffix: ".plist", label: "Preferences"), + LeftoverLocation(subpath: "Containers", label: "Container"), + LeftoverLocation(subpath: "Saved Application State", suffix: ".savedState", label: "Saved Application State"), + LeftoverLocation(subpath: "Logs", label: "Logs", supportsNameFallback: true), + LeftoverLocation(subpath: "HTTPStorages", label: "HTTP Storage"), + LeftoverLocation(subpath: "WebKit", label: "WebKit Data") + ] + + public static var defaultUserLibrary: URL { + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library") + } + + public static let defaultSystemLibrary = URL(fileURLWithPath: "/Library") + + /// Every directory this feature is allowed to remove a child from. Both Libraries are folded + /// into one flat list because the safety predicate only ever asks "is this URL a child of one + /// of these?" — it does not care which Library the answer came from. + public static func allowedRoots(userLibrary: URL, systemLibrary: URL) -> [URL] { + [userLibrary, systemLibrary].flatMap { library in + locations.map { library.appendingPathComponent($0.subpath) } + } + } + + // MARK: - Identifier hygiene + + /// Bundle identifiers arrive from `Info.plist` files this app did not write, so they are + /// untrusted input that is about to be pasted into a filesystem path. Anything that could + /// change the *shape* of that path — a separator, a relative reference, a control character — + /// is rejected outright rather than escaped, because a rejected identifier costs the user a + /// manual cleanup while a mis-escaped one costs them a directory. + public static func sanitizedIdentifier(_ raw: String?) -> String? { + guard let raw else { + return nil + } + + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + return nil + } + + // A single path component cannot exceed 255 bytes on APFS/HFS+, so a longer identifier + // could not name a real directory anyway. + guard trimmed.utf8.count <= 255 else { + return nil + } + + // "/" is the obvious traversal vector; ":" is the classic HFS separator and is still + // translated by some Carbon-era paths. + guard !trimmed.contains("/"), !trimmed.contains(":") else { + return nil + } + + guard trimmed.unicodeScalars.allSatisfy({ !CharacterSet.controlCharacters.contains($0) }) else { + return nil + } + + // Covers "." and ".." as well as hidden names. No shipping bundle identifier starts with + // a dot, so refusing them costs nothing and removes a whole class of surprise. + guard trimmed.first != "." else { + return nil + } + + // `appendingPathComponent` treats a leading "~" as an ordinary character, but + // `NSString.expandingTildeInPath` — which any future caller might reasonably reach for — + // turns it into the home directory. Refusing it here means that difference can never + // become a bug. + guard trimmed.first != "~" else { + return nil + } + + return trimmed + } + + /// Directory names that hold more than one vendor's — or more than one app's — data. The + /// display-name fallback must never produce one of these, because `~/Library/Application + /// Support/Google` is Chrome's *and* Drive's *and* Earth's, and uninstalling any one of them + /// must not take the other two with it. + private static let sharedDirectoryNames: Set = [ + "adobe", "apple", "google", "microsoft", "mozilla", "steam", "unity", "jetbrains", + "app", "apps", "application", "applications", "backup", "backups", "cache", "caches", + "common", "crashreporter", "data", "default", "defaults", "desktop", "documents", + "downloads", "files", "helper", "helpers", "home", "info", "install", "installer", + "library", "log", "logs", "mail", "main", "media", "music", "notes", "photos", + "preferences", "private", "public", "setup", "shared", "support", "system", "temp", + "test", "tests", "tmp", "update", "updater", "user", "users", "utilities", "utility" + ] + + /// Whether the app's display name is specific enough to guess a directory from. + /// + /// The fallback only exists for the handful of apps whose bundle identifier cannot be read, + /// and it is the one place a wrong answer deletes a *different* app's data, so three things + /// have to hold: the name has to survive the same path hygiene a bundle identifier does, it + /// has to be long enough that a collision is unlikely (three characters or fewer is a + /// coin-flip: "Go", "IDE", "Zip"), and it must not be a directory that is known to be shared. + /// Finally, if another installed app answers to the same name the guess is ambiguous by + /// definition and is refused. + public static func isNameSpecificEnough(_ rawName: String, otherAppNames: [String] = []) -> Bool { + guard let name = sanitizedIdentifier(rawName) else { + return false + } + + guard name.count >= 4 else { + return false + } + + let folded = normalized(name) + + guard !sharedDirectoryNames.contains(folded) else { + return false + } + + return !otherAppNames.contains { normalized($0) == folded } + } + + private static func normalized(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } + + // MARK: - Derivation + + /// Every place the app being uninstalled *could* have left something. Nothing here touches + /// the disk; `LeftoverScanner` is what decides which of these actually exist. + /// + /// Bundle-identifier candidates come first and cover all eight locations. Display-name + /// candidates are appended only for the three directories that are ever named that way, and + /// only when the name clears `isNameSpecificEnough`. + public static func candidates( + bundleIdentifier: String?, + appName: String, + userLibrary: URL, + systemLibrary: URL, + otherAppNames: [String] = [] + ) -> [LeftoverCandidate] { + let libraries: [(scope: LeftoverScope, url: URL)] = [ + (.user, userLibrary), + (.system, systemLibrary) + ] + + let identifier = sanitizedIdentifier(bundleIdentifier) + let nameIdentifier = isNameSpecificEnough(appName, otherAppNames: otherAppNames) + ? sanitizedIdentifier(appName) + : nil + let roots = allowedRoots(userLibrary: userLibrary, systemLibrary: systemLibrary) + + var candidates: [LeftoverCandidate] = [] + var seenPaths = Set() + + func append(_ leafIdentifier: String, match: LeftoverMatch, onlyNameable: Bool) { + for library in libraries { + for location in locations where !onlyNameable || location.supportsNameFallback { + let url = library.url + .appendingPathComponent(location.subpath) + .appendingPathComponent(leafIdentifier + location.suffix) + + // Belt and braces: derivation is exact, but the leaf still has to satisfy the + // same fence that gates the trash call. If the two ever disagree, the + // candidate is dropped here rather than surfacing as an un-removable row. + guard isRemovable(url, allowedRoots: roots) else { + continue + } + + guard seenPaths.insert(url.standardizedFileURL.path).inserted else { + continue + } + + candidates.append( + LeftoverCandidate(url: url, label: location.label, scope: library.scope, match: match) + ) + } + } + } + + if let identifier { + append(identifier, match: .bundleIdentifier, onlyNameable: false) + } + + if let nameIdentifier, nameIdentifier != identifier { + append(nameIdentifier, match: .appName, onlyNameable: true) + } + + return candidates + } + + // MARK: - The safety predicate + + /// The single gate every removal passes through, and the only thing standing between a + /// malformed bundle identifier and someone's home directory. + /// + /// A URL is removable only when **both** hold: + /// + /// - Lexically it is a direct child of one of `allowedRoots`. Anything with `..` in it + /// collapses during standardisation and lands somewhere that is not a known root, so a + /// traversal attempt fails this half. + /// - After symlinks are resolved on both the URL and the roots, it is still inside a root. + /// A planted `~/Library/Caches/com.evil.app -> ~/Documents` therefore fails this half even + /// though it passes the first, which is exactly the case the first half cannot see. + /// + /// Roots shallower than three path components (`/`, `/Users`, `/Library`) are ignored on + /// principle: no matter how this is called, the feature can never be pointed at a directory + /// whose children are whole volumes, user accounts or system frameworks. + public static func isRemovable(_ url: URL, allowedRoots: [URL]) -> Bool { + let standardized = url.standardizedFileURL + let leafName = standardized.lastPathComponent + + guard !leafName.isEmpty, leafName != "/", leafName != ".", leafName != ".." else { + return false + } + + // A root is allowed to be spelled two ways, because `/var`, `/tmp` and `/etc` are symlinks + // into `/private` and Foundation is inconsistent about which spelling it hands back. Both + // spellings name the same directory, so accepting either is not a widening of the fence — + // whereas accepting only one would make the resolved-path half below fail on paths that + // are perfectly legitimate. + let rootIdentities = allowedRoots + .map(\.standardizedFileURL) + .filter { $0.pathComponents.count >= 3 } + .flatMap { [$0, $0.resolvingSymlinksInPath().standardizedFileURL] } + + guard !rootIdentities.isEmpty else { + return false + } + + let parentPath = standardized.deletingLastPathComponent().standardizedFileURL.path + guard rootIdentities.contains(where: { $0.path == parentPath }) else { + return false + } + + // Deliberately the *resolved* URL only. Checking the unresolved spelling as a fallback + // here would hand a planted symlink the pass this half exists to deny it. + let resolved = standardized.resolvingSymlinksInPath().standardizedFileURL + return rootIdentities.contains { isStrictlyContained(resolved, in: $0) } + } + + /// Containment compared component-by-component rather than by string prefix, so that + /// `/Library/CachesElsewhere/x` is not mistaken for a child of `/Library/Caches`. + private static func isStrictlyContained(_ url: URL, in root: URL) -> Bool { + let rootComponents = root.pathComponents + let urlComponents = url.pathComponents + + guard urlComponents.count > rootComponents.count else { + return false + } + + return Array(urlComponents.prefix(rootComponents.count)) == rootComponents + } + + // MARK: - Selection arithmetic + + /// IDs ticked when the review sheet opens: exact bundle-identifier matches only. + public static func defaultSelection(for items: [LeftoverItem]) -> Set { + Set(items.filter(\.isSelectedByDefault).map(\.id)) + } + + /// Total of the ticked rows, so the sheet can say how much is about to move. + public static func selectedBytes(in items: [LeftoverItem], selection: Set) -> UInt64 { + items.reduce(UInt64(0)) { total, item in + selection.contains(item.id) ? total + item.bytes : total + } + } +} + +/// The I/O half: which candidates exist, how big they are, and moving the chosen ones to Trash. +/// +/// Every entry point re-runs `UninstallerLeftovers.isRemovable` against freshly computed roots +/// rather than trusting the caller's list, so a stale or hand-built `LeftoverItem` cannot become +/// a delete. +public enum LeftoverScanner { + + public static func scan( + bundleIdentifier: String?, + appName: String, + otherAppNames: [String] = [], + userLibrary: URL = UninstallerLeftovers.defaultUserLibrary, + systemLibrary: URL = UninstallerLeftovers.defaultSystemLibrary + ) -> [LeftoverItem] { + let candidates = UninstallerLeftovers.candidates( + bundleIdentifier: bundleIdentifier, + appName: appName, + userLibrary: userLibrary, + systemLibrary: systemLibrary, + otherAppNames: otherAppNames + ) + + return candidates.compactMap { candidate in + guard exists(at: candidate.url) else { + return nil + } + + return LeftoverItem(candidate: candidate, bytes: size(of: candidate.url)) + } + } + + /// Moves the given items to Trash — never `removeItem`. A leftover sweep is a heuristic by + /// nature, and the Trash is what makes a wrong guess a nuisance instead of a data loss. + public static func moveToTrash( + _ items: [LeftoverItem], + userLibrary: URL = UninstallerLeftovers.defaultUserLibrary, + systemLibrary: URL = UninstallerLeftovers.defaultSystemLibrary + ) -> LeftoverRemovalResult { + let roots = UninstallerLeftovers.allowedRoots(userLibrary: userLibrary, systemLibrary: systemLibrary) + var result = LeftoverRemovalResult() + + for item in items { + guard UninstallerLeftovers.isRemovable(item.url, allowedRoots: roots) else { + result.failures.append( + LeftoverRemovalFailure( + path: item.candidate.displayPath, + reason: "outside the app-support folders this tool is allowed to touch" + ) + ) + continue + } + + do { + var resultingURL: NSURL? + try FileManager.default.trashItem(at: item.url, resultingItemURL: &resultingURL) + result.trashedCount += 1 + result.trashedBytes += item.bytes + } catch { + result.failures.append( + LeftoverRemovalFailure(path: item.candidate.displayPath, reason: error.localizedDescription) + ) + } + } + + return result + } + + /// `fileExists` follows symlinks, which would hide a dangling link and — worse — report a + /// link to somewhere else as present. `attributesOfItem` does not follow, so a symlink is + /// reported on its own terms and the safety predicate gets to rule on where it points. + private static func exists(at url: URL) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: url.path)) != nil + } + + /// Allocated size on disk. Symlinks are counted as themselves: the enumerator does not + /// descend through them, so a link's target is never billed to the app being uninstalled. + private static func size(of url: URL) -> UInt64 { + let sizeKeys: Set = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return 0 + } + + if !isDirectory.boolValue { + let values = try? url.resourceValues(forKeys: sizeKeys) + return UInt64(values?.totalFileAllocatedSize ?? values?.fileAllocatedSize ?? 0) + } + + guard let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: Array(sizeKeys), + options: [] + ) else { + return 0 + } + + return enumerator.reduce(UInt64(0)) { partialResult, entry in + guard let fileURL = entry as? URL, + let values = try? fileURL.resourceValues(forKeys: sizeKeys) else { + return partialResult + } + + return partialResult + UInt64(values.totalFileAllocatedSize ?? values.fileAllocatedSize ?? 0) + } + } +} diff --git a/Tests/DMonteCoreTests/UninstallerLeftoversTests.swift b/Tests/DMonteCoreTests/UninstallerLeftoversTests.swift new file mode 100644 index 0000000..a7ba405 --- /dev/null +++ b/Tests/DMonteCoreTests/UninstallerLeftoversTests.swift @@ -0,0 +1,454 @@ +import XCTest +@testable import DMonteCore + +/// The leftover sweep is the one part of the Uninstaller that can destroy data belonging to an app +/// the user did not ask to remove, so the rules that decide *what* it touches are tested far more +/// aggressively than the feature is large. +/// +/// Everything here runs against synthetic Library roots inside a per-test temporary directory — +/// the real `~/Library` and `/Library` are never read, and nothing is ever actually moved to the +/// Trash. The removal tests only exercise the refusal path, which is the half that matters. +final class UninstallerLeftoversTests: XCTestCase { + + // MARK: - Fixtures + + private var sandbox: URL! + + /// Stands in for `~/Library`. + private var userLibrary: URL { sandbox.appendingPathComponent("Library") } + + /// Stands in for `/Library`. + private var systemLibrary: URL { sandbox.appendingPathComponent("SystemLibrary") } + + private var roots: [URL] { + UninstallerLeftovers.allowedRoots(userLibrary: userLibrary, systemLibrary: systemLibrary) + } + + override func setUpWithError() throws { + try super.setUpWithError() + + sandbox = FileManager.default.temporaryDirectory + .appendingPathComponent("UninstallerLeftoversTests-\(UUID().uuidString)") + + for library in [userLibrary, systemLibrary] { + for location in UninstallerLeftovers.locations { + try FileManager.default.createDirectory( + at: library.appendingPathComponent(location.subpath), + withIntermediateDirectories: true + ) + } + } + } + + override func tearDownWithError() throws { + if let sandbox, FileManager.default.fileExists(atPath: sandbox.path) { + try FileManager.default.removeItem(at: sandbox) + } + sandbox = nil + try super.tearDownWithError() + } + + private func candidates( + bundleIdentifier: String?, + appName: String = "zz", + otherAppNames: [String] = [] + ) -> [LeftoverCandidate] { + UninstallerLeftovers.candidates( + bundleIdentifier: bundleIdentifier, + appName: appName, + userLibrary: userLibrary, + systemLibrary: systemLibrary, + otherAppNames: otherAppNames + ) + } + + private func scan(bundleIdentifier: String?, appName: String = "zz", otherAppNames: [String] = []) -> [LeftoverItem] { + LeftoverScanner.scan( + bundleIdentifier: bundleIdentifier, + appName: appName, + otherAppNames: otherAppNames, + userLibrary: userLibrary, + systemLibrary: systemLibrary + ) + } + + /// Creates a directory holding one file of `bytes` bytes, so sizing has something to measure. + @discardableResult + private func makeLeaf(_ library: URL, _ subpath: String, _ leaf: String, bytes: Int = 64) throws -> URL { + let url = library.appendingPathComponent(subpath).appendingPathComponent(leaf) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + try Data(repeating: 0x41, count: bytes).write(to: url.appendingPathComponent("payload.bin")) + return url + } + + // MARK: - Derivation covers exactly the documented locations + + func testBundleIdentifierCoversEveryLocationInBothLibraries() { + let derived = candidates(bundleIdentifier: "com.example.app") + + XCTAssertEqual(derived.count, UninstallerLeftovers.locations.count * 2) + + let paths = Set(derived.map(\.url.path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Application Support/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Caches/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Preferences/com.example.app.plist").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Containers/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Saved Application State/com.example.app.savedState").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("Logs/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("HTTPStorages/com.example.app").path)) + XCTAssertTrue(paths.contains(userLibrary.appendingPathComponent("WebKit/com.example.app").path)) + XCTAssertTrue(paths.contains(systemLibrary.appendingPathComponent("Application Support/com.example.app").path)) + XCTAssertTrue(paths.contains(systemLibrary.appendingPathComponent("Preferences/com.example.app.plist").path)) + } + + func testEveryDerivedCandidateSatisfiesTheRemovalFence() { + for candidate in candidates(bundleIdentifier: "com.example.app") { + XCTAssertTrue( + UninstallerLeftovers.isRemovable(candidate.url, allowedRoots: roots), + "derivation produced a path the fence would refuse: \(candidate.url.path)" + ) + } + } + + func testBundleIdentifierCandidatesAreSelectedByDefault() { + let items = candidates(bundleIdentifier: "com.example.app") + .map { LeftoverItem(candidate: $0, bytes: 10) } + + XCTAssertEqual(UninstallerLeftovers.defaultSelection(for: items).count, items.count) + } + + // MARK: - Identifier hygiene + + func testDottedIdentifierIsUsedVerbatim() { + let derived = candidates(bundleIdentifier: "com.company.Product.v2") + + XCTAssertEqual( + derived.first { $0.label == "Caches" && $0.scope == .user }?.url.lastPathComponent, + "com.company.Product.v2" + ) + } + + func testIdentifierWithSpacesIsAccepted() { + let derived = candidates(bundleIdentifier: "com.example.My Great App") + + XCTAssertEqual(derived.count, UninstallerLeftovers.locations.count * 2) + XCTAssertEqual( + derived.first { $0.label == "Application Support" && $0.scope == .user }?.url.lastPathComponent, + "com.example.My Great App" + ) + } + + func testUnicodeIdentifierIsAcceptedAndNotMangled() { + let identifier = "com.exämple.日本語アプリ.Ω" + let derived = candidates(bundleIdentifier: identifier) + + XCTAssertEqual(derived.count, UninstallerLeftovers.locations.count * 2) + XCTAssertEqual( + derived.first { $0.label == "Preferences" && $0.scope == .user }?.url.lastPathComponent, + identifier + ".plist" + ) + } + + func testSurroundingWhitespaceIsTrimmedRatherThanBakedIntoThePath() { + XCTAssertEqual(UninstallerLeftovers.sanitizedIdentifier(" com.example.app\n"), "com.example.app") + } + + func testTraversalIdentifiersAreRefused() { + for hostile in ["../..", "../../etc", "com.example.app/../../..", "..", ".", "...", "/etc/passwd", "~", "~/Documents"] { + XCTAssertNil( + UninstallerLeftovers.sanitizedIdentifier(hostile), + "identifier \(hostile) should not be usable as a path component" + ) + } + } + + func testTraversalIdentifierProducesNoCandidatesAtAll() { + // The display name is deliberately too short to trigger the fallback, so an empty result + // proves the traversal attempt produced nothing rather than being quietly replaced. + XCTAssertTrue(candidates(bundleIdentifier: "../../../etc", appName: "ab").isEmpty) + } + + func testSeparatorAndControlCharacterIdentifiersAreRefused() { + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com/example/app")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com:example:app")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com.example\u{0}app")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("com.example\napp")) + } + + func testEmptyAndOverlongIdentifiersAreRefused() { + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier(nil)) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier("")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier(" ")) + XCTAssertNil(UninstallerLeftovers.sanitizedIdentifier(String(repeating: "a", count: 256))) + XCTAssertEqual(UninstallerLeftovers.sanitizedIdentifier(String(repeating: "a", count: 255))?.count, 255) + } + + // MARK: - Prefix collisions between apps + + func testCandidatesNeverReachAnIdentifierThatMerelySharesThePrefix() { + let paths = Set(candidates(bundleIdentifier: "com.example.app").map(\.url.path)) + + XCTAssertFalse(paths.contains { $0.contains("com.example.app.helper") }) + XCTAssertFalse(paths.contains { $0.contains("com.example.application") }) + } + + func testScanIgnoresNeighboursWhoseIdentifierExtendsTheOneBeingRemoved() throws { + try makeLeaf(userLibrary, "Application Support", "com.example.app") + try makeLeaf(userLibrary, "Application Support", "com.example.app.helper") + try makeLeaf(userLibrary, "Application Support", "com.example.application") + try makeLeaf(userLibrary, "Caches", "com.example.app.helper") + + let found = scan(bundleIdentifier: "com.example.app").map(\.url.path) + + XCTAssertEqual(found, [userLibrary.appendingPathComponent("Application Support/com.example.app").path]) + } + + func testScanFindsEveryLocationThatActuallyExists() throws { + try makeLeaf(userLibrary, "Application Support", "com.example.app", bytes: 100) + try makeLeaf(userLibrary, "Caches", "com.example.app", bytes: 100) + try makeLeaf(systemLibrary, "Logs", "com.example.app", bytes: 100) + + let found = scan(bundleIdentifier: "com.example.app") + + XCTAssertEqual(found.count, 3) + XCTAssertEqual(Set(found.map(\.candidate.label)), ["Application Support", "Caches", "Logs"]) + XCTAssertEqual(found.filter { $0.candidate.scope == .system }.count, 1) + XCTAssertTrue(found.allSatisfy { $0.bytes > 0 }, "an existing leftover should report a measured size") + } + + // MARK: - The display-name fallback + + func testNameFallbackOnlyTouchesTheDirectoriesThatAreEverNamedAfterAnApp() { + let derived = candidates(bundleIdentifier: nil, appName: "Fantastical") + + XCTAssertEqual(Set(derived.map(\.label)), ["Application Support", "Caches", "Logs"]) + XCTAssertEqual(derived.count, 6) + XCTAssertTrue(derived.allSatisfy { $0.match == .appName }) + } + + func testNameMatchedCandidatesAreNeverTickedByDefault() { + let items = candidates(bundleIdentifier: nil, appName: "Fantastical") + .map { LeftoverItem(candidate: $0, bytes: 10) } + + XCTAssertFalse(items.isEmpty) + XCTAssertTrue(UninstallerLeftovers.defaultSelection(for: items).isEmpty) + } + + func testNameFallbackRefusesUmbrellaVendorDirectories() { + for shared in ["Google", "Adobe", "Microsoft", "Mozilla", "Steam"] { + XCTAssertFalse( + UninstallerLeftovers.isNameSpecificEnough(shared), + "\(shared) names a directory shared by several apps" + ) + XCTAssertTrue(candidates(bundleIdentifier: nil, appName: shared).isEmpty) + } + } + + func testNameFallbackRefusesGenericDirectoryNames() { + for generic in ["Logs", "caches", "Support", "Preferences", "Data", "Updater"] { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough(generic)) + } + } + + func testNameFallbackRefusesNamesTooShortToBeUnambiguous() { + for short in ["Go", "Zip", "IDE", "A"] { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough(short)) + } + + XCTAssertTrue(UninstallerLeftovers.isNameSpecificEnough("Bear")) + } + + func testNameFallbackRefusesNamesAnotherInstalledAppAlsoAnswersTo() { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("Fantastical", otherAppNames: ["Fantastical"])) + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("Fantastical", otherAppNames: ["fantastical"])) + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("Café Noir", otherAppNames: ["Cafe Noir"])) + XCTAssertTrue(UninstallerLeftovers.isNameSpecificEnough("Fantastical", otherAppNames: ["Fantastical Helper"])) + } + + func testNameFallbackRefusesNamesThatWouldEscapeTheirDirectory() { + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("../../Documents")) + XCTAssertFalse(UninstallerLeftovers.isNameSpecificEnough("/Users/someone")) + XCTAssertTrue(candidates(bundleIdentifier: nil, appName: "../../Documents").isEmpty) + } + + func testNameCandidatesAreAddedAlongsideBundleIdentifierCandidatesWithoutDuplicates() { + let derived = candidates(bundleIdentifier: "com.flexibits.fantastical2.mac", appName: "Fantastical") + + XCTAssertEqual(derived.filter { $0.match == .bundleIdentifier }.count, UninstallerLeftovers.locations.count * 2) + XCTAssertEqual(derived.filter { $0.match == .appName }.count, 6) + XCTAssertEqual(Set(derived.map(\.id)).count, derived.count) + } + + // MARK: - The removal fence + + func testFenceAcceptsADirectChildOfAKnownRoot() { + let url = userLibrary.appendingPathComponent("Caches/com.example.app") + + XCTAssertTrue(UninstallerLeftovers.isRemovable(url, allowedRoots: roots)) + } + + func testFenceRefusesTheRootItself() { + for root in roots { + XCTAssertFalse(UninstallerLeftovers.isRemovable(root, allowedRoots: roots)) + } + + XCTAssertFalse(UninstallerLeftovers.isRemovable(userLibrary, allowedRoots: roots)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(sandbox, allowedRoots: roots)) + } + + func testFenceRefusesAnythingDeeperThanADirectChild() { + let nested = userLibrary.appendingPathComponent("Caches/com.example.app/Data/file.db") + + XCTAssertFalse(UninstallerLeftovers.isRemovable(nested, allowedRoots: roots)) + } + + func testFenceRefusesASiblingDirectoryThatMerelySharesTheRootsNamePrefix() { + let lookalike = userLibrary.appendingPathComponent("CachesElsewhere/com.example.app") + + XCTAssertFalse(UninstallerLeftovers.isRemovable(lookalike, allowedRoots: roots)) + } + + func testFenceRefusesTraversalOutOfARoot() { + let escapes = [ + userLibrary.appendingPathComponent("Caches/../../../etc/passwd"), + userLibrary.appendingPathComponent("Caches/../Keychains/login.keychain-db"), + userLibrary.appendingPathComponent("Caches/.."), + URL(fileURLWithPath: "/etc/passwd"), + sandbox.appendingPathComponent("Documents/Taxes") + ] + + for escape in escapes { + XCTAssertFalse( + UninstallerLeftovers.isRemovable(escape, allowedRoots: roots), + "\(escape.path) is outside every allowed root" + ) + } + } + + func testFenceIgnoresRootsShallowEnoughToHoldWholeVolumesOrAccounts() { + let reckless = [URL(fileURLWithPath: "/"), URL(fileURLWithPath: "/Users"), URL(fileURLWithPath: "/Volumes")] + + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Users"), allowedRoots: reckless)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Applications"), allowedRoots: reckless)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Volumes/Backup"), allowedRoots: reckless)) + XCTAssertFalse(UninstallerLeftovers.isRemovable(URL(fileURLWithPath: "/Library/Caches/com.example.app"), allowedRoots: [])) + } + + // MARK: - Symlinks + + func testFenceRefusesASymlinkThatResolvesOutsideEveryAllowedRoot() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + + let planted = userLibrary.appendingPathComponent("Caches/com.example.app") + try FileManager.default.createSymbolicLink(at: planted, withDestinationURL: secrets) + + XCTAssertFalse(UninstallerLeftovers.isRemovable(planted, allowedRoots: roots)) + } + + func testFenceRefusesASymlinkAimedAtTheHomeDirectory() throws { + let planted = userLibrary.appendingPathComponent("Application Support/com.example.app") + try FileManager.default.createSymbolicLink( + at: planted, + withDestinationURL: FileManager.default.homeDirectoryForCurrentUser + ) + + XCTAssertFalse(UninstallerLeftovers.isRemovable(planted, allowedRoots: roots)) + } + + func testFenceStillAcceptsASymlinkThatStaysInsideAnAllowedRoot() throws { + let real = try makeLeaf(userLibrary, "Caches", "com.example.real") + let planted = userLibrary.appendingPathComponent("Caches/com.example.app") + try FileManager.default.createSymbolicLink(at: planted, withDestinationURL: real) + + XCTAssertTrue(UninstallerLeftovers.isRemovable(planted, allowedRoots: roots)) + } + + func testScanDropsAnEscapingSymlinkInsteadOfOfferingIt() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + try Data(repeating: 0x42, count: 4_096).write(to: secrets.appendingPathComponent("taxes.pdf")) + + try FileManager.default.createSymbolicLink( + at: userLibrary.appendingPathComponent("Caches/com.example.app"), + withDestinationURL: secrets + ) + try makeLeaf(userLibrary, "Logs", "com.example.app") + + let found = scan(bundleIdentifier: "com.example.app") + + XCTAssertEqual(found.map(\.candidate.label), ["Logs"]) + XCTAssertTrue(FileManager.default.fileExists(atPath: secrets.appendingPathComponent("taxes.pdf").path)) + } + + // MARK: - Removal refuses what the fence refuses + + func testMoveToTrashRefusesAnItemOutsideTheAllowedRootsAndLeavesItOnDisk() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + let victim = secrets.appendingPathComponent("taxes.pdf") + try Data(repeating: 0x43, count: 128).write(to: victim) + + // A hand-built item, i.e. exactly the shape a bug elsewhere in the tool would produce. + let smuggled = LeftoverItem( + candidate: LeftoverCandidate(url: victim, label: "Caches", scope: .user, match: .bundleIdentifier), + bytes: 128 + ) + + let result = LeftoverScanner.moveToTrash([smuggled], userLibrary: userLibrary, systemLibrary: systemLibrary) + + XCTAssertEqual(result.trashedCount, 0) + XCTAssertEqual(result.trashedBytes, 0) + XCTAssertEqual(result.failures.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path), "the fence must leave a refused path untouched") + } + + func testMoveToTrashRefusesAnEscapingSymlinkWithoutFollowingIt() throws { + let secrets = sandbox.appendingPathComponent("Documents") + try FileManager.default.createDirectory(at: secrets, withIntermediateDirectories: true) + let victim = secrets.appendingPathComponent("taxes.pdf") + try Data(repeating: 0x44, count: 128).write(to: victim) + + let planted = userLibrary.appendingPathComponent("Caches/com.example.app") + try FileManager.default.createSymbolicLink(at: planted, withDestinationURL: secrets) + + let smuggled = LeftoverItem( + candidate: LeftoverCandidate(url: planted, label: "Caches", scope: .user, match: .bundleIdentifier), + bytes: 128 + ) + + let result = LeftoverScanner.moveToTrash([smuggled], userLibrary: userLibrary, systemLibrary: systemLibrary) + + XCTAssertEqual(result.trashedCount, 0) + XCTAssertEqual(result.failures.count, 1) + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path)) + XCTAssertNotNil(try? FileManager.default.destinationOfSymbolicLink(atPath: planted.path)) + } + + func testMoveToTrashOnNothingIsANoOp() { + let result = LeftoverScanner.moveToTrash([], userLibrary: userLibrary, systemLibrary: systemLibrary) + + XCTAssertEqual(result, LeftoverRemovalResult()) + } + + // MARK: - Selection arithmetic + + func testSelectedBytesCountsOnlyTheTickedRows() { + let items = candidates(bundleIdentifier: "com.example.app") + .prefix(3) + .enumerated() + .map { LeftoverItem(candidate: $1, bytes: UInt64(($0 + 1) * 100)) } + + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: []), 0) + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: [items[0].id, items[2].id]), 400) + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: Set(items.map(\.id))), 600) + } + + func testSelectedBytesIgnoresIdentifiersThatAreNotInTheList() { + let items = candidates(bundleIdentifier: "com.example.app") + .prefix(1) + .map { LeftoverItem(candidate: $0, bytes: 500) } + + XCTAssertEqual(UninstallerLeftovers.selectedBytes(in: items, selection: ["/some/other/path"]), 0) + } +} diff --git a/Tests/DMonteCoreTests/UninstallerSelectionTests.swift b/Tests/DMonteCoreTests/UninstallerSelectionTests.swift index 4e294e7..c8a32a7 100644 --- a/Tests/DMonteCoreTests/UninstallerSelectionTests.swift +++ b/Tests/DMonteCoreTests/UninstallerSelectionTests.swift @@ -37,4 +37,147 @@ final class UninstallerSelectionTests: XCTestCase { XCTAssertNil(selected) } + + // MARK: - The details pane + + /// The regression this file exists for. Uninstalling the last app that matches the current + /// search is the *common* case — users search to find the app they want gone — and it is also + /// the case where the leftover sweep has most likely been half-refused, because the tool is + /// not privileged and cannot touch `/Library`. Rendering the report inside the selected-app + /// branch threw it away exactly then, and the user walked away believing the machine was clean. + func testDetailsPaneStillReportsTheSweepWhenTheUninstallEmptiedTheList() { + var apps = [alpha, beta] + let searchText = "Alpha" + + // What `performUninstall` does after macOS accepts the bundle. + apps.removeAll { $0.id == alpha.id } + let filtered = UninstallerSelection.filteredApps(apps, query: searchText) + let selectedID = filtered.first?.id + let feedback = UninstallerFeedback.sweepFinished( + appName: alpha.name, + result: LeftoverRemovalResult( + trashedCount: 3, + trashedBytes: 412_000_000, + failures: [ + LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied") + ] + ) + ) + + XCTAssertTrue(filtered.isEmpty) + XCTAssertNil(selectedID) + + let pane = UninstallerDetailsPane.resolve( + filteredApps: filtered, + selectedID: selectedID, + feedback: feedback + ) + + XCTAssertNil(pane.app) + XCTAssertEqual(pane.placeholder, "No apps found") + XCTAssertTrue(pane.showsFeedback) + XCTAssertEqual(pane.feedback, feedback) + } + + func testDetailsPaneCarriesTheFeedbackAlongsideASelectedApp() { + let filtered = UninstallerSelection.filteredApps([alpha, beta], query: "") + let feedback = UninstallerFeedback.bundleTrashed(appName: "Gamma") + + let pane = UninstallerDetailsPane.resolve( + filteredApps: filtered, + selectedID: beta.id, + feedback: feedback + ) + + XCTAssertEqual(pane.app, beta) + XCTAssertNil(pane.placeholder) + XCTAssertEqual(pane.feedback, feedback) + } + + func testDetailsPaneShowsNoFeedbackWhenNothingHasHappenedYet() { + let pane = UninstallerDetailsPane.resolve( + filteredApps: [], + selectedID: nil, + feedback: .empty + ) + + XCTAssertNil(pane.app) + XCTAssertFalse(pane.showsFeedback) + } + + // MARK: - Uninstall feedback + + /// A partial sweep has to say both halves. Reporting only the failure sends the user hunting + /// in `~/Library` for files that are already in the Trash, or re-running the sweep. + func testSweepFeedbackReportsWhatMovedAndWhatDidNot() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult( + trashedCount: 3, + trashedBytes: 412_000_000, + failures: [ + LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied"), + LeftoverRemovalFailure(path: "/Library/Logs/com.example.alpha", reason: "Permission denied") + ] + ) + ) + + XCTAssertNotNil(feedback.status) + XCTAssertTrue(feedback.status?.contains("Alpha") == true) + XCTAssertTrue(feedback.status?.contains("3 support items") == true) + + XCTAssertTrue(feedback.failure?.contains("2 support items") == true) + XCTAssertTrue(feedback.failure?.contains("/Library/Caches/com.example.alpha") == true) + } + + func testSweepFeedbackNamesTheOnlyFailureOutright() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult( + trashedCount: 1, + trashedBytes: 1024, + failures: [LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied")] + ) + ) + + XCTAssertTrue(feedback.status?.contains("1 support item ") == true) + XCTAssertEqual(feedback.failure, "Could not remove /Library/Caches/com.example.alpha: Permission denied") + } + + func testSweepFeedbackDropsTheItemCountWhenEverySweepFailed() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult( + trashedCount: 0, + trashedBytes: 0, + failures: [LeftoverRemovalFailure(path: "/Library/Caches/com.example.alpha", reason: "Permission denied")] + ) + ) + + XCTAssertEqual(feedback.status, "Moved Alpha to Trash.") + XCTAssertNotNil(feedback.failure) + } + + func testSweepFeedbackWithNoFailuresHasNothingRedToSay() { + let feedback = UninstallerFeedback.sweepFinished( + appName: "Alpha", + result: LeftoverRemovalResult(trashedCount: 2, trashedBytes: 2048) + ) + + XCTAssertNotNil(feedback.status) + XCTAssertNil(feedback.failure) + } + + func testRefusedBundleReportsOnlyTheFailure() { + let feedback = UninstallerFeedback.bundleRefused(reason: "Operation not permitted") + + XCTAssertNil(feedback.status) + XCTAssertEqual(feedback.failure, "Operation not permitted") + XCTAssertFalse(feedback.isEmpty) + } + + func testEmptyFeedbackIsEmpty() { + XCTAssertTrue(UninstallerFeedback.empty.isEmpty) + XCTAssertFalse(UninstallerFeedback.bundleTrashed(appName: "Alpha").isEmpty) + } }