Files
conjure/Sources/ConjureKit/UninstallDiscovery.swift

236 lines
12 KiB
Swift

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
}
}