Files
wehub-resource-sync 869b84f27c
Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

394 lines
15 KiB
Swift

//
// 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<URL> = []
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<URL> {
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<URL>) -> 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<URL>) -> Set<URL> {
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)
}
}