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

375 lines
17 KiB
Swift

import Foundation
actor CleaningEngine {
private let fileManager = FileManager.default
struct CleaningResult {
var freedSpace: Int64 = 0
var itemsCleaned: Int = 0
var errors: [String] = []
var cleanedPaths: Set<String> = []
// Items that user-level FileManager.removeItem refused with EACCES /
// EPERM. These are root-owned and need an admin-privileged second
// pass via cleanWithAdminPrivileges(items:).
var requiresAdmin: [CleanableItem] = []
}
// MARK: - Public API
func cleanItems(_ items: [CleanableItem], progressHandler: @Sendable (Double) -> Void) async -> CleaningResult {
var result = CleaningResult()
let total = items.count
for (index, item) in items.enumerated() {
let progress = Double(index + 1) / Double(total)
progressHandler(progress)
if item.category == .purgeableSpace {
let purged = await purgePurgeableSpace()
result.freedSpace += purged
if purged > 0 { result.itemsCleaned += 1 }
// Purgeable space is a one-shot reclaim action, not a file
// unlink. Mark it handled so it isn't later mistaken for an
// item that "couldn't be removed" (the purge ran regardless of
// how much APFS chose to release). See issue #112.
result.cleanedPaths.insert(item.path)
continue
}
do {
let itemURL = URL(fileURLWithPath: item.path)
guard fileManager.fileExists(atPath: item.path) else { continue }
// Security: resolve symlinks, validate the real path, delete
// through the resolved URL. Deleting through the unresolved
// path lets an attacker-at-same-UID swap a component to a
// symlink after the check and have us follow it.
let resolvedURL = itemURL.resolvingSymlinksInPath()
let resolved = resolvedURL.path
// Large files surfaced by scanLargeFiles are per-file items
// under Downloads/Documents/Desktop; those get a narrower check
// instead of the whole-subtree allow-list.
let pathAccepted: Bool = {
if item.category == .largeFiles {
return isExplicitSingleFileDeletable(resolvedPath: resolved)
}
return isSafeToDelete(resolvedPath: resolved)
}()
guard pathAccepted else {
let msg = "Skipped symlink or unsafe path: \(item.path) -> \(resolved)"
Logger.shared.log(msg, level: .warning)
result.errors.append(msg)
continue
}
// Narrow the TOCTOU window: re-resolve right before the delete
// and require the resolved path to still match. Any concurrent
// swap between check and delete aborts the operation.
let reResolved = URL(fileURLWithPath: item.path).resolvingSymlinksInPath().path
guard reResolved == resolved else {
let msg = "Aborting delete: path resolution changed between check and unlink for \(item.path)"
Logger.shared.log(msg, level: .warning)
result.errors.append(msg)
continue
}
try fileManager.removeItem(at: resolvedURL)
result.freedSpace += item.size
result.itemsCleaned += 1
result.cleanedPaths.insert(item.path)
} catch {
let nsError = error as NSError
let isPermissionDenied =
(nsError.domain == NSCocoaErrorDomain &&
(nsError.code == NSFileWriteNoPermissionError ||
nsError.code == NSFileReadNoPermissionError)) ||
(nsError.domain == NSPOSIXErrorDomain &&
(nsError.code == Int(EACCES) || nsError.code == Int(EPERM)))
if isPermissionDenied {
// Defer to the admin pass — these are typically root-owned
// system caches that the user-level process can't unlink.
result.requiresAdmin.append(item)
Logger.shared.log("Deferring to admin pass: \(item.path)", level: .info)
} else {
let detail = "\(item.name) at \(item.path): \(error.localizedDescription)"
result.errors.append(detail)
Logger.shared.log("Clean failed: \(detail)", level: .error)
}
}
}
return result
}
func cleanCategory(_ result: CategoryResult, progressHandler: @Sendable (Double) -> Void) async -> CleaningResult {
let selectedItems = result.items.filter { $0.isSelected }
return await cleanItems(selectedItems, progressHandler: progressHandler)
}
/// Re-runs the deletion of the supplied items as root via NSAppleScript's
/// "with administrator privileges" clause. Triggers exactly one auth
/// prompt for the whole batch (macOS caches the credential for ~5 min).
///
/// Every path is re-validated against the same allow-list as the user-
/// level pass (isSafeToDelete / isExplicitSingleFileDeletable) before it
/// gets handed off to /bin/rm. Paths are passed via a NUL-separated
/// temp file consumed by xargs -0, so no shell-quoting pitfalls.
func cleanWithAdminPrivileges(items: [CleanableItem]) async -> CleaningResult {
var result = CleaningResult()
Logger.shared.log("Admin pass starting with \(items.count) item(s)", level: .info)
// Re-validate. Don't trust the caller — anything not on the allow-list
// refuses to escalate.
let validated: [(item: CleanableItem, resolved: String)] = items.compactMap { item in
let resolved = URL(fileURLWithPath: item.path).resolvingSymlinksInPath().path
let accepted: Bool = {
if item.category == .largeFiles {
return isExplicitSingleFileDeletable(resolvedPath: resolved)
}
return isSafeToDelete(resolvedPath: resolved) || isSafeUninstallEscalationPath(resolved)
}()
if !accepted {
Logger.shared.log("Refusing admin escalation for unsafe path: \(item.path)", level: .warning)
}
return accepted ? (item, resolved) : nil
}
guard !validated.isEmpty else {
Logger.shared.log("Admin pass: no items survived validation", level: .warning)
return result
}
// Stage paths NUL-separated so newlines/spaces in paths don't matter.
let staged = validated.map(\.resolved).joined(separator: "\u{0}")
guard let payload = staged.data(using: .utf8) else { return result }
let tempFile = FileManager.default.temporaryDirectory
.appendingPathComponent("puremac-rm-\(UUID().uuidString)")
do {
try payload.write(to: tempFile, options: [.atomic])
} catch {
Logger.shared.log("Couldn't stage admin path list: \(error.localizedDescription)", level: .error)
return result
}
defer { try? FileManager.default.removeItem(at: tempFile) }
let quotedTempPath = shellSingleQuoted(tempFile.path)
let script = """
do shell script "/usr/bin/xargs -0 /bin/rm -rf -- < \(quotedTempPath)" with administrator privileges
"""
let runResult: (success: Bool, error: String?) = await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
let appleScript = NSAppleScript(source: script)
var errorInfo: NSDictionary?
appleScript?.executeAndReturnError(&errorInfo)
if let errorInfo {
continuation.resume(returning: (false, "\(errorInfo)"))
} else {
continuation.resume(returning: (true, nil))
}
}
}
guard runResult.success else {
// -128 is "user cancelled" — log quietly, no need for an error row.
if let err = runResult.error, !err.contains("-128") {
Logger.shared.log("Admin clean failed: \(err)", level: .error)
result.errors.append("Administrator authorization failed")
}
return result
}
// Verify which items actually disappeared. xargs may have reported a
// partial failure even when the AppleScript exited cleanly, so we
// re-stat every path rather than trust the script's exit status.
for (item, resolved) in validated {
if !FileManager.default.fileExists(atPath: resolved) {
result.cleanedPaths.insert(item.path)
result.itemsCleaned += 1
result.freedSpace += item.size
} else {
let detail = "\(item.name) at \(item.path) survived admin removal"
result.errors.append(detail)
Logger.shared.log("Admin pass survivor: \(detail)", level: .error)
}
}
Logger.shared.log("Admin pass complete: \(result.itemsCleaned) deleted, \(result.errors.count) survived", level: .info)
return result
}
// MARK: - Purgeable Space
func purgePurgeableSpace() async -> Int64 {
// Get current purgeable space first
let beforeFree = getCurrentFreeSpace()
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil")
task.arguments = ["apfs", "purgePurgeable", "/"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = Pipe()
do {
try task.run()
task.waitUntilExit()
let afterFree = getCurrentFreeSpace()
let freedSpace = afterFree - beforeFree
return max(0, freedSpace)
} catch {
Logger.shared.log("diskutil purge failed: \(error.localizedDescription)", level: .error)
return 0
}
}
// MARK: - Trash
func emptyTrash() async -> Int64 {
let home = fileManager.homeDirectoryForCurrentUser.path
let trashPath = "\(home)/.Trash"
var totalFreed: Int64 = 0
do {
let contents = try fileManager.contentsOfDirectory(atPath: trashPath)
for item in contents {
let fullPath = (trashPath as NSString).appendingPathComponent(item)
if let attrs = try? fileManager.attributesOfItem(atPath: fullPath) {
totalFreed += (attrs[.size] as? Int64) ?? 0
}
try fileManager.removeItem(atPath: fullPath)
}
} catch {
Logger.shared.log("Trash cleanup incomplete: \(error.localizedDescription)", level: .warning)
}
return totalFreed
}
// MARK: - Helpers
/// Validates that a resolved path is safe to delete.
/// Prevents symlink attacks where a link in ~/Library/Caches points to ~/.ssh.
/// Downloads, Documents, and Desktop are intentionally NOT whole-subtree
/// allow-listed - scanLargeFiles emits per-file items instead, so those
/// deletions can still happen through the explicit per-item flow.
private func isSafeToDelete(resolvedPath: String) -> Bool {
let home = fileManager.homeDirectoryForCurrentUser.path
let allowedRoots = [
"\(home)/Library/Caches",
"\(home)/Library/Logs",
"\(home)/Library/Saved Application State",
"\(home)/Library/HTTPStorages",
"\(home)/Library/WebKit",
"\(home)/Library/Containers",
"\(home)/Library/Group Containers",
"\(home)/Library/Application Support",
"\(home)/Library/Preferences",
"\(home)/Library/LaunchAgents",
"\(home)/Library/Mail Downloads",
"\(home)/Library/Developer/Xcode/DerivedData",
"\(home)/Library/Developer/Xcode/Archives",
"\(home)/Library/Developer/CoreSimulator/Caches",
"\(home)/.Trash",
"\(home)/.npm",
"\(home)/.cache",
"\(home)/Library/Containers/com.docker.docker",
"/Library/Caches",
"/Library/Logs",
"/private/var/log",
"/private/var/tmp",
// /var is a symlink to /private/var, and resolvingSymlinksInPath
// gives the /var form. Both spellings must be allow-listed or
// every system log/tmp deletion silently fails the safety check.
"/var/log",
"/var/tmp",
"/tmp",
]
// Either the path equals an allow-listed root (whole-subtree wipe by
// the scanner that emits the root itself, e.g. DerivedData) or it
// sits strictly inside one. The trailing "/" on the prefix match
// prevents siblings like "/tmpfoo" from sneaking past "/tmp".
let normalized = (resolvedPath as NSString).standardizingPath
return allowedRoots.contains { root in
if normalized == root { return true }
let rootWithSeparator = root.hasSuffix("/") ? root : root + "/"
return normalized.hasPrefix(rootWithSeparator)
}
}
/// Allows the app uninstaller to escalate only the protected roots it owns:
/// app bundles, package receipts, and launch plists. This intentionally
/// stays narrower than the normal cleaner allow-list.
private func isSafeUninstallEscalationPath(_ path: String) -> Bool {
let normalized = (path as NSString).standardizingPath
let home = fileManager.homeDirectoryForCurrentUser.path
return isAppBundlePath(normalized, rootedAt: "/Applications")
|| isAppBundlePath(normalized, rootedAt: "\(home)/Applications")
|| isReceiptPath(normalized, rootedAt: "/private/var/db/receipts")
|| isReceiptPath(normalized, rootedAt: "/var/db/receipts")
|| isPlistUnder(normalized, root: "/Library/LaunchDaemons")
|| isPlistUnder(normalized, root: "/Library/LaunchAgents")
}
private func isAppBundlePath(_ path: String, rootedAt root: String) -> Bool {
guard isInside(path, root: root) else { return false }
let normalizedRoot = (root as NSString).standardizingPath
guard path != normalizedRoot else { return false }
let rootWithSeparator = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/"
let relative = String(path.dropFirst(rootWithSeparator.count))
return relative.split(separator: "/").contains { component in
component.lowercased().hasSuffix(".app")
}
}
private func isReceiptPath(_ path: String, rootedAt root: String) -> Bool {
let parent = ((path as NSString).deletingLastPathComponent as NSString).standardizingPath
guard parent == (root as NSString).standardizingPath else { return false }
let ext = (path as NSString).pathExtension.lowercased()
return ext == "plist" || ext == "bom"
}
private func isPlistUnder(_ path: String, root: String) -> Bool {
let parent = ((path as NSString).deletingLastPathComponent as NSString).standardizingPath
return parent == (root as NSString).standardizingPath && (path as NSString).pathExtension.lowercased() == "plist"
}
private func isInside(_ path: String, root: String) -> Bool {
let normalizedRoot = (root as NSString).standardizingPath
if path == normalizedRoot { return true }
let rootWithSeparator = normalizedRoot.hasSuffix("/") ? normalizedRoot : normalizedRoot + "/"
return path.hasPrefix(rootWithSeparator)
}
private func shellSingleQuoted(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Allow a single-file delete under Downloads/Documents/Desktop when it
/// was explicitly surfaced by a scanner (e.g. scanLargeFiles). Whole-subtree
/// deletion of those roots remains blocked.
func isExplicitSingleFileDeletable(resolvedPath: String) -> Bool {
let home = fileManager.homeDirectoryForCurrentUser.path
let perFileRoots = [
"\(home)/Downloads/",
"\(home)/Documents/",
"\(home)/Desktop/",
]
let normalized = (resolvedPath as NSString).standardizingPath
return perFileRoots.contains { normalized.hasPrefix($0) }
}
private func getCurrentFreeSpace() -> Int64 {
do {
let attrs = try fileManager.attributesOfFileSystem(forPath: "/")
return (attrs[.systemFreeSize] as? Int64) ?? 0
} catch {
Logger.shared.log("Cannot read filesystem attributes: \(error.localizedDescription)", level: .warning)
return 0
}
}
}