Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

317 lines
10 KiB
Swift

import Foundation
struct DiskCleanSafetyPolicy: Sendable {
let homeDirectory: String
let whitelistStore: DiskCleanWhitelistStore
init(
homeDirectory: String = NSHomeDirectory(),
whitelistStore: DiskCleanWhitelistStore? = nil
) {
self.homeDirectory = Self.normalizeSlashes(homeDirectory)
self.whitelistStore = whitelistStore ?? DiskCleanWhitelistStore(homeDirectory: homeDirectory)
}
func validatePathShape(
_ path: String,
isSymlink: Bool = false,
resolvedSymlinkTarget: String? = nil
) -> DiskCleanSafetyStatus {
let normalizedPath = normalizePath(path)
guard !normalizedPath.isEmpty else {
return .invalid(reason: "empty path")
}
guard normalizedPath.hasPrefix("/") else {
return .invalid(reason: "path must be absolute")
}
guard !Self.containsTraversalComponent(normalizedPath) else {
return .invalid(reason: "path traversal is not allowed")
}
guard !Self.containsControlCharacter(normalizedPath) else {
return .invalid(reason: "path contains control characters")
}
if isSymlink {
guard let resolvedSymlinkTarget, !resolvedSymlinkTarget.isEmpty else {
return .invalid(reason: "cannot validate symlink target")
}
let normalizedTarget = normalizePath(resolvedSymlinkTarget)
if Self.isProtectedSystemRoot(normalizedTarget) {
return .invalid(reason: "symlink points to protected system path")
}
}
if Self.isProtectedSystemRoot(normalizedPath) {
return .invalid(reason: "critical system path")
}
return .allowed
}
func safetyStatus(
for path: String,
isSymlink: Bool = false,
resolvedSymlinkTarget: String? = nil
) -> DiskCleanSafetyStatus {
let normalizedPath = normalizePath(path)
let shapeStatus = validatePathShape(
normalizedPath,
isSymlink: isSymlink,
resolvedSymlinkTarget: resolvedSymlinkTarget
)
guard case .allowed = shapeStatus else {
return shapeStatus
}
if let reason = sensitiveProtectionReason(for: normalizedPath) {
return .protected(reason: reason)
}
if let rule = whitelistStore.matchingRule(for: normalizedPath) {
return .whitelisted(rule: rule.expandedPattern)
}
return .allowed
}
private func normalizePath(_ path: String) -> String {
let expanded = expandHome(in: path.trimmingCharacters(in: .whitespacesAndNewlines))
return Self.normalizeSlashes(Self.stripTrailingSlash(expanded))
}
private func expandHome(in path: String) -> String {
if path == "~" || path == "$HOME" || path == "${HOME}" {
return homeDirectory
}
if path.hasPrefix("~/") {
return homeDirectory + String(path.dropFirst())
}
if path.hasPrefix("$HOME/") {
return homeDirectory + String(path.dropFirst("$HOME".count))
}
if path.hasPrefix("${HOME}/") {
return homeDirectory + String(path.dropFirst("${HOME}".count))
}
return path
}
private func sensitiveProtectionReason(for path: String) -> String? {
let lower = path.lowercased()
if Self.hasPathFragment(lower, "/library/keychains")
|| Self.hasPathFragment(lower, "/.ssh")
|| Self.hasPathFragment(lower, "/.gnupg")
|| lower.contains("keychain")
|| lower.contains("credential")
|| lower.contains("auth_token")
|| lower.contains("access_token") {
return "credentials and key material are protected"
}
if Self.hasPathFragment(lower, "/library/application support/com.apple.tcc")
|| lower.hasSuffix("/tcc.db")
|| lower.contains("/tcc/") {
return "privacy permission database is protected"
}
if Self.hasPathFragment(lower, "/library/mobile documents")
|| Self.hasPathFragment(lower, "/mobile documents") {
return "iCloud synced data is protected"
}
if isProtectedBrowserProfileData(lower) {
return "browser profile data is protected"
}
if isProtectedPasswordManagerData(lower) {
return "password manager data is protected"
}
if isProtectedProxyOrVPNData(lower) {
return "VPN and proxy data is protected"
}
if isProtectedSystemState(lower) {
return "macOS state data is protected"
}
if Self.hasPathFragment(lower, "/library/logs/mole")
|| Self.hasPathFragment(lower, "/library/logs/mactools")
|| Self.hasPathFragment(lower, "/.config/mole")
|| Self.hasPathFragment(lower, "/library/application support/mactools") {
return "cleanup tool state is protected"
}
return nil
}
private func isProtectedBrowserProfileData(_ lower: String) -> Bool {
if Self.hasPathFragment(lower, "/library/cookies")
|| lower.hasSuffix(".binarycookies")
|| lower.hasSuffix("/cookies")
|| lower.hasSuffix("/cookies-journal")
|| lower.hasSuffix("/history")
|| lower.hasSuffix("/history-journal")
|| lower.hasSuffix("/login data")
|| lower.hasSuffix("/login data-journal")
|| lower.hasSuffix("/web data")
|| lower.hasSuffix("/local state")
|| lower.hasSuffix("/bookmarks")
|| lower.hasSuffix("/sessions")
|| lower.hasSuffix("/session storage") {
return true
}
if Self.hasPathFragment(lower, "/library/safari")
&& (lower.hasSuffix("/history.db")
|| lower.hasSuffix("/downloads.plist")
|| lower.hasSuffix("/bookmarks.plist")
|| lower.hasSuffix("/lastsession.plist")) {
return true
}
if Self.hasPathFragment(lower, "/library/application support/firefox/profiles")
&& (lower.hasSuffix("/places.sqlite")
|| lower.hasSuffix("/cookies.sqlite")
|| lower.hasSuffix("/key4.db")
|| lower.hasSuffix("/logins.json")
|| lower.contains("/sessionstore")) {
return true
}
return false
}
private func isProtectedPasswordManagerData(_ lower: String) -> Bool {
let tokens = [
"1password",
"agilebits",
"lastpass",
"dashlane",
"bitwarden",
"keepass",
"keepassxc",
"authy",
"yubico"
]
return tokens.contains { lower.contains($0) }
}
private func isProtectedProxyOrVPNData(_ lower: String) -> Bool {
let tokens = [
"nssurge",
"surge-mac",
"clash",
"mihomo",
"v2ray",
"shadowsocks",
"sing-box",
"openvpn",
"tailscale",
"zerotier",
"nordvpn",
"expressvpn",
"protonvpn",
"surfshark",
"windscribe",
"mullvad",
"privateinternetaccess",
"quantumult"
]
return tokens.contains { lower.contains($0) }
}
private func isProtectedSystemState(_ lower: String) -> Bool {
if lower.contains("systemsettings")
|| lower.contains("systempreferences")
|| lower.contains("controlcenter")
|| lower.contains("com.apple.settings")
|| lower.contains("com.apple.notes")
|| lower.contains("com.apple.finder")
|| lower.contains("com.apple.dock")
|| lower.contains("com.apple.bluetooth")
|| lower.contains("com.apple.wifi")
|| lower.contains("org.cups.") {
return true
}
if lower.hasSuffix("/library/preferences/com.apple.dock.plist")
|| lower.hasSuffix("/library/preferences/com.apple.finder.plist") {
return true
}
if lower.contains("inputmethod")
|| lower.contains("textinput")
|| lower.contains("keyboard")
|| lower.contains("inputsource")
|| lower.contains("keylayout")
|| lower.contains("globalpreferences")
|| lower.contains("karabiner") {
return true
}
return false
}
private static func containsTraversalComponent(_ path: String) -> Bool {
path.split(separator: "/", omittingEmptySubsequences: false).contains("..")
}
private static func containsControlCharacter(_ path: String) -> Bool {
path.unicodeScalars.contains { scalar in
scalar.value < 32 || scalar.value == 127
}
}
private static func isProtectedSystemRoot(_ path: String) -> Bool {
let normalized = stripTrailingSlash(normalizeSlashes(path))
if normalized == "/" {
return true
}
let exactRoots = [
"/private",
"/var",
"/var/db",
"/private/var",
"/private/var/db"
]
if exactRoots.contains(normalized) {
return true
}
let protectedPrefixes = [
"/System",
"/bin",
"/sbin",
"/usr",
"/etc",
"/private/etc",
"/Library/Extensions"
]
return protectedPrefixes.contains { root in
normalized == root || normalized.hasPrefix(root + "/")
} || normalized.hasPrefix("/var/db/")
|| normalized.hasPrefix("/private/var/db/")
}
private static func hasPathFragment(_ lowerPath: String, _ fragment: String) -> Bool {
lowerPath == fragment || lowerPath.contains(fragment + "/") || lowerPath.contains(fragment)
}
private static func normalizeSlashes(_ path: String) -> String {
var normalized = path
while normalized.contains("//") {
normalized = normalized.replacingOccurrences(of: "//", with: "/")
}
return normalized
}
private static func stripTrailingSlash(_ path: String) -> String {
guard path.count > 1, path.hasSuffix("/") else { return path }
return String(path.dropLast())
}
}