// // AppPathFinder.swift // PureMac // // Heuristic file discovery engine that locates all filesystem artifacts // belonging to a given macOS application. Uses multi-level matching // against bundle identifiers, app names, entitlements, and team IDs // with configurable sensitivity. // import Foundation import AppKit class AppPathFinder { // MARK: - Types struct AppInfo { let appName: String let bundleIdentifier: String let path: URL let entitlements: [String]? let teamIdentifier: String? } enum Sensitivity { case strict // Exact bundle ID + exact name match only case enhanced // + partial matching, bundle components, stripped version case deep // + company name, entitlements, team ID } // MARK: - Properties private let appInfo: AppInfo private let locations: Locations private let sensitivity: Sensitivity private var collectionSet: Set = [] private let collectionQueue = DispatchQueue(label: "com.puremac.pathfinder.collection") // Pre-computed cached identifiers (computed once in init, used in hot loop) private let normalizedBundleID: String private let bundleLastTwo: String private let normalizedAppName: String private let appNameLettersOnly: String private let pathComponentName: String private let companyName: String? private let baseBundleID: String? private let strippedAppName: String? private let normalizedEntitlements: [String] private let normalizedTeamID: String? // MARK: - Initialization init(appInfo: AppInfo, locations: Locations, sensitivity: Sensitivity = .enhanced) { self.appInfo = appInfo self.locations = locations self.sensitivity = sensitivity self.normalizedBundleID = appInfo.bundleIdentifier.normalizedForMatching() self.bundleLastTwo = appInfo.bundleIdentifier.bundleLastTwoComponents self.normalizedAppName = appInfo.appName.normalizedForMatching() self.appNameLettersOnly = appInfo.appName.lettersOnly self.pathComponentName = appInfo.path.lastPathComponent .replacingOccurrences(of: ".app", with: "") .normalizedForMatching() self.companyName = appInfo.bundleIdentifier.bundleCompanyName self.baseBundleID = appInfo.bundleIdentifier.baseBundleIdentifier?.normalizedForMatching() let stripped = appInfo.appName.strippingTrailingVersion().normalizedForMatching() self.strippedAppName = (stripped != normalizedAppName && !stripped.isEmpty) ? stripped : nil self.normalizedEntitlements = appInfo.entitlements?.compactMap { e in let n = e.normalizedForMatching() return n.isEmpty ? nil : n } ?? [] self.normalizedTeamID = appInfo.teamIdentifier?.normalizedForMatching() } // MARK: - Public API /// Find all files related to this app synchronously. func findPaths() -> Set { collectionSet.insert(appInfo.path) for location in locations.appSearch.paths { let isLibRoot = isLibraryDirectory(location) let maxDepth = isLibRoot ? 2 : 1 processLocation(location, currentDepth: 0, maxDepth: maxDepth, isLibraryRootSearch: isLibRoot) } let containers = discoverContainers() collectionSet.formUnion(containers) applyConditions() return filterSubpaths(collectionSet) } /// Find all files related to this app with parallel location processing. func findPathsAsync(completion: @escaping (Set) -> Void) { collectionSet.insert(appInfo.path) let group = DispatchGroup() for location in locations.appSearch.paths { group.enter() DispatchQueue.global(qos: .userInitiated).async { let isLibRoot = self.isLibraryDirectory(location) let maxDepth = isLibRoot ? 2 : 1 self.processLocation(location, currentDepth: 0, maxDepth: maxDepth, isLibraryRootSearch: isLibRoot) group.leave() } } group.notify(queue: .global(qos: .userInitiated)) { let containers = self.discoverContainers() self.collectionQueue.sync { self.collectionSet.formUnion(containers) } self.applyConditions() let result = self.filterSubpaths(self.collectionSet) DispatchQueue.main.async { completion(result) } } } // MARK: - Location Processing private func processLocation(_ location: String, currentDepth: Int, maxDepth: Int, isLibraryRootSearch: Bool) { guard let contents = try? FileManager.default.contentsOfDirectory(atPath: location) else { return } var localResults: [URL] = [] var subdirs: [URL] = [] for item in contents { let itemURL = URL(fileURLWithPath: location).appendingPathComponent(item) let normalizedName: String if itemURL.hasDirectoryPath || itemURL.pathExtension.isEmpty { normalizedName = item.normalizedForMatching() } else { normalizedName = (item as NSString).deletingPathExtension.normalizedForMatching() } var isDir: ObjCBool = false guard FileManager.default.fileExists(atPath: itemURL.path, isDirectory: &isDir) else { continue } if shouldSkipItem(normalizedName, at: itemURL) { continue } if matchesApp(normalizedName: normalizedName, itemURL: itemURL) { let itemToAdd: URL // For depth-2 matches in Library root, add the parent directory when // it is a vendor folder (not a standard Library subdirectory). if isLibraryRootSearch && currentDepth == 2 { let parent = itemURL.deletingLastPathComponent() let parentName = parent.lastPathComponent if !Locations.standardLibrarySubdirectories.contains(parentName) { itemToAdd = parent } else { itemToAdd = itemURL } } else { itemToAdd = itemURL } localResults.append(itemToAdd) } // Recurse into subdirectories up to maxDepth if isDir.boolValue && currentDepth < maxDepth { if isLibraryRootSearch && currentDepth == 0 { if !skipDeepSearch.contains(itemURL.lastPathComponent) { subdirs.append(itemURL) } } else { subdirs.append(itemURL) } } } collectionQueue.sync { collectionSet.formUnion(localResults) } for subdir in subdirs { processLocation(subdir.path, currentDepth: currentDepth + 1, maxDepth: maxDepth, isLibraryRootSearch: isLibraryRootSearch) } } // MARK: - Matching Engine // Minimum token length required for a name-based match to pass. Prevents // a malicious app named "s-s-h" (normalized "ssh", 3 chars) from // short-name-bombing home dotfiles like ~/.ssh into the uninstall list. private static let minMatchTokenLength = 5 /// Anchored check for whether `self.normalizedBundleID` belongs to the /// family identified by `conditionBundleID`. Accepts exact equality, /// ".child" extension, or "parent." suffix - rejects a bundle ID that /// merely contains the condition string as a substring. This prevents /// `com.evil.jetbrainsapp` from hijacking the `jetbrains` rule. private func bundleIDMatchesCondition(_ conditionBundleID: String) -> Bool { guard !conditionBundleID.isEmpty else { return false } if normalizedBundleID == conditionBundleID { return true } if normalizedBundleID.hasPrefix(conditionBundleID + ".") { return true } if normalizedBundleID.hasSuffix("." + conditionBundleID) { return true } return false } /// Multi-level heuristic matcher. Returns true if the normalized filename /// belongs to the target app at the current sensitivity level. private func matchesApp(normalizedName: String, itemURL: URL) -> Bool { // Per-app condition overrides take priority. Anchor the bundle ID // check (see bundleIDMatchesCondition above). for condition in appConditions { guard bundleIDMatchesCondition(condition.bundleID) else { continue } if condition.excludeTerms.contains(where: { normalizedName.contains($0) }) { return false } if condition.includeTerms.contains(where: { normalizedName.contains($0) }) { return true } } // Entitlement-based matching for entitlement in normalizedEntitlements { let match = sensitivity == .strict ? normalizedName == entitlement : normalizedName.contains(entitlement) if match { return true } } // Level 1: Full bundle identifier let fullBundleMatch = normalizedName.contains(normalizedBundleID) // Level 2: App name let isStrict = sensitivity == .strict let minLen = AppPathFinder.minMatchTokenLength let appNameMatch = normalizedAppName.count >= minLen && (isStrict ? normalizedName == normalizedAppName : normalizedName.contains(normalizedAppName)) // Level 3: Path component name (the .app directory name without extension) let pathMatch = pathComponentName.count >= minLen && (isStrict ? normalizedName == pathComponentName : normalizedName.contains(pathComponentName)) // Level 4: Letters-only app name let lettersMatch = appNameLettersOnly.count >= minLen && (isStrict ? normalizedName == appNameLettersOnly : normalizedName.contains(appNameLettersOnly)) if (normalizedBundleID.count >= 5 && fullBundleMatch) || appNameMatch || pathMatch || lettersMatch { return true } // Enhanced mode: additional partial matching strategies if sensitivity != .strict { // Level 5: Last two bundle ID components if bundleLastTwo.count >= minLen, normalizedName.contains(bundleLastTwo) { return true } // Level 6: Base bundle ID (strips .helper/.agent/.daemon suffixes) if let base = baseBundleID, base.count >= minLen, normalizedName.contains(base) { return true } // Level 7: Version-stripped app name if let stripped = strippedAppName, stripped.count >= minLen, normalizedName.contains(stripped) { return true } } // Deep mode: broadest matching heuristics if sensitivity == .deep { // Level 8: Company name extracted from bundle ID if let company = companyName, company.count >= minLen, normalizedName.contains(company) { return true } // Level 9: Team identifier from code signature if let teamID = normalizedTeamID, teamID.count >= minLen, normalizedName.contains(teamID) { return true } } return false } // MARK: - Skip Logic private func shouldSkipItem(_ normalizedName: String, at url: URL) -> Bool { if collectionQueue.sync(execute: { collectionSet.contains(url) }) { return true } for skip in skipConditions { for path in skip.skipPaths { if url.path.hasPrefix(path) { return true } } if skip.skipPrefixes.contains(where: { normalizedName.hasPrefix($0) }) { if !skip.allowPrefixes.contains(where: { normalizedName.hasPrefix($0) }) { return true } } } return false } // MARK: - Container Discovery /// Discovers sandboxed app containers that belong to this app by checking /// both UUID-named containers (via metadata plist) and name-matched containers. private func discoverContainers() -> [URL] { var containers: [URL] = [] guard let containersPath = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask) .first?.appendingPathComponent("Containers") else { return containers } guard let dirs = try? FileManager.default.contentsOfDirectory( at: containersPath, includingPropertiesForKeys: nil, options: .skipsHiddenFiles ) else { return containers } for dir in dirs { let dirName = dir.lastPathComponent // UUID-named containers: read the metadata plist for the owning bundle ID if dirName.count == 36 && dirName.contains("-") { let metaPlist = dir.appendingPathComponent(".com.apple.containermanagerd.metadata.plist") if let meta = NSDictionary(contentsOf: metaPlist), let bundleID = meta["MCMMetadataIdentifier"] as? String, bundleID == appInfo.bundleIdentifier { containers.append(dir) } } // Named containers matching the bundle ID directly. Require the // bundle ID itself to be at least 5 chars to avoid picking up a // container owned by an app with a degenerate bundle identifier. if normalizedBundleID.count >= 5, dirName.normalizedForMatching() == normalizedBundleID { containers.append(dir) } } return containers } // MARK: - Condition Application /// Applies per-app force-include and force-exclude path overrides after /// the main scan has completed. private func applyConditions() { for condition in appConditions { guard bundleIDMatchesCondition(condition.bundleID) else { continue } if let paths = condition.forceIncludePaths { for path in paths { if FileManager.default.fileExists(atPath: path.path) { collectionSet.insert(path) } } } if let paths = condition.forceExcludePaths { for path in paths { collectionSet.remove(path) } } } } // MARK: - Helpers private func isLibraryDirectory(_ location: String) -> Bool { location == "\(home)/Library" || location == "/Library" } /// Removes child paths when a parent is already in the set, and discards /// results that consist solely of a Trash item. private func filterSubpaths(_ urls: Set) -> Set { let sorted = urls.map { $0.standardizedFileURL }.sorted { $0.path < $1.path } var filtered: [URL] = [] for url in sorted { // Remove any existing entries that are children of this URL filtered.removeAll { $0.path.hasPrefix(url.path + "/") } // Only add if this URL is not a child of an existing entry if !filtered.contains(where: { url.path.hasPrefix($0.path + "/") }) { filtered.append(url) } } // A single result pointing into the Trash is not meaningful if filtered.count == 1, let first = filtered.first, first.path.contains(".Trash") { return [] } return Set(filtered) } }