diff --git a/README.md b/README.md index f375350..49780a1 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Open `Package.swift` in Xcode to edit the project. Use the bundled app above to - Type to filter applications; the first match is selected automatically. - Up/Down or Control-K/Control-J changes the selection without leaving search or modifying its text. - Return, keypad Enter, the Open Application button, or a double-click opens the selection. +- Command-K or the bottom-right Actions button opens a searchable selector for the selected application. Return chooses **Uninstall application**; Escape closes the selector and returns focus to search. - Escape or Command-W dismisses the launcher and restores the previously active application. Switching focus away dismisses it without stealing focus back. - Reopen from Finder to show it again with an empty search. Dismissal leaves the process running; Command-Q quits it. - Every activation brings the launcher to the currently active macOS Space, including full-screen Spaces, without switching desktops. @@ -74,6 +75,20 @@ Background discovery preserves the selected application even if its row changes. Applications with identical display names show a muted containing-folder hint beside the name. Copies sharing the same folder show their full application bundle paths instead. Home paths abbreviate to `~`, and long hints truncate in the middle. Unique names have no hint; locations are recalculated whenever discovery refreshes. +## Application actions and uninstall + +Select an application and open **Actions ⌘K**. The red **Uninstall application** action opens a review sheet with file checkboxes, paths, sizes, and a selected-item count. Filtering by name or path does not change selections. Up/Down highlights rows; Space toggles the highlighted eligible item when the file list has focus. Back or Escape returns to the launcher. Return or the uninstall button opens a final confirmation; nothing is moved merely by choosing the action. + +Discovery includes the application bundle and exact bundle-ID paths in your Library: Preferences, Caches, Application Support, Logs, Saved Application State, Application Scripts, and Containers. Embedded extension identifiers are included. App-group entitlements are read from validated native code signatures; related shared Group Containers and Application Scripts remain unchecked because other applications may use them. Conjure does not guess name-based leftovers or include package receipts. This is conservative discovery, not a guarantee that every associated file is found. + +System-managed applications, Conjure itself, invalid bundles, symbolic-link paths, locked items, and files owned by another user are not removable. Locked candidates show a reason. A locked application bundle can still have eligible user-owned associated data; removing that data does not remove the locked application itself. No administrator authorization is requested. Running instances of the selected application and its embedded applications are asked to quit gracefully; refusal or timeout stops the operation before moving files. + +Uninstall freezes the selected paths and records filesystem identities, then stages items by exclusive same-volume moves into private quarantine directories. A durable journal records each intended move before it happens. Staging failure attempts reverse-order restoration without overwriting recreated original paths. Only after every item is staged is the operation committed and finalized into Trash. A finalization failure retains committed quarantine data rather than trying to undo an already committed uninstall. + +Interrupted operations are recovered on startup: uncommitted operations restore their originals; committed operations finish moving items to Trash. Unresolved operations block new uninstalls. Failure feedback offers **Retry recovery** and identifies retained journal/quarantine paths. Startup recovery feedback and retry remain available in Settings without taking focus during a login launch. Authenticated journals live under `~/Library/Application Support/Conjure/UninstallTransactions`; completed journals retain original-to-Trash mappings in `Completed`. + +Files are never permanently deleted by uninstall. Trash moves preserve file metadata, but Finder's **Put Back** metadata is not created; restore completed items manually using the retained original-path mappings. Rollback is best-effort, not atomic: inaccessible or disconnected volumes and recreated paths can require manual recovery. Same-user malicious code with access to the journal key is outside the journal-authentication guarantee. Unsupported filesystem semantics fail closed. + ## Native UI The launcher keeps its Raycast-inspired 750 × 474-point, titlebar-free rounded panel, large borderless search field, compact icon rows, right-aligned application labels, and bottom-right launch pill. Its Sunset palette uses a dark brown background, soft amber and rust glows, warm cream text, and orange accents on the caret, selection, and action outline. @@ -86,8 +101,8 @@ The brown tint is 35% opaque over an active, behind-window `NSVisualEffectView` AppKit still provides text editing, table navigation, scrolling, application icons, controls, menus, fonts, and visual-effect materials. The background uses cached native radial gradients that fade to transparent: static soft blobs, without animations, timers, image assets, blur filters, or third-party UI dependencies. Decorative drawing does not intercept clicks. Increased-contrast settings strengthen the selection outline, and text colors are tuned for readability across the glows and selection. -- `Sources/Conjure`: application lifecycle, launcher and Settings windows, and application launching. -- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, and launcher alignment geometry. -- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, and installation safety checks. +- `Sources/Conjure`: application lifecycle, launcher, Settings, contextual actions and uninstall review windows, and application launching. +- `Sources/ConjureKit`: filesystem discovery, search ranking, launch history, global shortcut registration, launcher alignment geometry, associated-file discovery, and journaled uninstall/recovery. +- `Tests/ConjureKitTests`: deterministic discovery, ranking, history, shortcut validation, alignment boundaries, installation safety, and uninstall failure/recovery checks. The launcher uses Apple's [visual-effect materials](https://developer.apple.com/documentation/appkit/nsvisualeffectview), [table row selection drawing](https://developer.apple.com/documentation/appkit/nstablerowview/drawselection(in:)), [application launching](https://developer.apple.com/documentation/appkit/nsworkspace/openapplication(at:configuration:completionhandler:)), and [reopen lifecycle](https://developer.apple.com/documentation/appkit/nsapplicationdelegate/applicationshouldhandlereopen(_:hasvisiblewindows:)). diff --git a/Sources/Conjure/ApplicationActionsController.swift b/Sources/Conjure/ApplicationActionsController.swift new file mode 100644 index 0000000..f8853fc --- /dev/null +++ b/Sources/Conjure/ApplicationActionsController.swift @@ -0,0 +1,121 @@ +import AppKit +import ConjureKit + +@MainActor +final class ApplicationActionsController: NSViewController, NSSearchFieldDelegate, NSPopoverDelegate { + private let popover = NSPopover() + private let search = ActionsSearchField() + private let action = NSButton(title: "Uninstall application", target: nil, action: nil) + private let empty = NSTextField(labelWithString: "No matching actions") + private let applicationLabel = NSTextField(labelWithString: "") + var uninstall: (() -> Void)? + var closed: (() -> Void)? + var isShown: Bool { popover.isShown } + + init() { + super.init(nibName: nil, bundle: nil) + popover.contentViewController = self + popover.behavior = .semitransient + popover.delegate = self + } + + required init?(coder: NSCoder) { fatalError("Conjure creates actions programmatically") } + + override func loadView() { + let content = NSVisualEffectView() + content.material = .popover + content.state = .active + content.appearance = NSAppearance(named: .darkAqua) + view = content + preferredContentSize = NSSize(width: 340, height: 150) + applicationLabel.textColor = SunsetPalette.secondaryText + applicationLabel.lineBreakMode = .byTruncatingTail + applicationLabel.font = .systemFont(ofSize: 12, weight: .medium) + action.isBordered = false + action.alignment = .left + action.font = .systemFont(ofSize: 16, weight: .medium) + action.contentTintColor = .systemRed + action.attributedTitle = NSAttributedString(string: action.title, attributes: [ + .foregroundColor: NSColor.systemRed, .font: NSFont.systemFont(ofSize: 16, weight: .medium) + ]) + action.image = NSImage(systemSymbolName: "trash", accessibilityDescription: nil) + action.imagePosition = .imageLeading + action.target = self + action.action = #selector(chooseAction) + action.setAccessibilityLabel("Uninstall application") + search.placeholderString = "Search actions…" + search.delegate = self + search.sendsSearchStringImmediately = true + search.setAccessibilityLabel("Search application actions") + search.closeActions = { [weak self] in self?.close() } + empty.textColor = SunsetPalette.secondaryText + empty.isHidden = true + let stack = NSStackView(views: [applicationLabel, action, empty, search]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 14), + search.widthAnchor.constraint(equalTo: stack.widthAnchor), + action.widthAnchor.constraint(equalTo: stack.widthAnchor), + applicationLabel.widthAnchor.constraint(equalTo: stack.widthAnchor) + ]) + } + + func show(for application: Application, relativeTo anchor: NSView) { + if isShown { close(); return } + _ = view + applicationLabel.stringValue = application.name + search.stringValue = "" + controlTextDidChange(Notification(name: NSControl.textDidChangeNotification)) + popover.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxY) + view.window?.makeFirstResponder(search) + } + + func close() { popover.performClose(nil) } + + func popoverDidClose(_ notification: Notification) { closed?() } + + func controlTextDidChange(_ notification: Notification) { + let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let matches = query.isEmpty || action.title.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil + action.isHidden = !matches + empty.isHidden = matches + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.insertNewline(_:)): + if !action.isHidden { chooseAction() } + case #selector(NSResponder.cancelOperation(_:)): + close() + case #selector(NSResponder.moveUp(_:)), #selector(NSResponder.moveDown(_:)): + return true // There is currently one contextual action. + default: return false + } + return true + } + + @objc private func chooseAction() { + close() + uninstall?() + } +} + +@MainActor +private final class ActionsSearchField: NSSearchField { + var closeActions: (() -> Void)? + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.modifierFlags.intersection([.command, .control, .option, .shift]) == .command, + event.charactersIgnoringModifiers?.lowercased() == "k" { + closeActions?() + return true + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/Sources/Conjure/ConjureApp.swift b/Sources/Conjure/ConjureApp.swift index 4be9608..b47456d 100644 --- a/Sources/Conjure/ConjureApp.swift +++ b/Sources/Conjure/ConjureApp.swift @@ -90,10 +90,12 @@ struct ConjureApp { @MainActor private final class ApplicationDelegate: NSObject, NSApplicationDelegate { - private lazy var launcher = LauncherWindowController() + private let uninstaller = ApplicationUninstaller() + private lazy var launcher = LauncherWindowController(uninstaller: uninstaller) private lazy var shortcut = GlobalShortcut { [weak self] in self?.launcher.toggle() } private lazy var settings = SettingsWindowController(shortcut: shortcut) private var startupShortcutError: String? + private var startupRecoveryMessage: String? private var isLoginLaunch: Bool { let event = NSAppleEventManager.shared().currentAppleEvent @@ -109,6 +111,15 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate { let quitItem = menu.addItem(withTitle: "Quit Conjure", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") quitItem.target = NSApplication.shared launcher.window?.contentView?.menu = menu + let loginLaunch = isLoginLaunch + settings.retryUninstallRecovery = { [uninstaller] in await uninstaller.recover() } + Task { + let report = await uninstaller.recover() + guard !report.messages.isEmpty else { return } + startupRecoveryMessage = report.messages.joined(separator: "\n") + settings.setUninstallRecoveryReport(report) + if !loginLaunch { showSettings(nil) } + } if !isLoginLaunch { launcher.present() } @@ -125,7 +136,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate { func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { guard !isLoginLaunch else { return false } launcher.present() - if startupShortcutError != nil { + if startupShortcutError != nil || startupRecoveryMessage != nil { showSettings(nil) } return false @@ -134,6 +145,7 @@ private final class ApplicationDelegate: NSObject, NSApplicationDelegate { @objc func showSettings(_ sender: Any?) { settings.present(error: startupShortcutError) startupShortcutError = nil + startupRecoveryMessage = nil } func applicationWillTerminate(_ notification: Notification) { diff --git a/Sources/Conjure/LauncherWindowController.swift b/Sources/Conjure/LauncherWindowController.swift index ef1ff49..fff8483 100644 --- a/Sources/Conjure/LauncherWindowController.swift +++ b/Sources/Conjure/LauncherWindowController.swift @@ -17,9 +17,14 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private var previousApplication: NSRunningApplication? private var alignmentGuides: LauncherAlignmentGuides? private static let positionKey = "launcherPosition" + private let uninstaller: ApplicationUninstaller + private let actionsButton = NSButton(title: "Actions ⌘K", target: nil, action: nil) + private let actions = ApplicationActionsController() + private var uninstallReview: UninstallReviewController? - init(history: LaunchHistory = LaunchHistory()) { + init(history: LaunchHistory = LaunchHistory(), uninstaller: ApplicationUninstaller = ApplicationUninstaller()) { self.history = history + self.uninstaller = uninstaller let window = LauncherPanel( contentRect: NSRect(x: 0, y: 0, width: 750, height: 474), styleMask: [.borderless], @@ -46,6 +51,11 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe self.alignmentGuides?.end(snapping: self.window) self.savePosition() } + actions.closed = { [weak self] in + guard let self else { return } + if NSApp.isActive { self.window?.makeFirstResponder(self.searchField) } + else { self.dismiss(restoringFocus: false) } + } restorePosition() } @@ -81,6 +91,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe func dismiss(restoringFocus: Bool = true) { guard !restoringFocus || (!isLaunching && window?.attachedSheet == nil) else { return } + actions.close() alignmentGuides?.end() let previous = previousApplication previousApplication = nil @@ -132,7 +143,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe } func windowDidResignKey(_ notification: Notification) { - guard !isLaunching, window?.attachedSheet == nil else { return } + guard !isLaunching, !actions.isShown, window?.attachedSheet == nil else { return } dismiss(restoringFocus: false) } @@ -228,6 +239,16 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe openButton.target = self openButton.action = #selector(openSelection) openButton.isEnabled = false + actionsButton.isBordered = false + actionsButton.font = .systemFont(ofSize: 14, weight: .medium) + actionsButton.contentTintColor = SunsetPalette.text + actionsButton.keyEquivalent = "k" + actionsButton.keyEquivalentModifierMask = .command + actionsButton.target = self + actionsButton.action = #selector(showActions) + actionsButton.setAccessibilityLabel("Application actions") + actionsButton.toolTip = "Actions for the selected application (Command-K)" + actionsButton.isEnabled = false openButton.toolTip = "Open the selected application (Return). Use ↑ and ↓ to select." let returnKey = NSTextField(labelWithString: "↵") returnKey.font = .systemFont(ofSize: 14) @@ -244,7 +265,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe actionPill.state = .active roundMaterialCorners(actionPill, radius: 18) actionPill.layer?.borderWidth = 0.5 - for view in [openButton, returnKey] { + for view in [openButton, returnKey, actionsButton] { view.translatesAutoresizingMaskIntoConstraints = false actionPill.addSubview(view) } @@ -282,7 +303,9 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe openButton.leadingAnchor.constraint(equalTo: actionPill.leadingAnchor, constant: 14), openButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.leadingAnchor.constraint(equalTo: openButton.trailingAnchor, constant: 8), - returnKey.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -12), + actionsButton.leadingAnchor.constraint(equalTo: returnKey.trailingAnchor, constant: 14), + actionsButton.trailingAnchor.constraint(equalTo: actionPill.trailingAnchor, constant: -14), + actionsButton.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.centerYAnchor.constraint(equalTo: actionPill.centerYAnchor), returnKey.widthAnchor.constraint(equalToConstant: 18), returnKey.heightAnchor.constraint(equalToConstant: 18), @@ -305,6 +328,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe ApplicationCatalog.discover() }.value guard let self else { return } + guard !Task.isCancelled else { return } self.applications = applications self.updateLocationLabels() self.discoveryTask = nil @@ -388,6 +412,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe private func updateOpenButton() { openButton.isEnabled = !isLaunching && results.indices.contains(tableView.selectedRow) + actionsButton.isEnabled = openButton.isEnabled } func tableViewSelectionDidChange(_ notification: Notification) { @@ -465,6 +490,30 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe return cell } + @objc private func showActions() { + guard !isLaunching, window?.attachedSheet == nil, results.indices.contains(tableView.selectedRow) else { return } + let application = results[tableView.selectedRow] + actions.uninstall = { [weak self] in self?.reviewUninstall(application) } + actions.show(for: application, relativeTo: actionsButton) + } + + private func reviewUninstall(_ application: Application) { + guard let window, window.attachedSheet == nil else { return } + let review = UninstallReviewController(application: application, uninstaller: uninstaller) + uninstallReview = review + review.closed = { [weak self] in + self?.uninstallReview = nil + self?.window?.makeFirstResponder(self?.searchField) + } + review.changed = { [weak self] in + guard let self else { return } + self.discoveryTask?.cancel() + self.discoveryTask = nil + self.refreshApplications() + } + review.present(on: window) + } + @objc private func openSelection() { guard !isLaunching, results.indices.contains(tableView.selectedRow) else { return } let application = results[tableView.selectedRow] @@ -551,7 +600,7 @@ private final class LauncherPanel: NSPanel { } @MainActor -private final class LauncherRowView: NSTableRowView { +final class LauncherRowView: NSTableRowView { override func drawSelection(in dirtyRect: NSRect) { let selection = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.75, dy: 0.75), xRadius: 10, yRadius: 10) SunsetPalette.selectionFill.setFill() diff --git a/Sources/Conjure/SettingsWindowController.swift b/Sources/Conjure/SettingsWindowController.swift index 4d9bdb1..c21e74d 100644 --- a/Sources/Conjure/SettingsWindowController.swift +++ b/Sources/Conjure/SettingsWindowController.swift @@ -12,6 +12,10 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { private let loginToggle = NSButton(checkboxWithTitle: "Launch at login", target: nil, action: nil) private let loginStatus = NSTextField(wrappingLabelWithString: "") private let loginSettings = NSButton(title: "Open Login Items Settings…", target: nil, action: nil) + private let recoveryLabel = NSTextField(wrappingLabelWithString: "") + private let recoveryButton = NSButton(title: "Retry uninstall recovery…", target: nil, action: nil) + private var recoveryReport: UninstallReport? + var retryUninstallRecovery: (() async -> UninstallReport)? init(shortcut: GlobalShortcut) { self.shortcut = shortcut @@ -113,6 +117,13 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { loginSettings.action = #selector(openLoginSettings) loginSettings.bezelStyle = .rounded loginStatus.setAccessibilityLabel("Launch at login status") + recoveryLabel.textColor = SunsetPalette.secondaryText + recoveryLabel.isHidden = true + recoveryLabel.setAccessibilityLabel("Uninstall recovery status") + recoveryButton.isHidden = true + recoveryButton.bezelStyle = .rounded + recoveryButton.target = self + recoveryButton.action = #selector(showUninstallRecovery) heading.font = .systemFont(ofSize: 20, weight: .semibold) heading.textColor = SunsetPalette.text @@ -128,7 +139,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { let stack = NSStackView(views: [ heading, controls, instructions, statusLabel, errorLabel, spotlight, keyboardSettings, - loginHeading, loginToggle, loginStatus, loginSettings + loginHeading, loginToggle, loginStatus, loginSettings, recoveryLabel, recoveryButton ]) stack.orientation = .vertical stack.alignment = .leading @@ -152,11 +163,38 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate { stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor, constant: -24), recorder.widthAnchor.constraint(greaterThanOrEqualToConstant: 190) ]) - for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus] { + for label in [instructions, statusLabel, errorLabel, spotlight, loginStatus, recoveryLabel] { label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } } + func setUninstallRecoveryReport(_ report: UninstallReport) { + recoveryReport = report + recoveryLabel.isHidden = report.messages.isEmpty && !report.needsRecovery + recoveryButton.isHidden = recoveryLabel.isHidden + recoveryLabel.stringValue = report.needsRecovery + ? "An interrupted uninstall needs attention. Files have been retained for recovery." + : "Interrupted uninstall recovery completed." + recoveryButton.title = report.needsRecovery ? "Retry uninstall recovery…" : "View recovery details…" + } + + @objc private func showUninstallRecovery() { + guard let report = recoveryReport, let window else { return } + recoveryButton.isEnabled = false + Task { + let result: UninstallReport + if report.needsRecovery, let retryUninstallRecovery { + result = await retryUninstallRecovery() + setUninstallRecoveryReport(result) + } else { result = report } + let alert = NSAlert() + alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete" + alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n") + await alert.beginSheetModal(for: window) + recoveryButton.isEnabled = true + } + } + private func refreshLogin() { let status = SMAppService.mainApp.status loginToggle.state = (status == .enabled || status == .requiresApproval) ? .on : .off diff --git a/Sources/Conjure/UninstallReviewController.swift b/Sources/Conjure/UninstallReviewController.swift new file mode 100644 index 0000000..0313252 --- /dev/null +++ b/Sources/Conjure/UninstallReviewController.swift @@ -0,0 +1,383 @@ +import AppKit +import ConjureKit + +@MainActor +final class UninstallReviewController: NSWindowController, NSWindowDelegate, NSSearchFieldDelegate, NSTableViewDataSource, NSTableViewDelegate { + private let application: Application + private let uninstaller: ApplicationUninstaller + private let search = NSSearchField() + private let table = UninstallFilesTable() + private let status = NSTextField(wrappingLabelWithString: "Finding associated files…") + private let remove = NSButton(title: "Uninstall application", target: nil, action: nil) + private let back = NSButton(title: "Back", target: nil, action: nil) + private var candidates: [UninstallCandidate] = [] + private var visible: [UninstallCandidate] = [] + private var selected: Set = [] + private var busy = false + private var scanTask: Task? + var closed: (() -> Void)? + var changed: (() -> Void)? + + init(application: Application, uninstaller: ApplicationUninstaller) { + self.application = application + self.uninstaller = uninstaller + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 540), + styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false) + window.title = "Uninstall \(application.name)" + window.appearance = NSAppearance(named: .darkAqua) + window.titlebarAppearsTransparent = true + window.backgroundColor = .clear + window.isOpaque = false + window.isReleasedWhenClosed = false + super.init(window: window) + window.delegate = self + configure(window) + } + + required init?(coder: NSCoder) { fatalError("Conjure creates uninstall review programmatically") } + + func present(on parent: NSWindow) { + guard let window else { return } + parent.beginSheet(window) + window.makeFirstResponder(search) + scanTask = Task { [weak self, application] in + do { + let found = try await Task.detached(priority: .userInitiated) { + try UninstallDiscovery.scan(application: application) + }.value + guard let self, !Task.isCancelled else { return } + candidates = found + selected = Set(found.filter { $0.isSelectable && $0.defaultSelected }.map(\.url)) + reload() + } catch { + guard let self, !Task.isCancelled else { return } + status.stringValue = "Could not inspect application files: \(error.localizedDescription)" + } + } + } + + private func configure(_ window: NSWindow) { + let content = NSVisualEffectView() + content.material = .hudWindow + content.blendingMode = .behindWindow + content.state = .active + window.contentView = content + let background = LauncherBackground(frame: content.bounds) + background.autoresizingMask = [.width, .height] + background.setAccessibilityElement(false) + content.addSubview(background) + search.placeholderString = "Filter files and folders by name or path…" + search.delegate = self + search.sendsSearchStringImmediately = true + search.setAccessibilityLabel("Filter uninstall files") + search.font = .systemFont(ofSize: 18) + search.textColor = SunsetPalette.text + search.isBordered = false + search.drawsBackground = false + search.focusRingType = .none + status.font = .systemFont(ofSize: 12) + status.textColor = SunsetPalette.secondaryText + status.setAccessibilityLabel("Uninstall selection and progress") + back.target = self + back.action = #selector(goBack) + back.bezelStyle = .rounded + remove.target = self + remove.action = #selector(confirmUninstall) + remove.contentTintColor = .systemRed + remove.bezelStyle = .rounded + remove.keyEquivalent = "\r" + remove.isEnabled = false + table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("file"))) + table.headerView = nil + table.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + table.backgroundColor = .clear + table.rowHeight = 56 + table.style = .plain + table.dataSource = self + table.delegate = self + table.allowsEmptySelection = false + table.setAccessibilityLabel("Files to uninstall") + table.toggle = { [weak self] in self?.toggleHighlighted() } + table.dismiss = { [weak self] in self?.goBack() } + let scroll = NSScrollView() + scroll.documentView = table + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.drawsBackground = false + scroll.contentView.drawsBackground = false + let footer = NSStackView(views: [back, NSView(), remove]) + footer.orientation = .horizontal + footer.spacing = 12 + for view in [search, status, scroll, footer] { + view.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(view) + } + NSLayoutConstraint.activate([ + search.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + search.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + search.topAnchor.constraint(equalTo: (window.contentLayoutGuide as! NSLayoutGuide).topAnchor, constant: 16), + search.heightAnchor.constraint(equalToConstant: 32), + status.leadingAnchor.constraint(equalTo: search.leadingAnchor), + status.trailingAnchor.constraint(equalTo: search.trailingAnchor), + status.topAnchor.constraint(equalTo: search.bottomAnchor, constant: 12), + scroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 12), + scroll.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12), + scroll.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12), + scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -16), + footer.leadingAnchor.constraint(equalTo: search.leadingAnchor), + footer.trailingAnchor.constraint(equalTo: search.trailingAnchor), + footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16), + footer.heightAnchor.constraint(equalToConstant: 32) + ]) + } + + private func reload() { + let query = search.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + visible = candidates.filter { query.isEmpty || $0.url.path.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil } + table.reloadData() + if !visible.isEmpty { table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) } + updateStatus() + } + + private func updateStatus() { + let chosen = candidates.filter { selected.contains($0.url) } + let size = chosen.compactMap(\.size).reduce(Int64(0), +) + let unknown = chosen.contains { $0.size == nil } ? " (some sizes unavailable)" : "" + status.stringValue = "\(chosen.count) of \(candidates.count) files selected · \(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))\(unknown)" + if visible.isEmpty && !candidates.isEmpty { status.stringValue += " · No matching files" } + remove.isEnabled = !busy && !chosen.isEmpty + } + + func controlTextDidChange(_ notification: Notification) { reload() } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool { + switch selector { + case #selector(NSResponder.moveDown(_:)): moveSelection(1) + case #selector(NSResponder.moveUp(_:)): moveSelection(-1) + case #selector(NSResponder.insertNewline(_:)): confirmUninstall() + case #selector(NSResponder.cancelOperation(_:)): goBack() + default: return false + } + return true + } + + private func moveSelection(_ offset: Int) { + guard !visible.isEmpty else { return } + let row = min(max(table.selectedRow + offset, 0), visible.count - 1) + table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + table.scrollRowToVisible(row) + } + + func numberOfRows(in tableView: NSTableView) -> Int { visible.count } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + LauncherRowView() + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let candidate = visible[row] + let identifier = NSUserInterfaceItemIdentifier("uninstallFile") + let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? UninstallFileCell) + ?? UninstallFileCell() + cell.identifier = identifier + cell.toggle.target = self + cell.toggle.action = #selector(toggleFile(_:)) + cell.toggle.url = candidate.url + cell.toggle.state = selected.contains(candidate.url) ? .on : .off + cell.toggle.isEnabled = candidate.isSelectable && !busy + cell.toggle.setAccessibilityLabel("Select \(candidate.url.lastPathComponent)") + cell.name.stringValue = candidate.url.lastPathComponent + cell.name.textColor = candidate.isSelectable ? SunsetPalette.text : SunsetPalette.secondaryText + let path = (candidate.url.deletingLastPathComponent().path as NSString).abbreviatingWithTildeInPath + cell.detail.stringValue = candidate.reason.map { "\(path) · \($0)" } ?? path + cell.size.stringValue = candidate.size.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) } ?? "—" + cell.fileIcon.image = candidate.isSelectable ? NSWorkspace.shared.icon(forFile: candidate.url.path) + : NSImage(systemSymbolName: "lock", accessibilityDescription: "Unavailable") + cell.toolTip = candidate.url.path + (candidate.reason.map { "\n\($0)" } ?? "") + return cell + } + + @objc private func toggleFile(_ sender: UninstallFileToggle) { + guard !busy, let url = sender.url else { return } + if sender.state == .on { selected.insert(url) } else { selected.remove(url) } + updateStatus() + } + + private func toggleHighlighted() { + guard !busy, visible.indices.contains(table.selectedRow) else { return } + let candidate = visible[table.selectedRow] + guard candidate.isSelectable else { return } + if !selected.insert(candidate.url).inserted { selected.remove(candidate.url) } + table.reloadData() + updateStatus() + } + + private func runningApplications() -> [NSRunningApplication] { + NSWorkspace.shared.runningApplications.filter { + guard let url = $0.bundleURL?.standardizedFileURL.resolvingSymlinksInPath() else { return false } + return url.path == application.url.path || url.path.hasPrefix(application.url.path + "/") + } + } + + @objc private func confirmUninstall() { + guard !busy, !selected.isEmpty, let window else { return } + let urls = candidates.filter { $0.isSelectable && selected.contains($0.url) }.map(\.url) + guard !urls.isEmpty else { return } + let running = runningApplications() + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Uninstall \(application.name)?" + alert.informativeText = "Move \(urls.count) selected files and folders to Trash." + if !running.isEmpty { alert.informativeText += " Save your work first; Conjure will ask the application to quit." } + alert.addButton(withTitle: running.isEmpty ? "Uninstall application" : "Quit and Uninstall") + alert.addButton(withTitle: "Cancel") + setBusy(true) + Task { + guard await alert.beginSheetModal(for: window) == .alertFirstButtonReturn else { + setBusy(false) + return + } + do { + status.stringValue = "Waiting for application to quit…" + for app in running where !app.isTerminated { + guard app.terminate() else { throw ReviewError("\(app.localizedName ?? application.name) could not quit. No files were moved.") } + } + for _ in 0..<100 { + if runningApplications().isEmpty { break } + try await Task.sleep(for: .milliseconds(100)) + } + guard runningApplications().isEmpty else { throw ReviewError("The application is still running. No files were moved.") } + status.stringValue = "Uninstalling \(application.name)…" + let report = try await uninstaller.execute(urls) + changed?() + if report.needsRecovery || !report.messages.isEmpty { + await showFeedback(report.needsRecovery ? "Uninstall needs attention" : "Uninstall complete", + report.messages.joined(separator: "\n"), allowRecovery: report.needsRecovery) + } + setBusy(false) + goBack() + } catch { + changed?() + await showFeedback("Could not uninstall \(application.name)", error.localizedDescription, + allowRecovery: !(error is ReviewError)) + setBusy(false) + // Refresh identities and availability after a failed operation before another review. + do { + candidates = try await Task.detached { [application] in + try UninstallDiscovery.scan(application: application) + }.value + selected.formIntersection(Set(candidates.filter(\.isSelectable).map(\.url))) + reload() + } catch { status.stringValue = error.localizedDescription; remove.isEnabled = false } + } + } + } + + private func showFeedback(_ title: String, _ message: String, allowRecovery: Bool = false) async { + guard let window else { return } + var title = title + var message = message + var canRecover = allowRecovery + while true { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: "OK") + if canRecover { alert.addButton(withTitle: "Retry recovery") } + guard await alert.beginSheetModal(for: window) == .alertSecondButtonReturn, canRecover else { return } + status.stringValue = "Recovering pending uninstall…" + let report = await uninstaller.recover() + changed?() + title = report.needsRecovery ? "Recovery needs attention" : "Recovery complete" + message = report.messages.isEmpty ? "No pending files need recovery." : report.messages.joined(separator: "\n") + canRecover = report.needsRecovery + } + } + + private func setBusy(_ value: Bool) { + busy = value + search.isEnabled = !value + back.isEnabled = !value + remove.isEnabled = !value && !selected.isEmpty + table.reloadData() + if !value { updateStatus() } + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + if !busy { goBack() } + return false + } + + @objc private func goBack() { + guard !busy, let window, let parent = window.sheetParent else { return } + scanTask?.cancel() + parent.endSheet(window) + window.orderOut(nil) + closed?() + } +} + +private struct ReviewError: LocalizedError { + let errorDescription: String? + init(_ message: String) { errorDescription = message } +} + +@MainActor +private final class UninstallFileToggle: NSButton { + var url: URL? +} + +@MainActor +private final class UninstallFileCell: NSTableCellView { + let toggle = UninstallFileToggle(checkboxWithTitle: "", target: nil, action: nil) + let name = NSTextField(labelWithString: "") + let detail = NSTextField(labelWithString: "") + let size = NSTextField(labelWithString: "") + let fileIcon = NSImageView() + + init() { + super.init(frame: .zero) + name.font = .systemFont(ofSize: 14, weight: .medium) + detail.font = .systemFont(ofSize: 11) + detail.textColor = SunsetPalette.secondaryText + for label in [name, detail] { + label.lineBreakMode = .byTruncatingMiddle + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + } + let labels = NSStackView(views: [name, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 3 + size.textColor = SunsetPalette.secondaryText + size.font = .systemFont(ofSize: 12) + size.setContentCompressionResistancePriority(.required, for: .horizontal) + fileIcon.imageScaling = .scaleProportionallyDown + let row = NSStackView(views: [toggle, labels, NSView(), size, fileIcon]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 10 + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + NSLayoutConstraint.activate([ + row.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + row.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + row.topAnchor.constraint(equalTo: topAnchor, constant: 4), + row.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + fileIcon.widthAnchor.constraint(equalToConstant: 22), + fileIcon.heightAnchor.constraint(equalToConstant: 22) + ]) + } + + required init?(coder: NSCoder) { fatalError("Conjure creates uninstall rows programmatically") } +} + +@MainActor +private final class UninstallFilesTable: NSTableView { + var toggle: (() -> Void)? + var dismiss: (() -> Void)? + override func keyDown(with event: NSEvent) { + if event.keyCode == 49 { toggle?() } + else if event.keyCode == 53 { dismiss?() } + else { super.keyDown(with: event) } + } +} diff --git a/Sources/ConjureKit/ApplicationUninstaller.swift b/Sources/ConjureKit/ApplicationUninstaller.swift new file mode 100644 index 0000000..a494db8 --- /dev/null +++ b/Sources/ConjureKit/ApplicationUninstaller.swift @@ -0,0 +1,738 @@ +import CryptoKit +import Darwin +import Foundation + +public struct UninstallReport: Sendable { + public let messages: [String] + public let needsRecovery: Bool + + public init(messages: [String], needsRecovery: Bool) { + self.messages = messages + self.needsRecovery = needsRecovery + } +} + +// Internal checkpoints interrupt the real filesystem implementation, rather than replace it. +enum UninstallCheckpoint: Sendable { + case beforeStage(Int), afterStageMove(Int), beforeCommit + case beforeRollback(Int), beforeTrash(Int), afterTrashMove(Int) +} + +struct UninstallInterruption: Error {} + +public actor ApplicationUninstaller { + private let journalDirectory: URL + private let trashDirectory: @Sendable (URL) throws -> URL + private let checkpoint: @Sendable (UninstallCheckpoint) throws -> Void + + public init(journalDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Conjure/UninstallTransactions", isDirectory: true)) { + self.journalDirectory = journalDirectory + trashDirectory = Self.nativeTrashDirectory + checkpoint = { _ in } + } + + init( + journalDirectory: URL, + trashDirectory: @escaping @Sendable (URL) throws -> URL, + checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in } + ) { + self.journalDirectory = journalDirectory + self.trashDirectory = trashDirectory + self.checkpoint = checkpoint + } + + public func execute(_ urls: [URL]) throws -> UninstallReport { + let store = try Store(directory: journalDirectory) + let recovery = recover(in: store) + guard !recovery.needsRecovery else { + throw Failure("Uninstall cannot start until recovery is resolved. " + recovery.messages.joined(separator: " ")) + } + let originals = try selectedPaths(urls) + guard !originals.isEmpty else { throw Failure("No items were selected for uninstall.") } + let key = try store.key(create: true) + var journal = Journal(id: UUID().uuidString, roots: [], entries: [], committed: false) + do { + for (index, original) in originals.enumerated() { + let parent = try Directory(path: parentPath(original)) + let info = try requiredInfo(parent.fd, leaf(original)) + try validateCandidate(original, info: info) + let parentIdentity = try parent.identity() + guard parentIdentity.device == info.identity.device else { + throw Failure("A mounted volume itself cannot be uninstalled: \(original).") + } + let rootIndex: Int + if let existing = journal.roots.firstIndex(where: { $0.identity.device == info.identity.device }) { + rootIndex = existing + } else { + let name = ".conjure-uninstall-\(journal.id)-\(info.identity.device)" + try makeDirectory(parent.fd, name, mode: 0o700) + let root = try Directory(path: originalParentPath(original, name)) + try root.requirePrivate() + let identity = try root.identity() + guard identity.device == info.identity.device else { throw Failure("Quarantine is not on the selected item's volume.") } + journal.roots.append(Root(path: root.path, identity: identity, parentIdentity: parentIdentity)) + rootIndex = journal.roots.count - 1 + } + journal.entries.append(Entry(original: original, identity: info.identity, + parentIdentity: parentIdentity, root: rootIndex, + stagedName: "item-\(index)", state: .planned, trash: nil)) + } + try store.save(journal, key: key) + } catch { + // Initial persistence may have succeeded before its durability acknowledgement failed. + if (try? store.contains(journal)) != false { + throw Failure("Uninstall did not start: \(error.localizedDescription)" + retained(journal, store: store)) + } + let cleanup = removeEmptyRoots(journal) + throw Failure("Uninstall did not start: \(error.localizedDescription). No selected items were moved." + + (cleanup.isEmpty ? "" : retained(journal, store: store, details: cleanup))) + } + + do { + for index in journal.entries.indices { + journal.entries[index].state = .stageIntent + try store.save(journal, key: key) + try checkpoint(.beforeStage(index)) + let entry = journal.entries[index] + let parent = try originalParent(entry) + let root = try quarantine(journal.roots[entry.root]) + try requireIdentity(parent.fd, leaf(entry.original), entry.identity) + try move(parent, leaf(entry.original), root, entry.stagedName) + try checkpoint(.afterStageMove(index)) + try requireIdentity(root.fd, entry.stagedName, entry.identity) + journal.entries[index].state = .staged + try store.save(journal, key: key) + } + try checkpoint(.beforeCommit) + } catch is UninstallInterruption { + throw Failure("Uninstall was interrupted before commit; recovery will restore staged items." + retained(journal, store: store)) + } catch { + let failures = rollback(&journal, store: store, key: key) + let restored = journal.entries.allSatisfy { $0.state == .restored } + let outcome = restored ? " All selected items were restored." : " Rollback could not restore every item." + throw Failure("Uninstall failed: \(error.localizedDescription)." + outcome + + (failures.isEmpty ? "" : retained(journal, store: store, details: failures))) + } + + journal.committed = true + do { + try store.save(journal, key: key) + } catch { + // A failed durability acknowledgement may still have persisted the commit. Never guess and roll back. + return UninstallReport(messages: recovery.messages + ["Commit persistence needs recovery: \(error.localizedDescription)" + + retained(journal, store: store)], needsRecovery: true) + } + let failures = finalize(&journal, store: store, key: key) + if !failures.isEmpty { + return UninstallReport(messages: recovery.messages + ["Uninstall committed, but Trash finalization needs recovery." + + retained(journal, store: store, details: failures)], needsRecovery: true) + } + return UninstallReport(messages: recovery.messages + ["Moved \(journal.entries.count) selected item(s) to Trash. Finder's Put Back command is not available; original paths and Trash destinations are retained in \(store.path)/Completed/\(journal.id).json."], needsRecovery: false) + } + + public func recover() -> UninstallReport { + do { return recover(in: try Store(directory: journalDirectory)) } + catch { return UninstallReport(messages: ["Uninstall recovery could not open \(journalDirectory.path): \(error.localizedDescription). No files were moved."], needsRecovery: true) } + } + + private func recover(in store: Store) -> UninstallReport { + var messages: [String] = [] + var needsRecovery = false + do { + let names = try store.journalNames() + guard !names.isEmpty else { return UninstallReport(messages: [], needsRecovery: false) } + let key = try store.key(create: false) + for name in names { + do { + var journal = try store.load(name, key: key) + try validate(journal, name: name) + let committed = journal.committed + let failures = committed ? finalize(&journal, store: store, key: key) + : rollback(&journal, store: store, key: key) + if failures.isEmpty { + messages.append(committed ? "Recovered a committed uninstall and finished moving its items to Trash. Original paths are retained in \(store.path)/Completed/\(journal.id).json; Finder Put Back is unavailable." + : "Recovered an interrupted uninstall and restored its original items.") + } else { + needsRecovery = true + messages.append("Uninstall recovery needs attention." + retained(journal, store: store, details: failures)) + } + } catch { + needsRecovery = true + messages.append("Recovery refused journal \(store.path)/\(name): \(error.localizedDescription). No moves were authorized by this journal; retain it and its quarantine for manual inspection.") + } + } + } catch { + needsRecovery = true + messages.append("Recovery refused journals in \(store.path): \(error.localizedDescription). No files were moved.") + } + return UninstallReport(messages: messages, needsRecovery: needsRecovery) + } + + private func rollback(_ journal: inout Journal, store: Store, key: SymmetricKey) -> [String] { + var failures: [String] = [] + for index in journal.entries.indices.reversed() { + if journal.entries[index].state == .restored { continue } + do { + let entry = journal.entries[index] + let parent = try originalParent(entry) + let root = try quarantine(journal.roots[entry.root]) + let original = try fileInfo(parent.fd, leaf(entry.original)) + let staged = try fileInfo(root.fd, entry.stagedName) + if let staged { + guard staged.identity == entry.identity else { throw Failure("Quarantined identity changed; nothing at that path will be moved.") } + guard original == nil else { throw Failure("Original path is occupied; it will not be overwritten.") } + journal.entries[index].state = .rollbackIntent + try store.save(journal, key: key) + try checkpoint(.beforeRollback(index)) + try requireIdentity(root.fd, entry.stagedName, entry.identity) + try move(root, entry.stagedName, parent, leaf(entry.original)) + try requireIdentity(parent.fd, leaf(entry.original), entry.identity) + } else { + guard original?.identity == entry.identity else { throw Failure("Original identity is missing or changed; no unrelated file will be moved.") } + } + journal.entries[index].state = .restored + try store.save(journal, key: key) + } catch { + failures.append("\(journal.entries[index].original): \(error.localizedDescription)") + } + } + if failures.isEmpty { failures += finish(journal, store: store) } + return failures + } + + private func finalize(_ journal: inout Journal, store: Store, key: SymmetricKey) -> [String] { + var failures: [String] = [] + for index in journal.entries.indices { + if journal.entries[index].state == .trashed { continue } + do { + var entry = journal.entries[index] + let root = try quarantine(journal.roots[entry.root]) + if entry.trash == nil { + let url = try trashDirectory(URL(fileURLWithPath: entry.original)) + let trash = try Directory(path: checkedPath(url)) + try trash.requirePrivate() + let identity = try trash.identity() + guard identity.device == entry.identity.device else { throw Failure("Trash is on another volume; the committed item remains in quarantine.") } + let name = trashName(original: entry.original, id: journal.id, index: index) + entry.trash = Location(path: trash.path, identity: identity, name: name) + entry.state = .trashIntent + journal.entries[index] = entry + try store.save(journal, key: key) + } + guard let destination = entry.trash else { throw Failure("Missing Trash intent.") } + let trash = try Directory(path: destination.path) + try trash.requirePrivate() + guard try trash.identity() == destination.identity else { throw Failure("Trash directory identity changed.") } + let staged = try fileInfo(root.fd, entry.stagedName) + let alreadyTrashed = try fileInfo(trash.fd, destination.name) + if let staged { + guard staged.identity == entry.identity, alreadyTrashed == nil else { throw Failure("Staging identity changed or Trash destination is occupied; neither item will be overwritten.") } + try checkpoint(.beforeTrash(index)) + try requireIdentity(root.fd, entry.stagedName, entry.identity) + try move(root, entry.stagedName, trash, destination.name) + try checkpoint(.afterTrashMove(index)) + try requireIdentity(trash.fd, destination.name, entry.identity) + } else { + guard alreadyTrashed?.identity == entry.identity else { throw Failure("Cannot identify the item in either quarantine or its recorded Trash destination.") } + } + journal.entries[index].state = .trashed + try store.save(journal, key: key) + } catch is UninstallInterruption { + failures.append("Interrupted finalization of \(journal.entries[index].original).") + break + } catch { + failures.append("\(journal.entries[index].original): \(error.localizedDescription)") + } + } + if failures.isEmpty { failures += finish(journal, store: store) } + return failures + } + + private func finish(_ journal: Journal, store: Store) -> [String] { + let failures = removeEmptyRoots(journal) + guard failures.isEmpty else { return failures } + do { + if journal.committed { try store.archive(journal) } + else { try store.remove(journal) } + return [] + } catch { return ["Journal cleanup failed: \(error.localizedDescription)"] } + } + + private func removeEmptyRoots(_ journal: Journal) -> [String] { + journal.roots.compactMap { root in + do { + let parent = try Directory(path: parentPath(root.path)) + guard try parent.identity() == root.parentIdentity else { throw Failure("Quarantine parent identity changed.") } + guard let current = try fileInfo(parent.fd, leaf(root.path)) else { return nil } + guard current.identity == root.identity else { throw Failure("Quarantine identity changed.") } + guard unlinkat(parent.fd, leaf(root.path), AT_REMOVEDIR) == 0 else { throw posixFailure("Removing empty quarantine", root.path) } + try parent.sync() + return nil + } catch { return "Retained quarantine \(root.path): \(error.localizedDescription)" } + } + } + + private func selectedPaths(_ urls: [URL]) throws -> [String] { + let paths = try Set(urls.map(checkedPath)).sorted() + guard paths.count <= 512 else { throw Failure("Too many uninstall items were selected.") } + var selected: [String] = [] + var identities = Set() + for path in paths { + if selected.contains(where: { path.hasPrefix($0 + "/") }) { continue } + let parent = try Directory(path: parentPath(path)) + let info = try requiredInfo(parent.fd, leaf(path)) + try validateCandidate(path, info: info) + guard identities.insert(info.identity).inserted else { throw Failure("Selected paths refer to the same filesystem identity: \(path).") } + selected.append(path) + } + return selected + } + + private func validateCandidate(_ path: String, info: FileInfo) throws { + let own = Bundle.main.bundleURL.resolvingSymlinksInPath().path + let home = FileManager.default.homeDirectoryForCurrentUser.resolvingSymlinksInPath().path + let journal = try checkedPath(journalDirectory) + let protected = ["/System", "/usr", "/bin", "/sbin", "/dev", "/private/etc", "/Library/Apple"] + guard path != "/", path != "/private", path != home, + !protected.contains(where: { path == $0 || path.hasPrefix($0 + "/") }), + path != own, !own.hasPrefix(path + "/"), !path.hasPrefix(own + "/"), + path != journal, !journal.hasPrefix(path + "/"), !path.hasPrefix(journal + "/") else { + throw Failure("Protected path cannot be uninstalled: \(path).") + } + guard info.owner == getuid(), info.isRegular || info.isDirectory else { + throw Failure("Only your own regular files or directories without symlink ancestry can be uninstalled: \(path).") + } + guard info.flags & UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND) == 0 else { + throw Failure("Locked item cannot be uninstalled: \(path).") + } + } + + private func validate(_ journal: Journal, name: String) throws { + guard UUID(uuidString: journal.id)?.uuidString == journal.id, + name == journal.id + ".json", journal.version == 1, + !journal.entries.isEmpty, journal.entries.count <= 512, + !journal.roots.isEmpty, journal.roots.count <= journal.entries.count else { throw Failure("Invalid journal structure.") } + var paths: [String] = [] + var identities = Set() + var usedRoots = Set() + for (index, entry) in journal.entries.enumerated() { + guard try checkedPath(URL(fileURLWithPath: entry.original)) == entry.original, + journal.roots.indices.contains(entry.root), + entry.stagedName == "item-\(index)", + identities.insert(entry.identity).inserted, + !paths.contains(where: { entry.original == $0 || entry.original.hasPrefix($0 + "/") || $0.hasPrefix(entry.original + "/") }) else { throw Failure("Invalid immutable selection in journal.") } + let root = journal.roots[entry.root] + guard root.identity.device == entry.identity.device, + root.identity.kind == UInt32(S_IFDIR), + !entry.original.hasPrefix(root.path + "/") else { throw Failure("Invalid quarantine location.") } + if usedRoots.insert(entry.root).inserted { + guard root.parentIdentity == entry.parentIdentity, + root.path == originalParentPath(entry.original, ".conjure-uninstall-\(journal.id)-\(entry.identity.device)") else { + throw Failure("Invalid quarantine parent or location.") + } + } + if journal.committed { + guard [.staged, .trashIntent, .trashed].contains(entry.state), + (entry.state == .staged) == (entry.trash == nil) else { throw Failure("Invalid committed journal state.") } + } else { + guard entry.trash == nil, ![.trashIntent, .trashed].contains(entry.state) else { throw Failure("Invalid precommit journal state.") } + } + if let trash = entry.trash { + guard try checkedPath(URL(fileURLWithPath: trash.path)) == trash.path, + trash.identity.device == entry.identity.device, + trash.identity.kind == UInt32(S_IFDIR), + trash.name == trashName(original: entry.original, id: journal.id, index: index) else { + throw Failure("Invalid Trash intent.") + } + } + // Signed journals also undergo protected-path validation without trusting live candidate contents. + let synthetic = FileInfo(identity: entry.identity, owner: getuid(), flags: 0) + try validateCandidate(entry.original, info: synthetic) + paths.append(entry.original) + } + guard usedRoots.count == journal.roots.count else { throw Failure("Unused quarantine in journal.") } + } + + private func originalParent(_ entry: Entry) throws -> Directory { + let parent = try Directory(path: parentPath(entry.original)) + guard try parent.identity() == entry.parentIdentity else { throw Failure("Original parent directory identity changed; recovery will not use a replacement.") } + return parent + } + + private func quarantine(_ root: Root) throws -> Directory { + let directory = try Directory(path: root.path) + try directory.requirePrivate() + guard try directory.identity() == root.identity else { throw Failure("Quarantine directory identity changed.") } + return directory + } + + private func retained(_ journal: Journal, store: Store, details: [String] = []) -> String { + " Journal: \(store.path)/\(journal.id).json. Quarantine: " + + journal.roots.map(\.path).joined(separator: ", ") + ". " + details.joined(separator: " ") + } + + private static func nativeTrashDirectory(_ original: URL) throws -> URL { + let parent = try Directory(path: parentPath(checkedPath(original))) + let sourceDevice = try parent.identity().device + let homeTrash = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash", isDirectory: true) + let home = try Directory(path: homeTrash.deletingLastPathComponent().path) + if try home.identity().device == sourceDevice { + try makeDirectory(home.fd, ".Trash", mode: 0o700, allowExisting: true) + return homeTrash + } + var filesystem = statfs() + guard fstatfs(parent.fd, &filesystem) == 0 else { throw posixFailure("Finding volume", original.path) } + let mount = withUnsafePointer(to: &filesystem.f_mntonname) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { String(cString: $0) } + } + return try volumeTrashDirectory(URL(fileURLWithPath: mount, isDirectory: true)) + } + + // Also used by real-filesystem tests without requiring an external disk to be mounted. + static func volumeTrashDirectory(_ url: URL) throws -> URL { + let volume = try Directory(path: checkedPath(url)) + try makeDirectory(volume.fd, ".Trashes", mode: 0o1777, allowExisting: true) + let trashes = try Directory(path: volume.path + "/.Trashes") + var value = stat() + guard fstat(trashes.fd, &value) == 0, + value.st_uid == 0 || value.st_uid == getuid(), + value.st_mode & 0o002 == 0 || value.st_mode & mode_t(S_ISVTX) != 0 else { + throw Failure("Untrusted volume Trash directory: \(trashes.path).") + } + let user = String(getuid()) + try makeDirectory(trashes.fd, user, mode: 0o700, allowExisting: true) + let destination = try Directory(path: trashes.path + "/" + user) + try destination.requirePrivate() + return URL(fileURLWithPath: destination.path, isDirectory: true) + } +} + +private struct Failure: LocalizedError { + let errorDescription: String? + init(_ description: String) { errorDescription = description } +} + +private struct Identity: Codable, Hashable { + let device: UInt64 + let inode: UInt64 + let birthSeconds: Int64 + let birthNanoseconds: Int64 + let kind: UInt32 + + init(_ value: stat) { + device = UInt64(UInt32(bitPattern: value.st_dev)) + inode = UInt64(value.st_ino) + birthSeconds = Int64(value.st_birthtimespec.tv_sec) + birthNanoseconds = Int64(value.st_birthtimespec.tv_nsec) + kind = UInt32(value.st_mode) & UInt32(S_IFMT) + } +} + +private struct FileInfo { + let identity: Identity + let owner: uid_t + let flags: UInt32 + var isRegular: Bool { identity.kind == UInt32(S_IFREG) } + var isDirectory: Bool { identity.kind == UInt32(S_IFDIR) } +} + +private struct Root: Codable { + let path: String + let identity: Identity + let parentIdentity: Identity +} + +private struct Location: Codable { + let path: String + let identity: Identity + let name: String +} + +private enum State: String, Codable { case planned, stageIntent, staged, rollbackIntent, restored, trashIntent, trashed } + +private struct Entry: Codable { + let original: String + let identity: Identity + let parentIdentity: Identity + let root: Int + let stagedName: String + var state: State + var trash: Location? +} + +private struct Journal: Codable { + var version = 1 + let id: String + var roots: [Root] + var entries: [Entry] + var committed: Bool +} + +private struct Envelope: Codable { + let payload: Data + let authentication: Data +} + +// Descriptor-relative walks never follow ancestor symlinks. Renames are exclusive and never copy. +private final class Directory { + let path: String + let fd: Int32 + + init(path: String, create: Bool = false) throws { + self.path = try checkedPath(URL(fileURLWithPath: path)) + // Directory operations need search access, not permission to list contents (notably macOS Trash). + var current = open("/", O_SEARCH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard current >= 0 else { throw posixFailure("Opening root", path) } + do { + for component in self.path.split(separator: "/") { + let part = String(component) + if create { try makeDirectory(current, part, mode: 0o700, allowExisting: true) } + let next = openat(current, part, O_SEARCH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + guard next >= 0 else { throw posixFailure("Opening directory without symlink traversal", self.path) } + close(current) + current = next + } + fd = current + } catch { close(current); throw error } + } + + deinit { close(fd) } + + func identity() throws -> Identity { + var value = stat() + guard fstat(fd, &value) == 0 else { throw posixFailure("Reading directory identity", path) } + return Identity(value) + } + + func requirePrivate() throws { + var value = stat() + guard fstat(fd, &value) == 0, value.st_uid == getuid(), value.st_mode & 0o077 == 0 else { + throw Failure("Directory must be owned by you with private permissions: \(path).") + } + } + + func sync() throws { + guard fsync(fd) == 0 else { throw posixFailure("Persisting directory changes", path) } + } +} + +private final class Store { + let directory: Directory + let lock: Int32 + var path: String { directory.path } + private var savedIdentities: [String: Identity] = [:] + + init(directory url: URL) throws { + directory = try Directory(path: checkedPath(url), create: true) + try directory.requirePrivate() + lock = openat(directory.fd, ".lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard lock >= 0 else { throw posixFailure("Opening uninstall lock", directory.path) } + do { + try privateFile(lock, path: directory.path + "/.lock") + guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { throw Failure("Another uninstall or recovery is active. Try again when it finishes.") } + } catch { close(lock); throw error } + } + + deinit { close(lock) } + + func journalNames() throws -> [String] { + let copy = openat(directory.fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard copy >= 0, let stream = fdopendir(copy) else { + if copy >= 0 { close(copy) } + throw posixFailure("Listing uninstall journals", path) + } + defer { closedir(stream) } + var names: [String] = [] + while let item = readdir(stream) { + let name = withUnsafePointer(to: &item.pointee.d_name) { + $0.withMemoryRebound(to: CChar.self, capacity: Int(item.pointee.d_namlen) + 1) { String(cString: $0) } + } + if name == "." || name == ".." || name == ".lock" || name == "key" || name.hasPrefix(".tmp-") { continue } + if name == "Completed" { + let completed = try Directory(path: path + "/Completed") + try completed.requirePrivate() + continue + } + guard name.hasSuffix(".json") else { throw Failure("Unexpected entry in private journal directory: \(path)/\(name).") } + names.append(name) + } + return names.sorted() + } + + func key(create: Bool) throws -> SymmetricKey { + if let _ = try fileInfo(directory.fd, "key") { + let bytes = try readFile("key", maximum: 32) + guard bytes.count == 32 else { throw Failure("Invalid uninstall journal authentication key.") } + return SymmetricKey(data: bytes) + } + guard create else { throw Failure("Journal authentication key is missing; existing journals cannot be trusted.") } + let key = SymmetricKey(size: .bits256) + let bytes = key.withUnsafeBytes { Data($0) } + try writeFile(bytes, name: "key") + try directory.sync() + return key + } + + func load(_ name: String, key: SymmetricKey) throws -> Journal { + let bytes = try readFile(name, maximum: 4 * 1024 * 1024) + let envelope = try JSONDecoder().decode(Envelope.self, from: bytes) + guard HMAC.isValidAuthenticationCode(envelope.authentication, authenticating: envelope.payload, using: key) else { + throw Failure("Journal authentication failed (modified or untrusted journal).") + } + return try JSONDecoder().decode(Journal.self, from: envelope.payload) + } + + func contains(_ journal: Journal) throws -> Bool { + try fileInfo(directory.fd, journal.id + ".json") != nil + } + + func archive(_ journal: Journal) throws { + try makeDirectory(directory.fd, "Completed", mode: 0o700, allowExisting: true) + let completed = try Directory(path: path + "/Completed") + try completed.requirePrivate() + let name = journal.id + ".json" + try requireIdentity(directory.fd, name, savedIdentities[name]) + try move(directory, name, completed, name) + savedIdentities.removeValue(forKey: name) + } + + func save(_ journal: Journal, key: SymmetricKey) throws { + let payload = try JSONEncoder().encode(journal) + let authentication = Data(HMAC.authenticationCode(for: payload, using: key)) + let bytes = try JSONEncoder().encode(Envelope(payload: payload, authentication: authentication)) + guard bytes.count <= 4 * 1024 * 1024 else { throw Failure("Transaction journal exceeds its recoverable size limit.") } + let name = journal.id + ".json" + let temporary = ".tmp-" + UUID().uuidString + try writeFile(bytes, name: temporary) + if let current = try fileInfo(directory.fd, name) { + guard savedIdentities[name] == current.identity else { throw Failure("Journal identity changed; refusing to replace it: \(path)/\(name).") } + guard renameat(directory.fd, temporary, directory.fd, name) == 0 else { throw posixFailure("Replacing journal", path + "/" + name) } + } else { + guard savedIdentities[name] == nil else { throw Failure("Journal disappeared; refusing to recreate it.") } + guard renameatx_np(directory.fd, temporary, directory.fd, name, UInt32(RENAME_EXCL)) == 0 else { throw posixFailure("Creating journal", path + "/" + name) } + } + savedIdentities[name] = try requiredInfo(directory.fd, name).identity + try directory.sync() + } + + func remove(_ journal: Journal) throws { + let name = journal.id + ".json" + try requireIdentity(directory.fd, name, savedIdentities[name]) + guard unlinkat(directory.fd, name, 0) == 0 else { throw posixFailure("Removing completed journal", path + "/" + name) } + savedIdentities.removeValue(forKey: name) + try directory.sync() + } + + private func readFile(_ name: String, maximum: Int) throws -> Data { + let fd = openat(directory.fd, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) + guard fd >= 0 else { throw posixFailure("Reading journal file", path + "/" + name) } + defer { close(fd) } + try privateFile(fd, path: path + "/" + name) + var value = stat() + guard fstat(fd, &value) == 0, value.st_size >= 0, value.st_size <= maximum else { throw Failure("Journal file exceeds its size limit.") } + var data = Data(count: Int(value.st_size)) + try data.withUnsafeMutableBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.read(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw posixFailure("Reading complete journal", path + "/" + name) } + offset += count + } + } + savedIdentities[name] = Identity(value) + return data + } + + private func writeFile(_ bytes: Data, name: String) throws { + let fd = openat(directory.fd, name, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_EXCL, 0o600) + guard fd >= 0 else { throw posixFailure("Writing journal file", path + "/" + name) } + defer { close(fd) } + try privateFile(fd, path: path + "/" + name) + try bytes.withUnsafeBytes { buffer in + var offset = 0 + while offset < buffer.count { + let count = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) + if count < 0, errno == EINTR { continue } + guard count > 0 else { throw posixFailure("Writing complete journal", path + "/" + name) } + offset += count + } + } + guard fsync(fd) == 0 else { throw posixFailure("Persisting journal", path + "/" + name) } + // Full sync asks macOS to flush drive caches too; some filesystems support only fsync. + if fcntl(fd, F_FULLFSYNC) != 0, errno != EINVAL, errno != ENOTSUP { throw posixFailure("Flushing journal", path + "/" + name) } + } +} + +private func privateFile(_ fd: Int32, path: String) throws { + var value = stat() + guard fstat(fd, &value) == 0, value.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG), + value.st_uid == getuid(), value.st_mode & 0o077 == 0, value.st_nlink == 1 else { + throw Failure("Untrusted journal file (type, owner, permissions, or hard links): \(path).") + } +} + +private func checkedPath(_ url: URL) throws -> String { + let path = url.path + guard url.isFileURL, url.host == nil || url.host == "" || url.host == "localhost", + url.query == nil, url.fragment == nil, + path.hasPrefix("/"), !path.contains("\0"), path.utf8.count < 16_384, + !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == "." || $0 == ".." }) else { + throw Failure("Uninstall requires an absolute local path without dot components.") + } + return path == "/" ? path : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) +} + +private func parentPath(_ path: String) -> String { URL(fileURLWithPath: path).deletingLastPathComponent().path } +private func leaf(_ path: String) -> String { URL(fileURLWithPath: path).lastPathComponent } +private func originalParentPath(_ original: String, _ name: String) -> String { URL(fileURLWithPath: parentPath(original)).appendingPathComponent(name).path } + +private func trashName(original: String, id: String, index: Int) -> String { + let url = URL(fileURLWithPath: original) + let ext = url.pathExtension + let suffix = !ext.isEmpty && ext.utf8.count <= 32 ? "." + ext : "" + let base = suffix.isEmpty ? url.lastPathComponent : url.deletingPathExtension().lastPathComponent + // Leave room for a collision-free transaction suffix even for maximum-length source filenames. + return String(decoding: base.utf8.prefix(120), as: UTF8.self) + " (Conjure \(id)-\(index))" + suffix +} + +private func fileInfo(_ parent: Int32, _ name: String) throws -> FileInfo? { + var value = stat() + guard fstatat(parent, name, &value, AT_SYMLINK_NOFOLLOW) == 0 else { + if errno == ENOENT { return nil } + throw posixFailure("Reading filesystem identity", name) + } + return FileInfo(identity: Identity(value), owner: value.st_uid, flags: value.st_flags) +} + +private func requiredInfo(_ parent: Int32, _ name: String) throws -> FileInfo { + guard let info = try fileInfo(parent, name) else { throw Failure("Selected item is missing: \(name).") } + return info +} + +private func requireIdentity(_ parent: Int32, _ name: String, _ identity: Identity?) throws { + guard let identity, try requiredInfo(parent, name).identity == identity else { + throw Failure("Filesystem identity changed: \(name). No replacement will be moved.") + } +} + +private func makeDirectory(_ parent: Int32, _ name: String, mode: mode_t, allowExisting: Bool = false) throws { + if mkdirat(parent, name, mode) == 0 { + guard fsync(parent) == 0 else { throw posixFailure("Persisting new directory", name) } + } else if !(allowExisting && errno == EEXIST) { throw posixFailure("Creating directory", name) } +} + +private func move(_ source: Directory, _ name: String, _ destination: Directory, _ target: String) throws { + guard try source.identity().device == destination.identity().device else { throw Failure("Refusing a cross-volume move.") } + guard renameatx_np(source.fd, name, destination.fd, target, UInt32(RENAME_EXCL)) == 0 else { + throw posixFailure("Moving without overwriting", source.path + "/" + name + " → " + destination.path + "/" + target) + } + try destination.sync() + try source.sync() +} + +private func posixFailure(_ action: String, _ path: String) -> Failure { + let code = errno + return Failure("\(action) \(path): \(String(cString: strerror(code))) (\(code)).") +} diff --git a/Sources/ConjureKit/UninstallDiscovery.swift b/Sources/ConjureKit/UninstallDiscovery.swift new file mode 100644 index 0000000..c9dbea3 --- /dev/null +++ b/Sources/ConjureKit/UninstallDiscovery.swift @@ -0,0 +1,235 @@ +import Darwin +import Foundation +import Security + +public struct UninstallCandidate: Identifiable, Sendable { + public var id: URL { url } + public let url: URL + public let size: Int64? + public let isSelectable: Bool + public let defaultSelected: Bool + public let reason: String? + + public init(url: URL, size: Int64?, isSelectable: Bool, defaultSelected: Bool, reason: String?) { + self.url = url + self.size = size + self.isSelectable = isSelectable + self.defaultSelected = isSelectable && defaultSelected + self.reason = reason + } +} + +public enum UninstallDiscovery { + /// Performs filesystem and signing reads synchronously; callers must run this off the UI thread. + public static func scan( + application: Application, + home: URL = FileManager.default.homeDirectoryForCurrentUser, + ownApplicationURL: URL? = Bundle.main.bundleURL + ) throws -> [UninstallCandidate] { + let app = application.url.standardizedFileURL + let ownApp = ownApplicationURL?.standardizedFileURL.resolvingSymlinksInPath() + let invalidReason = applicationProblem(at: app, ownApp: ownApp) + let appCandidate = candidate(at: app, shared: false, ownApp: ownApp, problem: invalidReason) + guard invalidReason == nil, let identifier = bundleIdentifier(at: app) else { + return [appCandidate] + } + + let extensions = embeddedExtensions(in: app) + let identifiers = Set([identifier] + extensions.compactMap { bundleIdentifier(at: $0) }) + if let ownApp, let ownIdentifier = bundleIdentifier(at: ownApp), identifiers.contains(ownIdentifier) { + return [candidate( + at: app, shared: false, ownApp: ownApp, + problem: "This application shares Conjure's bundle identifier and could remove Conjure's own data." + )] + } + let library = home.standardizedFileURL.appendingPathComponent("Library", isDirectory: true) + var paths: [URL: Bool] = [:] + for identifier in identifiers { + let relativePaths = [ + "Preferences/\(identifier).plist", + "Caches/\(identifier)", + "Application Support/\(identifier)", + "Logs/\(identifier)", + "Saved Application State/\(identifier).savedState", + "Application Scripts/\(identifier)", + "Containers/\(identifier)", + ] + for path in relativePaths { + paths[library.appendingPathComponent(path)] = false + } + } + for bundle in [app] + extensions { + for group in signedApplicationGroups(at: bundle) { + paths[library.appendingPathComponent("Group Containers/\(group)")] = true + paths[library.appendingPathComponent("Application Scripts/\(group)")] = true + } + } + let associated = paths.keys.sorted { $0.path < $1.path }.compactMap { url -> UninstallCandidate? in + // Inspect ancestors before existence/size queries, so a linked Library never reads its target. + guard ancestryProblem(at: url, includeLeaf: false) == nil, + fileStatus(at: url) != nil else { return nil } + return candidate(at: url, shared: paths[url] == true, ownApp: ownApp) + } + return [appCandidate] + associated + } + + private static func applicationProblem(at url: URL, ownApp: URL?) -> String? { + if let problem = protectedProblem(at: url, ownApp: ownApp) { return problem } + if let problem = ancestryProblem(at: url) { return problem } + guard url.isFileURL, url.pathExtension.lowercased() == "app", + let status = fileStatus(at: url), status.st_mode & S_IFMT == S_IFDIR, + let info = bundleInfo(at: url), info["CFBundlePackageType"] as? String == "APPL", + let executable = info["CFBundleExecutable"] as? String, safeComponent(executable), + let identifier = info["CFBundleIdentifier"] as? String, safeIdentifier(identifier) else { + return "This application does not have a valid application bundle and bundle identifier." + } + let binary = url.appendingPathComponent("Contents/MacOS/\(executable)") + guard ancestryProblem(at: binary) == nil, + let binaryStatus = fileStatus(at: binary), binaryStatus.st_mode & S_IFMT == S_IFREG, + FileManager.default.isExecutableFile(atPath: binary.path) else { + return "This application does not have a valid executable." + } + return nil + } + + private static func bundleInfo(at url: URL) -> [String: Any]? { + let infoURL = url.appendingPathComponent("Contents/Info.plist") + guard ancestryProblem(at: infoURL) == nil, + let status = fileStatus(at: infoURL), status.st_mode & S_IFMT == S_IFREG, + let data = try? Data(contentsOf: infoURL), + let info = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) else { + return nil + } + return info as? [String: Any] + } + + private static func bundleIdentifier(at url: URL) -> String? { + guard let identifier = bundleInfo(at: url)?["CFBundleIdentifier"] as? String, + safeIdentifier(identifier) else { return nil } + return identifier + } + + private static func safeIdentifier(_ value: String) -> Bool { + !value.isEmpty && value.utf8.count <= 255 + && value.split(separator: ".", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty } + && value.utf8.allSatisfy { + (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 || $0 == 46 + } + } + + private static func safeComponent(_ value: String) -> Bool { + !value.isEmpty && value != "." && value != ".." && !value.contains("/") && !value.contains("\0") + } + + private static func embeddedExtensions(in app: URL) -> [URL] { + var pending = [app] + var result: [URL] = [] + while let bundle = pending.popLast() { + for relativePath in ["Contents/PlugIns", "Contents/Extensions"] { + let directory = bundle.appendingPathComponent(relativePath) + guard ancestryProblem(at: directory) == nil, + let entries = try? FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: nil, options: [] + ) else { continue } + for entry in entries where entry.pathExtension.lowercased() == "appex" { + guard ancestryProblem(at: entry) == nil, + let status = fileStatus(at: entry), status.st_mode & S_IFMT == S_IFDIR, + bundleIdentifier(at: entry) != nil else { continue } + result.append(entry) + pending.append(entry) + } + } + } + return result + } + + private static func signedApplicationGroups(at url: URL) -> [String] { + var code: SecStaticCode? + guard SecStaticCodeCreateWithPath(url as CFURL, SecCSFlags(rawValue: 0), &code) == errSecSuccess, + let code, + SecStaticCodeCheckValidity(code, SecCSFlags(rawValue: 0), nil) == errSecSuccess else { return [] } + var information: CFDictionary? + guard SecCodeCopySigningInformation(code, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess, + let info = information as? [String: Any], + let entitlements = info[kSecCodeInfoEntitlementsDict as String] as? [String: Any], + let groups = entitlements["com.apple.security.application-groups"] as? [String] else { return [] } + return groups.filter(safeIdentifier) + } + + private static func candidate(at url: URL, shared: Bool, ownApp: URL?, problem: String? = nil) -> UninstallCandidate { + let lock = problem ?? protectedProblem(at: url, ownApp: ownApp) + ?? ancestryProblem(at: url) ?? removalProblem(at: url) + let canReadSize = protectedProblem(at: url, ownApp: ownApp) == nil + && ancestryProblem(at: url, includeLeaf: false) == nil + let size = canReadSize ? logicalSize(at: url) : nil + return UninstallCandidate( + url: url, size: size, isSelectable: lock == nil, defaultSelected: !shared, + reason: lock ?? (shared ? "Shared app-group data may be used by other applications; left unchecked." : nil) + ) + } + + private static func protectedProblem(at url: URL, ownApp: URL?) -> String? { + for root in ["/System", "/usr", "/bin", "/sbin", "/Library/Apple"] { + if contains(URL(fileURLWithPath: root), url) { return "System-managed applications and files cannot be uninstalled." } + } + if let ownApp, contains(url, ownApp) || contains(ownApp, url) { + return "Conjure cannot uninstall itself or its own files." + } + return nil + } + + private static func contains(_ parent: URL, _ child: URL) -> Bool { + child.path == parent.path || child.path.hasPrefix(parent.path + "/") + } + + private static func ancestryProblem(at url: URL, includeLeaf: Bool = true) -> String? { + guard url.isFileURL, url.path.hasPrefix("/") else { return "This is not a local filesystem path." } + let path = includeLeaf ? url : url.deletingLastPathComponent() + var current = URL(fileURLWithPath: "/", isDirectory: true) + for component in path.pathComponents.dropFirst() { + current.appendPathComponent(component) + if let status = fileStatus(at: current), status.st_mode & S_IFMT == S_IFLNK { + return "This path contains a symbolic link; its target will not be followed or removed." + } + } + return nil + } + + private static func removalProblem(at url: URL) -> String? { + guard let status = fileStatus(at: url) else { return "This item no longer exists or cannot be inspected." } + guard status.st_uid == geteuid() else { + return "This item is owned by another user or an administrator and cannot be uninstalled without authorization." + } + let immutableFlags = UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND) + guard status.st_flags & immutableFlags == 0 else { return "This item is locked or immutable and cannot be moved to Trash." } + let parent = url.deletingLastPathComponent() + if let parentStatus = fileStatus(at: parent), parentStatus.st_flags & immutableFlags != 0 { + return "The containing folder is locked or immutable." + } + guard access(parent.path, W_OK | X_OK) == 0 else { + return "You do not have permission to remove this item from its containing folder." + } + return nil + } + + private static func fileStatus(at url: URL) -> stat? { + var status = stat() + return lstat(url.path, &status) == 0 ? status : nil + } + + private static func logicalSize(at url: URL) -> Int64? { + guard let status = fileStatus(at: url) else { return nil } + if status.st_mode & S_IFMT != S_IFDIR { return Int64(status.st_size) } + guard let children = try? FileManager.default.contentsOfDirectory( + at: url, includingPropertiesForKeys: nil, options: [] + ) else { return nil } + var total: Int64 = 0 + for child in children { + guard let size = logicalSize(at: child) else { return nil } + let sum = total.addingReportingOverflow(size) + guard !sum.overflow else { return nil } + total = sum.partialValue + } + return total + } +} diff --git a/Tests/ConjureKitTests/ApplicationUninstallerTests.swift b/Tests/ConjureKitTests/ApplicationUninstallerTests.swift new file mode 100644 index 0000000..e9df7ca --- /dev/null +++ b/Tests/ConjureKitTests/ApplicationUninstallerTests.swift @@ -0,0 +1,530 @@ +@testable import ConjureKit +import Darwin +import Foundation +import Testing + +@Test func uninstallStagesRealFilesAndRetainsOriginalPathManifestInTrash() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor.app", text: "selected data") + let report = try await fixture.engine().execute([original]) + + #expect(!report.needsRecovery) + #expect(!fixture.exists(original)) + let trashed = try #require(fixture.trashItems().first) + #expect(try fixture.contents(trashed) == "selected data") + #expect(trashed.pathExtension == "app") + #expect(try fixture.activeJournals().isEmpty) + #expect(try fixture.quarantines().isEmpty) + let completed = try #require(fixture.completedJournals().first) + let entries = try fixture.journalEntries(completed) + #expect(entries.count == 1) + #expect(entries[0]["original"] as? String == original.path) + let destination = try #require(entries[0]["trash"] as? [String: Any]) + #expect(destination["path"] as? String == fixture.trash.path) + #expect(destination["name"] as? String == trashed.lastPathComponent) + #expect(report.messages.joined().contains(completed.path)) + #expect(!(await fixture.engine().recover()).needsRecovery) +} + +@Test func uninstallMidStageFailureRollsBackInReverseOrder() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let alpha = try fixture.file("Alpha", text: "alpha") + let beta = try fixture.file("Beta", text: "beta") + let gamma = try fixture.file("Gamma", text: "gamma") + let engine = fixture.engine { event in + switch event { + case .beforeStage(2): throw UninstallTestFailure() + case .beforeRollback(1): + #expect(!fixture.exists(alpha)) + #expect(!fixture.exists(beta)) + case .beforeRollback(0): + #expect(fixture.exists(beta)) + #expect(!fixture.exists(alpha)) + default: break + } + } + _ = await uninstallFailure(engine, [gamma, beta, alpha]) + #expect(try fixture.contents(alpha) == "alpha") + #expect(try fixture.contents(beta) == "beta") + #expect(try fixture.contents(gamma) == "gamma") + #expect(try fixture.quarantines().isEmpty) + #expect(try fixture.activeJournals().isEmpty) + #expect(try fixture.trashItems().isEmpty) +} + +@Test func uninstallRollbackConflictPreservesRecreatedOriginalAndQuarantinedData() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let alpha = try fixture.file("Alpha", text: "original alpha") + let beta = try fixture.file("Beta", text: "original beta") + let engine = fixture.engine { event in + if case .beforeStage(1) = event { + try Data("recreated alpha".utf8).write(to: alpha) + throw UninstallTestFailure() + } + } + let message = await uninstallFailure(engine, [alpha, beta]) + #expect(message.contains(fixture.journals.path)) + let quarantine = try #require(fixture.quarantines().first) + #expect(message.contains(quarantine.path)) + #expect(try fixture.contents(alpha) == "recreated alpha") + #expect(try fixture.contents(quarantine.appendingPathComponent("item-0")) == "original alpha") + #expect(try fixture.contents(beta) == "original beta") + #expect(try fixture.activeJournals().count == 1) + + let next = try fixture.file("Next", text: "must stay") + _ = await uninstallFailure(fixture.engine(), [next]) + #expect(try fixture.contents(next) == "must stay") + #expect(try fixture.contents(alpha) == "recreated alpha") + + try FileManager.default.moveItem(at: alpha, to: fixture.base.appendingPathComponent("RecreatedAlpha")) + let recovered = await fixture.engine().recover() + #expect(!recovered.needsRecovery) + #expect(try fixture.contents(alpha) == "original alpha") + #expect(try fixture.activeJournals().isEmpty) +} + +@Test func uninstallRecoversMoveThatInterruptedBeforeStageJournalUpdate() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "recover me") + let interrupted = fixture.engine { event in + if case .afterStageMove(0) = event { throw UninstallInterruption() } + } + _ = await uninstallFailure(interrupted, [original]) + #expect(!fixture.exists(original)) + let journal = try #require(fixture.activeJournals().first) + #expect(try fixture.journalEntries(journal)[0]["state"] as? String == "stageIntent") + + let report = await fixture.engine().recover() + #expect(!report.needsRecovery) + #expect(try fixture.contents(original) == "recover me") + #expect(try fixture.activeJournals().isEmpty) + #expect(try fixture.quarantines().isEmpty) + #expect(try fixture.trashItems().isEmpty) +} + +@Test func uninstallRecoversPartialCommittedTrashFinalizationWithoutRollingBack() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let alpha = try fixture.file("Alpha", text: "alpha") + let beta = try fixture.file("Beta", text: "beta") + let failing = fixture.engine { event in + if case .beforeTrash(1) = event { throw UninstallTestFailure() } + } + let report = try await failing.execute([alpha, beta]) + #expect(report.needsRecovery) + #expect(try fixture.trashItems().count == 1) + #expect(!fixture.exists(alpha)) + #expect(!fixture.exists(beta)) + let firstTrash = try #require(fixture.trashItems().first) + try Data("new alpha".utf8).write(to: alpha) + + let recovery = await fixture.engine().recover() + #expect(!recovery.needsRecovery) + #expect(try fixture.contents(alpha) == "new alpha") + #expect(!fixture.exists(beta)) + #expect(try fixture.contents(firstTrash) == "alpha") + let contents = try fixture.trashItems().map { try fixture.contents($0) }.sorted() + #expect(contents == ["alpha", "beta"]) + #expect(try fixture.activeJournals().isEmpty) +} + +@Test func uninstallRecoversTrashMoveThatInterruptedBeforeJournalUpdate() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let alpha = try fixture.file("Alpha", text: "alpha") + let beta = try fixture.file("Beta", text: "beta") + let interrupted = fixture.engine { event in + if case .afterTrashMove(0) = event { throw UninstallInterruption() } + } + let report = try await interrupted.execute([alpha, beta]) + #expect(report.needsRecovery) + #expect(try fixture.trashItems().count == 1) + let journal = try #require(fixture.activeJournals().first) + #expect(try fixture.journalEntries(journal)[0]["state"] as? String == "trashIntent") + #expect(!(await fixture.engine().recover()).needsRecovery) + #expect(try fixture.trashItems().count == 2) + #expect(!fixture.exists(alpha)) + #expect(!fixture.exists(beta)) +} + +@Test func uninstallCollapsesNestedAndRepeatedSelectionsIntoOneMove() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let bundle = fixture.selected.appendingPathComponent("Editor.app", isDirectory: true) + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: false) + let child = bundle.appendingPathComponent("Settings") + try Data("nested data".utf8).write(to: child) + let report = try await fixture.engine().execute([child, bundle, bundle]) + #expect(!report.needsRecovery) + #expect(try fixture.trashItems().count == 1) + let trashed = try #require(fixture.trashItems().first) + #expect(try fixture.contents(trashed.appendingPathComponent("Settings")) == "nested data") + let completed = try #require(fixture.completedJournals().first) + #expect(try fixture.journalEntries(completed).count == 1) +} + +@Test func uninstallRejectsChangedIdentityBetweenIntentAndMove() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "reviewed identity") + let displaced = fixture.base.appendingPathComponent("Displaced") + let engine = fixture.engine { event in + if case .beforeStage(0) = event { + try FileManager.default.moveItem(at: original, to: displaced) + try Data("replacement identity".utf8).write(to: original) + } + } + _ = await uninstallFailure(engine, [original]) + #expect(try fixture.contents(original) == "replacement identity") + #expect(try fixture.contents(displaced) == "reviewed identity") + #expect(try fixture.activeJournals().count == 1) + #expect((await fixture.engine().recover()).needsRecovery) + #expect(try fixture.contents(original) == "replacement identity") + #expect(try fixture.trashItems().isEmpty) +} + +@Test func uninstallRecoveryRejectsChangedQuarantineIdentity() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected identity") + _ = await uninstallFailure(fixture.engine { event in + if case .afterStageMove(0) = event { throw UninstallInterruption() } + }, [original]) + let root = try #require(fixture.quarantines().first) + let staged = root.appendingPathComponent("item-0") + let displaced = fixture.base.appendingPathComponent("Displaced") + try FileManager.default.moveItem(at: staged, to: displaced) + try Data("untrusted replacement".utf8).write(to: staged) + let recovery = await fixture.engine().recover() + #expect(recovery.needsRecovery) + #expect(!fixture.exists(original)) + #expect(try fixture.contents(staged) == "untrusted replacement") + #expect(try fixture.contents(displaced) == "selected identity") + #expect(try fixture.activeJournals().count == 1) +} + +@Test func uninstallTamperedJournalNeverAuthorizesAnArbitraryMove() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + let victim = try fixture.file("Victim", text: "unrelated data") + _ = await uninstallFailure(fixture.engine { event in + if case .afterStageMove(0) = event { throw UninstallInterruption() } + }, [original]) + let journal = try #require(fixture.activeJournals().first) + var envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: journal)) as? [String: Any]) + let encoded = try #require(envelope["payload"] as? String) + let data = try #require(Data(base64Encoded: encoded)) + var payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var entries = try #require(payload["entries"] as? [[String: Any]]) + entries[0]["original"] = victim.path + payload["entries"] = entries + envelope["payload"] = try JSONSerialization.data(withJSONObject: payload).base64EncodedString() + try JSONSerialization.data(withJSONObject: envelope).write(to: journal) + let recovery = await fixture.engine().recover() + #expect(recovery.needsRecovery) + #expect(try fixture.contents(victim) == "unrelated data") + #expect(!fixture.exists(original)) + let root = try #require(fixture.quarantines().first) + #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") + #expect(fixture.exists(journal)) +} + +@Test func uninstallRefusesJournalSymlinksAndMissingAuthenticationKeys() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + _ = await uninstallFailure(fixture.engine { event in + if case .afterStageMove(0) = event { throw UninstallInterruption() } + }, [original]) + let journal = try #require(fixture.activeJournals().first) + let saved = fixture.base.appendingPathComponent("SavedJournal") + try FileManager.default.moveItem(at: journal, to: saved) + let victim = try fixture.file("Victim", text: "untrusted journal target") + try FileManager.default.createSymbolicLink(at: journal, withDestinationURL: victim) + #expect((await fixture.engine().recover()).needsRecovery) + #expect(try fixture.contents(victim) == "untrusted journal target") + #expect(!fixture.exists(original)) + try FileManager.default.removeItem(at: journal) + try FileManager.default.moveItem(at: saved, to: journal) + try FileManager.default.removeItem(at: fixture.journals.appendingPathComponent("key")) + let report = await fixture.engine().recover() + #expect(report.needsRecovery) + #expect(fixture.exists(journal)) + #expect(!fixture.exists(original)) +} + +@Test func uninstallRejectsCandidateAndAncestorSymlinksWithoutFollowingThem() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "must stay") + let alias = fixture.selected.appendingPathComponent("Alias") + try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: original) + _ = await uninstallFailure(fixture.engine(), [alias]) + let linked = fixture.base.appendingPathComponent("Linked") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: fixture.selected) + _ = await uninstallFailure(fixture.engine(), [linked.appendingPathComponent("Editor")]) + #expect(try fixture.contents(original) == "must stay") + #expect(try fixture.activeJournals().isEmpty) + #expect(try fixture.quarantines().isEmpty) +} + +@Test func uninstallAncestorSymlinkRaceCannotMoveVictimData() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected") + let displaced = fixture.base.appendingPathComponent("Displaced", isDirectory: true) + let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true) + try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false) + let victim = victimDirectory.appendingPathComponent("Editor") + try Data("victim".utf8).write(to: victim) + let engine = fixture.engine { event in + if case .beforeStage(0) = event { + try FileManager.default.moveItem(at: fixture.selected, to: displaced) + try FileManager.default.createSymbolicLink(at: fixture.selected, withDestinationURL: victimDirectory) + } + } + _ = await uninstallFailure(engine, [original]) + #expect(try fixture.contents(victim) == "victim") + #expect(try fixture.contents(displaced.appendingPathComponent("Editor")) == "selected") + #expect((await fixture.engine().recover()).needsRecovery) + #expect(try fixture.trashItems().isEmpty) +} + +@Test func uninstallCreatesMissingPerVolumeTrashAndLeavesOriginalMapping() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor.app", text: "external volume data") + let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true) + try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false) + let engine = ApplicationUninstaller(journalDirectory: fixture.journals, + trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) }) + #expect(!fixture.exists(volume.appendingPathComponent(".Trashes"))) + let report = try await engine.execute([original]) + #expect(!report.needsRecovery) + let userTrash = volume.appendingPathComponent(".Trashes/\(getuid())", isDirectory: true) + let items = try FileManager.default.contentsOfDirectory(at: userTrash, includingPropertiesForKeys: nil) + #expect(items.count == 1) + #expect(try fixture.contents(try #require(items.first)) == "external volume data") + #expect(try fixture.completedJournals().count == 1) +} + +@Test func uninstallInaccessibleTrashRetainsCommitForLaterRecovery() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "retained data") + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fixture.trash.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) } + let report = try await fixture.engine().execute([original]) + #expect(report.needsRecovery) + #expect(!fixture.exists(original)) + let root = try #require(fixture.quarantines().first) + #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "retained data") + #expect(try fixture.activeJournals().count == 1) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) + #expect(!(await fixture.engine().recover()).needsRecovery) + #expect(try fixture.trashItems().count == 1) +} + +@Test func uninstallSystemAndCurrentApplicationSelectionsFailClosed() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + for url in [URL(fileURLWithPath: "/System", isDirectory: true), Bundle.main.bundleURL.resolvingSymlinksInPath()] { + _ = await uninstallFailure(fixture.engine(), [url]) + } + #expect(try fixture.activeJournals().isEmpty) +} + +@Test func uninstallPreservesOccupiedTrashDestinationAndRetainsQuarantine() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + let engine = fixture.engine { event in + if case .beforeTrash(0) = event { + let journal = try #require(fixture.activeJournals().first) + let entry = try fixture.journalEntries(journal)[0] + let destination = try #require(entry["trash"] as? [String: Any]) + let name = try #require(destination["name"] as? String) + try Data("unrelated Trash data".utf8).write(to: fixture.trash.appendingPathComponent(name)) + } + } + let report = try await engine.execute([original]) + #expect(report.needsRecovery) + #expect(!fixture.exists(original)) + let unrelated = try #require(fixture.trashItems().first) + #expect(try fixture.contents(unrelated) == "unrelated Trash data") + let root = try #require(fixture.quarantines().first) + #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") + #expect((await fixture.engine().recover()).needsRecovery) + #expect(try fixture.contents(unrelated) == "unrelated Trash data") +} + +@Test func uninstallDoesNotDeleteUnknownFilesAddedToQuarantine() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + let engine = fixture.engine { event in + if case .beforeTrash(0) = event { + let root = try #require(fixture.quarantines().first) + try Data("untrusted data".utf8).write(to: root.appendingPathComponent("Unknown")) + } + } + let report = try await engine.execute([original]) + #expect(report.needsRecovery) + let root = try #require(fixture.quarantines().first) + #expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data") + #expect(try fixture.activeJournals().count == 1) + #expect(try fixture.trashItems().count == 1) + #expect((await fixture.engine().recover()).needsRecovery) + #expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data") +} + +@Test func uninstallMalformedJournalRetainsAllStagedData() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + _ = await uninstallFailure(fixture.engine { event in + if case .afterStageMove(0) = event { throw UninstallInterruption() } + }, [original]) + let journal = try #require(fixture.activeJournals().first) + try Data("not a journal".utf8).write(to: journal) + #expect((await fixture.engine().recover()).needsRecovery) + #expect(!fixture.exists(original)) + let root = try #require(fixture.quarantines().first) + #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") + #expect(try fixture.contents(journal) == "not a journal") +} + +@Test func uninstallHandlesMaximumLengthFilenamesWithoutLosingOriginalAttribution() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let name = String(repeating: "A", count: 250) + ".app" + let original = try fixture.file(name, text: "long filename") + let report = try await fixture.engine().execute([original]) + #expect(!report.needsRecovery) + let trashed = try #require(fixture.trashItems().first) + #expect(trashed.lastPathComponent.utf8.count <= 255) + #expect(trashed.pathExtension == "app") + #expect(try fixture.contents(trashed) == "long filename") + let completed = try #require(fixture.completedJournals().first) + #expect(try fixture.journalEntries(completed)[0]["original"] as? String == original.path) +} + +@Test func uninstallRejectsJournalDirectoryAndVolumeTrashSymlinks() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true) + try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + let victim = victimDirectory.appendingPathComponent("Keep") + try Data("unrelated data".utf8).write(to: victim) + try FileManager.default.createSymbolicLink(at: fixture.journals, withDestinationURL: victimDirectory) + _ = await uninstallFailure(fixture.engine(), [original]) + #expect(try fixture.contents(original) == "selected data") + #expect(try fixture.contents(victim) == "unrelated data") + #expect(!fixture.exists(victimDirectory.appendingPathComponent(".lock"))) + try FileManager.default.removeItem(at: fixture.journals) + + let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true) + try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false) + try FileManager.default.createSymbolicLink(at: volume.appendingPathComponent(".Trashes"), withDestinationURL: victimDirectory) + let engine = ApplicationUninstaller(journalDirectory: fixture.journals, + trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) }) + let report = try await engine.execute([original]) + #expect(report.needsRecovery) + #expect(try fixture.contents(victim) == "unrelated data") + #expect(!fixture.exists(original)) + let root = try #require(fixture.quarantines().first) + #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") +} + +@Test func uninstallNeedsSearchAndWritePermissionButNotDirectoryListingPermission() async throws { + let fixture = try UninstallFixture() + defer { fixture.remove() } + let original = try fixture.file("Editor", text: "selected data") + for directory in [fixture.selected, fixture.trash] { + try FileManager.default.setAttributes([.posixPermissions: 0o300], ofItemAtPath: directory.path) + } + defer { + for directory in [fixture.selected, fixture.trash] { + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) + } + } + let report = try await fixture.engine().execute([original]) + #expect(!report.needsRecovery) + #expect(!fixture.exists(original)) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) + let trashed = try #require(fixture.trashItems().first) + #expect(try fixture.contents(trashed) == "selected data") +} + +private struct UninstallTestFailure: Error {} + +private func uninstallFailure(_ engine: ApplicationUninstaller, _ urls: [URL]) async -> String { + do { + _ = try await engine.execute(urls) + Issue.record("Expected uninstall execution to throw.") + return "" + } catch { return error.localizedDescription } +} + +private struct UninstallFixture: Sendable { + let base: URL + let selected: URL + let trash: URL + let journals: URL + + init() throws { + guard let temporary = realpath(FileManager.default.temporaryDirectory.path, nil) else { + throw CocoaError(.fileReadUnknown) + } + defer { free(temporary) } + base = URL(fileURLWithPath: String(cString: temporary), isDirectory: true) + .appendingPathComponent("ConjureUninstallTests-" + UUID().uuidString, isDirectory: true) + selected = base.appendingPathComponent("Selected", isDirectory: true) + trash = base.appendingPathComponent("Trash", isDirectory: true) + journals = base.appendingPathComponent("Journals", isDirectory: true) + for url in [base, selected, trash] { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700]) + } + } + + func engine(checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in }) -> ApplicationUninstaller { + let destination = trash + return ApplicationUninstaller(journalDirectory: journals, trashDirectory: { _ in destination }, checkpoint: checkpoint) + } + + func file(_ name: String, text: String) throws -> URL { + let url = selected.appendingPathComponent(name) + try Data(text.utf8).write(to: url) + return url + } + + func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) } + func contents(_ url: URL) throws -> String { String(decoding: try Data(contentsOf: url), as: UTF8.self) } + func trashItems() throws -> [URL] { try children(trash) } + func quarantines() throws -> [URL] { try children(selected).filter { $0.lastPathComponent.hasPrefix(".conjure-uninstall-") } } + func activeJournals() throws -> [URL] { try children(journals).filter { $0.pathExtension == "json" } } + func completedJournals() throws -> [URL] { try children(journals.appendingPathComponent("Completed", isDirectory: true)) } + + func journalEntries(_ url: URL) throws -> [[String: Any]] { + let envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) + let encoded = try #require(envelope["payload"] as? String) + let bytes = try #require(Data(base64Encoded: encoded)) + let payload = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) + return try #require(payload["entries"] as? [[String: Any]]) + } + + private func children(_ url: URL) throws -> [URL] { + if !exists(url) { return [] } + return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil).sorted { $0.path < $1.path } + } + + func remove() { try? FileManager.default.removeItem(at: base) } +} diff --git a/Tests/ConjureKitTests/UninstallDiscoveryTests.swift b/Tests/ConjureKitTests/UninstallDiscoveryTests.swift new file mode 100644 index 0000000..6492b83 --- /dev/null +++ b/Tests/ConjureKitTests/UninstallDiscoveryTests.swift @@ -0,0 +1,228 @@ +import ConjureKit +import Darwin +import Foundation +import Testing + +@Test func uninstallDiscoveryUsesOnlyExactApplicationAndExtensionIdentifiersWithoutWriting() throws { + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let identifier = "test.conjure.editor" + let extensionIdentifier = "test.conjure.editor.share" + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: identifier) + let extensionURL = app.appendingPathComponent("Contents/PlugIns/Share.appex") + _ = try uninstallDiscoveryBundle(at: extensionURL, identifier: extensionIdentifier, packageType: "XPC!") + let relatives = [ + "Preferences/\(identifier).plist", + "Caches/\(identifier)", + "Application Support/\(identifier)", + "Logs/\(identifier)", + "Saved Application State/\(identifier).savedState", + "Application Scripts/\(identifier)", + "Containers/\(identifier)", + "Preferences/\(extensionIdentifier).plist", + "Caches/\(extensionIdentifier)", + ] + let candidates = try relatives.map { try uninstallDiscoveryFile(root.appendingPathComponent("Library/\($0)"), data: Data("safe".utf8)) } + let lookalikes = try [ + "Caches/\(identifier).other", "Caches/prefix.\(identifier)", + "Application Support/Editor", "Preferences/\(identifier).plist.backup", + "Group Containers/group.\(identifier)", + ].map { try uninstallDiscoveryFile(root.appendingPathComponent("Library/\($0)"), data: Data("unrelated".utf8)) } + let before = try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted() + + let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + + #expect(result.map(\.url) == [app] + candidates.sorted { $0.path < $1.path }) + #expect(result.allSatisfy { $0.isSelectable && $0.defaultSelected && $0.reason == nil }) + #expect(result.dropFirst().allSatisfy { $0.size == 4 }) + #expect(try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted() == before) + for url in candidates { #expect(try Data(contentsOf: url) == Data("safe".utf8)) } + for url in lookalikes { #expect(try Data(contentsOf: url) == Data("unrelated".utf8)) } +} + +@Test func uninstallDiscoveryIncludesNestedExtensionsButNeverLinkedExtensionIdentifiers() throws { + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor") + let first = try uninstallDiscoveryBundle( + at: app.appendingPathComponent("Contents/Extensions/First.appex"), identifier: "test.editor.first", packageType: "XPC!" + ) + _ = try uninstallDiscoveryBundle( + at: first.appendingPathComponent("Contents/PlugIns/Nested.appex"), identifier: "test.editor.nested", packageType: "XPC!" + ) + let external = try uninstallDiscoveryBundle(at: root.appendingPathComponent("External.appex"), identifier: "test.external", packageType: "XPC!") + try FileManager.default.createSymbolicLink(at: app.appendingPathComponent("Contents/Extensions/Linked.appex"), withDestinationURL: external) + let firstData = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor.first")) + let nestedData = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor.nested")) + _ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.external")) + + let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + #expect(Set(result.map(\.url)) == Set([app, firstData, nestedData])) +} + +@Test func uninstallDiscoveryPreservesAndLocksLinksWithoutSizingTheirTargets() throws { + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor") + let outside = root.appendingPathComponent("Outside", isDirectory: true) + _ = try uninstallDiscoveryFile(outside.appendingPathComponent("payload"), data: Data(repeating: 1, count: 100_000)) + let cache = root.appendingPathComponent("Library/Caches/test.editor", isDirectory: true) + try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true) + _ = try uninstallDiscoveryFile(cache.appendingPathComponent("local"), data: Data("123".utf8)) + let childLink = cache.appendingPathComponent("external") + try FileManager.default.createSymbolicLink(at: childLink, withDestinationURL: outside) + let logs = root.appendingPathComponent("Library/Logs/test.editor") + try FileManager.default.createDirectory(at: logs.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: logs, withDestinationURL: outside) + let preferences = root.appendingPathComponent("Library/Preferences") + try FileManager.default.createSymbolicLink(at: preferences, withDestinationURL: outside) + _ = try uninstallDiscoveryFile(outside.appendingPathComponent("test.editor.plist")) + + let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + let cacheCandidate = try #require(result.first { $0.url.path == cache.path }) + let logsCandidate = try #require(result.first { $0.url.path == logs.path }) + #expect(result.count == 3) + #expect(cacheCandidate.isSelectable) + #expect(cacheCandidate.size == 3 + Int64(outside.path.utf8.count)) + #expect(!logsCandidate.isSelectable && !logsCandidate.defaultSelected) + #expect(logsCandidate.url.path != outside.path) + #expect(logsCandidate.size == Int64(outside.path.utf8.count)) + #expect(try Data(contentsOf: outside.appendingPathComponent("payload")).count == 100_000) +} + +@Test func uninstallDiscoveryLeavesActuallySignedSharedGroupsUncheckedAndIgnoresUnsignedClaims() throws { + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor", nativeExecutable: true) + let extensionURL = try uninstallDiscoveryBundle( + at: app.appendingPathComponent("Contents/PlugIns/Share.appex"), identifier: "test.editor.share", packageType: "XPC!", nativeExecutable: true + ) + try uninstallDiscoverySign(extensionURL, groups: ["group.test.extension"], in: root) + try uninstallDiscoverySign(app, groups: ["group.test.shared", "../unsafe"], in: root) + let shared = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.shared")) + let scripts = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Application Scripts/group.test.shared")) + let extensionGroup = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.extension")) + _ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.editor")) + + let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + #expect(Set(result.map(\.url)) == Set([app, shared, scripts, extensionGroup])) + #expect(result.dropFirst().allSatisfy { $0.isSelectable && !$0.defaultSelected }) + + let unsigned = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Unsigned.app"), identifier: "test.unsigned") + let unsignedInfo = unsigned.appendingPathComponent("Contents/Info.plist") + var info = try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: unsignedInfo), format: nil) as? [String: Any]) + info["com.apple.security.application-groups"] = ["group.test.shared"] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: unsignedInfo) + let unsignedResult = try UninstallDiscovery.scan(application: Application(url: unsigned, name: "Unsigned"), home: root, ownApplicationURL: nil) + #expect(unsignedResult.map(\.url) == [unsigned]) +} + +@Test func uninstallDiscoveryLocksProtectedAndInvalidApplicationsWithClearReasons() throws { + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Conjure.app"), identifier: "test.conjure") + _ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.conjure")) + let own = try UninstallDiscovery.scan(application: Application(url: app, name: "Conjure"), home: root, ownApplicationURL: app) + #expect(own.count == 1 && own[0].url == app) + #expect(!own[0].isSelectable && !own[0].defaultSelected) + + let system = try UninstallDiscovery.scan( + application: Application(url: URL(fileURLWithPath: "/System/Applications/Protected.app"), name: "Protected"), + home: root, ownApplicationURL: nil + ) + #expect(system.count == 1 && !system[0].isSelectable) + #expect(system[0].size == nil) + let duplicate = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Duplicate.app"), identifier: "test.conjure") + let duplicateResult = try UninstallDiscovery.scan( + application: Application(url: duplicate, name: "Duplicate"), home: root, ownApplicationURL: app + ) + #expect(duplicateResult.count == 1 && !duplicateResult[0].isSelectable) + + for identifier in ["../outside", "test/escape", "test..invalid", ""] { + let invalid = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Invalid.app"), identifier: identifier) + let result = try UninstallDiscovery.scan(application: Application(url: invalid, name: "Invalid"), home: root, ownApplicationURL: nil) + #expect(result.count == 1 && !result[0].isSelectable && !result[0].defaultSelected) + } + let missing = try UninstallDiscovery.scan(application: Application(url: root.appendingPathComponent("Missing.app"), name: "Missing"), home: root, ownApplicationURL: nil) + #expect(missing.count == 1 && !missing[0].isSelectable && missing[0].reason != nil) +} + +@Test func uninstallDiscoveryExplainsLockedFileAndParentPermissions() throws { + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor") + let cache = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor")) + #expect(chflags(cache.path, UInt32(UF_IMMUTABLE)) == 0) + defer { _ = chflags(cache.path, 0) } + let locked = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + let candidate = try #require(locked.first { $0.url == cache }) + #expect(!candidate.isSelectable && !candidate.defaultSelected) + #expect(candidate.size == 4) + + // Root can bypass POSIX permissions; the immutable-file check above applies to every runner. + if geteuid() != 0 { + let preferences = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Preferences/test.editor.plist")) + let parent = preferences.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: parent.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: parent.path) } + let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + let denied = try #require(result.first { $0.url == preferences }) + #expect(!denied.isSelectable && !denied.defaultSelected) + } +} + +@Test func uninstallDiscoveryRejectsUnownedRootsWhenFixtureOwnershipCanBeChanged() throws { + guard geteuid() == 0 else { return } + let root = try uninstallDiscoveryRoot() + defer { try? FileManager.default.removeItem(at: root) } + let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor") + #expect(chown(app.path, 501, gid_t.max) == 0) + let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil) + #expect(result.count == 1 && !result[0].isSelectable && !result[0].defaultSelected) +} + +private func uninstallDiscoveryRoot() throws -> URL { + let root = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".ConjureDiscoveryTests-" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root +} + +@discardableResult +private func uninstallDiscoveryFile(_ url: URL, data: Data = Data("data".utf8)) throws -> URL { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: url) + return url +} + +@discardableResult +private func uninstallDiscoveryBundle(at url: URL, identifier: String, packageType: String = "APPL", nativeExecutable: Bool = false) throws -> URL { + let url = URL(fileURLWithPath: url.path, isDirectory: true) + let binary = url.appendingPathComponent("Contents/MacOS/Launcher") + try FileManager.default.createDirectory(at: binary.deletingLastPathComponent(), withIntermediateDirectories: true) + let info: [String: Any] = ["CFBundleIdentifier": identifier, "CFBundlePackageType": packageType, "CFBundleExecutable": "Launcher"] + try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: url.appendingPathComponent("Contents/Info.plist")) + if nativeExecutable { + try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/echo"), to: binary) + } else { + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binary) + } + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path) + return url +} + +private func uninstallDiscoverySign(_ url: URL, groups: [String], in root: URL) throws { + let entitlements = root.appendingPathComponent("\(url.lastPathComponent).entitlements") + try PropertyListSerialization.data( + fromPropertyList: ["com.apple.security.application-groups": groups], format: .xml, options: 0 + ).write(to: entitlements) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") + process.arguments = ["--force", "--sign", "-", "--timestamp=none", "--entitlements", entitlements.path, url.path] + let errors = Pipe() + process.standardError = errors + try process.run() + process.waitUntilExit() + let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self) + guard process.terminationStatus == 0 else { throw NSError(domain: "UninstallDiscoveryTests.codesign", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message]) } +}