Files
CleanMM/Helpers/AtlasPrivilegedHelper/Sources/AtlasPrivilegedHelperCore/HelperActionExecutor.swift
2026-03-10 17:09:35 +08:00

205 lines
8.1 KiB
Swift

import AtlasProtocol
import Foundation
import Darwin
public struct AtlasPrivilegedHelperActionExecutor {
private let fileManager: FileManager
private let allowedRoots: [String]
private let currentUserID: UInt32
private let currentGroupID: UInt32
private let homeDirectoryURL: URL
public init(
fileManager: FileManager = .default,
allowedRoots: [String]? = nil,
currentUserID: UInt32 = getuid(),
currentGroupID: UInt32 = getgid(),
homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser
) {
self.fileManager = fileManager
self.currentUserID = currentUserID
self.currentGroupID = currentGroupID
self.homeDirectoryURL = homeDirectoryURL
self.allowedRoots = allowedRoots ?? [
URL(fileURLWithPath: "/Applications", isDirectory: true).path,
homeDirectoryURL.appendingPathComponent("Applications", isDirectory: true).path,
homeDirectoryURL.appendingPathComponent("Library/LaunchAgents", isDirectory: true).path,
URL(fileURLWithPath: "/Library/LaunchAgents", isDirectory: true).path,
URL(fileURLWithPath: "/Library/LaunchDaemons", isDirectory: true).path,
]
}
public func perform(_ action: AtlasHelperAction) throws -> AtlasHelperActionResult {
let targetURL = URL(fileURLWithPath: action.targetPath).resolvingSymlinksInPath()
let destinationURL = action.destinationPath.map { URL(fileURLWithPath: $0).resolvingSymlinksInPath() }
try validate(action: action, targetURL: targetURL, destinationURL: destinationURL)
switch action.kind {
case .trashItems:
var trashedURL: NSURL?
try fileManager.trashItem(at: targetURL, resultingItemURL: &trashedURL)
return AtlasHelperActionResult(
action: action,
success: true,
message: "Moved item to Trash.",
resolvedPath: (trashedURL as URL?)?.path
)
case .restoreItem:
guard let destinationURL else {
throw HelperValidationError.invalidRestoreDestination(nil)
}
try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try fileManager.moveItem(at: targetURL, to: destinationURL)
return AtlasHelperActionResult(
action: action,
success: true,
message: "Restored item from Trash.",
resolvedPath: destinationURL.path
)
case .removeLaunchService:
try fileManager.removeItem(at: targetURL)
return AtlasHelperActionResult(
action: action,
success: true,
message: "Removed launch service file.",
resolvedPath: targetURL.path
)
case .repairOwnership:
return try repairOwnership(for: action, targetURL: targetURL)
}
}
private func repairOwnership(for action: AtlasHelperAction, targetURL: URL) throws -> AtlasHelperActionResult {
let targets = try ownershipTargets(for: targetURL)
var updatedCount = 0
var failedPaths: [String] = []
for url in targets {
do {
let attributes = try fileManager.attributesOfItem(atPath: url.path)
let ownerID = attributes[.ownerAccountID] as? NSNumber
let groupID = attributes[.groupOwnerAccountID] as? NSNumber
let alreadyOwned = ownerID?.uint32Value == currentUserID && groupID?.uint32Value == currentGroupID
if !alreadyOwned {
try fileManager.setAttributes([
.ownerAccountID: NSNumber(value: currentUserID),
.groupOwnerAccountID: NSNumber(value: currentGroupID),
], ofItemAtPath: url.path)
updatedCount += 1
}
} catch {
failedPaths.append(url.path)
}
}
if !failedPaths.isEmpty {
throw HelperValidationError.repairOwnershipFailed(failedPaths)
}
let message: String
if updatedCount == 0 {
message = "Ownership already matched the current user."
} else {
message = "Repaired ownership for \(updatedCount) item\(updatedCount == 1 ? "" : "s")."
}
return AtlasHelperActionResult(
action: action,
success: true,
message: message,
resolvedPath: targetURL.path
)
}
private func ownershipTargets(for rootURL: URL) throws -> [URL] {
var urls: [URL] = [rootURL]
let values = try rootURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
guard values.isDirectory == true, values.isSymbolicLink != true else {
return urls
}
if let enumerator = fileManager.enumerator(
at: rootURL,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
) {
for case let url as URL in enumerator {
let resourceValues = try? url.resourceValues(forKeys: [.isSymbolicLinkKey])
if resourceValues?.isSymbolicLink == true {
continue
}
urls.append(url)
}
}
return urls
}
private func validate(action: AtlasHelperAction, targetURL: URL, destinationURL: URL?) throws {
guard fileManager.fileExists(atPath: targetURL.path) else {
throw HelperValidationError.pathNotFound(targetURL.path)
}
let isAllowed = { (url: URL) in
allowedRoots.contains { root in
url.path == root || url.path.hasPrefix(root + "/")
}
}
switch action.kind {
case .trashItems, .removeLaunchService, .repairOwnership:
guard isAllowed(targetURL) else {
throw HelperValidationError.pathNotAllowed(targetURL.path)
}
case .restoreItem:
let trashRoot = homeDirectoryURL.appendingPathComponent(".Trash", isDirectory: true).path
guard targetURL.path == trashRoot || targetURL.path.hasPrefix(trashRoot + "/") else {
throw HelperValidationError.pathNotAllowed(targetURL.path)
}
guard let destinationURL else {
throw HelperValidationError.invalidRestoreDestination(nil)
}
guard isAllowed(destinationURL) else {
throw HelperValidationError.invalidRestoreDestination(destinationURL.path)
}
if fileManager.fileExists(atPath: destinationURL.path) {
throw HelperValidationError.restoreDestinationExists(destinationURL.path)
}
}
if action.kind == .removeLaunchService {
guard targetURL.pathExtension == "plist" else {
throw HelperValidationError.invalidLaunchServicePath(targetURL.path)
}
}
}
}
enum HelperValidationError: LocalizedError {
case pathNotFound(String)
case pathNotAllowed(String)
case invalidLaunchServicePath(String)
case invalidRestoreDestination(String?)
case restoreDestinationExists(String)
case repairOwnershipFailed([String])
var errorDescription: String? {
switch self {
case let .pathNotFound(path):
return "Target path not found: \(path)"
case let .pathNotAllowed(path):
return "Target path is outside the helper allowlist: \(path)"
case let .invalidLaunchServicePath(path):
return "Launch service removal requires a plist path: \(path)"
case let .invalidRestoreDestination(path):
return "Restore destination is invalid: \(path ?? "<missing>")"
case let .restoreDestinationExists(path):
return "Restore destination already exists: \(path)"
case let .repairOwnershipFailed(paths):
return "Failed to repair ownership for: \(paths.joined(separator: ", "))"
}
}
}