236 lines
12 KiB
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
|
|
}
|
|
}
|