From c9c5d71e8cdde315aaaa4cf328745c0e8dfadad5 Mon Sep 17 00:00:00 2001 From: aleksandrlugchenko Date: Sun, 19 Jul 2026 23:29:31 +0200 Subject: [PATCH] Harden privileged filesystem deletion --- .github/workflows/build.yml | 28 + .github/workflows/release.yml | 23 + PureMac.xcodeproj/project.pbxproj | 209 +- PureMac/Models/Models.swift | 33 + PureMac/Services/CleaningEngine.swift | 470 ++-- PureMac/Services/PermissionCoordinator.swift | 8 +- .../Services/PrivilegedCleaningClient.swift | 1368 ++++++++++ PureMac/ViewModels/AppState.swift | 933 ++++++- PureMac/Views/Apps/AppFilesView.swift | 6 +- .../Views/Components/PermissionSheet.swift | 10 +- PureMac/Views/MainWindow.swift | 8 +- PureMac/Views/Orphans/OrphanListView.swift | 97 +- .../com.puremac.privileged-cleaning.plist | 23 + PureMacPrivilegedHelper/main.swift | 724 +++++ PureMacTests/AppStateTests.swift | 159 ++ PureMacTests/SecureDeletionTests.swift | 959 +++++++ Shared/SecureDeletion.swift | 2396 +++++++++++++++++ project.yml | 49 +- scripts/release-local.sh | 24 + 19 files changed, 7070 insertions(+), 457 deletions(-) create mode 100644 PureMac/Services/PrivilegedCleaningClient.swift create mode 100644 PureMacPrivilegedHelper/com.puremac.privileged-cleaning.plist create mode 100644 PureMacPrivilegedHelper/main.swift create mode 100644 PureMacTests/SecureDeletionTests.swift create mode 100644 Shared/SecureDeletion.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e85a8ae..506bd1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,3 +30,31 @@ jobs: CODE_SIGN_IDENTITY="" \ CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO + + - name: Verify privileged helper packaging + run: | + set -euo pipefail + APP="build/Build/Products/Release/PureMac.app" + HELPER="${APP}/Contents/Library/LaunchServices/com.puremac.privileged-helper" + PLIST="${APP}/Contents/Library/LaunchDaemons/com.puremac.privileged-cleaning.plist" + test -x "${HELPER}" + test -f "${PLIST}" + plutil -lint "${PLIST}" + test "$(/usr/libexec/PlistBuddy -c 'Print :Label' "${PLIST}")" = "com.puremac.privileged-cleaning" + test "$(/usr/libexec/PlistBuddy -c 'Print :BundleProgram' "${PLIST}")" = "Contents/Library/LaunchServices/com.puremac.privileged-helper" + test "$(/usr/libexec/PlistBuddy -c 'Print :AssociatedBundleIdentifiers:0' "${PLIST}")" = "com.puremac.app" + test "$(/usr/libexec/PlistBuddy -c 'Print :MachServices:com.puremac.privileged-cleaning' "${PLIST}")" = "true" + lipo -archs "${HELPER}" | grep -q "arm64" + lipo -archs "${HELPER}" | grep -q "x86_64" + + - name: Test + run: | + xcodebuild -project PureMac.xcodeproj \ + -scheme PureMac \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath build-tests \ + test \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9fa285..a28bc3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,12 +129,35 @@ jobs: run: | set -euo pipefail APP="build/export/PureMac.app" + HELPER="${APP}/Contents/Library/LaunchServices/com.puremac.privileged-helper" + DAEMON_PLIST="${APP}/Contents/Library/LaunchDaemons/com.puremac.privileged-cleaning.plist" + APP_REQUIREMENT='anchor apple generic and identifier "com.puremac.app" and certificate leaf[subject.OU] = "H3WXHVTP97"' + HELPER_REQUIREMENT='anchor apple generic and identifier "com.puremac.privileged-helper" and certificate leaf[subject.OU] = "H3WXHVTP97"' + test -x "${HELPER}" + test -f "${DAEMON_PLIST}" + plutil -lint "${DAEMON_PLIST}" + test "$(/usr/libexec/PlistBuddy -c 'Print :Label' "${DAEMON_PLIST}")" = "com.puremac.privileged-cleaning" + test "$(/usr/libexec/PlistBuddy -c 'Print :BundleProgram' "${DAEMON_PLIST}")" = "Contents/Library/LaunchServices/com.puremac.privileged-helper" + test "$(/usr/libexec/PlistBuddy -c 'Print :AssociatedBundleIdentifiers:0' "${DAEMON_PLIST}")" = "com.puremac.app" + test "$(/usr/libexec/PlistBuddy -c 'Print :MachServices:com.puremac.privileged-cleaning' "${DAEMON_PLIST}")" = "true" codesign --verify --deep --strict --verbose=2 "${APP}" + codesign --verify --strict --verbose=2 "${HELPER}" + codesign --verify --strict --verbose=2 -R="${APP_REQUIREMENT}" "${APP}" + codesign --verify --strict --verbose=2 -R="${HELPER_REQUIREMENT}" "${HELPER}" codesign -dvv "${APP}" 2>&1 | tee /tmp/cs-info.txt + codesign -dvv "${HELPER}" 2>&1 | tee /tmp/helper-cs-info.txt + codesign -d --entitlements :- "${APP}" > /tmp/app-entitlements.plist + codesign -d --entitlements :- "${HELPER}" > /tmp/helper-entitlements.plist + ! plutil -p /tmp/app-entitlements.plist | grep -E 'get-task-allow|disable-library-validation|allow-dyld-environment-variables' + ! plutil -p /tmp/helper-entitlements.plist | grep -E 'get-task-allow|disable-library-validation|allow-dyld-environment-variables' grep -q "flags=0x10000(runtime)" /tmp/cs-info.txt || { echo "::error::Hardened runtime missing"; exit 1; } + grep -q "Identifier=com.puremac.privileged-helper" /tmp/helper-cs-info.txt || { echo "::error::Unexpected privileged helper identifier"; exit 1; } + grep -q "TeamIdentifier=${TEAM_ID}" /tmp/helper-cs-info.txt || { echo "::error::Unexpected privileged helper team"; exit 1; } + grep -q "flags=0x10000(runtime)" /tmp/helper-cs-info.txt || { echo "::error::Privileged helper hardened runtime missing"; exit 1; } spctl --assess --type execute --verbose=4 "${APP}" || true # Universal arch check lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "x86_64" && lipo -archs "${APP}/Contents/MacOS/PureMac" | grep -q "arm64" + lipo -archs "${HELPER}" | grep -q "x86_64" && lipo -archs "${HELPER}" | grep -q "arm64" - name: Build DMG env: diff --git a/PureMac.xcodeproj/project.pbxproj b/PureMac.xcodeproj/project.pbxproj index 9b1352e..a2e0463 100644 --- a/PureMac.xcodeproj/project.pbxproj +++ b/PureMac.xcodeproj/project.pbxproj @@ -8,9 +8,13 @@ /* Begin PBXBuildFile section */ 013EF7B96BF046D4DB38FBC5 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 241E0895B09C71AB423B2F9E /* Localizable.strings */; }; + 0D61C211D7AE769DD62C0C42 /* SecureDeletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B769B1B502A66B82ED436E19 /* SecureDeletion.swift */; }; 1B0D043102FDB245312A5147 /* AppFilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 798B80977D14647A5691B0A0 /* AppFilesView.swift */; }; 2253F11BDF561B617439C96B /* FullDiskAccessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */; }; + 23D8EED08901836FC18522AC /* SecureDeletion.swift in Sources */ = {isa = PBXBuildFile; fileRef = B769B1B502A66B82ED436E19 /* SecureDeletion.swift */; }; 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28181F034530331A550C6A3D /* SchedulerService.swift */; }; + 29046E5FA69F724CCB545777 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF7515ECE6083C27CF5CFA07 /* Security.framework */; }; + 33BCD53E1D841FD03BFF99DD /* PrivilegedCleaningClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06241A592A5EE0CA23A9812 /* PrivilegedCleaningClient.swift */; }; 340E424F759ACCDE7372F99F /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5ADDC35F31E40780FB5D017 /* Theme.swift */; }; 41B27CACD7C6C7471EFD03F9 /* UpdateService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022D0EAEE2B6E54069FC2CBE /* UpdateService.swift */; }; 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3AC5F17D7CA94FAB1E8D7A /* StringNormalization.swift */; }; @@ -20,9 +24,11 @@ 52F11962555C639F0EECADD6 /* FDADemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231FF7CBB5F621B66F2AB8A5 /* FDADemoView.swift */; }; 535B23C0108C06475215B8E8 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1AB003A7751F05727DBFD1A5 /* AppTheme.swift */; }; 59B3096E2AE8ED0397B16798 /* AppLanguagePreferencesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */; }; + 5C9884DEE213389C1040842B /* PureMacPrivilegedHelper in Embed Dependencies */ = {isa = PBXBuildFile; fileRef = E33C8319D683EFCDD5BAE812 /* PureMacPrivilegedHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 61D285E96DED76FDCD54570F /* PermissionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069AAE87E3F9EDE5593AD8B3 /* PermissionSheet.swift */; }; 6E72022A661CBB41331A442C /* PermissionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9DCBD97CC88D77AE8DB01A4 /* PermissionCoordinator.swift */; }; 75B5F0401D37F2872B1AD85A /* AppListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60E6BC61614B27C065BB18C9 /* AppListView.swift */; }; + 7636D504CDED48FBE95CB6A6 /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF7515ECE6083C27CF5CFA07 /* Security.framework */; }; 76B132F9C499225D33E0D075 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424F7B28624C271620E13BBC /* EmptyStateView.swift */; }; 826A750D2D7EC14C2AE306A3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3667D46D8E2004EB4D73835A /* Models.swift */; }; 8947F0CE448791BD50EECF46 /* Conditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB94E06E145558123BB5BFB3 /* Conditions.swift */; }; @@ -37,9 +43,13 @@ A549D8A39A9E5E70D91B90A6 /* Haptics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40A3F22FCEF534DE4A2CAD0E /* Haptics.swift */; }; A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */; }; ABDDD4F35102A39CC1A2C325 /* ConfettiView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0599AA604B0D6BA4F957537 /* ConfettiView.swift */; }; + B0ED11FF9EA355A7A8AB669A /* SecureDeletionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE727E1578EF56ED53E9F9A /* SecureDeletionTests.swift */; }; B51E5A95BB49A6C0F5273E21 /* FileSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE522B406791BCE905BC55B /* FileSize.swift */; }; B52938BBD11842631314543D /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10B0AF194677EAC1D5568785 /* CategoryDetailView.swift */; }; + B7C2C50BF8BE18BEFE335078 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB775352D81AC55FB6882BA /* main.swift */; }; BC6C800216343438413349A3 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D4FD34378988D430A582ED0 /* OnboardingView.swift */; }; + C5B5B638580C0D3FAB90E4E9 /* ServiceManagement.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E819FEBFC5CA249E83C68EAD /* ServiceManagement.framework */; }; + C81D6961223CD28F8BDB2163 /* com.puremac.privileged-cleaning.plist in CopyFiles */ = {isa = PBXBuildFile; fileRef = EA96F35FA8F6D643A0FF4A09 /* com.puremac.privileged-cleaning.plist */; }; CDCDFEBA39A3290101F86AC6 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F510F232341EE18F11DC934 /* MainWindow.swift */; }; CFA64F54CDBF8A4765E0068D /* LocalizationFilesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */; }; D50EB059E741011EB2523731 /* ScanError.swift in Sources */ = {isa = PBXBuildFile; fileRef = EEF15CB1B8EFCA78EF491824 /* ScanError.swift */; }; @@ -58,6 +68,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 1CC903453C231B0626782648 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 37BE87544E2EC9F6FF1CD280 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4BB056D561103EDFD2E61F8A; + remoteInfo = PureMacPrivilegedHelper; + }; 8D91DC174C821BE74C4B518C /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 37BE87544E2EC9F6FF1CD280 /* Project object */; @@ -67,6 +84,30 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 48C101F2D58D310E1272456D /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchDaemons; + dstSubfolderSpec = 1; + files = ( + C81D6961223CD28F8BDB2163 /* com.puremac.privileged-cleaning.plist in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CCA46CC6FA1303A003AADC1A /* Embed Dependencies */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = Contents/Library/LaunchServices; + dstSubfolderSpec = 1; + files = ( + 5C9884DEE213389C1040842B /* PureMacPrivilegedHelper in Embed Dependencies */, + ); + name = "Embed Dependencies"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 01B2C5F66B6D812572BD4F05 /* CLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLI.swift; sourceTree = ""; }; 022D0EAEE2B6E54069FC2CBE /* UpdateService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateService.swift; sourceTree = ""; }; @@ -90,10 +131,8 @@ 3D4FD34378988D430A582ED0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 40A3F22FCEF534DE4A2CAD0E /* Haptics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Haptics.swift; sourceTree = ""; }; 424F7B28624C271620E13BBC /* EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmptyStateView.swift; sourceTree = ""; }; - 46660271CFF167AB0FE7371D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLanguagePreferencesTests.swift; sourceTree = ""; }; 4AE790496C936424EF98320A /* OrphanSafetyPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrphanSafetyPolicy.swift; sourceTree = ""; }; - 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PureMac.entitlements; sourceTree = ""; }; 5785762276FB5E3209C6DE4D /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 5A5C80929EE4A430272674BC /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PureMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -112,16 +151,24 @@ A27DB5A70FA829B6C3ED0AC3 /* MenuBarMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarMonitorView.swift; sourceTree = ""; }; A711CDF5285F68775D9B5513 /* ScanEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanEngine.swift; sourceTree = ""; }; AC0FEE7141871ED5F9E36121 /* AppPathFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPathFinder.swift; sourceTree = ""; }; - A1B3C4D5E6F7A8B9C0D1E2F3 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; B2CD0028599BE178B96F18A6 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B71E8F62DB76D85F41F9A2E9 /* OrphanListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrphanListView.swift; sourceTree = ""; }; + B769B1B502A66B82ED436E19 /* SecureDeletion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureDeletion.swift; sourceTree = ""; }; + C5963EBBEE5BA6E147700821 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; C5ADDC35F31E40780FB5D017 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; CB94E06E145558123BB5BFB3 /* Conditions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Conditions.swift; sourceTree = ""; }; + CCE727E1578EF56ED53E9F9A /* SecureDeletionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureDeletionTests.swift; sourceTree = ""; }; + CDB775352D81AC55FB6882BA /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; CED1B71D5F9510582E869CFD /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + D06241A592A5EE0CA23A9812 /* PrivilegedCleaningClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivilegedCleaningClient.swift; sourceTree = ""; }; DB798781B99987F55D77E2E3 /* SystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemMonitor.swift; sourceTree = ""; }; + E33C8319D683EFCDD5BAE812 /* PureMacPrivilegedHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = PureMacPrivilegedHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + E819FEBFC5CA249E83C68EAD /* ServiceManagement.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ServiceManagement.framework; path = System/Library/Frameworks/ServiceManagement.framework; sourceTree = SDKROOT; }; E866A1541D289C69144A5E62 /* FullDiskAccessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullDiskAccessManager.swift; sourceTree = ""; }; + EA96F35FA8F6D643A0FF4A09 /* com.puremac.privileged-cleaning.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "com.puremac.privileged-cleaning.plist"; sourceTree = ""; }; EEF15CB1B8EFCA78EF491824 /* ScanError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanError.swift; sourceTree = ""; }; + EF7515ECE6083C27CF5CFA07 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; F0599AA604B0D6BA4F957537 /* ConfettiView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfettiView.swift; sourceTree = ""; }; F31F91226CDCFBB8E303B7DA /* AppConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConstants.swift; sourceTree = ""; }; F661A0F64CF93E482CB1728F /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; @@ -129,11 +176,21 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 51132C09D3556B3F9E1989A6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 29046E5FA69F724CCB545777 /* Security.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; F8C2F3E176B50B537CC3381D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 50304378D99F2E9E48B642AF /* Sparkle in Frameworks */, + C5B5B638580C0D3FAB90E4E9 /* ServiceManagement.framework in Frameworks */, + 7636D504CDED48FBE95CB6A6 /* Security.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -144,17 +201,30 @@ isa = PBXGroup; children = ( E383F69184F4552E5A41D010 /* PureMac */, + 614C2860B2B0197F2D5B92B0 /* PureMacPrivilegedHelper */, 261FB8D961FEC76F15EA523A /* PureMacTests */, + 6D0BC32329B2742CD1D3E65D /* Shared */, + 25E21632A20C805640CA8568 /* Frameworks */, 4562CA9E5625FA4EEEFECB6D /* Products */, ); sourceTree = ""; }; + 25E21632A20C805640CA8568 /* Frameworks */ = { + isa = PBXGroup; + children = ( + EF7515ECE6083C27CF5CFA07 /* Security.framework */, + E819FEBFC5CA249E83C68EAD /* ServiceManagement.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 261FB8D961FEC76F15EA523A /* PureMacTests */ = { isa = PBXGroup; children = ( 491771F923C93FD61A263893 /* AppLanguagePreferencesTests.swift */, 752603285F7DBBA12BB3AA91 /* AppStateTests.swift */, 155CE1B8CDCCCA6F9FD028C1 /* LocalizationFilesTests.swift */, + CCE727E1578EF56ED53E9F9A /* SecureDeletionTests.swift */, ); path = PureMacTests; sourceTree = ""; @@ -173,6 +243,7 @@ isa = PBXGroup; children = ( 311078221878708524283765 /* PureMac.app */, + E33C8319D683EFCDD5BAE812 /* PureMacPrivilegedHelper */, 607B9A8C7B34D6A7DCF4FFE2 /* PureMacTests.xctest */, ); name = Products; @@ -193,6 +264,15 @@ path = Views; sourceTree = ""; }; + 614C2860B2B0197F2D5B92B0 /* PureMacPrivilegedHelper */ = { + isa = PBXGroup; + children = ( + EA96F35FA8F6D643A0FF4A09 /* com.puremac.privileged-cleaning.plist */, + CDB775352D81AC55FB6882BA /* main.swift */, + ); + path = PureMacPrivilegedHelper; + sourceTree = ""; + }; 6184B2EC3D01E6E95633406E /* Services */ = { isa = PBXGroup; children = ( @@ -201,6 +281,7 @@ 40A3F22FCEF534DE4A2CAD0E /* Haptics.swift */, 913E3064AA9BD7BE94A315CF /* MenuBarController.swift */, F9DCBD97CC88D77AE8DB01A4 /* PermissionCoordinator.swift */, + D06241A592A5EE0CA23A9812 /* PrivilegedCleaningClient.swift */, A711CDF5285F68775D9B5513 /* ScanEngine.swift */, 28181F034530331A550C6A3D /* SchedulerService.swift */, DB798781B99987F55D77E2E3 /* SystemMonitor.swift */, @@ -209,6 +290,14 @@ path = Services; sourceTree = ""; }; + 6D0BC32329B2742CD1D3E65D /* Shared */ = { + isa = PBXGroup; + children = ( + B769B1B502A66B82ED436E19 /* SecureDeletion.swift */, + ); + path = Shared; + sourceTree = ""; + }; 7BC68AB045DA6E5299FD3CB6 /* Settings */ = { isa = PBXGroup; children = ( @@ -289,8 +378,6 @@ isa = PBXGroup; children = ( B2EA41E1096FA8E3B916AD13 /* Assets.xcassets */, - 46660271CFF167AB0FE7371D /* Info.plist */, - 5664D2BDAEAA9AE3A53DB364 /* PureMac.entitlements */, 63581B70F9B10231964E3602 /* PureMacApp.swift */, D4333B07691BD85CAE0E5B15 /* Core */, 7C1729F88C0E5563E1A3DB40 /* Extensions */, @@ -328,17 +415,38 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 4BB056D561103EDFD2E61F8A /* PureMacPrivilegedHelper */ = { + isa = PBXNativeTarget; + buildConfigurationList = B9BF14F733E894572273CCC7 /* Build configuration list for PBXNativeTarget "PureMacPrivilegedHelper" */; + buildPhases = ( + FE04119C940F200CD4796E90 /* Sources */, + 51132C09D3556B3F9E1989A6 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PureMacPrivilegedHelper; + packageProductDependencies = ( + ); + productName = PureMacPrivilegedHelper; + productReference = E33C8319D683EFCDD5BAE812 /* PureMacPrivilegedHelper */; + productType = "com.apple.product-type.tool"; + }; B19E38DEAA0144A77120F39C /* PureMac */ = { isa = PBXNativeTarget; buildConfigurationList = 2754925F163D15C85EDF494D /* Build configuration list for PBXNativeTarget "PureMac" */; buildPhases = ( 63C589367B714FC06B0519E5 /* Sources */, 3975A867E04014FA565E9F46 /* Resources */, + 48C101F2D58D310E1272456D /* CopyFiles */, F8C2F3E176B50B537CC3381D /* Frameworks */, + CCA46CC6FA1303A003AADC1A /* Embed Dependencies */, ); buildRules = ( ); dependencies = ( + 02F3C51A206DB754EB942AFA /* PBXTargetDependency */, ); name = PureMac; packageProductDependencies = ( @@ -375,6 +483,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1640; TargetAttributes = { + 4BB056D561103EDFD2E61F8A = { + DevelopmentTeam = H3WXHVTP97; + ProvisioningStyle = Automatic; + }; B19E38DEAA0144A77120F39C = { DevelopmentTeam = H3WXHVTP97; ProvisioningStyle = Automatic; @@ -410,6 +522,7 @@ projectRoot = ""; targets = ( B19E38DEAA0144A77120F39C /* PureMac */, + 4BB056D561103EDFD2E61F8A /* PureMacPrivilegedHelper */, FBA8B0DE95746207A043B802 /* PureMacTests */, ); }; @@ -465,10 +578,12 @@ A9C3A1F643C26930F442E729 /* OrphanSafetyPolicy.swift in Sources */, 6E72022A661CBB41331A442C /* PermissionCoordinator.swift in Sources */, 61D285E96DED76FDCD54570F /* PermissionSheet.swift in Sources */, + 33BCD53E1D841FD03BFF99DD /* PrivilegedCleaningClient.swift in Sources */, EDEF28CAD23E936FBED1783B /* PureMacApp.swift in Sources */, D9445C2641A8637B65DA5ACE /* ScanEngine.swift in Sources */, D50EB059E741011EB2523731 /* ScanError.swift in Sources */, 27F449EDD1B082FE11FEC9DF /* SchedulerService.swift in Sources */, + 23D8EED08901836FC18522AC /* SecureDeletion.swift in Sources */, 93743B036059418560D876E6 /* SettingsView.swift in Sources */, 47C5ECD49C4DD75F271DB6CE /* StringNormalization.swift in Sources */, DBFD73E3D9BDA74EC1CA56AB /* SystemMonitor.swift in Sources */, @@ -484,12 +599,27 @@ 59B3096E2AE8ED0397B16798 /* AppLanguagePreferencesTests.swift in Sources */, E2403D82F00D749C9AD4A6D4 /* AppStateTests.swift in Sources */, CFA64F54CDBF8A4765E0068D /* LocalizationFilesTests.swift in Sources */, + B0ED11FF9EA355A7A8AB669A /* SecureDeletionTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FE04119C940F200CD4796E90 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0D61C211D7AE769DD62C0C42 /* SecureDeletion.swift in Sources */, + B7C2C50BF8BE18BEFE335078 /* main.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 02F3C51A206DB754EB942AFA /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4BB056D561103EDFD2E61F8A /* PureMacPrivilegedHelper */; + targetProxy = 1CC903453C231B0626782648 /* PBXContainerItemProxy */; + }; 0E97FDF7F6FA15845FCE6737 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B19E38DEAA0144A77120F39C /* PureMac */; @@ -505,7 +635,7 @@ 9F04B811BB0012F6D2F07F91 /* en */, 340D303C60FF878B019056B6 /* es */, 5A5C80929EE4A430272674BC /* ja */, - A1B3C4D5E6F7A8B9C0D1E2F3 /* pl */, + C5963EBBEE5BA6E147700821 /* pl */, 8D5E63D733D1A3BDEDF4DCA5 /* pt-BR */, 2641C6376DD6F5889F35510E /* zh-Hans */, 02E502E2B5C6AECC76E5CFEF /* zh-Hant */, @@ -551,12 +681,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = PureMac/PureMac.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 20; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = H3WXHVTP97; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -578,7 +708,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.8.3; + MARKETING_VERSION = 2.8.5; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -595,9 +725,9 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGNING_ALLOWED = NO; - CODE_SIGNING_REQUIRED = NO; + CODE_SIGN_ENTITLEMENTS = PureMac/PureMac.entitlements; COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = PureMac/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; PRODUCT_NAME = PureMac; @@ -623,6 +753,26 @@ }; name = Debug; }; + 835AD6CBB70786F1D37A73EF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGN_ENTITLEMENTS = ""; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.puremac.privileged-helper"; + PRODUCT_NAME = "com.puremac.privileged-helper"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Debug; + }; 8632219D8F7DC9932C9DCC9F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -640,6 +790,26 @@ }; name = Release; }; + C1D72E93B5598DA6CE231E62 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = ""; + CODE_SIGN_ENTITLEMENTS = ""; + COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.puremac.privileged-helper"; + PRODUCT_NAME = "com.puremac.privileged-helper"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + }; + name = Release; + }; D1B9D379DD6CC0DA24E58BCF /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -675,12 +845,12 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_ENTITLEMENTS = PureMac/PureMac.entitlements; + CODE_SIGN_ENTITLEMENTS = ""; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 18; + CURRENT_PROJECT_VERSION = 20; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = H3WXHVTP97; ENABLE_NS_ASSERTIONS = NO; @@ -696,7 +866,7 @@ GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PureMac/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 2.8.3; + MARKETING_VERSION = 2.8.5; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = NO; @@ -713,7 +883,9 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = PureMac/PureMac.entitlements; COMBINE_HIDPI_IMAGES = YES; + ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = PureMac/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; PRODUCT_NAME = PureMac; @@ -752,6 +924,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + B9BF14F733E894572273CCC7 /* Build configuration list for PBXNativeTarget "PureMacPrivilegedHelper" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 835AD6CBB70786F1D37A73EF /* Debug */, + C1D72E93B5598DA6CE231E62 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/PureMac/Models/Models.swift b/PureMac/Models/Models.swift index 44bfd43..9fa7349 100644 --- a/PureMac/Models/Models.swift +++ b/PureMac/Models/Models.swift @@ -1,3 +1,4 @@ +import Darwin import SwiftUI // MARK: - Cleaning Category @@ -109,6 +110,38 @@ struct CleanableItem: Identifiable, Hashable { let category: CleaningCategory var isSelected: Bool let lastModified: Date? + /// Filesystem identity captured when the scan result is created. Deletion + /// is allowed only while this exact directory entry still has the same + /// device/inode/type/owner identity. + let fileIdentity: FileIdentity? + + init( + name: String, + path: String, + size: Int64, + category: CleaningCategory, + isSelected: Bool, + lastModified: Date?, + fileIdentity: FileIdentity? = nil + ) { + self.name = name + self.path = path + self.size = size + self.category = category + self.isSelected = isSelected + self.lastModified = lastModified + + if let fileIdentity { + self.fileIdentity = fileIdentity + } else { + let policy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path + ) + let canonicalPath = (try? policy.canonicalPath(path)) ?? path + self.fileIdentity = FileIdentity.capture(path: canonicalPath) + } + } var formattedSize: String { ByteCountFormatter.string(fromByteCount: size, countStyle: .file) diff --git a/PureMac/Services/CleaningEngine.swift b/PureMac/Services/CleaningEngine.swift index 5dc3c1b..10a033b 100644 --- a/PureMac/Services/CleaningEngine.swift +++ b/PureMac/Services/CleaningEngine.swift @@ -1,31 +1,83 @@ +import Darwin import Foundation actor CleaningEngine { private let fileManager = FileManager.default + private let deletionPolicy: SecureDeletionPolicy + private let secureDeleter: SecureFileDeleter + private let privilegedClient: PrivilegedCleaningClient + + init(privilegedClient: PrivilegedCleaningClient = PrivilegedCleaningClient()) { + let policy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path + ) + deletionPolicy = policy + secureDeleter = SecureFileDeleter(policy: policy) + self.privilegedClient = privilegedClient + } struct CleaningResult { var freedSpace: Int64 = 0 var itemsCleaned: Int = 0 var errors: [String] = [] var cleanedPaths: Set = [] - // Items that user-level FileManager.removeItem refused with EACCES / - // EPERM. These are root-owned and need an admin-privileged second - // pass via cleanWithAdminPrivileges(items:). + // Paths whose identity lookup was explicitly denied during the + // unprivileged pass. Only these are eligible for an FDA retry; helper + // failures and policy rejections must not be mislabeled as TCC issues. + var fullDiskAccessPaths: Set = [] + // Items that the descriptor-based user-level pass refused with EACCES + // or EPERM. These need an authorized root-helper second pass. var requiresAdmin: [CleanableItem] = [] } // MARK: - Public API func cleanItems(_ items: [CleanableItem], progressHandler: @Sendable (Double) -> Void) async -> CleaningResult { + let logger = await Logger.shared + do { + try await FilesystemMutationCoordinator.shared.acquire() + } catch { + var result = CleaningResult() + result.errors.append(error.localizedDescription) + logger.log( + "Cleaning blocked because the filesystem mutation lease failed: \(error.localizedDescription)", + level: .error + ) + return result + } + let result = await cleanItemsHoldingMutationLease( + items, + logger: logger, + progressHandler: progressHandler + ) + await FilesystemMutationCoordinator.shared.release() + return result + } + + private func cleanItemsHoldingMutationLease( + _ items: [CleanableItem], + logger: Logger, + progressHandler: @Sendable (Double) -> Void + ) async -> CleaningResult { var result = CleaningResult() let total = items.count + do { + try await privilegedClient.ensureFilesystemIsReconciled() + } catch { + let detail = error.localizedDescription + result.errors.append(detail) + logger.log("Cleaning blocked by unresolved privileged deletion: \(detail)", level: .error) + return result + } + for (index, item) in items.enumerated() { let progress = Double(index + 1) / Double(total) progressHandler(progress) if item.category == .purgeableSpace { - let purged = await purgePurgeableSpace() + let purged = purgePurgeableSpaceHoldingMutationLease(logger: logger) result.freedSpace += purged if purged > 0 { result.itemsCleaned += 1 } // Purgeable space is a one-shot reclaim action, not a file @@ -37,64 +89,75 @@ actor CleaningEngine { } 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) + let identity: FileIdentity + if let scannedIdentity = item.fileIdentity { + identity = scannedIdentity + } else { + // Synthetic scan rows (for example APFS purgeable space) + // have no filesystem object. Real rows must carry the lstat + // snapshot made when the row was created. + switch FileIdentity.lookup(path: item.path) { + case .missing: + result.cleanedPaths.insert(item.path) + continue + case .found: + throw SecureDeletionError.identityChanged(item.path) + case let .failed(code): + throw SecureDeletionError.posix( + operation: "lstat", + path: item.path, + code: code + ) } - 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) + let request = PrivilegedDeletionRequest( + path: item.path, + identity: identity, + operation: item.category == .largeFiles ? .largeFile : .cleaner + ) + try secureDeleter.remove(request) result.freedSpace += item.size result.itemsCleaned += 1 result.cleanedPaths.insert(item.path) } catch { let nsError = error as NSError let isPermissionDenied = + ({ + if case let SecureDeletionError.posix(_, _, code) = error { + return code == EACCES || code == EPERM + } + return false + }()) || (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) + if item.fileIdentity == nil { + // Never ask root to delete an object whose scan identity + // we could not capture. Keep it visible so the normal + // survivor path can offer Full Disk Access instead. + let detail = "Could not verify \(item.name) at \(item.path): \(error.localizedDescription)" + result.errors.append(detail) + result.fullDiskAccessPaths.insert(item.path) + logger.log("Identity lookup denied: \(item.path)", level: .warning) + } else { + // 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.log("Deferring to admin pass: \(item.path)", level: .info) + } + } else if case SecureDeletionError.topLevelMissing = error { + // Only the initial top-level lstat may report an item as + // already gone. Nested ENOENT races are handled inside the + // walker and never count the whole request as cleaned. + result.cleanedPaths.insert(item.path) } else { let detail = "\(item.name) at \(item.path): \(error.localizedDescription)" result.errors.append(detail) - Logger.shared.log("Clean failed: \(detail)", level: .error) + logger.log("Clean failed: \(detail)", level: .error) } } } @@ -102,108 +165,149 @@ actor CleaningEngine { return result } + /// Used by uninstall's direct-to-Trash path, which bypasses `cleanItems` + /// for user-owned objects. It must observe the same write-ahead operation + /// fence before treating ENOENT as a successfully removed row. + func ensurePrivilegedDeletionsAreSettled() async throws { + try await privilegedClient.ensureFilesystemIsReconciled() + } + 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. + /// Re-runs the deletion through a root LaunchDaemon registered by + /// SMAppService. Requests travel as immutable XPC messages and include the + /// identity captured by the scanner. The helper independently validates + /// policy, owner, type and identity before descriptor-based unlinkat calls. func cleanWithAdminPrivileges(items: [CleanableItem]) async -> CleaningResult { + let logger = await Logger.shared + do { + try await FilesystemMutationCoordinator.shared.acquire() + } catch { + var result = CleaningResult() + result.errors.append(error.localizedDescription) + logger.log( + "Administrator cleaning blocked because the filesystem mutation lease failed: \(error.localizedDescription)", + level: .error + ) + return result + } + let result = await cleanWithAdminPrivilegesHoldingMutationLease( + items: items, + logger: logger + ) + await FilesystemMutationCoordinator.shared.release() + return result + } + + private func cleanWithAdminPrivilegesHoldingMutationLease( + items: [CleanableItem], + logger: Logger + ) async -> CleaningResult { var result = CleaningResult() - Logger.shared.log("Admin pass starting with \(items.count) item(s)", level: .info) + logger.log("Admin pass starting with \(items.count) item(s)", level: .info) + + let validated: [(item: CleanableItem, request: PrivilegedDeletionRequest)] = items.compactMap { item in + guard let identity = item.fileIdentity else { + logger.log("Refusing admin escalation without scan identity: \(item.path)", level: .warning) + result.errors.append("\(item.name) changed or disappeared before administrator authorization") + return nil + } + + let operations: [PrivilegedDeletionOperation] + if item.category == .largeFiles { + operations = [.largeFile] + } else { + // App-uninstall rows currently use .systemJunk. Try the normal + // cleaner policy first, then the deliberately narrower + // uninstall policy for bundles/receipts/launch plists. + operations = [.cleaner, .uninstall] + } - // 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) + for operation in operations { + let request = PrivilegedDeletionRequest( + path: item.path, + identity: identity, + operation: operation + ) + if (try? deletionPolicy.validate(request)) != nil { + return (item, request) } - 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 + + logger.log("Refusing admin escalation for unsafe path: \(item.path)", level: .warning) + result.errors.append("Refused unsafe administrator deletion: \(item.path)") + return nil } guard !validated.isEmpty else { - Logger.shared.log("Admin pass: no items survived validation", level: .warning) + logger.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)") + let responses: [PrivilegedDeletionResponse] do { - try payload.write(to: tempFile, options: [.atomic]) + responses = try await privilegedClient.deleteItems(validated.map(\.request)) } catch { - Logger.shared.log("Couldn't stage admin path list: \(error.localizedDescription)", level: .error) + logger.log("Privileged helper failed: \(error.localizedDescription)", level: .error) + result.errors.append(error.localizedDescription) 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") - } + guard responses.count == validated.count else { + result.errors.append("Privileged helper returned an incomplete response") + logger.log("Admin pass returned \(responses.count) response(s) for \(validated.count) request(s)", level: .error) 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) { + for ((item, request), response) in zip(validated, responses) { + guard response.requestID == request.id, response.path == request.path else { + result.errors.append("Privileged helper returned a response for the wrong item") + continue + } + switch response.status { + case .deleted: result.cleanedPaths.insert(item.path) result.itemsCleaned += 1 result.freedSpace += item.size - } else { - let detail = "\(item.name) at \(item.path) survived admin removal" + case .missing: + // The item was already gone before the helper's initial + // descriptor lookup. Treat the UI row as handled, but do not + // claim bytes or a deletion that this run did not perform. + result.cleanedPaths.insert(item.path) + case .rejected, .failed, .unknown: + let detail = response.message ?? "\(item.name) at \(item.path) survived administrator removal" result.errors.append(detail) - Logger.shared.log("Admin pass survivor: \(detail)", level: .error) + logger.log("Admin pass survivor: \(detail)", level: .error) } } - Logger.shared.log("Admin pass complete: \(result.itemsCleaned) deleted, \(result.errors.count) survived", level: .info) + logger.log("Admin pass complete: \(result.itemsCleaned) deleted, \(result.errors.count) survived", level: .info) return result } // MARK: - Purgeable Space func purgePurgeableSpace() async -> Int64 { + let logger = await Logger.shared + do { + try await FilesystemMutationCoordinator.shared.acquire() + } catch { + logger.log( + "Purgeable-space cleanup blocked because the filesystem mutation lease failed: \(error.localizedDescription)", + level: .error + ) + return 0 + } + let result = purgePurgeableSpaceHoldingMutationLease(logger: logger) + await FilesystemMutationCoordinator.shared.release() + return result + } + + private func purgePurgeableSpaceHoldingMutationLease(logger: Logger) -> Int64 { // Get current purgeable space first - let beforeFree = getCurrentFreeSpace() + let beforeFree = getCurrentFreeSpace(logger: logger) let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") @@ -216,11 +320,11 @@ actor CleaningEngine { try task.run() task.waitUntilExit() - let afterFree = getCurrentFreeSpace() + let afterFree = getCurrentFreeSpace(logger: logger) let freedSpace = afterFree - beforeFree return max(0, freedSpace) } catch { - Logger.shared.log("diskutil purge failed: \(error.localizedDescription)", level: .error) + logger.log("diskutil purge failed: \(error.localizedDescription)", level: .error) return 0 } } @@ -228,6 +332,32 @@ actor CleaningEngine { // MARK: - Trash func emptyTrash() async -> Int64 { + let logger = await Logger.shared + do { + try await FilesystemMutationCoordinator.shared.acquire() + } catch { + logger.log( + "Trash cleanup blocked because the filesystem mutation lease failed: \(error.localizedDescription)", + level: .error + ) + return 0 + } + do { + try await privilegedClient.ensureFilesystemIsReconciled() + } catch { + await FilesystemMutationCoordinator.shared.release() + logger.log( + "Trash cleanup blocked by unresolved privileged deletion: \(error.localizedDescription)", + level: .error + ) + return 0 + } + let result = emptyTrashHoldingMutationLease(logger: logger) + await FilesystemMutationCoordinator.shared.release() + return result + } + + private func emptyTrashHoldingMutationLease(logger: Logger) -> Int64 { let home = fileManager.homeDirectoryForCurrentUser.path let trashPath = "\(home)/.Trash" var totalFreed: Int64 = 0 @@ -242,7 +372,7 @@ actor CleaningEngine { try fileManager.removeItem(atPath: fullPath) } } catch { - Logger.shared.log("Trash cleanup incomplete: \(error.localizedDescription)", level: .warning) + logger.log("Trash cleanup incomplete: \(error.localizedDescription)", level: .warning) } return totalFreed @@ -250,124 +380,12 @@ actor CleaningEngine { // 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 { + private func getCurrentFreeSpace(logger: Logger) -> 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) + logger.log("Cannot read filesystem attributes: \(error.localizedDescription)", level: .warning) return 0 } } diff --git a/PureMac/Services/PermissionCoordinator.swift b/PureMac/Services/PermissionCoordinator.swift index bec4c66..39894d7 100644 --- a/PureMac/Services/PermissionCoordinator.swift +++ b/PureMac/Services/PermissionCoordinator.swift @@ -2,17 +2,17 @@ import AppKit import Combine import Foundation -/// Centralized coordinator for Full Disk Access (FDA) prompts and retry flow. +/// Centralized coordinator for Full Disk Access (FDA) prompts and continuation flow. /// /// Replaces the bare "Open System Settings" alert with a sheet-driven flow that: /// - Auto-opens the Settings pane and reveals PureMac.app in Finder so users can /// drag the bundle into the FDA list when macOS hasn't auto-registered it /// (common with Homebrew installs that strip the quarantine attribute). /// - Polls `FullDiskAccessManager.hasFullDiskAccess` once per second while the -/// sheet is on-screen and auto-dismisses + invokes the retry callback the +/// sheet is on-screen and auto-dismisses + invokes the continuation callback the /// moment the user toggles permission on. -/// - Carries the failed-item batch across the prompt so the caller can re-run -/// the clean without the user having to re-select anything. +/// - Carries the failed-item batch across the prompt so the caller can retry +/// identity-verified items or require a safe rescan for unverified paths. @MainActor final class PermissionCoordinator: ObservableObject { static let shared = PermissionCoordinator() diff --git a/PureMac/Services/PrivilegedCleaningClient.swift b/PureMac/Services/PrivilegedCleaningClient.swift new file mode 100644 index 0000000..8c59db2 --- /dev/null +++ b/PureMac/Services/PrivilegedCleaningClient.swift @@ -0,0 +1,1368 @@ +import Darwin +import Foundation +import Security +@preconcurrency import ServiceManagement + +enum PrivilegedCleaningClientError: LocalizedError { + case helperNotFound + case helperRequiresApproval + case helperRegistrationFailed(String) + case helperIncompatible + case helperRecovering(String) + case helperRecoveryFailed(String) + case authorizationFailed(OSStatus) + case requestTooLarge + case invalidResponse + case requestTimedOut + case requestCancelled + case reconciliationPending(String) + case connectionFailed(String) + + var errorDescription: String? { + switch self { + case .helperNotFound: + return "The PureMac privileged helper is missing from the app bundle." + case .helperRequiresApproval: + return "Enable the PureMac privileged helper in System Settings → General → Login Items, then try again." + case let .helperRegistrationFailed(detail): + return "Could not register the PureMac privileged helper: \(detail)" + case .helperIncompatible: + return "The installed PureMac privileged helper uses an incompatible security protocol." + case let .helperRecovering(detail): + return "The PureMac privileged helper is recovering an interrupted deletion: \(detail)" + case let .helperRecoveryFailed(detail): + return "The PureMac privileged helper could not recover an interrupted deletion: \(detail)" + case let .authorizationFailed(status): + if status == errAuthorizationCanceled { + return "Administrator authorization was canceled." + } + return "Administrator authorization failed (\(status))." + case .requestTooLarge: + return "The privileged deletion request exceeded the safety limit." + case .invalidResponse: + return "The privileged helper returned an invalid response." + case .requestTimedOut: + return "The privileged helper did not respond in time." + case .requestCancelled: + return "The privileged deletion request was canceled." + case let .reconciliationPending(detail): + return "A previous privileged deletion has not been safely reconciled yet: \(detail)" + case let .connectionFailed(detail): + return "Could not contact the PureMac privileged helper: \(detail)" + } + } +} + +/// Thin client for the single-purpose privileged deletion service. No paths +/// are written to disk and the connection accepts only the helper signed with +/// PureMac's bundle identifier and Team ID. +final class PrivilegedCleaningClient: @unchecked Sendable { + private static let unresolvedOperations = PrivilegedDeletionOperationLatch() + + func deleteItems(_ requests: [PrivilegedDeletionRequest]) async throws -> [PrivilegedDeletionResponse] { + guard !requests.isEmpty else { return [] } + + try await ensureFilesystemIsReconciled() + try ensureHelperIsEnabled() + try await ensureCompatibleHelper() + let authorization = try PrivilegedAuthorization() + defer { authorization.destroy() } + let authorizationToken = try authorization.externalForm() + let operationDeadline = Date().addingTimeInterval(120) + + return try await sendAllBatches( + requests, + authorization: authorizationToken, + deadline: operationDeadline + ) + } + + /// Blocks every new filesystem mutation while an earlier XPC operation + /// could still own an entry in the privileged quarantine. The latch is + /// persisted before submission, so an application restart cannot turn a + /// transient source-path absence into a confirmed deletion. + func ensureFilesystemIsReconciled() async throws { + let pendingIDs = try Self.unresolvedOperations.snapshot() + guard !pendingIDs.isEmpty else { return } + + try ensureHelperIsEnabled() + try await ensureCompatibleHelper() + + for batchID in pendingIDs { + switch await waitForDeletionReconciliation(batchID: batchID) { + case .settled, .notAccepted: + try Self.unresolvedOperations.resolve(batchID) + case let .unresolved(detail): + throw PrivilegedCleaningClientError.reconciliationPending(detail) + } + } + } + + private func sendAllBatches( + _ requests: [PrivilegedDeletionRequest], + authorization: Data, + deadline: Date + ) async throws -> [PrivilegedDeletionResponse] { + var allResponses: [PrivilegedDeletionResponse] = [] + allResponses.reserveCapacity(requests.count) + for batchRequests in Self.batches(for: requests) { + do { + let delivery = try await sendBatch( + batchRequests, + authorization: authorization, + deadline: deadline + ) + allResponses.append(contentsOf: delivery.responses) + if let latchFailure = delivery.latchFailure { + allResponses.append(contentsOf: requests.dropFirst(allResponses.count).map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: "Not submitted because the local reconciliation latch could not be updated: \(latchFailure)" + ) + }) + return allResponses + } + if case .notAccepted = delivery.disposition { + let detail = delivery.responses.first?.message + ?? "The privileged helper did not accept this deletion batch." + allResponses.append(contentsOf: requests.dropFirst(allResponses.count).map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: "Not submitted after an earlier batch was rejected: \(detail)" + ) + }) + return allResponses + } + } catch let failure as SubmittedDeletionBatchFailure { + // Preserve confirmed results from earlier batches. The caller + // can update those rows while every unconfirmed request gets + // an identity-correlated unknown result instead of either + // disappearing or being falsely reported as a confirmed + // failure. A lost reply may follow a completed deletion. + let reconciliation = await waitForDeletionReconciliation( + batchID: failure.batchID + ) + let status: PrivilegedDeletionStatus + let detail: String + switch reconciliation { + case .settled: + try? Self.unresolvedOperations.resolve(failure.batchID) + status = .unknown + detail = "Deletion completed or rolled back safely, but its reply was lost: \(failure.underlying.localizedDescription)" + case .notAccepted: + try? Self.unresolvedOperations.resolve(failure.batchID) + status = .failed + detail = "Deletion was not accepted by the helper: \(failure.underlying.localizedDescription)" + case let .unresolved(reconciliationDetail): + // Keep the write-ahead latch. Any later Trash/direct + // mutation or privileged batch must reconcile this exact + // operation ID before it may act on a possibly quarantined + // path. + status = .unknown + detail = "Deletion outcome is unresolved: \(reconciliationDetail)" + } + allResponses.append(contentsOf: requests.dropFirst(allResponses.count).map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: status, + message: detail + ) + }) + return allResponses + } catch { + guard !allResponses.isEmpty else { throw error } + allResponses.append(contentsOf: requests.dropFirst(allResponses.count).map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: "Not submitted after a local client failure: \(error.localizedDescription)" + ) + }) + return allResponses + } + } + return allResponses + } + + private enum ReconciliationOutcome: Sendable { + case settled + case notAccepted + case unresolved(String) + } + + private func waitForDeletionReconciliation( + batchID: UUID + ) async -> ReconciliationOutcome { + await Task.detached(priority: .userInitiated) { [self] in + let deadline = Date().addingTimeInterval(130) + var lastFailure = "the helper did not confirm a safe terminal state" + repeat { + do { + let response = try await reconciliationState(for: batchID) + switch response.state { + case .settled: + return .settled + case .notAccepted: + return .notAccepted + case .pending: + lastFailure = response.message ?? "the operation is still active" + case .unavailable: + lastFailure = response.message ?? "the helper could not install an operation fence" + case .recoveryFailed: + return .unresolved( + response.message ?? "privileged quarantine recovery failed" + ) + } + } catch { + lastFailure = error.localizedDescription + } + + if Date() >= deadline { return .unresolved(lastFailure) } + try? await Task.sleep(nanoseconds: 200_000_000) + } while true + }.value + } + + private func reconciliationState( + for batchID: UUID + ) async throws -> PrivilegedDeletionReconciliationResponse { + let state = PrivilegedXPCRequestState() + return try await withCheckedThrowingContinuation { continuation in + let connection = NSXPCConnection( + machServiceName: PrivilegedCleaningConstants.machServiceName, + options: .privileged + ) + guard state.install(continuation: continuation, connection: connection) else { + return + } + connection.remoteObjectInterface = NSXPCInterface( + with: PrivilegedCleaningXPCProtocol.self + ) + connection.setCodeSigningRequirement( + PrivilegedCleaningConstants.helperCodeSigningRequirement + ) + connection.interruptionHandler = { + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed( + "reconciliation connection interrupted" + ) + ) + } + connection.invalidationHandler = { + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed( + "reconciliation connection invalidated" + ) + ) + } + connection.activate() + state.scheduleTimeout(after: 3) + + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed( + error.localizedDescription + ) + ) + }) as? PrivilegedCleaningXPCProtocol else { + state.finish(throwing: PrivilegedCleaningClientError.invalidResponse) + return + } + proxy.reconcileDeletion(batchID as NSUUID) { encodedResponse in + do { + guard encodedResponse.length <= 16_384 else { + throw PrivilegedCleaningClientError.invalidResponse + } + let response = try PropertyListDecoder().decode( + PrivilegedDeletionReconciliationResponse.self, + from: encodedResponse as Data + ) + guard response.protocolVersion == PrivilegedCleaningConstants.protocolVersion, + response.securityPolicyVersion == PrivilegedCleaningConstants.securityPolicyVersion, + response.batchID == batchID + else { + throw PrivilegedCleaningClientError.invalidResponse + } + state.finish(returning: response) + } catch { + state.finish(throwing: error) + } + } + } + } + + static func batches( + for requests: [PrivilegedDeletionRequest] + ) -> [[PrivilegedDeletionRequest]] { + guard !requests.isEmpty else { return [] } + var batches: [[PrivilegedDeletionRequest]] = [] + batches.reserveCapacity( + (requests.count + PrivilegedCleaningConstants.maximumBatchCount - 1) + / PrivilegedCleaningConstants.maximumBatchCount + ) + var start = requests.startIndex + while start < requests.endIndex { + let end = requests.index( + start, + offsetBy: PrivilegedCleaningConstants.maximumBatchCount, + limitedBy: requests.endIndex + ) ?? requests.endIndex + batches.append(Array(requests[start.. Bool { + responses.count == requests.count + && zip(requests, responses).allSatisfy { request, response in + response.requestID == request.id && response.path == request.path + } + } + + static func batchResponseIsValid( + _ response: PrivilegedDeletionBatchResponse, + batchID: UUID, + requests: [PrivilegedDeletionRequest] + ) -> Bool { + guard response.protocolVersion == PrivilegedCleaningConstants.protocolVersion, + response.securityPolicyVersion == PrivilegedCleaningConstants.securityPolicyVersion, + response.batchID == batchID + else { + return false + } + + switch response.disposition { + case .completed: + return responsesAreValid(response.responses, for: requests) + && !response.responses.contains(where: { $0.status == .unknown }) + case .notAccepted: + return response.responses.isEmpty + } + } + + private struct BatchDelivery { + let disposition: PrivilegedDeletionBatchDisposition + let responses: [PrivilegedDeletionResponse] + let latchFailure: String? + } + + private func sendBatch( + _ requests: [PrivilegedDeletionRequest], + authorization: Data, + deadline: Date + ) async throws -> BatchDelivery { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let batch = PrivilegedDeletionBatch( + requests: requests, + authorization: authorization, + deadline: deadline + ) + let payload = try encoder.encode(batch) + guard payload.count <= PrivilegedCleaningConstants.maximumEncodedSize else { + throw PrivilegedCleaningClientError.requestTooLarge + } + + try Self.unresolvedOperations.register(batch.id) + let state = PrivilegedXPCRequestState() + do { + let response = try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let connection = NSXPCConnection( + machServiceName: PrivilegedCleaningConstants.machServiceName, + options: .privileged + ) + guard state.install(continuation: continuation, connection: connection) else { + return + } + connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedCleaningXPCProtocol.self) + connection.setCodeSigningRequirement(PrivilegedCleaningConstants.helperCodeSigningRequirement) + + connection.interruptionHandler = { + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed("connection interrupted") + ) + } + connection.invalidationHandler = { + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed("connection invalidated") + ) + } + connection.activate() + state.scheduleTimeout(after: 130) + + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed(error.localizedDescription) + ) + }) as? PrivilegedCleaningXPCProtocol else { + state.finish(throwing: PrivilegedCleaningClientError.invalidResponse) + return + } + + proxy.deleteItems(batch.id as NSUUID, encodedBatch: payload as NSData) { encodedResponse in + do { + guard encodedResponse.length <= PrivilegedCleaningConstants.maximumEncodedSize else { + throw PrivilegedCleaningClientError.invalidResponse + } + let response = try PropertyListDecoder().decode( + PrivilegedDeletionBatchResponse.self, + from: encodedResponse as Data + ) + state.finish(returning: response) + } catch { + state.finish(throwing: error) + } + } + } + } onCancel: { + state.finish(throwing: PrivilegedCleaningClientError.requestCancelled) + } + + let responses: [PrivilegedDeletionResponse] + guard Self.batchResponseIsValid( + response, + batchID: batch.id, + requests: requests + ) else { + throw PrivilegedCleaningClientError.invalidResponse + } + switch response.disposition { + case .completed: + responses = response.responses + case .notAccepted: + let message = response.message ?? "The privileged helper did not accept this deletion batch." + responses = requests.map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: message + ) + } + } + + let latchFailure: String? + do { + try Self.unresolvedOperations.resolve(batch.id) + latchFailure = nil + } catch { + // The authenticated helper reply is already terminal, so keep + // its item-level results. Leave the operation ID on disk and + // stop before another batch; the next mutation must reconcile + // and clear that fail-closed marker. + latchFailure = error.localizedDescription + } + return BatchDelivery( + disposition: response.disposition, + responses: responses, + latchFailure: latchFailure + ) + } catch { + throw SubmittedDeletionBatchFailure(batchID: batch.id, underlying: error) + } + } + + private func ensureHelperIsEnabled() throws { + let service = SMAppService.daemon( + plistName: PrivilegedCleaningConstants.launchDaemonPlistName + ) + + switch service.status { + case .enabled: + return + case .notRegistered: + do { + try service.register() + } catch { + if service.status == .requiresApproval { + openHelperApprovalSettings() + throw PrivilegedCleaningClientError.helperRequiresApproval + } + throw PrivilegedCleaningClientError.helperRegistrationFailed(error.localizedDescription) + } + + guard service.status == .enabled else { + openHelperApprovalSettings() + throw PrivilegedCleaningClientError.helperRequiresApproval + } + case .requiresApproval: + openHelperApprovalSettings() + throw PrivilegedCleaningClientError.helperRequiresApproval + case .notFound: + throw PrivilegedCleaningClientError.helperNotFound + @unknown default: + throw PrivilegedCleaningClientError.helperRegistrationFailed("unknown helper status") + } + } + + private func ensureCompatibleHelper() async throws { + let recoveryDeadline = Date().addingTimeInterval(130) + var transportFailureCount = 0 + var didRefreshIncompatibleHelper = false + var lastError: Error = PrivilegedCleaningClientError.connectionFailed( + "the helper did not answer its compatibility handshake" + ) + + while Date() < recoveryDeadline { + do { + try await verifyHelperCompatibility() + return + } catch PrivilegedCleaningClientError.requestCancelled { + throw PrivilegedCleaningClientError.requestCancelled + } catch PrivilegedCleaningClientError.helperIncompatible { + // Refresh only after a successfully decoded service-info reply + // explicitly proves a version mismatch. Transport silence may + // be a daemon launch failure and must never be used to kill a + // helper that is durably recovering a COMMITTED transaction. + guard !didRefreshIncompatibleHelper else { + throw PrivilegedCleaningClientError.helperIncompatible + } + try await refreshHelperRegistration() + didRefreshIncompatibleHelper = true + transportFailureCount = 0 + lastError = PrivilegedCleaningClientError.helperIncompatible + } catch let error as PrivilegedCleaningClientError { + switch error { + case .helperRecovering: + transportFailureCount = 0 + lastError = error + case .helperRecoveryFailed: + throw error + default: + transportFailureCount += 1 + lastError = error + if transportFailureCount >= 4 { + throw error + } + } + } catch { + transportFailureCount += 1 + lastError = error + if transportFailureCount >= 4 { + throw error + } + } + try await Task.sleep(nanoseconds: 250_000_000) + } + throw lastError + } + + private func verifyHelperCompatibility() async throws { + let info = try await fetchServiceInfo() + guard info.protocolVersion == PrivilegedCleaningConstants.protocolVersion, + info.securityPolicyVersion == PrivilegedCleaningConstants.securityPolicyVersion, + info.helperBundleIdentifier == PrivilegedCleaningConstants.helperBundleIdentifier + else { + throw PrivilegedCleaningClientError.helperIncompatible + } + guard let recoveryState = info.recoveryState else { + throw PrivilegedCleaningClientError.invalidResponse + } + switch recoveryState { + case .ready: + return + case .recovering: + throw PrivilegedCleaningClientError.helperRecovering( + info.recoveryMessage ?? "recovery is still in progress" + ) + case .failed: + throw PrivilegedCleaningClientError.helperRecoveryFailed( + info.recoveryMessage ?? "recovery failed without a diagnostic" + ) + } + } + + private func fetchServiceInfo() async throws -> PrivilegedCleaningServiceInfo { + let state = PrivilegedXPCRequestState() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + let connection = NSXPCConnection( + machServiceName: PrivilegedCleaningConstants.machServiceName, + options: .privileged + ) + guard state.install(continuation: continuation, connection: connection) else { + return + } + connection.remoteObjectInterface = NSXPCInterface( + with: PrivilegedCleaningXPCProtocol.self + ) + connection.setCodeSigningRequirement( + PrivilegedCleaningConstants.helperCodeSigningRequirement + ) + connection.interruptionHandler = { + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed("connection interrupted") + ) + } + connection.invalidationHandler = { + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed("connection invalidated") + ) + } + connection.activate() + state.scheduleTimeout(after: 3) + + guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in + state.finish( + throwing: PrivilegedCleaningClientError.connectionFailed(error.localizedDescription) + ) + }) as? PrivilegedCleaningXPCProtocol else { + state.finish(throwing: PrivilegedCleaningClientError.invalidResponse) + return + } + + proxy.serviceInfo { encodedInfo in + do { + guard encodedInfo.length <= 16_384 else { + throw PrivilegedCleaningClientError.invalidResponse + } + let info = try PropertyListDecoder().decode( + PrivilegedCleaningServiceInfo.self, + from: encodedInfo as Data + ) + state.finish(returning: info) + } catch { + state.finish(throwing: error) + } + } + } + } onCancel: { + state.finish(throwing: PrivilegedCleaningClientError.requestCancelled) + } + } + + private func refreshHelperRegistration() async throws { + let service = SMAppService.daemon( + plistName: PrivilegedCleaningConstants.launchDaemonPlistName + ) + do { + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + service.unregister { error in + if let error, service.status != .notRegistered { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + try service.register() + } catch { + if service.status == .requiresApproval { + openHelperApprovalSettings() + throw PrivilegedCleaningClientError.helperRequiresApproval + } + throw PrivilegedCleaningClientError.helperRegistrationFailed(error.localizedDescription) + } + guard service.status == .enabled else { + openHelperApprovalSettings() + throw PrivilegedCleaningClientError.helperRequiresApproval + } + } + + private func openHelperApprovalSettings() { + DispatchQueue.main.async { + SMAppService.openSystemSettingsLoginItems() + } + } +} + +private struct SubmittedDeletionBatchFailure: Error, @unchecked Sendable { + let batchID: UUID + let underlying: Error +} + +/// A write-ahead, fail-closed list of XPC operation IDs whose terminal reply +/// has not yet been authenticated. The file contains no paths or credentials; +/// it exists only to force an exact-ID helper reconciliation after an app crash. +private final class PrivilegedDeletionOperationLatch: @unchecked Sendable { + private static let maximumCount = 256 + private static let maximumEncodedSize = 65_536 + + private let lock = NSLock() + private let directoryURL: URL + private let fileURL: URL + private let lockFileURL: URL + + init() { + let manager = FileManager.default + let base = manager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? manager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + directoryURL = base.appendingPathComponent("PureMac", isDirectory: true) + fileURL = directoryURL + .appendingPathComponent("privileged-deletion-latch.plist") + lockFileURL = directoryURL + .appendingPathComponent("privileged-deletion-latch.lock") + } + + func snapshot() throws -> [UUID] { + lock.lock() + defer { lock.unlock() } + return try withInterprocessLock { + try loadFromDisk().sorted { $0.uuidString < $1.uuidString } + } + } + + func register(_ operationID: UUID) throws { + lock.lock() + defer { lock.unlock() } + try withInterprocessLock { + var updated = try loadFromDisk() + guard updated.count < Self.maximumCount || updated.contains(operationID) else { + throw PrivilegedCleaningClientError.reconciliationPending( + "too many unresolved privileged operations" + ) + } + updated.insert(operationID) + try persist(updated) + } + } + + func resolve(_ operationID: UUID) throws { + lock.lock() + defer { lock.unlock() } + try withInterprocessLock { + var updated = try loadFromDisk() + guard updated.remove(operationID) != nil else { return } + try persist(updated) + } + } + + private func withInterprocessLock(_ body: () throws -> T) throws -> T { + let manager = FileManager.default + do { + try manager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw latchError("create latch directory", detail: error.localizedDescription) + } + + let descriptor = Darwin.open( + lockFileURL.path, + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, + mode_t(S_IRUSR | S_IWUSR) + ) + guard descriptor >= 0 else { + throw latchError("open latch lock", code: errno) + } + defer { Darwin.close(descriptor) } + + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw latchError("inspect latch lock", code: errno) + } + guard (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), + info.st_uid == geteuid(), + info.st_nlink == 1 + else { + throw latchError("validate latch lock", detail: "unexpected owner or file type") + } + guard Darwin.fchmod(descriptor, mode_t(S_IRUSR | S_IWUSR)) == 0 else { + throw latchError("harden latch lock", code: errno) + } + + while Darwin.lockf(descriptor, F_LOCK, 0) != 0 { + if errno == EINTR { continue } + throw latchError("lock operation latch", code: errno) + } + defer { _ = Darwin.lockf(descriptor, F_ULOCK, 0) } + return try body() + } + + private func loadFromDisk() throws -> Set { + let descriptor = Darwin.open( + fileURL.path, + O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK + ) + if descriptor < 0, errno == ENOENT { return [] } + guard descriptor >= 0 else { + throw latchError("open operation latch", code: errno) + } + defer { Darwin.close(descriptor) } + + var initialInfo = stat() + guard Darwin.fstat(descriptor, &initialInfo) == 0 else { + throw latchError("inspect operation latch", code: errno) + } + guard (initialInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), + initialInfo.st_uid == geteuid(), + initialInfo.st_nlink == 1, + initialInfo.st_size > 0, + initialInfo.st_size <= off_t(Self.maximumEncodedSize) + else { + throw latchError("validate operation latch", detail: "unexpected owner, type, or size") + } + + let byteCount = Int(initialInfo.st_size) + var encoded = Data(count: byteCount) + try encoded.withUnsafeMutableBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < byteCount { + let count = Darwin.pread( + descriptor, + baseAddress.advanced(by: offset), + byteCount - offset, + off_t(offset) + ) + if count < 0 { + if errno == EINTR { continue } + throw latchError("read operation latch", code: errno) + } + guard count > 0 else { + throw latchError("read operation latch", detail: "unexpected end of file") + } + offset += count + } + } + + var finalInfo = stat() + guard Darwin.fstat(descriptor, &finalInfo) == 0, + FileIdentity(stat: finalInfo) == FileIdentity(stat: initialInfo), + finalInfo.st_size == initialInfo.st_size, + finalInfo.st_nlink == 1 + else { + throw latchError("revalidate operation latch", detail: "file changed while reading") + } + + do { + let rawIDs = try PropertyListDecoder().decode([String].self, from: encoded) + guard rawIDs.count <= Self.maximumCount else { + throw PrivilegedCleaningClientError.invalidResponse + } + let decoded = rawIDs.compactMap(UUID.init(uuidString:)) + guard decoded.count == rawIDs.count, + Set(decoded).count == decoded.count + else { + throw PrivilegedCleaningClientError.invalidResponse + } + return Set(decoded) + } catch { + throw latchError("decode operation latch", detail: error.localizedDescription) + } + } + + private func persist(_ operationIDs: Set) throws { + let rawIDs = operationIDs.map(\.uuidString).sorted() + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let encoded: Data + do { + encoded = try encoder.encode(rawIDs) + } catch { + throw latchError("encode operation latch", detail: error.localizedDescription) + } + guard !encoded.isEmpty, encoded.count <= Self.maximumEncodedSize else { + throw PrivilegedCleaningClientError.requestTooLarge + } + + let directoryFD = Darwin.open( + directoryURL.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard directoryFD >= 0 else { + throw latchError("open latch directory", code: errno) + } + defer { Darwin.close(directoryFD) } + + let temporaryName = ".privileged-deletion-latch-\(UUID().uuidString)" + let temporaryFD = temporaryName.withCString { pointer in + Darwin.openat( + directoryFD, + pointer, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(S_IRUSR | S_IWUSR) + ) + } + guard temporaryFD >= 0 else { + throw latchError("create operation latch", code: errno) + } + var shouldRemoveTemporary = true + defer { + Darwin.close(temporaryFD) + if shouldRemoveTemporary { + temporaryName.withCString { pointer in + _ = Darwin.unlinkat(directoryFD, pointer, 0) + } + } + } + + try encoded.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let written = Darwin.write( + temporaryFD, + baseAddress.advanced(by: offset), + rawBuffer.count - offset + ) + if written < 0 { + if errno == EINTR { continue } + throw latchError("write operation latch", code: errno) + } + guard written > 0 else { + throw latchError("write operation latch", detail: "zero-byte write") + } + offset += written + } + } + guard Darwin.fsync(temporaryFD) == 0 else { + throw latchError("sync operation latch", code: errno) + } + + let renameStatus = temporaryName.withCString { temporaryPointer in + fileURL.lastPathComponent.withCString { filePointer in + Darwin.renameat(directoryFD, temporaryPointer, directoryFD, filePointer) + } + } + guard renameStatus == 0 else { + throw latchError("install operation latch", code: errno) + } + shouldRemoveTemporary = false + guard Darwin.fsync(directoryFD) == 0 else { + throw latchError("sync latch directory", code: errno) + } + } + + private func latchError( + _ operation: String, + code: Int32? = nil, + detail: String? = nil + ) -> PrivilegedCleaningClientError { + let explanation: String + if let detail { + explanation = detail + } else if let code { + explanation = String(cString: strerror(code)) + } else { + explanation = "unknown error" + } + return .reconciliationPending( + "\(operation) failed (\(explanation))" + ) + } +} + +actor FilesystemMutationCoordinator { + static let shared = FilesystemMutationCoordinator() + + private static let blockingLockQueue = DispatchQueue( + label: "com.puremac.filesystem-mutation-lock", + qos: .userInitiated + ) + + private let lockDirectoryURL: URL + private var isAvailable = true + private var lockDescriptor: Int32? + private var waiters: [CheckedContinuation] = [] + + init(lockDirectoryURL: URL? = nil) { + if let lockDirectoryURL { + self.lockDirectoryURL = lockDirectoryURL + } else { + let manager = FileManager.default + let base = manager.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? manager.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support") + self.lockDirectoryURL = base.appendingPathComponent( + "PureMac", + isDirectory: true + ) + } + } + + /// Acquires both an in-process FIFO lease and an advisory lock shared by + /// every running copy of PureMac. All legitimate mutation entry points hold + /// this lease across `reconcile -> mutation`, so one process cannot observe + /// another process's temporary privileged quarantine absence as ENOENT. + func acquire() async throws { + guard isAvailable else { + try await withCheckedThrowingContinuation { continuation in + waiters.append(continuation) + } + return + } + + isAvailable = false + do { + lockDescriptor = try await Self.acquireInterprocessLock( + directoryURL: lockDirectoryURL + ) + } catch { + isAvailable = true + let blockedWaiters = waiters + waiters.removeAll(keepingCapacity: true) + for waiter in blockedWaiters { + waiter.resume(throwing: error) + } + throw error + } + } + + func release() { + guard let descriptor = lockDescriptor else { + // A failed acquire never grants a lease. Keep this fail-closed and + // do not accidentally wake a waiter without the process lock. + return + } + guard !waiters.isEmpty else { + _ = Darwin.lockf(descriptor, F_ULOCK, 0) + Darwin.close(descriptor) + lockDescriptor = nil + isAvailable = true + return + } + let next = waiters.removeFirst() + // Transfer the existing process + interprocess lease without an + // unlock window in which another app instance could interleave. + next.resume(returning: ()) + } + + private static func acquireInterprocessLock( + directoryURL: URL + ) async throws -> Int32 { + try await withCheckedThrowingContinuation { continuation in + blockingLockQueue.async { + do { + continuation.resume( + returning: try openAndLockMutationFile( + directoryURL: directoryURL + ) + ) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func openAndLockMutationFile( + directoryURL: URL + ) throws -> Int32 { + let manager = FileManager.default + do { + try manager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + } catch { + throw mutationLockError( + "create mutation-lock directory", + detail: error.localizedDescription + ) + } + + let directoryFD = Darwin.open( + directoryURL.path, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard directoryFD >= 0 else { + throw mutationLockError("open mutation-lock directory", code: errno) + } + defer { Darwin.close(directoryFD) } + + var directoryInfo = stat() + guard Darwin.fstat(directoryFD, &directoryInfo) == 0 else { + throw mutationLockError("inspect mutation-lock directory", code: errno) + } + guard (directoryInfo.st_mode & mode_t(S_IFMT)) == mode_t(S_IFDIR), + directoryInfo.st_uid == geteuid() + else { + throw mutationLockError( + "validate mutation-lock directory", + detail: "unexpected owner or file type" + ) + } + guard Darwin.fchmod(directoryFD, mode_t(S_IRWXU)) == 0 else { + throw mutationLockError("harden mutation-lock directory", code: errno) + } + + let lockName = "filesystem-mutation.lock" + let descriptor = lockName.withCString { pointer in + Darwin.openat( + directoryFD, + pointer, + O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, + mode_t(S_IRUSR | S_IWUSR) + ) + } + guard descriptor >= 0 else { + throw mutationLockError("open filesystem mutation lock", code: errno) + } + + do { + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw mutationLockError("inspect filesystem mutation lock", code: errno) + } + guard (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG), + info.st_uid == geteuid(), + info.st_nlink == 1 + else { + throw mutationLockError( + "validate filesystem mutation lock", + detail: "unexpected owner or file type" + ) + } + guard Darwin.fchmod(descriptor, mode_t(S_IRUSR | S_IWUSR)) == 0 else { + throw mutationLockError("harden filesystem mutation lock", code: errno) + } + + while Darwin.lockf(descriptor, F_LOCK, 0) != 0 { + if errno == EINTR { continue } + throw mutationLockError("lock filesystem mutation", code: errno) + } + + var namedInfo = stat() + let namedStatus = lockName.withCString { pointer in + Darwin.fstatat(directoryFD, pointer, &namedInfo, AT_SYMLINK_NOFOLLOW) + } + guard namedStatus == 0, + FileIdentity(stat: namedInfo) == FileIdentity(stat: info) + else { + _ = Darwin.lockf(descriptor, F_ULOCK, 0) + throw mutationLockError( + "revalidate filesystem mutation lock", + detail: "lock file changed while acquiring it" + ) + } + return descriptor + } catch { + Darwin.close(descriptor) + throw error + } + } + + private static func mutationLockError( + _ operation: String, + code: Int32? = nil, + detail: String? = nil + ) -> PrivilegedCleaningClientError { + let explanation: String + if let detail { + explanation = detail + } else if let code { + explanation = String(cString: strerror(code)) + } else { + explanation = "unknown error" + } + return .reconciliationPending("\(operation) failed (\(explanation))") + } +} + +private final class PrivilegedAuthorization { + private var reference: AuthorizationRef? + + init() throws { + var createdReference: AuthorizationRef? + let createStatus = AuthorizationCreate( + nil, + nil, + [.interactionAllowed], + &createdReference + ) + guard createStatus == errAuthorizationSuccess, + let createdReference + else { + throw PrivilegedCleaningClientError.authorizationFailed(createStatus) + } + + do { + try Self.installAuthorizationRightIfNeeded(using: createdReference) + } catch { + AuthorizationFree(createdReference, []) + throw error + } + + let authorizationStatus = PrivilegedCleaningConstants.authorizationRight.withCString { rightName in + var item = AuthorizationItem( + name: rightName, + valueLength: 0, + value: nil, + flags: 0 + ) + return withUnsafeMutablePointer(to: &item) { itemPointer in + var rights = AuthorizationRights(count: 1, items: itemPointer) + return AuthorizationCopyRights( + createdReference, + &rights, + nil, + [.interactionAllowed, .extendRights, .preAuthorize], + nil + ) + } + } + guard authorizationStatus == errAuthorizationSuccess else { + AuthorizationFree(createdReference, []) + throw PrivilegedCleaningClientError.authorizationFailed(authorizationStatus) + } + reference = createdReference + } + + private static func installAuthorizationRightIfNeeded( + using authorization: AuthorizationRef + ) throws { + var existingDefinition: CFDictionary? + let getStatus = PrivilegedCleaningConstants.authorizationRight.withCString { rightName in + AuthorizationRightGet(rightName, &existingDefinition) + } + + if getStatus == errAuthorizationSuccess, + let existingDefinition, + authorizationRightIsCurrent(existingDefinition) + { + return + } + guard getStatus == errAuthorizationSuccess || getStatus == errAuthorizationDenied else { + throw PrivilegedCleaningClientError.authorizationFailed(getStatus) + } + + let definition: [String: Any] = [ + "class": "rule", + kAuthorizationRightRule: kAuthorizationRuleAuthenticateAsAdmin, + "shared": false, + "timeout": PrivilegedCleaningConstants.authorizationRightTimeout, + "version": PrivilegedCleaningConstants.securityPolicyVersion, + kAuthorizationComment: "Authorizes deletion of explicitly selected, policy-validated items by PureMac", + ] + let description = "PureMac needs permission to delete the selected protected items." as CFString + let setStatus = PrivilegedCleaningConstants.authorizationRight.withCString { rightName in + AuthorizationRightSet( + authorization, + rightName, + definition as CFDictionary, + description, + nil, + nil + ) + } + guard setStatus == errAuthorizationSuccess else { + throw PrivilegedCleaningClientError.authorizationFailed(setStatus) + } + } + + private static func authorizationRightIsCurrent(_ definition: CFDictionary) -> Bool { + let values = definition as NSDictionary + guard values["class"] as? String == "rule", + values[kAuthorizationRightRule] as? String == kAuthorizationRuleAuthenticateAsAdmin, + let shared = values["shared"] as? Bool, + shared == false, + let timeout = values["timeout"] as? NSNumber, + timeout.intValue == PrivilegedCleaningConstants.authorizationRightTimeout, + let version = values["version"] as? NSNumber, + version.intValue == PrivilegedCleaningConstants.securityPolicyVersion + else { + return false + } + return true + } + + func externalForm() throws -> Data { + guard let reference else { + throw PrivilegedCleaningClientError.authorizationFailed(errAuthorizationInvalidRef) + } + var externalForm = AuthorizationExternalForm() + let status = AuthorizationMakeExternalForm(reference, &externalForm) + guard status == errAuthorizationSuccess else { + throw PrivilegedCleaningClientError.authorizationFailed(status) + } + return withUnsafeBytes(of: &externalForm) { Data($0) } + } + + func destroy() { + guard let reference else { return } + self.reference = nil + AuthorizationFree(reference, [.destroyRights]) + } + + deinit { + destroy() + } +} + +private final class PrivilegedXPCRequestState: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var connection: NSXPCConnection? + private var terminalResult: Result? + private var timeoutWorkItem: DispatchWorkItem? + + func install( + continuation: CheckedContinuation, + connection: NSXPCConnection + ) -> Bool { + lock.lock() + if let terminalResult { + lock.unlock() + connection.invalidate() + continuation.resume(with: terminalResult) + return false + } + self.continuation = continuation + self.connection = connection + lock.unlock() + return true + } + + func scheduleTimeout(after seconds: TimeInterval) { + let workItem = DispatchWorkItem { [weak self] in + self?.finish(throwing: PrivilegedCleaningClientError.requestTimedOut) + } + lock.lock() + guard terminalResult == nil else { + lock.unlock() + return + } + timeoutWorkItem = workItem + lock.unlock() + DispatchQueue.global(qos: .userInitiated).asyncAfter( + deadline: .now() + seconds, + execute: workItem + ) + } + + func finish(returning value: Value) { + finish(with: .success(value)) + } + + func finish(throwing error: Error) { + finish(with: .failure(error)) + } + + private func finish(with result: Result) { + lock.lock() + guard terminalResult == nil else { + lock.unlock() + return + } + terminalResult = result + let pendingContinuation = continuation + continuation = nil + let activeConnection = connection + connection = nil + let timeout = timeoutWorkItem + timeoutWorkItem = nil + lock.unlock() + + timeout?.cancel() + activeConnection?.invalidate() + pendingContinuation?.resume(with: result) + } +} diff --git a/PureMac/ViewModels/AppState.swift b/PureMac/ViewModels/AppState.swift index cb62eac..ed9b6b3 100644 --- a/PureMac/ViewModels/AppState.swift +++ b/PureMac/ViewModels/AppState.swift @@ -1,3 +1,4 @@ +import Darwin import SwiftUI import Combine import UserNotifications @@ -27,6 +28,540 @@ enum ExternalUninstallBuffer { static var pendingPath: String? } +/// An identity-bound source entry prepared for a later move to the user's +/// Trash. Keeping the lstat snapshot separate from the mutation lets a whole +/// uninstall batch be frozen before its first filesystem change. +struct SecureTrashCandidate: Sendable { + let originalURL: URL + let canonicalPath: String + let identity: FileIdentity +} + +enum SecureTrashMoveError: LocalizedError, Equatable { + case invalidPath(String) + case missing(String) + case unsupportedType(String) + case identityChanged(String) + case unsafeTrash(String) + case crossedDeviceBoundary(String) + case recoveryFailed(String) + case posix(operation: String, path: String, code: Int32) + + var isPermissionDenied: Bool { + if case let .posix(_, _, code) = self { + return code == EACCES || code == EPERM + } + return false + } + + var errorDescription: String? { + switch self { + case let .invalidPath(path): + return "Invalid Trash source path: \(path)" + case let .missing(path): + return "The item disappeared before it could be moved to Trash: \(path)" + case let .unsupportedType(path): + return "Unsupported filesystem object type: \(path)" + case let .identityChanged(path): + return "The item changed before it could be moved to Trash: \(path)" + case let .unsafeTrash(path): + return "The Trash directory is not safe to use: \(path)" + case let .crossedDeviceBoundary(path): + return "The item is on a different volume from the user's Trash: \(path)" + case let .recoveryFailed(path): + return "A raced item could not be restored safely after a Trash move: \(path)" + case let .posix(operation, path, code): + return "\(operation) failed for \(path): \(String(cString: strerror(code)))" + } + } +} + +/// Moves an exact lstat identity into ~/.Trash without asking Foundation to +/// resolve the source pathname again. Every parent component is opened with +/// O_NOFOLLOW, the leaf is held by a no-follow descriptor, and renameatx_np is +/// performed relative to the verified source/Trash directory descriptors. +struct SecureTrashMover: Sendable { + private struct OpenTrash { + let homeFD: Int32 + let trashFD: Int32 + let identity: FileIdentity + let path: String + } + + private let policy: SecureDeletionPolicy + private let homeDirectory: String + private let userID: uid_t + private let beforeRevalidation: (@Sendable () throws -> Void)? + private let afterRevalidationBeforeRename: (@Sendable () throws -> Void)? + + init( + homeDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path, + userID: uid_t = getuid(), + beforeRevalidation: (@Sendable () throws -> Void)? = nil, + afterRevalidationBeforeRename: (@Sendable () throws -> Void)? = nil + ) { + self.homeDirectory = homeDirectory + self.userID = userID + policy = SecureDeletionPolicy(userID: userID, homeDirectory: homeDirectory) + self.beforeRevalidation = beforeRevalidation + self.afterRevalidationBeforeRename = afterRevalidationBeforeRename + } + + func prepare(_ url: URL) throws -> SecureTrashCandidate { + let path: String + do { + path = try policy.canonicalPath(url.path) + } catch { + throw SecureTrashMoveError.invalidPath(url.path) + } + + let identity: FileIdentity + switch FileIdentity.lookup(path: path) { + case let .found(found): + identity = found + case .missing: + throw SecureTrashMoveError.missing(path) + case let .failed(code): + throw SecureTrashMoveError.posix(operation: "lstat", path: path, code: code) + } + + guard identity.isDirectory || identity.isRegularFile || identity.isSymbolicLink else { + throw SecureTrashMoveError.unsupportedType(path) + } + return SecureTrashCandidate( + originalURL: url, + canonicalPath: path, + identity: identity + ) + } + + @discardableResult + func moveToTrash(_ candidate: SecureTrashCandidate) throws -> URL { + let source = try openSource(candidate) + defer { Darwin.close(source.parentFD) } + defer { Darwin.close(source.leafFD) } + + let trash = try openTrashDirectory() + defer { Darwin.close(trash.trashFD) } + defer { Darwin.close(trash.homeFD) } + + guard candidate.identity.device == trash.identity.device else { + throw SecureTrashMoveError.crossedDeviceBoundary(candidate.canonicalPath) + } + guard !isAncestor(candidate.canonicalPath, of: trash.path) else { + throw SecureTrashMoveError.unsafeTrash(trash.path) + } + + try beforeRevalidation?() + try verifySource( + parentFD: source.parentFD, + leafFD: source.leafFD, + name: source.name, + expected: candidate.identity, + path: candidate.canonicalPath + ) + try verifyTrashIsStillReachable(trash) + + // This hook exists solely for deterministic security tests. Shipping + // callers leave it nil, so renameatx_np follows the final fstatat + // revalidation immediately. + try afterRevalidationBeforeRename?() + + let destinationName = try renameExclusivelyToTrash( + sourceParentFD: source.parentFD, + sourceName: source.name, + sourcePath: candidate.canonicalPath, + trashFD: trash.trashFD + ) + + do { + let movedIdentity = try identityAt( + parentFD: trash.trashFD, + name: destinationName, + path: candidate.canonicalPath + ) + guard movedIdentity == candidate.identity else { + try restoreUnexpectedMove( + trashFD: trash.trashFD, + trashName: destinationName, + movedIdentity: movedIdentity, + sourceParentFD: source.parentFD, + sourceName: source.name, + sourcePath: candidate.canonicalPath + ) + throw SecureTrashMoveError.identityChanged(candidate.canonicalPath) + } + + do { + try verifyTrashIsStillReachable(trash) + } catch { + try restoreUnexpectedMove( + trashFD: trash.trashFD, + trashName: destinationName, + movedIdentity: movedIdentity, + sourceParentFD: source.parentFD, + sourceName: source.name, + sourcePath: candidate.canonicalPath + ) + throw error + } + } catch let error as SecureTrashMoveError { + throw error + } catch { + throw SecureTrashMoveError.recoveryFailed(candidate.canonicalPath) + } + + return URL(fileURLWithPath: trash.path).appendingPathComponent( + destinationName, + isDirectory: candidate.identity.isDirectory + ) + } + + private func openSource( + _ candidate: SecureTrashCandidate + ) throws -> (parentFD: Int32, leafFD: Int32, name: String) { + let components = candidate.canonicalPath.split(separator: "/").map(String.init) + guard let name = components.last else { + throw SecureTrashMoveError.invalidPath(candidate.canonicalPath) + } + + let parentFD = try openDirectoryComponents( + Array(components.dropLast()), + displayPath: candidate.canonicalPath + ) + do { + var flags = O_EVTONLY | O_CLOEXEC + if candidate.identity.isDirectory { + flags |= O_DIRECTORY | O_NOFOLLOW + } else if candidate.identity.isSymbolicLink { + // O_SYMLINK opens the link vnode itself. Darwin rejects the + // redundant O_NOFOLLOW combination with ELOOP. + flags |= O_SYMLINK + } else { + flags |= O_NOFOLLOW + } + let leafFD = name.withCString { pointer in + Darwin.openat(parentFD, pointer, flags) + } + guard leafFD >= 0 else { + let code = errno + if code == ENOENT { + throw SecureTrashMoveError.identityChanged(candidate.canonicalPath) + } + throw SecureTrashMoveError.posix( + operation: "openat", + path: candidate.canonicalPath, + code: code + ) + } + do { + let descriptorIdentity = try identityOfDescriptor( + leafFD, + path: candidate.canonicalPath + ) + guard descriptorIdentity == candidate.identity else { + throw SecureTrashMoveError.identityChanged(candidate.canonicalPath) + } + return (parentFD, leafFD, name) + } catch { + Darwin.close(leafFD) + throw error + } + } catch { + Darwin.close(parentFD) + throw error + } + } + + private func openTrashDirectory() throws -> OpenTrash { + let canonicalHome: String + do { + canonicalHome = try policy.canonicalPath(homeDirectory) + } catch { + throw SecureTrashMoveError.unsafeTrash(homeDirectory) + } + let homeComponents = canonicalHome.split(separator: "/").map(String.init) + let homeFD = try openDirectoryComponents(homeComponents, displayPath: canonicalHome) + + do { + let homeIdentity = try identityOfDescriptor(homeFD, path: canonicalHome) + guard homeIdentity.isDirectory, homeIdentity.owner == UInt32(userID) else { + throw SecureTrashMoveError.unsafeTrash(canonicalHome) + } + + let trashName = ".Trash" + var trashFD = trashName.withCString { pointer in + Darwin.openat( + homeFD, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + if trashFD < 0, errno == ENOENT { + let createStatus = trashName.withCString { pointer in + Darwin.mkdirat(homeFD, pointer, mode_t(S_IRWXU)) + } + if createStatus != 0, errno != EEXIST { + throw SecureTrashMoveError.posix( + operation: "mkdirat", + path: canonicalHome + "/.Trash", + code: errno + ) + } + trashFD = trashName.withCString { pointer in + Darwin.openat( + homeFD, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + } + guard trashFD >= 0 else { + throw SecureTrashMoveError.posix( + operation: "openat", + path: canonicalHome + "/.Trash", + code: errno + ) + } + + do { + var info = stat() + guard Darwin.fstat(trashFD, &info) == 0 else { + throw SecureTrashMoveError.posix( + operation: "fstat", + path: canonicalHome + "/.Trash", + code: errno + ) + } + let identity = FileIdentity(stat: info) + let permissionBits = info.st_mode & mode_t(0o077) + guard identity.isDirectory, + identity.owner == UInt32(userID), + permissionBits == 0 + else { + throw SecureTrashMoveError.unsafeTrash(canonicalHome + "/.Trash") + } + return OpenTrash( + homeFD: homeFD, + trashFD: trashFD, + identity: identity, + path: canonicalHome + "/.Trash" + ) + } catch { + Darwin.close(trashFD) + throw error + } + } catch { + Darwin.close(homeFD) + throw error + } + } + + private func openDirectoryComponents( + _ components: [String], + displayPath: String + ) throws -> Int32 { + var currentFD = Darwin.open( + "/", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard currentFD >= 0 else { + throw SecureTrashMoveError.posix(operation: "open", path: "/", code: errno) + } + + var traversed = "" + do { + for component in components { + traversed += "/" + component + let nextFD = component.withCString { pointer in + Darwin.openat( + currentFD, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + guard nextFD >= 0 else { + throw SecureTrashMoveError.posix( + operation: "openat", + path: traversed, + code: errno + ) + } + Darwin.close(currentFD) + currentFD = nextFD + } + return currentFD + } catch { + Darwin.close(currentFD) + throw error + } + } + + private func verifySource( + parentFD: Int32, + leafFD: Int32, + name: String, + expected: FileIdentity, + path: String + ) throws { + guard try identityOfDescriptor(leafFD, path: path) == expected, + try identityAt(parentFD: parentFD, name: name, path: path) == expected + else { + throw SecureTrashMoveError.identityChanged(path) + } + } + + private func verifyTrashIsStillReachable(_ trash: OpenTrash) throws { + let visibleIdentity = try identityAt( + parentFD: trash.homeFD, + name: ".Trash", + path: trash.path + ) + guard visibleIdentity == trash.identity else { + throw SecureTrashMoveError.unsafeTrash(trash.path) + } + } + + private func renameExclusivelyToTrash( + sourceParentFD: Int32, + sourceName: String, + sourcePath: String, + trashFD: Int32 + ) throws -> String { + for attempt in 0..<8 { + let destinationName = attempt == 0 + ? sourceName + : collisionSafeName(for: sourceName) + let status = sourceName.withCString { sourcePointer in + destinationName.withCString { destinationPointer in + Darwin.renameatx_np( + sourceParentFD, + sourcePointer, + trashFD, + destinationPointer, + UInt32(RENAME_EXCL) + ) + } + } + if status == 0 { return destinationName } + + let code = errno + if code == EEXIST { continue } + if code == ENOENT { + throw SecureTrashMoveError.identityChanged(sourcePath) + } + if code == EXDEV { + throw SecureTrashMoveError.crossedDeviceBoundary(sourcePath) + } + throw SecureTrashMoveError.posix( + operation: "renameatx_np", + path: sourcePath, + code: code + ) + } + throw SecureTrashMoveError.posix( + operation: "renameatx_np", + path: sourcePath, + code: EEXIST + ) + } + + private func restoreUnexpectedMove( + trashFD: Int32, + trashName: String, + movedIdentity: FileIdentity, + sourceParentFD: Int32, + sourceName: String, + sourcePath: String + ) throws { + guard try identityAt( + parentFD: trashFD, + name: trashName, + path: sourcePath + ) == movedIdentity else { + throw SecureTrashMoveError.recoveryFailed(sourcePath) + } + + let status = trashName.withCString { trashPointer in + sourceName.withCString { sourcePointer in + Darwin.renameatx_np( + trashFD, + trashPointer, + sourceParentFD, + sourcePointer, + UInt32(RENAME_EXCL) + ) + } + } + guard status == 0, + try identityAt( + parentFD: sourceParentFD, + name: sourceName, + path: sourcePath + ) == movedIdentity + else { + throw SecureTrashMoveError.recoveryFailed(sourcePath) + } + } + + private func identityAt( + parentFD: Int32, + name: String, + path: String + ) throws -> FileIdentity { + var info = stat() + let status = name.withCString { pointer in + Darwin.fstatat(parentFD, pointer, &info, AT_SYMLINK_NOFOLLOW) + } + guard status == 0 else { + let code = errno + if code == ENOENT { + throw SecureTrashMoveError.identityChanged(path) + } + throw SecureTrashMoveError.posix( + operation: "fstatat", + path: path, + code: code + ) + } + return FileIdentity(stat: info) + } + + private func identityOfDescriptor(_ descriptor: Int32, path: String) throws -> FileIdentity { + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw SecureTrashMoveError.posix( + operation: "fstat", + path: path, + code: errno + ) + } + return FileIdentity(stat: info) + } + + private func collisionSafeName(for originalName: String) -> String { + let original = originalName as NSString + let pathExtension = original.pathExtension + let extensionSuffix = pathExtension.isEmpty ? "" : "." + pathExtension + let suffix = " \(UUID().uuidString)" + extensionSuffix + let stem = pathExtension.isEmpty + ? originalName + : original.deletingPathExtension + let maximumStemBytes = max(1, Int(NAME_MAX) - suffix.utf8.count) + var shortened = "" + for character in stem { + let candidate = shortened + String(character) + guard candidate.utf8.count <= maximumStemBytes else { break } + shortened = candidate + } + return shortened + suffix + } + + private func isAncestor(_ possibleAncestor: String, of path: String) -> Bool { + possibleAncestor == path || path.hasPrefix(possibleAncestor + "/") + } +} + /// Standalone observable for the live scan-path ticker. The scan engine reports /// the filesystem path it is touching ~10×/sec. Routing that through AppState's /// own `@Published` storage republished the *entire* view tree at that rate, @@ -70,9 +605,9 @@ final class AppState: ObservableObject { @Published var hasFullDiskAccess: Bool = true @Published var fdaBannerDismissed: Bool = false @Published var cleanError: String? - /// True when the most recent clean error is rooted in a TCC/FDA refusal - /// (i.e. items survived even the admin pass). MainWindow uses this to - /// route the user into the PermissionSheet instead of the generic alert. + /// True only when every surviving item has an explicit TCC/FDA denial + /// from the user-level identity lookup. MainWindow uses this to route the + /// user into PermissionSheet instead of the generic alert. @Published var cleanErrorIsFDAFixable: Bool = false /// Items that survived the most recent clean attempt — used to re-run the /// operation after the user grants Full Disk Access without forcing them @@ -288,81 +823,152 @@ final class AppState: ObservableObject { } return } - trashDirectly(urls: urls) { [weak self] removed, needsFullDiskAccess, needsAdmin, failed in - Task { @MainActor in - guard let self else { return } - - self.applyRemovedAppFiles(removed) - - guard !needsAdmin.isEmpty else { - self.finishRemoval( - removedAny: !removed.isEmpty, - needsFullDiskAccess: needsFullDiskAccess, - attemptedAdmin: false, - failed: failed, - adminError: nil - ) - return - } - - let items = needsAdmin.map { self.cleanableUninstallItem(for: $0) } - let adminResult = await self.cleaningEngine.cleanWithAdminPrivileges(items: items) - let adminRemoved = needsAdmin.filter { adminResult.cleanedPaths.contains($0.path) } - let adminFailed = needsAdmin.filter { !adminResult.cleanedPaths.contains($0.path) } + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await FilesystemMutationCoordinator.shared.acquire() + } catch { + self.removalError = error.localizedDescription + Logger.shared.log( + "Uninstall blocked because the filesystem mutation lease failed: \(error.localizedDescription)", + level: .error + ) + return + } + let directResult: DirectTrashResult + do { + try await self.cleaningEngine.ensurePrivilegedDeletionsAreSettled() + directResult = await self.trashDirectly(urls: urls) + } catch { + await FilesystemMutationCoordinator.shared.release() + self.removalError = error.localizedDescription + Logger.shared.log( + "Uninstall blocked by unresolved privileged deletion: \(error.localizedDescription)", + level: .error + ) + return + } + await FilesystemMutationCoordinator.shared.release() - self.applyRemovedAppFiles(adminRemoved) - for url in adminRemoved { - Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) - } + self.applyRemovedAppFiles(directResult.removed) + guard !directResult.needsAdmin.isEmpty else { self.finishRemoval( - removedAny: !removed.isEmpty || !adminRemoved.isEmpty, - needsFullDiskAccess: needsFullDiskAccess, - attemptedAdmin: true, - failed: failed + adminFailed, - adminError: adminResult.errors.joined(separator: "; ") + removedAny: !directResult.removed.isEmpty, + fullDiskAccessDenied: directResult.fullDiskAccessDenied, + ordinaryFailures: directResult.failures, + adminFailures: [], + adminError: nil ) + return } + + let items = directResult.needsAdmin.map { + self.cleanableUninstallItem( + for: URL(fileURLWithPath: $0.canonicalPath), + fileIdentity: $0.identity + ) + } + let adminResult = await self.cleaningEngine.cleanWithAdminPrivileges(items: items) + let adminRemoved = directResult.needsAdmin.filter { + adminResult.cleanedPaths.contains($0.canonicalPath) + }.map(\.originalURL) + let adminFailed = directResult.needsAdmin.filter { + !adminResult.cleanedPaths.contains($0.canonicalPath) + }.map(\.originalURL) + + self.applyRemovedAppFiles(adminRemoved) + for url in adminRemoved { + Logger.shared.log("Removed \(url.path) with administrator privileges", level: .info) + } + + self.finishRemoval( + removedAny: !directResult.removed.isEmpty || !adminRemoved.isEmpty, + fullDiskAccessDenied: directResult.fullDiskAccessDenied, + ordinaryFailures: directResult.failures, + adminFailures: adminFailed, + adminError: adminResult.errors.joined(separator: "; ") + ) } } - /// Move files to the Trash via FileManager.trashItem so the syscall - /// originates from PureMac itself - TCC then registers PureMac in the - /// Full Disk Access list. The previous AppleScript-via-Finder bridge - /// caused the syscall to originate from Finder, which is why granting - /// FDA to PureMac made no difference (issue #75). - private func trashDirectly(urls: [URL], completion: @escaping ([URL], Bool, [URL], [URL]) -> Void) { - DispatchQueue.global(qos: .userInitiated).async { - let hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess - var removed: [URL] = [] - var needsFullDiskAccess = false - var needsAdmin: [URL] = [] - var failed: [URL] = [] + private struct DirectTrashResult: Sendable { + let removed: [URL] + let fullDiskAccessDenied: [URL] + let needsAdmin: [SecureTrashCandidate] + let failures: [URL] + } - for url in urls { - var resulting: NSURL? - do { - try FileManager.default.trashItem(at: url, resultingItemURL: &resulting) - removed.append(url) - } catch { - let nsError = error as NSError - if Self.isMissingFileError(nsError) { - Logger.shared.log("Trash skipped for \(url.path): file no longer exists", level: .info) + /// Move exact lstat identities into ~/.Trash with descriptor-relative, + /// no-follow renames. This keeps the normal recoverable Trash UX while + /// ensuring neither Foundation nor Finder resolves a raced pathname. + private func trashDirectly(urls: [URL]) async -> DirectTrashResult { + let logger = Logger.shared + return await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + let hasFullDiskAccess = FullDiskAccessManager.shared.hasFullDiskAccess + let trashMover = SecureTrashMover() + var removed: [URL] = [] + var fullDiskAccessDenied: [URL] = [] + var needsAdmin: [SecureTrashCandidate] = [] + var failed: [URL] = [] + var candidates: [SecureTrashCandidate] = [] + + // Freeze every source identity before the first namespace + // mutation in this batch. A temporary ENOENT is deliberately + // retained as a failure, never promoted to "removed". + for url in urls { + do { + candidates.append(try trashMover.prepare(url)) + } catch { + let nsError = error as NSError + if Self.isPermissionDeniedError(nsError) + || (error as? SecureTrashMoveError)?.isPermissionDenied == true { + // No identity was captured, so root escalation is + // forbidden. FDA + a fresh user-confirmed retry is + // the only safe continuation. + fullDiskAccessDenied.append(url) + } else { + logger.log( + "Trash preparation failed for \(url.path): \(error.localizedDescription)", + level: .error + ) + failed.append(url) + } + } + } + + for candidate in candidates { + let url = candidate.originalURL + do { + _ = try trashMover.moveToTrash(candidate) removed.append(url) - } else if Self.isPermissionDeniedError(nsError) { - if hasFullDiskAccess || Self.isLikelyAdministratorRemovalPath(url) { - needsAdmin.append(url) + } catch { + let nsError = error as NSError + let permissionDenied = Self.isPermissionDeniedError(nsError) + || (error as? SecureTrashMoveError)?.isPermissionDenied == true + if permissionDenied { + if hasFullDiskAccess || Self.isLikelyAdministratorRemovalPath(url) { + needsAdmin.append(candidate) + } else { + fullDiskAccessDenied.append(url) + } } else { - needsFullDiskAccess = true + logger.log( + "Secure Trash move failed for \(url.path): \(error.localizedDescription)", + level: .error + ) failed.append(url) } - } else { - Logger.shared.log("Trash failed for \(url.path): \(error.localizedDescription)", level: .error) - failed.append(url) } } + continuation.resume(returning: DirectTrashResult( + removed: removed, + fullDiskAccessDenied: fullDiskAccessDenied, + needsAdmin: needsAdmin, + failures: failed + )) } - completion(removed, needsFullDiskAccess, needsAdmin, failed) } } @@ -384,26 +990,54 @@ final class AppState: ObservableObject { private func finishRemoval( removedAny: Bool, - needsFullDiskAccess: Bool, - attemptedAdmin: Bool, - failed: [URL], + fullDiskAccessDenied: [URL], + ordinaryFailures: [URL], + adminFailures: [URL], adminError: String? ) { + let normalizedAdminError = adminError?.trimmingCharacters(in: .whitespacesAndNewlines) + let shouldOfferFullDiskAccess = !fullDiskAccessDenied.isEmpty + && ordinaryFailures.isEmpty + && adminFailures.isEmpty + && (normalizedAdminError?.isEmpty ?? true) + // Freeze the failed batch before the FDA sheet opens so the retry // path can't be poisoned by later selection edits or app switches. - lastFailedRemovalURLs = needsFullDiskAccess ? failed : [] - removalNeedsFullDiskAccess = needsFullDiskAccess + lastFailedRemovalURLs = shouldOfferFullDiskAccess ? fullDiskAccessDenied : [] + removalNeedsFullDiskAccess = shouldOfferFullDiskAccess if let message = removalFailureMessage( - needsFullDiskAccess: needsFullDiskAccess, - attemptedAdmin: attemptedAdmin, - failed: failed, - adminError: adminError + fullDiskAccessDenied: fullDiskAccessDenied, + ordinaryFailures: ordinaryFailures, + adminFailures: adminFailures, + adminError: normalizedAdminError ) { removalError = message Logger.shared.log(message, level: .error) } if removedAny { - pruneMissingInstalledApps() + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await FilesystemMutationCoordinator.shared.acquire() + } catch { + Logger.shared.log( + "Installed-app pruning blocked because the filesystem mutation lease failed: \(error.localizedDescription)", + level: .warning + ) + return + } + do { + try await self.cleaningEngine.ensurePrivilegedDeletionsAreSettled() + self.pruneMissingInstalledApps() + await FilesystemMutationCoordinator.shared.release() + } catch { + await FilesystemMutationCoordinator.shared.release() + Logger.shared.log( + "Installed-app pruning deferred until privileged reconciliation: \(error.localizedDescription)", + level: .warning + ) + } + } } } @@ -413,7 +1047,10 @@ final class AppState: ObservableObject { cleanableUninstallItem(for: url) } - private func cleanableUninstallItem(for url: URL) -> CleanableItem { + private func cleanableUninstallItem( + for url: URL, + fileIdentity: FileIdentity? = nil + ) -> CleanableItem { let values = try? url.resourceValues(forKeys: [ .totalFileAllocatedSizeKey, .fileAllocatedSizeKey, @@ -426,32 +1063,43 @@ final class AppState: ObservableObject { size: size, category: .systemJunk, isSelected: true, - lastModified: values?.contentModificationDate + lastModified: values?.contentModificationDate, + fileIdentity: fileIdentity ) } private func removalFailureMessage( - needsFullDiskAccess: Bool, - attemptedAdmin: Bool, - failed: [URL], + fullDiskAccessDenied: [URL], + ordinaryFailures: [URL], + adminFailures: [URL], adminError: String? ) -> String? { - if needsFullDiskAccess { - let prefix = failed.isEmpty ? "Some selected files" : "\(failed.count) file\(failed.count == 1 ? "" : "s")" - return "\(prefix) could not be removed because PureMac does not have Full Disk Access. Grant Full Disk Access in System Settings, then try again." + var messages: [String] = [] + + if let adminError, !adminError.isEmpty { + messages.append("Administrator removal failed: \(adminError)") + } else if !adminFailures.isEmpty { + let noun = adminFailures.count == 1 ? "file" : "files" + messages.append( + "\(adminFailures.count) \(noun) could not be removed with administrator privileges. The items may have changed or macOS denied access." + ) } - if !failed.isEmpty { - if attemptedAdmin { - return "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed with administrator privileges. The items may have changed or macOS denied access." - } - return "\(failed.count) file\(failed.count == 1 ? "" : "s") could not be removed. Check that the items still exist and are not in use." + if !ordinaryFailures.isEmpty { + let noun = ordinaryFailures.count == 1 ? "file" : "files" + messages.append( + "\(ordinaryFailures.count) \(noun) could not be removed. Check that the items still exist and are not in use." + ) } - if let adminError, !adminError.isEmpty { - return "Administrator removal failed: \(adminError)" + if !fullDiskAccessDenied.isEmpty { + let noun = fullDiskAccessDenied.count == 1 ? "file" : "files" + messages.append( + "\(fullDiskAccessDenied.count) \(noun) could not be removed because PureMac does not have Full Disk Access. Grant Full Disk Access in System Settings, then try again." + ) } - return nil + + return messages.isEmpty ? nil : messages.joined(separator: " ") } private nonisolated static func isMissingFileError(_ nsError: NSError) -> Bool { @@ -553,6 +1201,50 @@ final class AppState: ObservableObject { } } + /// Removes orphan rows through the same identity-bound descriptor walker as + /// the main cleaner. Permission failures are sent to the single-purpose XPC + /// helper; no orphan path is ever interpolated into a root shell command. + func removeOrphansSecurely( + _ urls: [URL] + ) async -> (removed: Set, failedPaths: [String], failureDetails: [String]) { + var failedPaths: [String] = [] + var failureDetails: [String] = [] + let candidates = urls.filter { url in + guard OrphanSafetyPolicy.isSafeCandidate(url) else { + failedPaths.append(url.path) + failureDetails.append("\(url.path) (blocked by safety policy)") + return false + } + return true + } + + let items = candidates.map { url in + CleanableItem( + name: url.lastPathComponent, + path: url.path, + size: 0, + category: .systemJunk, + isSelected: true, + lastModified: nil + ) + } + + var cleanResult = await cleaningEngine.cleanItems(items) { _ in } + if !cleanResult.requiresAdmin.isEmpty { + let admin = await cleaningEngine.cleanWithAdminPrivileges(items: cleanResult.requiresAdmin) + cleanResult.cleanedPaths.formUnion(admin.cleanedPaths) + cleanResult.fullDiskAccessPaths.formUnion(admin.fullDiskAccessPaths) + cleanResult.errors.append(contentsOf: admin.errors) + } + + let removed = Set(candidates.filter { cleanResult.cleanedPaths.contains($0.path) }) + failureDetails.append(contentsOf: cleanResult.errors) + failedPaths.append(contentsOf: candidates + .filter { !cleanResult.cleanedPaths.contains($0.path) } + .map(\.path)) + return (removed, failedPaths, failureDetails) + } + // MARK: - Orphan ignore list (#114) static let ignoredOrphansKey = "settings.orphans.ignored" @@ -708,9 +1400,10 @@ final class AppState: ObservableObject { FullDiskAccessManager.shared.openFullDiskAccessSettings() } - /// Request Full Disk Access via the rich PermissionCoordinator sheet and - /// retry the supplied items once the user grants permission. Used by both - /// the cleanup and app-uninstall flows so they share a single UI surface. + /// Request Full Disk Access via the rich PermissionCoordinator sheet. A + /// retry is automatic only when every item already has a scan identity; + /// paths that TCC prevented us from identifying must be rescanned and + /// confirmed rather than silently bound to whatever appears after grant. /// /// The retry callback captures `items` directly rather than reading /// `pendingPermissionRetryItems` at fire time — that field is mutable @@ -729,6 +1422,16 @@ final class AppState: ObservableObject { self.cleanError = nil self.cleanErrorIsFDAFixable = false guard !capturedItems.isEmpty else { return } + guard capturedItems.allSatisfy({ $0.fileIdentity != nil }) else { + let message = "Full Disk Access is enabled. Scan again and confirm the items before deleting them; PureMac will not automatically reuse a path whose filesystem identity could not be verified." + switch context { + case .uninstall: + self.removalError = message + case .cleanup, .general: + self.cleanError = message + } + return + } await self.retryCleanItems(capturedItems) } } @@ -747,6 +1450,7 @@ final class AppState: ObservableObject { if !result.requiresAdmin.isEmpty { let admin = await cleaningEngine.cleanWithAdminPrivileges(items: result.requiresAdmin) result.cleanedPaths.formUnion(admin.cleanedPaths) + result.fullDiskAccessPaths.formUnion(admin.fullDiskAccessPaths) result.itemsCleaned += admin.itemsCleaned result.freedSpace += admin.freedSpace result.errors.append(contentsOf: admin.errors) @@ -778,7 +1482,11 @@ final class AppState: ObservableObject { // cleanup uses. Without this, an FDA revocation between grant and // retry would silently drop errors instead of re-popping the sheet. let survivors = items.filter { !result.cleanedPaths.contains($0.path) } - handleCleanOutcome(errors: result.errors, survivors: survivors) + handleCleanOutcome( + errors: result.errors, + survivors: survivors, + fullDiskAccessPaths: result.fullDiskAccessPaths + ) scanState = .cleaned loadDiskInfo() @@ -875,11 +1583,12 @@ final class AppState: ObservableObject { } } - // Escalate root-owned items via "with administrator privileges". - // One auth prompt covers the entire batch. + // Escalate root-owned items through the authenticated XPC helper. + // One Authorization Services prompt covers the chunked batch. if !result.requiresAdmin.isEmpty { let admin = await cleaningEngine.cleanWithAdminPrivileges(items: result.requiresAdmin) result.cleanedPaths.formUnion(admin.cleanedPaths) + result.fullDiskAccessPaths.formUnion(admin.fullDiskAccessPaths) result.itemsCleaned += admin.itemsCleaned result.freedSpace += admin.freedSpace result.errors.append(contentsOf: admin.errors) @@ -910,7 +1619,11 @@ final class AppState: ObservableObject { } totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } - handleCleanOutcome(errors: result.errors, survivors: survivors) + handleCleanOutcome( + errors: result.errors, + survivors: survivors, + fullDiskAccessPaths: result.fullDiskAccessPaths + ) scanState = .cleaned loadDiskInfo() @@ -941,6 +1654,7 @@ final class AppState: ObservableObject { if !cleanResult.requiresAdmin.isEmpty { let admin = await cleaningEngine.cleanWithAdminPrivileges(items: cleanResult.requiresAdmin) cleanResult.cleanedPaths.formUnion(admin.cleanedPaths) + cleanResult.fullDiskAccessPaths.formUnion(admin.fullDiskAccessPaths) cleanResult.itemsCleaned += admin.itemsCleaned cleanResult.freedSpace += admin.freedSpace cleanResult.errors.append(contentsOf: admin.errors) @@ -969,7 +1683,11 @@ final class AppState: ObservableObject { totalJunkSize = categoryResults.values.reduce(0) { $0 + $1.totalSize } let survivors = selectedItems.filter { !cleanResult.cleanedPaths.contains($0.path) } - handleCleanOutcome(errors: cleanResult.errors, survivors: survivors) + handleCleanOutcome( + errors: cleanResult.errors, + survivors: survivors, + fullDiskAccessPaths: cleanResult.fullDiskAccessPaths + ) scanState = .cleaned loadDiskInfo() @@ -983,7 +1701,11 @@ final class AppState: ObservableObject { /// Inspect a clean batch's leftovers and either route the user into the /// PermissionSheet (FDA is the most likely cause) or surface a richer /// error alert that lists actual paths instead of "Check the log". - private func handleCleanOutcome(errors: [String], survivors: [CleanableItem]) { + private func handleCleanOutcome( + errors: [String], + survivors: [CleanableItem], + fullDiskAccessPaths: Set + ) { guard !errors.isEmpty || !survivors.isEmpty else { cleanError = nil cleanErrorIsFDAFixable = false @@ -992,15 +1714,22 @@ final class AppState: ObservableObject { } let fdaGranted = FullDiskAccessManager.shared.hasFullDiskAccess - let likelyFDA = !fdaGranted && !survivors.isEmpty + let fdaSurvivors = survivors.filter { fullDiskAccessPaths.contains($0.path) } + let hasNonFDAFailure = survivors.contains { !fullDiskAccessPaths.contains($0.path) } + let likelyFDA = !fdaGranted && !fdaSurvivors.isEmpty && !hasNonFDAFailure cleanErrorIsFDAFixable = likelyFDA - pendingPermissionRetryItems = survivors + pendingPermissionRetryItems = likelyFDA ? fdaSurvivors : [] if likelyFDA { cleanError = String( - format: String(localized: "%lld item(s) need Full Disk Access to remove. Tap Grant Access to fix in one step."), - Int64(survivors.count) + format: String(localized: "%lld item(s) need Full Disk Access to inspect. Grant access, then rescan to confirm them safely."), + Int64(fdaSurvivors.count) ) + } else if let first = errors.first { + // Authorization cancellation, helper registration/transport + // errors and policy rejections are actionable as reported. Never + // replace them with an unrelated FDA or "in use" suggestion. + cleanError = first } else if !survivors.isEmpty { let preview = survivors.prefix(2).map { ($0.path as NSString).lastPathComponent }.joined(separator: ", ") let extra = survivors.count > 2 ? String(format: String(localized: " and %lld more"), Int64(survivors.count - 2)) : "" @@ -1008,8 +1737,6 @@ final class AppState: ObservableObject { format: String(localized: "Couldn't remove %@%@. They may be in use or protected by macOS."), preview, extra ) - } else if let first = errors.first { - cleanError = first } } diff --git a/PureMac/Views/Apps/AppFilesView.swift b/PureMac/Views/Apps/AppFilesView.swift index 5f00902..128fe6f 100644 --- a/PureMac/Views/Apps/AppFilesView.swift +++ b/PureMac/Views/Apps/AppFilesView.swift @@ -111,9 +111,9 @@ struct AppFilesView: View { .onChange(of: appState.removalNeedsFullDiskAccess) { needs in // FDA-fixable removals jump straight into the rich sheet, the // same flow cleanup uses. The user grants permission once and we - // re-fire the failed batch — using the frozen snapshot from - // AppState so a mid-sheet selection change or app switch doesn't - // re-trash the wrong files. + // continue the failed batch when its identities were captured. + // Unverified paths require a rescan, and the frozen snapshot keeps + // a mid-sheet selection change from substituting another batch. guard needs else { return } let toRetry = appState.lastFailedRemovalURLs let items = toRetry.map { appState.makeUninstallCleanableItem(for: $0) } diff --git a/PureMac/Views/Components/PermissionSheet.swift b/PureMac/Views/Components/PermissionSheet.swift index ebeb437..e30ebab 100644 --- a/PureMac/Views/Components/PermissionSheet.swift +++ b/PureMac/Views/Components/PermissionSheet.swift @@ -1,7 +1,7 @@ import SwiftUI /// Premium Full Disk Access prompt that replaces the bare permission-denied -/// alert. Auto-polls FDA state, auto-retries the failed operation on grant, +/// alert. Auto-polls FDA state, safely continues verified operations on grant, /// and offers escape hatches for the "PureMac isn't in the list" case. struct PermissionSheet: View { @ObservedObject private var coordinator = PermissionCoordinator.shared @@ -51,7 +51,7 @@ struct PermissionSheet: View { Text(coordinator.context.headline) .font(.system(size: 16, weight: .bold)) Text(coordinator.hasFullDiskAccess - ? "Access granted. Retrying…" + ? "Access granted. Checking items…" : "1-tap setup. We'll detect the change automatically.") .font(.system(size: 12)) .foregroundStyle(.secondary) @@ -136,7 +136,7 @@ struct PermissionSheet: View { caption: "Touch ID or password.") stepDivider stepCell(number: 3, title: "Done", - caption: "We auto-retry — no need to come back.") + caption: "We'll continue safely; a re-scan may be required.") } .padding(.vertical, 4) .background( @@ -317,7 +317,7 @@ struct PermissionSheet: View { SuccessMedal(size: 72) Text("Access granted") .font(.system(size: 15, weight: .bold)) - Text("Retrying the operation now.") + Text("Checking whether the selected items can be retried safely.") .font(.system(size: 11.5)) .foregroundStyle(.secondary) } @@ -338,7 +338,7 @@ struct PermissionSheet: View { Spacer() if !coordinator.hasFullDiskAccess { - Button("I granted it — retry") { + Button("I granted it — continue") { coordinator.refreshStatus() DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { if coordinator.hasFullDiskAccess { diff --git a/PureMac/Views/MainWindow.swift b/PureMac/Views/MainWindow.swift index 49765e6..e835fc7 100644 --- a/PureMac/Views/MainWindow.swift +++ b/PureMac/Views/MainWindow.swift @@ -43,9 +43,9 @@ struct MainWindow: View { } } .onChange(of: appState.cleanErrorIsFDAFixable) { isFDAFixable in - // Auto-route FDA-fixable clean errors straight into the rich - // sheet — skip the generic alert entirely so the user gets - // 1-tap remediation instead of "Check the log for details". + // Auto-route explicit FDA denials into the rich sheet. Items that + // lacked a scan identity are rescanned after grant, never silently + // rebound to whatever later appears at the same path. guard isFDAFixable else { return } let pending = appState.pendingPermissionRetryItems appState.cleanError = nil @@ -247,7 +247,7 @@ struct MainWindow: View { VStack(alignment: .leading, spacing: 1) { Text("Full Disk Access required") .font(.system(size: 13, weight: .semibold)) - Text("1-tap setup. We'll auto-retry what failed.") + Text("1-tap setup. We'll continue safely after access is granted.") .font(.system(size: 11.5)) .foregroundStyle(.secondary) } diff --git a/PureMac/Views/Orphans/OrphanListView.swift b/PureMac/Views/Orphans/OrphanListView.swift index 652524c..53ccd6b 100644 --- a/PureMac/Views/Orphans/OrphanListView.swift +++ b/PureMac/Views/Orphans/OrphanListView.swift @@ -149,40 +149,9 @@ struct OrphanListView: View { isRemoving = true defer { isRemoving = false } - let urlsToRemove = selectedOrphans - var failedPaths: [String] = [] - var removedURLs: Set = [] - var needsAdminURLs: [URL] = [] - - for url in urlsToRemove { - guard OrphanSafetyPolicy.isSafeCandidate(url) else { - failedPaths.append("\(url.path) (blocked by safety policy)") - continue - } - - switch removeOrphan(url) { - case .removed: - removedURLs.insert(url) - case .needsAdmin: - needsAdminURLs.append(url) - case .failed: - failedPaths.append(url.path) - } - } - - if !needsAdminURLs.isEmpty { - if removeWithAdminPrivileges(needsAdminURLs) { - for url in needsAdminURLs { - if !FileManager.default.fileExists(atPath: url.path) { - removedURLs.insert(url) - } else { - failedPaths.append(url.path) - } - } - } else { - failedPaths.append(contentsOf: needsAdminURLs.map(\.path)) - } - } + let outcome = await appState.removeOrphansSecurely(Array(selectedOrphans)) + let failedPaths = outcome.failedPaths + let removedURLs = outcome.removed // Sweep removed rows out (per-row transitions are attached in the // List above); plain assignment under Reduce Motion. @@ -198,35 +167,8 @@ struct OrphanListView: View { if !failedPaths.isEmpty { let preview = failedPaths.prefix(3).joined(separator: "\n") let suffix = failedPaths.count > 3 ? "\n…" : "" - removalErrorMessage = "\(failedPaths.count) item(s) failed to delete.\n\n\(preview)\(suffix)" - } - } - - private enum OrphanRemoveOutcome { - case removed - case needsAdmin - case failed - } - - private func removeOrphan(_ url: URL) -> OrphanRemoveOutcome { - do { - try FileManager.default.removeItem(at: url) - return .removed - } catch { - let nsError = error as NSError - let permissionDeniedCodes = [ - NSFileReadNoPermissionError, - NSFileWriteNoPermissionError, - NSFileWriteUnknownError, - 257, - 513, - ] - - guard permissionDeniedCodes.contains(nsError.code) else { - return .failed - } - - return .needsAdmin + let detail = outcome.failureDetails.first.map { "\n\n\($0)" } ?? "" + removalErrorMessage = "\(failedPaths.count) item(s) failed to delete.\n\n\(preview)\(suffix)\(detail)" } } @@ -255,35 +197,6 @@ struct OrphanListView: View { selectedOrphans = previous.subtracting([url]) } - private func removeWithAdminPrivileges(_ urls: [URL]) -> Bool { - guard !urls.isEmpty else { return true } - guard urls.allSatisfy({ OrphanSafetyPolicy.isSafeCandidate($0) }) else { return false } - - // Quote path for a POSIX shell command. - let quotedPaths = urls.map { url in - "'\(url.path.replacingOccurrences(of: "'", with: "'\\\"'\\\"'"))'" - } - let shellCommand = "rm -rf -- \(quotedPaths.joined(separator: " "))" - let appleScriptCommand = shellCommand - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - let script = "do shell script \"\(appleScriptCommand)\" with administrator privileges" - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", script] - - do { - try process.run() - process.waitUntilExit() - if process.terminationStatus != 0 { - return false - } - return true - } catch { - return false - } - } } // MARK: - Row diff --git a/PureMacPrivilegedHelper/com.puremac.privileged-cleaning.plist b/PureMacPrivilegedHelper/com.puremac.privileged-cleaning.plist new file mode 100644 index 0000000..5b9a3f8 --- /dev/null +++ b/PureMacPrivilegedHelper/com.puremac.privileged-cleaning.plist @@ -0,0 +1,23 @@ + + + + + AssociatedBundleIdentifiers + + com.puremac.app + + BundleProgram + Contents/Library/LaunchServices/com.puremac.privileged-helper + Label + com.puremac.privileged-cleaning + MachServices + + com.puremac.privileged-cleaning + + + ProcessType + Background + UserName + root + + diff --git a/PureMacPrivilegedHelper/main.swift b/PureMacPrivilegedHelper/main.swift new file mode 100644 index 0000000..97769cb --- /dev/null +++ b/PureMacPrivilegedHelper/main.swift @@ -0,0 +1,724 @@ +import Darwin +import Foundation +import Security + +private final class PrivilegedCleaningListenerDelegate: NSObject, NSXPCListenerDelegate { + func listener( + _ listener: NSXPCListener, + shouldAcceptNewConnection newConnection: NSXPCConnection + ) -> Bool { + let userID = newConnection.effectiveUserIdentifier + guard userID != 0, let homeDirectory = homeDirectory(for: userID) else { + return false + } + + let cancellation = ConnectionCancellation() + newConnection.exportedInterface = NSXPCInterface(with: PrivilegedCleaningXPCProtocol.self) + newConnection.exportedObject = PrivilegedCleaningSession( + userID: userID, + homeDirectory: homeDirectory, + cancellation: cancellation + ) + newConnection.invalidationHandler = { cancellation.cancel() } + newConnection.interruptionHandler = { cancellation.cancel() } + newConnection.activate() + return true + } + + private func homeDirectory(for userID: uid_t) -> String? { + var record = passwd() + var result: UnsafeMutablePointer? + let configuredSize = sysconf(_SC_GETPW_R_SIZE_MAX) + let bufferSize = max(configuredSize > 0 ? Int(configuredSize) : 16_384, 16_384) + var buffer = [CChar](repeating: 0, count: bufferSize) + + let status = buffer.withUnsafeMutableBufferPointer { pointer in + getpwuid_r(userID, &record, pointer.baseAddress, pointer.count, &result) + } + guard status == 0, result != nil, let directory = record.pw_dir else { + return nil + } + return String(cString: directory) + } +} + +private final class ConnectionCancellation: @unchecked Sendable { + private let lock = NSLock() + private var cancelled = false + + var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } +} + +private struct PrivilegedDeletionOperationKey: Hashable { + let userID: uid_t + let batchID: UUID +} + +/// Serializes mutation admission and gives reconciliation an atomic view of +/// each operation ID. Terminal entries are deliberately short lived: request +/// identities make a replay safe after expiry, while the bounds prevent a +/// signed-but-compromised client from growing root-owned memory indefinitely. +private final class PrivilegedDeletionOperationRegistry: @unchecked Sendable { + enum Admission { + case accepted + /// The operation ID was retained as a fence, so the helper can prove + /// this delivered invocation was never admitted for mutation. + case notAccepted(String) + /// No fence was retained. The caller must treat this as a transport + /// failure and reconcile instead of trusting a notAccepted claim. + case unavailable(String) + } + + enum Completion { + case settled + case notAccepted(String) + case recoveryFailed(String) + } + + struct Reconciliation { + let state: PrivilegedDeletionReconciliationState + let message: String? + } + + private enum State { + case active + case fenced(expiresAt: UInt64, message: String) + case settled(expiresAt: UInt64) + case recoveryFailed(expiresAt: UInt64, message: String) + } + + private enum RecoveryState { + case pending + case ready + case failed(String) + } + + private let lock = NSLock() + private let maximumEntryCount = 256 + private let maximumEntryCountPerUser = 64 + private let retentionNanoseconds = UInt64( + PrivilegedCleaningConstants.authorizationRightTimeout + 60 + ) * 1_000_000_000 + private var entries: [PrivilegedDeletionOperationKey: State] = [:] + private var activeKey: PrivilegedDeletionOperationKey? + /// Admission starts closed. The helper opens it only after durable startup + /// recovery has completed on the same serial queue used for deletion. + private var recoveryState: RecoveryState = .pending + + func recoveryStatus() -> ( + state: PrivilegedCleaningRecoveryState, + message: String? + ) { + lock.lock() + defer { lock.unlock() } + switch recoveryState { + case .pending: + return (.recovering, "Privileged quarantine recovery is in progress") + case .ready: + return (.ready, nil) + case .failed(let message): + return (.failed, message) + } + } + + func markStartupRecoverySucceeded() { + lock.lock() + recoveryState = .ready + lock.unlock() + } + + func markRecoveryFailed(_ message: String) { + lock.lock() + recoveryState = .failed(message) + lock.unlock() + } + + func begin(_ key: PrivilegedDeletionOperationKey) -> Admission { + lock.lock() + defer { lock.unlock() } + + let now = DispatchTime.now().uptimeNanoseconds + purgeExpiredEntries(now: now) + + switch recoveryState { + case .pending: + return .unavailable("Quarantine recovery is still in progress") + case .failed(let message): + return .unavailable("Quarantine recovery is required: \(message)") + case .ready: + break + } + if let state = entries[key] { + switch state { + case .active: + return .unavailable("This deletion operation is already pending") + case .fenced(_, let message): + return .notAccepted(message) + case .settled: + return .unavailable("This deletion operation has already settled") + case .recoveryFailed(_, let message): + return .unavailable("Quarantine recovery is required: \(message)") + } + } + + guard activeKey == nil else { + let message = "Privileged deletion service is busy" + if retainFenceIfPossible(key, message: message, now: now) { + return .notAccepted(message) + } + return .unavailable(message) + } + guard canRetain(key) else { + return .unavailable("Privileged deletion operation registry is full") + } + + entries[key] = .active + activeKey = key + return .accepted + } + + /// Records an ID even when payload validation fails before admission, so + /// the correlated notAccepted reply cannot later race with a retry using + /// the same ID. + @discardableResult + func fence(_ key: PrivilegedDeletionOperationKey, message: String) -> Bool { + lock.lock() + defer { lock.unlock() } + + let now = DispatchTime.now().uptimeNanoseconds + purgeExpiredEntries(now: now) + guard case .ready = recoveryState else { return false } + if let state = entries[key] { + guard case .fenced = state else { return false } + return true + } + return retainFenceIfPossible(key, message: message, now: now) + } + + func finish(_ key: PrivilegedDeletionOperationKey, completion: Completion) { + lock.lock() + defer { lock.unlock() } + + guard let state = entries[key], case .active = state else { + return + } + + if activeKey == key { + activeKey = nil + } + let expiry = expiration(after: DispatchTime.now().uptimeNanoseconds) + switch completion { + case .settled: + entries[key] = .settled(expiresAt: expiry) + case .notAccepted(let message): + entries[key] = .fenced(expiresAt: expiry, message: message) + case .recoveryFailed(let message): + entries[key] = .recoveryFailed(expiresAt: expiry, message: message) + recoveryState = .failed(message) + } + } + + func reconcile(_ key: PrivilegedDeletionOperationKey) -> Reconciliation { + lock.lock() + defer { lock.unlock() } + + let now = DispatchTime.now().uptimeNanoseconds + purgeExpiredEntries(now: now) + + switch recoveryState { + case .pending: + return Reconciliation( + state: .unavailable, + message: "Quarantine recovery is still in progress" + ) + case .failed(let message): + return Reconciliation(state: .recoveryFailed, message: message) + case .ready: + break + } + + if let state = entries[key] { + switch state { + case .active: + return Reconciliation(state: .pending, message: nil) + case .fenced(_, let message): + return Reconciliation(state: .notAccepted, message: message) + case .settled: + return Reconciliation(state: .settled, message: nil) + case .recoveryFailed(_, let message): + return Reconciliation(state: .recoveryFailed, message: message) + } + } + + // The in-memory registry is intentionally lost across helper restarts. + // Durable recovery can prove the namespace is settled, but not whether + // this ID reached a pre-restart COMMITTED transaction. Install a + // settled tombstone atomically so a later deleteItems delivery cannot + // run, then report the conservative outcome instead of notAccepted. + guard canRetain(key) else { + return Reconciliation( + state: .unavailable, + message: "Privileged deletion operation registry is full" + ) + } + entries[key] = .settled(expiresAt: expiration(after: now)) + return Reconciliation( + state: .settled, + message: "No live operation remained after durable recovery" + ) + } + + private func retainFenceIfPossible( + _ key: PrivilegedDeletionOperationKey, + message: String, + now: UInt64 + ) -> Bool { + guard canRetain(key) else { return false } + entries[key] = .fenced( + expiresAt: expiration(after: now), + message: message + ) + return true + } + + private func canRetain(_ key: PrivilegedDeletionOperationKey) -> Bool { + guard entries.count < maximumEntryCount else { return false } + let userEntryCount = entries.keys.reduce(into: 0) { count, existingKey in + if existingKey.userID == key.userID { + count += 1 + } + } + return userEntryCount < maximumEntryCountPerUser + } + + private func purgeExpiredEntries(now: UInt64) { + let expiredKeys = entries.compactMap { key, state -> PrivilegedDeletionOperationKey? in + switch state { + case .active: + return nil + case .fenced(let expiresAt, _), + .settled(let expiresAt), + .recoveryFailed(let expiresAt, _): + return expiresAt <= now ? key : nil + } + } + for key in expiredKeys { + entries.removeValue(forKey: key) + } + } + + private func expiration(after now: UInt64) -> UInt64 { + let (result, overflow) = now.addingReportingOverflow(retentionNanoseconds) + return overflow ? UInt64.max : result + } +} + +private final class PrivilegedCleaningSession: NSObject, PrivilegedCleaningXPCProtocol, @unchecked Sendable { + private static let deletionQueue = DispatchQueue( + label: "com.puremac.privileged-cleaning.deletion", + qos: .userInitiated + ) + private static let operationRegistry = PrivilegedDeletionOperationRegistry() + private let userID: uid_t + private let homeDirectory: String + private let cancellation: ConnectionCancellation + + init( + userID: uid_t, + homeDirectory: String, + cancellation: ConnectionCancellation + ) { + self.userID = userID + self.homeDirectory = homeDirectory + self.cancellation = cancellation + } + + /// Starts immediately after listener activation. Service-info requests can + /// therefore distinguish a live recovery from an incompatible or missing + /// helper, while the registry keeps mutation admission closed. Recovery + /// and every later deletion share this serial queue. + static func beginStartupRecovery() { + deletionQueue.async { + do { + try SecureFileDeleter.recoverInterruptedQuarantines() + operationRegistry.markStartupRecoverySucceeded() + } catch { + operationRegistry.markRecoveryFailed(error.localizedDescription) + } + } + } + + func serviceInfo(withReply reply: @escaping (NSData) -> Void) { + let recovery = Self.operationRegistry.recoveryStatus() + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let info = PrivilegedCleaningServiceInfo( + recoveryState: recovery.state, + recoveryMessage: recovery.message + ) + reply(((try? encoder.encode(info)) ?? Data()) as NSData) + } + + func reconcileDeletion( + _ batchID: NSUUID, + withReply reply: @escaping (NSData) -> Void + ) { + let operationID = batchID as UUID + let reconciliation = Self.operationRegistry.reconcile( + PrivilegedDeletionOperationKey(userID: userID, batchID: operationID) + ) + let response = PrivilegedDeletionReconciliationResponse( + batchID: operationID, + state: reconciliation.state, + message: reconciliation.message + ) + reply(encode(response)) + } + + func deleteItems( + _ batchID: NSUUID, + encodedBatch: NSData, + withReply reply: @escaping (NSData) -> Void + ) { + let operationID = batchID as UUID + let operationKey = PrivilegedDeletionOperationKey( + userID: userID, + batchID: operationID + ) + + // Reject oversized values before copying them. The external operation + // ID still gives the reply a trustworthy correlation value even though + // the payload itself was never decoded. + guard encodedBatch.length <= PrivilegedCleaningConstants.maximumEncodedSize else { + let message = "Rejected oversized deletion batch" + if Self.operationRegistry.fence(operationKey, message: message) { + reply(encodeNotAcceptedResponse(batchID: operationID, message: message)) + } else { + reply(encodeUnavailableTransport()) + } + return + } + + switch Self.operationRegistry.begin(operationKey) { + case .accepted: + break + case .notAccepted(let message): + reply(encodeNotAcceptedResponse(batchID: operationID, message: message)) + return + case .unavailable: + reply(encodeUnavailableTransport()) + return + } + + // Copy the immutable XPC value before returning to Foundation's + // delivery queue. Actual filesystem work runs on a separate serial + // queue so connection invalidation can set the cancellation flag while + // a recursive deletion is in progress. + let immutableBatch = encodedBatch.copy() as! NSData + Self.deletionQueue.async { [self] in + let result = autoreleasepool { + processBatch(immutableBatch, expectedBatchID: operationID) + } + // Publish the terminal state and release the single mutation slot + // before invoking the XPC reply. A racing reconnect therefore sees + // settled/notAccepted, never a stale busy state after its reply. + Self.operationRegistry.finish(operationKey, completion: result.completion) + reply(result.response) + } + } + + private struct OperationResult { + let completion: PrivilegedDeletionOperationRegistry.Completion + let response: NSData + } + + private func processBatch( + _ encodedBatch: NSData, + expectedBatchID: UUID + ) -> OperationResult { + guard let batch = try? PropertyListDecoder().decode( + PrivilegedDeletionBatch.self, + from: encodedBatch as Data + ), + batch.id == expectedBatchID, + batch.protocolVersion == PrivilegedCleaningConstants.protocolVersion, + batch.securityPolicyVersion == PrivilegedCleaningConstants.securityPolicyVersion, + !batch.requests.isEmpty, + batch.requests.count <= PrivilegedCleaningConstants.maximumBatchCount, + Set(batch.requests.map(\.id)).count == batch.requests.count + else { + let message = "Rejected malformed deletion batch" + return OperationResult( + completion: .notAccepted(message), + response: encodeNotAcceptedResponse( + batchID: expectedBatchID, + message: message + ) + ) + } + + guard isAuthorized(batch.authorization) else { + return completedResult( + batchID: batch.id, + responses: batch.requests.map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .rejected, + message: "Administrator authorization is missing or expired" + ) + } + ) + } + + let now = Date() + guard batch.deadline > now else { + return completedResult( + batchID: batch.id, + responses: batch.requests.map { request in + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: "Privileged deletion deadline expired" + ) + } + ) + } + let effectiveDeadline = min(batch.deadline, now.addingTimeInterval(120)) + + let policy = SecureDeletionPolicy(userID: userID, homeDirectory: homeDirectory) + var responses: [PrivilegedDeletionResponse] = [] + responses.reserveCapacity(batch.requests.count) + var recoveryFailure: String? + + for request in batch.requests { + if let recoveryFailure { + responses.append( + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: "Skipped because quarantine recovery is required: \(recoveryFailure)" + ) + ) + continue + } + + do { + let deleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + cancellationCheck: { [cancellation] in + if cancellation.isCancelled { + throw SecureDeletionError.operationCancelled(request.path) + } + if Date() >= effectiveDeadline { + throw SecureDeletionError.deadlineExceeded(request.path) + } + }, + privilegedOperationID: batch.id + ) + // Policy, type, owner and scan identity are deliberately + // checked here in the root process, even though the app + // performs the same validation before opening XPC. + try deleter.remove(request) + responses.append( + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .deleted, + message: nil + ) + ) + } catch SecureDeletionError.topLevelMissing { + responses.append( + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .missing, + message: nil + ) + ) + } catch let error as SecureDeletionError { + let message = error.localizedDescription + if case .quarantineRecoveryFailed = error { + recoveryFailure = message + } + responses.append( + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: isPolicyRejection(error) ? .rejected : .failed, + message: message + ) + ) + } catch { + responses.append( + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .failed, + message: error.localizedDescription + ) + ) + } + } + + let response = encode( + PrivilegedDeletionBatchResponse( + batchID: batch.id, + responses: responses + ) + ) + if let recoveryFailure { + return OperationResult( + completion: .recoveryFailed(recoveryFailure), + response: response + ) + } + return OperationResult(completion: .settled, response: response) + } + + private func completedResult( + batchID: UUID, + responses: [PrivilegedDeletionResponse] + ) -> OperationResult { + OperationResult( + completion: .settled, + response: encode( + PrivilegedDeletionBatchResponse( + batchID: batchID, + responses: responses + ) + ) + ) + } + + private func isPolicyRejection(_ error: SecureDeletionError) -> Bool { + switch error { + case .invalidPath, .outsideAllowlist, .unsupportedType, .ownerMismatch, + .identityChanged, .topLevelMissing, .crossedDeviceBoundary, + .traversalLimitExceeded: + return true + case .quarantineRecoveryFailed, .operationCancelled, .deadlineExceeded, .posix: + return false + } + } + + private func isAuthorized(_ token: Data) -> Bool { + guard authorizationRightDefinitionIsCurrent() else { + return false + } + guard token.count == MemoryLayout.size else { + return false + } + + var externalForm = AuthorizationExternalForm() + _ = withUnsafeMutableBytes(of: &externalForm) { buffer in + token.copyBytes(to: buffer) + } + + var authorization: AuthorizationRef? + let createStatus = AuthorizationCreateFromExternalForm( + &externalForm, + &authorization + ) + guard createStatus == errAuthorizationSuccess, + let authorization + else { + return false + } + defer { AuthorizationFree(authorization, []) } + + let rightsStatus = PrivilegedCleaningConstants.authorizationRight.withCString { rightName in + var item = AuthorizationItem( + name: rightName, + valueLength: 0, + value: nil, + flags: 0 + ) + return withUnsafeMutablePointer(to: &item) { itemPointer in + var rights = AuthorizationRights(count: 1, items: itemPointer) + return AuthorizationCopyRights( + authorization, + &rights, + nil, + [], + nil + ) + } + } + return rightsStatus == errAuthorizationSuccess + } + + private func authorizationRightDefinitionIsCurrent() -> Bool { + var definition: CFDictionary? + let status = PrivilegedCleaningConstants.authorizationRight.withCString { rightName in + AuthorizationRightGet(rightName, &definition) + } + guard status == errAuthorizationSuccess, let definition else { + return false + } + + let values = definition as NSDictionary + guard values["class"] as? String == "rule", + values[kAuthorizationRightRule] as? String == kAuthorizationRuleAuthenticateAsAdmin, + let shared = values["shared"] as? Bool, + shared == false, + let timeout = values["timeout"] as? NSNumber, + timeout.intValue == PrivilegedCleaningConstants.authorizationRightTimeout, + let version = values["version"] as? NSNumber, + version.intValue == PrivilegedCleaningConstants.securityPolicyVersion + else { + return false + } + return true + } + + private func encodeNotAcceptedResponse(batchID: UUID, message: String) -> NSData { + encode( + PrivilegedDeletionBatchResponse( + batchID: batchID, + disposition: .notAccepted, + message: message, + responses: [] + ) + ) + } + + private func encodeUnavailableTransport() -> NSData { + // There is deliberately no valid batch response here. Returning an + // undecodable value prevents the client from treating busy/full or an + // unresolved recovery gate as proof that the operation ID was fenced. + Data() as NSData + } + + private func encode(_ value: Value) -> NSData { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + return ((try? encoder.encode(value)) ?? Data()) as NSData + } +} + +private let listener = NSXPCListener( + machServiceName: PrivilegedCleaningConstants.machServiceName +) +private let delegate = PrivilegedCleaningListenerDelegate() +listener.delegate = delegate +listener.setConnectionCodeSigningRequirement( + PrivilegedCleaningConstants.appCodeSigningRequirement +) +listener.activate() +PrivilegedCleaningSession.beginStartupRecovery() +dispatchMain() diff --git a/PureMacTests/AppStateTests.swift b/PureMacTests/AppStateTests.swift index f87c6c4..5f52b33 100644 --- a/PureMacTests/AppStateTests.swift +++ b/PureMacTests/AppStateTests.swift @@ -1,4 +1,5 @@ import AppKit +import Darwin import XCTest @testable import PureMac @@ -41,6 +42,136 @@ final class AppStateTests: XCTestCase { XCTAssertEqual(appState.currentAppFileSearchLocationCount, urls.count) } + func testSecureTrashMoverMovesThePreparedIdentityIntoTrash() throws { + let fixture = try makeTrashFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let source = fixture.home.appendingPathComponent("Preferences/com.example.test.plist") + try FileManager.default.createDirectory( + at: source.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data("expected".utf8).write(to: source) + + let mover = SecureTrashMover(homeDirectory: fixture.home.path, userID: geteuid()) + let candidate = try mover.prepare(source) + let destination = try mover.moveToTrash(candidate) + + guard case .missing = FileIdentity.lookup(path: source.path) else { + return XCTFail("The prepared source entry should no longer exist") + } + XCTAssertEqual(FileIdentity.capture(path: destination.path), candidate.identity) + XCTAssertEqual(try String(contentsOf: destination, encoding: .utf8), "expected") + } + + func testSecureTrashMoverRejectsSymlinkedParentComponent() throws { + let fixture = try makeTrashFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let outside = fixture.root.appendingPathComponent("outside", isDirectory: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + let canary = outside.appendingPathComponent("canary") + try Data("keep".utf8).write(to: canary) + let linkedParent = fixture.home.appendingPathComponent("linked") + try FileManager.default.createSymbolicLink(at: linkedParent, withDestinationURL: outside) + + let mover = SecureTrashMover(homeDirectory: fixture.home.path, userID: geteuid()) + let candidate = try mover.prepare(linkedParent.appendingPathComponent("canary")) + + XCTAssertThrowsError(try mover.moveToTrash(candidate)) + XCTAssertEqual(try String(contentsOf: canary, encoding: .utf8), "keep") + XCTAssertTrue(try trashContents(fixture.trash).isEmpty) + } + + func testSecureTrashMoverRejectsReplacementBeforeFinalRevalidation() throws { + let fixture = try makeTrashFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let source = fixture.home.appendingPathComponent("victim") + let original = fixture.home.appendingPathComponent("original") + let canary = fixture.home.appendingPathComponent("canary") + try Data("original".utf8).write(to: source) + try Data("keep".utf8).write(to: canary) + + let mover = SecureTrashMover( + homeDirectory: fixture.home.path, + userID: geteuid(), + beforeRevalidation: { + try FileManager.default.moveItem(at: source, to: original) + try FileManager.default.createSymbolicLink(at: source, withDestinationURL: canary) + } + ) + let candidate = try mover.prepare(source) + + XCTAssertThrowsError(try mover.moveToTrash(candidate)) { error in + guard case SecureTrashMoveError.identityChanged = error else { + return XCTFail("Expected identityChanged, got \(error)") + } + } + XCTAssertEqual(FileIdentity.capture(path: original.path), candidate.identity) + XCTAssertEqual(FileIdentity.capture(path: source.path)?.isSymbolicLink, true) + XCTAssertEqual(try String(contentsOf: canary, encoding: .utf8), "keep") + XCTAssertTrue(try trashContents(fixture.trash).isEmpty) + } + + func testSecureTrashMoverRestoresEntryRacedAfterFinalRevalidation() throws { + let fixture = try makeTrashFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let source = fixture.home.appendingPathComponent("victim") + let original = fixture.home.appendingPathComponent("original") + let canary = fixture.home.appendingPathComponent("canary") + try Data("original".utf8).write(to: source) + try Data("keep".utf8).write(to: canary) + + let mover = SecureTrashMover( + homeDirectory: fixture.home.path, + userID: geteuid(), + afterRevalidationBeforeRename: { + try FileManager.default.moveItem(at: source, to: original) + try FileManager.default.createSymbolicLink(at: source, withDestinationURL: canary) + } + ) + let candidate = try mover.prepare(source) + + XCTAssertThrowsError(try mover.moveToTrash(candidate)) { error in + guard case SecureTrashMoveError.identityChanged = error else { + return XCTFail("Expected identityChanged, got \(error)") + } + } + XCTAssertEqual(FileIdentity.capture(path: original.path), candidate.identity) + XCTAssertEqual(FileIdentity.capture(path: source.path)?.isSymbolicLink, true) + XCTAssertEqual(try String(contentsOf: canary, encoding: .utf8), "keep") + XCTAssertTrue(try trashContents(fixture.trash).isEmpty) + } + + func testSecureTrashMoverDoesNotTreatMissingSourceAsSuccess() throws { + let fixture = try makeTrashFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let absent = fixture.home.appendingPathComponent("absent") + let mover = SecureTrashMover(homeDirectory: fixture.home.path, userID: geteuid()) + + XCTAssertThrowsError(try mover.prepare(absent)) { error in + guard case SecureTrashMoveError.missing = error else { + return XCTFail("Expected missing, got \(error)") + } + } + XCTAssertTrue(try trashContents(fixture.trash).isEmpty) + } + + func testSecureTrashMoverMovesFinalSymlinkWithoutFollowingTarget() throws { + let fixture = try makeTrashFixture() + defer { try? FileManager.default.removeItem(at: fixture.root) } + let canary = fixture.home.appendingPathComponent("canary") + let source = fixture.home.appendingPathComponent("link") + try Data("keep".utf8).write(to: canary) + try FileManager.default.createSymbolicLink(at: source, withDestinationURL: canary) + + let mover = SecureTrashMover(homeDirectory: fixture.home.path, userID: geteuid()) + let candidate = try mover.prepare(source) + let destination = try mover.moveToTrash(candidate) + + XCTAssertEqual(FileIdentity.capture(path: destination.path), candidate.identity) + XCTAssertEqual(FileIdentity.capture(path: destination.path)?.isSymbolicLink, true) + XCTAssertEqual(try String(contentsOf: canary, encoding: .utf8), "keep") + } + private func makeApp() -> InstalledApp { InstalledApp( id: UUID(), @@ -51,6 +182,34 @@ final class AppStateTests: XCTestCase { size: 1 ) } + + private func makeTrashFixture() throws -> ( + root: URL, + home: URL, + trash: URL + ) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "PureMacSecureTrashTests-\(UUID().uuidString)", + isDirectory: true + ) + let home = root.appendingPathComponent("home", isDirectory: true) + let trash = home.appendingPathComponent(".Trash", isDirectory: true) + try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) + let chmodStatus = trash.path.withCString { pointer in + Darwin.chmod(pointer, mode_t(S_IRWXU)) + } + guard chmodStatus == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + return (root, home, trash) + } + + private func trashContents(_ trash: URL) throws -> [URL] { + try FileManager.default.contentsOfDirectory( + at: trash, + includingPropertiesForKeys: nil + ) + } } private final class StubLocations: Locations { diff --git a/PureMacTests/SecureDeletionTests.swift b/PureMacTests/SecureDeletionTests.swift new file mode 100644 index 0000000..c42de78 --- /dev/null +++ b/PureMacTests/SecureDeletionTests.swift @@ -0,0 +1,959 @@ +import Darwin +import XCTest +@testable import PureMac + +private actor MutationLeaseProbe { + private(set) var acquired = false + private(set) var failure: String? + + func recordAcquired() { + acquired = true + } + + func recordFailure(_ error: Error) { + failure = error.localizedDescription + } +} + +final class SecureDeletionTests: XCTestCase { + private var containerURL: URL! + private var allowedURL: URL! + private var outsideURL: URL! + private var stagingURL: URL! + private var policy: SecureDeletionPolicy! + private var deleter: SecureFileDeleter! + + override func setUpWithError() throws { + try super.setUpWithError() + containerURL = FileManager.default.temporaryDirectory + .appendingPathComponent("PureMacSecureDeletionTests-\(UUID().uuidString)", isDirectory: true) + allowedURL = containerURL.appendingPathComponent("allowed", isDirectory: true) + outsideURL = containerURL.appendingPathComponent("outside", isDirectory: true) + stagingURL = containerURL.appendingPathComponent("privileged-staging", isDirectory: true) + try FileManager.default.createDirectory(at: allowedURL, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outsideURL, withIntermediateDirectories: true) + + let basePolicy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path + ) + let canonicalAllowed = try basePolicy.canonicalPath(allowedURL.path) + policy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path, + cleanerRootsOverride: [ + .init(path: canonicalAllowed, mayDeleteRoot: false), + ], + largeFileRootsOverride: [] + ) + deleter = SecureFileDeleter(policy: policy) + } + + override func tearDownWithError() throws { + if let containerURL { + try? FileManager.default.removeItem(at: containerURL) + } + try super.tearDownWithError() + } + + func testRemovesUnchangedFileWithMatchingIdentityAndShellCharacters() throws { + let victim = allowedURL.appendingPathComponent("cache 'line\n\u{24}(touch nope).bin") + try Data("payload".utf8).write(to: victim) + + try deleter.remove(try request(for: victim)) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + } + + func testRejectsLeafReplacedWithSymlink() throws { + let victim = allowedURL.appendingPathComponent("victim") + let movedOriginal = allowedURL.appendingPathComponent("original") + let canary = outsideURL.appendingPathComponent("canary") + try Data("original".utf8).write(to: victim) + try Data("keep".utf8).write(to: canary) + let request = try request(for: victim) + + try FileManager.default.moveItem(at: victim, to: movedOriginal) + try FileManager.default.createSymbolicLink(at: victim, withDestinationURL: canary) + + XCTAssertThrowsError(try deleter.remove(request)) { error in + guard case SecureDeletionError.identityChanged = error else { + return XCTFail("Expected identityChanged, got \(error)") + } + } + XCTAssertEqual(try String(contentsOf: canary), "keep") + XCTAssertTrue(FileManager.default.fileExists(atPath: movedOriginal.path)) + } + + func testRejectsIntermediateComponentReplacedWithSymlink() throws { + let parent = allowedURL.appendingPathComponent("parent", isDirectory: true) + let movedParent = allowedURL.appendingPathComponent("parent-original", isDirectory: true) + let victim = parent.appendingPathComponent("victim") + let outsideVictim = outsideURL.appendingPathComponent("victim") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + try Data("original".utf8).write(to: victim) + try Data("keep".utf8).write(to: outsideVictim) + let request = try request(for: victim) + + try FileManager.default.moveItem(at: parent, to: movedParent) + try FileManager.default.createSymbolicLink(at: parent, withDestinationURL: outsideURL) + + XCTAssertThrowsError(try deleter.remove(request)) + XCTAssertEqual(try String(contentsOf: outsideVictim), "keep") + XCTAssertTrue(FileManager.default.fileExists( + atPath: movedParent.appendingPathComponent("victim").path + )) + } + + func testRejectsRecreatedPathWithDifferentInode() throws { + let victim = allowedURL.appendingPathComponent("victim") + try Data("same-size".utf8).write(to: victim) + let request = try request(for: victim) + + try FileManager.default.removeItem(at: victim) + try Data("same-size".utf8).write(to: victim) + + XCTAssertThrowsError(try deleter.remove(request)) { error in + guard case SecureDeletionError.identityChanged = error else { + return XCTFail("Expected identityChanged, got \(error)") + } + } + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path)) + } + + func testRejectsDirectoryReplacement() throws { + let victim = allowedURL.appendingPathComponent("victim", isDirectory: true) + let movedOriginal = allowedURL.appendingPathComponent("original", isDirectory: true) + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + try Data("old".utf8).write(to: victim.appendingPathComponent("old")) + let request = try request(for: victim) + + try FileManager.default.moveItem(at: victim, to: movedOriginal) + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + try Data("keep".utf8).write(to: victim.appendingPathComponent("keep")) + + XCTAssertThrowsError(try deleter.remove(request)) { error in + guard case SecureDeletionError.identityChanged = error else { + return XCTFail("Expected identityChanged, got \(error)") + } + } + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.appendingPathComponent("keep").path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: movedOriginal.appendingPathComponent("old").path)) + } + + func testRecursiveRemovalUnlinksChildSymlinkWithoutFollowingIt() throws { + let victim = allowedURL.appendingPathComponent("victim", isDirectory: true) + let canary = outsideURL.appendingPathComponent("canary") + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + try Data("keep".utf8).write(to: canary) + try FileManager.default.createSymbolicLink( + at: victim.appendingPathComponent("outside-link"), + withDestinationURL: outsideURL + ) + + try deleter.remove(try request(for: victim)) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + XCTAssertEqual(try String(contentsOf: canary), "keep") + } + + func testRecursiveRemovalHandlesFIFOWithoutFollowingOrOpeningIt() throws { + let victim = allowedURL.appendingPathComponent("victim", isDirectory: true) + let fifo = victim.appendingPathComponent("worker.sock") + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + XCTAssertEqual(Darwin.mkfifo(fifo.path, mode_t(0o600)), 0) + try Data("payload".utf8).write(to: victim.appendingPathComponent("regular")) + + try deleter.remove(try request(for: victim)) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + } + + func testReenumeratesDirectoryFromStartAfterConcurrentInsertion() throws { + let victim = allowedURL.appendingPathComponent("victim", isDirectory: true) + let lateEntry = victim.appendingPathComponent("arrived-after-enumeration") + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + let injection = OneShotGate() + let racingDeleter = SecureFileDeleter( + policy: policy, + beforeDirectoryUnlink: { path in + guard path == victim.path, injection.claim() else { return } + try Data("late".utf8).write(to: lateEntry) + } + ) + + try racingDeleter.remove(try request(for: victim)) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + } + + func testInitialTopLevelAbsenceHasDedicatedMissingResult() throws { + let victim = allowedURL.appendingPathComponent("victim") + try Data("payload".utf8).write(to: victim) + let scannedRequest = try request(for: victim) + try FileManager.default.removeItem(at: victim) + + XCTAssertThrowsError(try deleter.remove(scannedRequest)) { error in + guard case SecureDeletionError.topLevelMissing = error else { + return XCTFail("Expected topLevelMissing, got \(error)") + } + } + } + + func testInitialIntermediateAbsenceHasDedicatedMissingResult() throws { + let parent = allowedURL.appendingPathComponent("parent", isDirectory: true) + let victim = parent.appendingPathComponent("victim") + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + try Data("payload".utf8).write(to: victim) + let scannedRequest = try request(for: victim) + try FileManager.default.removeItem(at: parent) + + XCTAssertThrowsError(try deleter.remove(scannedRequest)) { error in + guard case SecureDeletionError.topLevelMissing = error else { + return XCTFail("Expected topLevelMissing, got \(error)") + } + } + } + + func testIdentityLookupDistinguishesMissingFromLookupFailure() { + let absent = allowedURL.appendingPathComponent("definitely-absent") + + guard case .missing = FileIdentity.lookup(path: absent.path) else { + return XCTFail("Expected an errno-aware missing lookup result") + } + + let deniedDirectory = allowedURL.appendingPathComponent("denied", isDirectory: true) + let hiddenFile = deniedDirectory.appendingPathComponent("item") + do { + try FileManager.default.createDirectory( + at: deniedDirectory, + withIntermediateDirectories: true + ) + try Data("payload".utf8).write(to: hiddenFile) + XCTAssertEqual(Darwin.chmod(deniedDirectory.path, mode_t(0)), 0) + defer { _ = Darwin.chmod(deniedDirectory.path, mode_t(S_IRWXU)) } + + guard case let .failed(code) = FileIdentity.lookup(path: hiddenFile.path) else { + return XCTFail("Expected an errno-aware lookup failure") + } + XCTAssertEqual(code, EACCES) + } catch { + XCTFail("Could not prepare denied lookup fixture: \(error)") + } + } + + func testPrivilegedQuarantineRemovesIdentityBoundEntryAndCleansStagingDirectory() throws { + let victim = allowedURL.appendingPathComponent("victim", isDirectory: true) + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + try Data("payload".utf8).write(to: victim.appendingPathComponent("nested")) + let quarantinedDeleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + privilegedStagingRootPathOverride: stagingURL.path + ) + + try quarantinedDeleter.remove(try request(for: victim)) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: stagingURL.path), + [] + ) + } + + func testPrivilegedQuarantineValidatesIdentityAfterAtomicRename() throws { + let victim = allowedURL.appendingPathComponent("victim") + let original = allowedURL.appendingPathComponent("original") + let canary = outsideURL.appendingPathComponent("canary") + try Data("original".utf8).write(to: victim) + try Data("keep".utf8).write(to: canary) + let scannedRequest = try request(for: victim) + let quarantinedDeleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + beforeQuarantineRename: { + try FileManager.default.moveItem(at: victim, to: original) + try FileManager.default.createSymbolicLink(at: victim, withDestinationURL: canary) + }, + privilegedStagingRootPathOverride: stagingURL.path + ) + + XCTAssertThrowsError(try quarantinedDeleter.remove(scannedRequest)) { error in + guard case SecureDeletionError.identityChanged = error else { + return XCTFail("Expected identityChanged, got \(error)") + } + } + XCTAssertEqual(try String(contentsOf: canary), "keep") + XCTAssertTrue(FileManager.default.fileExists(atPath: original.path)) + XCTAssertEqual(FileIdentity.capture(path: victim.path)?.isSymbolicLink, true) + } + + func testPrivilegedQuarantineRestoresAfterPostRenameFailure() throws { + let victim = allowedURL.appendingPathComponent("victim") + try Data("original".utf8).write(to: victim) + let scannedRequest = try request(for: victim) + let quarantinedDeleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + afterQuarantineRename: { + throw InjectedDeletionError.afterRename + }, + privilegedStagingRootPathOverride: stagingURL.path + ) + + XCTAssertThrowsError(try quarantinedDeleter.remove(scannedRequest)) + XCTAssertEqual(try String(contentsOf: victim), "original") + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: stagingURL.path), + [] + ) + } + + func testPrivilegedQuarantineStripsInheritedACLBeforeStaging() throws { + let victim = allowedURL.appendingPathComponent("victim") + try Data("payload".utf8).write(to: victim) + try runChmod([ + "+a", + "everyone allow list,search,add_file,add_subdirectory,delete_child,file_inherit,directory_inherit", + containerURL.path, + ]) + defer { try? runChmod(["-N", containerURL.path]) } + + let stagingPath = stagingURL.path + let quarantinedDeleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + beforeQuarantineRename: { + let quarantineName = try XCTUnwrap( + FileManager.default.contentsOfDirectory(atPath: stagingPath) + .first { $0.hasPrefix(".puremac-delete-") } + ) + let quarantinePath = (stagingPath as NSString) + .appendingPathComponent(quarantineName) + let descriptor = Darwin.open( + quarantinePath, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + XCTAssertGreaterThanOrEqual(descriptor, 0) + guard descriptor >= 0 else { return } + defer { Darwin.close(descriptor) } + + errno = 0 + let inheritedACL = Darwin.acl_get_fd_np(descriptor, ACL_TYPE_EXTENDED) + if let inheritedACL { + Darwin.acl_free(UnsafeMutableRawPointer(inheritedACL)) + } + XCTAssertNil(inheritedACL) + XCTAssertEqual(errno, ENOENT) + }, + privilegedStagingRootPathOverride: stagingURL.path + ) + + try quarantinedDeleter.remove(try request(for: victim)) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + } + + func testRecoveryRestoresPreparedQuarantineTransaction() throws { + let victim = allowedURL.appendingPathComponent("prepared-victim") + try Data("original".utf8).write(to: victim) + let fixture = try makeRecoveryFixture(state: .prepared, victim: victim) + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + try fixture.deleter.recoverPendingQuarantines() + + XCTAssertEqual(try String(contentsOf: victim), "original") + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: stagingURL.path), + [] + ) + } + + func testRecoveryFinishesCommittedQuarantineTransaction() throws { + let victim = allowedURL.appendingPathComponent("committed-victim", isDirectory: true) + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + try Data("first".utf8).write(to: victim.appendingPathComponent("first")) + try Data("second".utf8).write(to: victim.appendingPathComponent("second")) + let fixture = try makeRecoveryFixture(state: .committed, victim: victim) + + // Model a helper crash after recursive deletion already removed one + // child. COMMITTED recovery must continue; it must never restore a + // partially deleted tree into the user's namespace. + try FileManager.default.removeItem( + at: fixture.quarantineURL + .appendingPathComponent("item", isDirectory: true) + .appendingPathComponent("first") + ) + try fixture.deleter.recoverPendingQuarantines() + + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + XCTAssertEqual( + try FileManager.default.contentsOfDirectory(atPath: stagingURL.path), + [] + ) + } + + func testPreparedRecoveryPreservesCollidingSourceEntry() throws { + let victim = allowedURL.appendingPathComponent("collision-victim") + try Data("original".utf8).write(to: victim) + let fixture = try makeRecoveryFixture(state: .prepared, victim: victim) + try Data("racer".utf8).write(to: victim) + + try fixture.deleter.recoverPendingQuarantines() + + XCTAssertEqual(try String(contentsOf: victim), "original") + let recoveredName = try XCTUnwrap( + FileManager.default.contentsOfDirectory(atPath: allowedURL.path) + .first { $0.hasPrefix(".puremac-recovered-") } + ) + XCTAssertEqual( + try String(contentsOf: allowedURL.appendingPathComponent(recoveredName)), + "racer" + ) + } + + func testRecoveryNeverDeletesMismatchedCommittedEntry() throws { + let victim = allowedURL.appendingPathComponent("mismatched-victim") + try Data("original".utf8).write(to: victim) + let fixture = try makeRecoveryFixture(state: .committed, victim: victim) + let staged = fixture.quarantineURL.appendingPathComponent("item") + try FileManager.default.removeItem(at: staged) + try Data("replacement".utf8).write(to: staged) + + XCTAssertThrowsError(try fixture.deleter.recoverPendingQuarantines()) { error in + guard case SecureDeletionError.quarantineRecoveryFailed = error else { + return XCTFail("Expected quarantineRecoveryFailed, got \(error)") + } + } + XCTAssertEqual(try String(contentsOf: staged), "replacement") + XCTAssertFalse(FileManager.default.fileExists(atPath: victim.path)) + } + + func testDepthLimitFailsSafelyBeforeFileDescriptorExhaustion() throws { + let victim = allowedURL.appendingPathComponent("victim", isDirectory: true) + try FileManager.default.createDirectory(at: victim, withIntermediateDirectories: true) + var deepest = victim + for index in 0..<66 { + deepest.appendPathComponent("d\(index)", isDirectory: true) + try FileManager.default.createDirectory(at: deepest, withIntermediateDirectories: false) + } + + XCTAssertThrowsError(try deleter.remove(try request(for: victim))) { error in + guard case SecureDeletionError.traversalLimitExceeded = error else { + return XCTFail("Expected traversalLimitExceeded, got \(error)") + } + } + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path)) + } + + func testRejectsExpectedDeviceMismatch() throws { + let victim = allowedURL.appendingPathComponent("victim") + try Data("payload".utf8).write(to: victim) + let original = try XCTUnwrap(identity(for: victim)) + let changed = FileIdentity( + device: original.device &+ 1, + inode: original.inode, + fileType: original.fileType, + owner: original.owner, + generation: original.generation, + birthtimeSeconds: original.birthtimeSeconds, + birthtimeNanoseconds: original.birthtimeNanoseconds + ) + + let request = PrivilegedDeletionRequest( + path: victim.path, + identity: changed, + operation: .cleaner + ) + XCTAssertThrowsError(try deleter.remove(request)) + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path)) + } + + func testRejectsChangedOwnerInRequest() throws { + let victim = allowedURL.appendingPathComponent("victim") + try Data("payload".utf8).write(to: victim) + let original = try XCTUnwrap(identity(for: victim)) + let changed = FileIdentity( + device: original.device, + inode: original.inode, + fileType: original.fileType, + owner: UInt32(getuid()) &+ 1, + generation: original.generation, + birthtimeSeconds: original.birthtimeSeconds, + birthtimeNanoseconds: original.birthtimeNanoseconds + ) + + let request = PrivilegedDeletionRequest( + path: victim.path, + identity: changed, + operation: .cleaner + ) + XCTAssertThrowsError(try deleter.remove(request)) + XCTAssertTrue(FileManager.default.fileExists(atPath: victim.path)) + } + + func testRejectsAllowlistRootSiblingAndDotComponents() throws { + let canonicalAllowed = try policy.canonicalPath(allowedURL.path) + let rootIdentity = try XCTUnwrap(FileIdentity.capture(path: canonicalAllowed)) + let rootRequest = PrivilegedDeletionRequest( + path: allowedURL.path, + identity: rootIdentity, + operation: .cleaner + ) + XCTAssertThrowsError(try policy.validate(rootRequest)) + + let sibling = containerURL.appendingPathComponent("allowed-evil") + try FileManager.default.createDirectory(at: sibling, withIntermediateDirectories: true) + let siblingRequest = try request(for: sibling) + XCTAssertThrowsError(try policy.validate(siblingRequest)) + + let dotPath = allowedURL.path + "/../outside/canary" + let dotRequest = PrivilegedDeletionRequest( + path: dotPath, + identity: rootIdentity, + operation: .cleaner + ) + XCTAssertThrowsError(try policy.validate(dotRequest)) + } + + func testMostSpecificAllowlistRootControlsWhetherRootItselfMayBeDeleted() throws { + let specificPolicy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path, + cleanerRootsOverride: [ + .init(path: containerURL.path, mayDeleteRoot: true), + .init(path: allowedURL.path, mayDeleteRoot: false), + ], + largeFileRootsOverride: [] + ) + let identity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let rootRequest = PrivilegedDeletionRequest( + path: allowedURL.path, + identity: identity, + operation: .cleaner + ) + + XCTAssertThrowsError(try specificPolicy.validate(rootRequest)) { error in + guard case SecureDeletionError.outsideAllowlist = error else { + return XCTFail("Expected outsideAllowlist, got \(error)") + } + } + } + + func testDefaultUninstallPolicyAllowsAppsButNotBundlesNestedInsideApps() throws { + let defaultPolicy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path + ) + let directoryIdentity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let app = PrivilegedDeletionRequest( + path: "/Applications/Utilities/Example.app", + identity: directoryIdentity, + operation: .uninstall + ) + XCTAssertNoThrow(try defaultPolicy.validate(app)) + + let nestedHelper = PrivilegedDeletionRequest( + path: "/Applications/Example.app/Contents/Library/LoginItems/Helper.app", + identity: directoryIdentity, + operation: .uninstall + ) + XCTAssertThrowsError(try defaultPolicy.validate(nestedHelper)) + } + + func testDefaultLargeFilePolicyRequiresARegularFileInUserDocumentRoots() throws { + let defaultPolicy = SecureDeletionPolicy( + userID: getuid(), + homeDirectory: FileManager.default.homeDirectoryForCurrentUser.path + ) + let file = allowedURL.appendingPathComponent("large") + try Data("payload".utf8).write(to: file) + let regularIdentity = try XCTUnwrap(FileIdentity.capture(path: file.path)) + let home = FileManager.default.homeDirectoryForCurrentUser.path + let allowedRequest = PrivilegedDeletionRequest( + path: "\(home)/Downloads/archive.bin", + identity: regularIdentity, + operation: .largeFile + ) + XCTAssertNoThrow(try defaultPolicy.validate(allowedRequest)) + + let directoryIdentity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let directoryRequest = PrivilegedDeletionRequest( + path: "\(home)/Downloads/folder", + identity: directoryIdentity, + operation: .largeFile + ) + XCTAssertThrowsError(try defaultPolicy.validate(directoryRequest)) + } + + func testRejectsPathWhoseBytesPlusTerminatorExceedPATHMAX() throws { + let tooLong = "/" + String(repeating: "a", count: Int(PATH_MAX) - 1) + XCTAssertThrowsError(try policy.canonicalPath(tooLong)) + } + + func testPrivilegedClientChunksLargeRequestSetsWithoutDroppingOrder() throws { + let identity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let requests = (0..<513).map { index in + PrivilegedDeletionRequest( + path: allowedURL.appendingPathComponent("item-\(index)").path, + identity: identity, + operation: .cleaner + ) + } + + let batches = PrivilegedCleaningClient.batches(for: requests) + + XCTAssertEqual(batches.count, 5) + XCTAssertTrue(batches.allSatisfy { + $0.count <= PrivilegedCleaningConstants.maximumBatchCount + }) + XCTAssertEqual(batches.flatMap { $0 }.map(\.id), requests.map(\.id)) + } + + func testFilesystemMutationCoordinatorWaitsForAnotherProcess() async throws { + let lockDirectory = containerURL.appendingPathComponent( + "mutation-lock", + isDirectory: true + ) + try FileManager.default.createDirectory( + at: lockDirectory, + withIntermediateDirectories: true + ) + let lockURL = lockDirectory.appendingPathComponent("filesystem-mutation.lock") + let childInput = Pipe() + let childOutput = Pipe() + let child = Process() + child.executableURL = URL(fileURLWithPath: "/usr/bin/python3") + child.arguments = [ + "-c", + """ + import fcntl, os, sys + fd = os.open(sys.argv[1], os.O_RDWR | os.O_CREAT, 0o600) + fcntl.lockf(fd, fcntl.LOCK_EX) + sys.stdout.write("ready\\n") + sys.stdout.flush() + sys.stdin.readline() + fcntl.lockf(fd, fcntl.LOCK_UN) + os.close(fd) + """, + lockURL.path, + ] + child.standardInput = childInput + child.standardOutput = childOutput + child.standardError = childOutput + try child.run() + defer { + if child.isRunning { child.terminate() } + child.waitUntilExit() + } + + let readyData = childOutput.fileHandleForReading.availableData + XCTAssertEqual(String(data: readyData, encoding: .utf8), "ready\n") + + let coordinator = FilesystemMutationCoordinator( + lockDirectoryURL: lockDirectory + ) + let probe = MutationLeaseProbe() + let acquisitionTask = Task { + do { + try await coordinator.acquire() + await probe.recordAcquired() + await coordinator.release() + } catch { + await probe.recordFailure(error) + } + } + + try await Task.sleep(nanoseconds: 150_000_000) + let acquiredWhileChildHeldLock = await probe.acquired + XCTAssertFalse(acquiredWhileChildHeldLock) + + childInput.fileHandleForWriting.write(Data("release\n".utf8)) + childInput.fileHandleForWriting.closeFile() + await acquisitionTask.value + child.waitUntilExit() + XCTAssertEqual(child.terminationStatus, 0) + + let acquiredAfterRelease = await probe.acquired + let acquisitionFailure = await probe.failure + XCTAssertTrue(acquiredAfterRelease) + XCTAssertNil(acquisitionFailure) + } + + func testPrivilegedClientRejectsReorderedDuplicateOrMismatchedResponses() throws { + let identity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let first = PrivilegedDeletionRequest( + path: allowedURL.appendingPathComponent("first").path, + identity: identity, + operation: .cleaner + ) + let second = PrivilegedDeletionRequest( + path: allowedURL.appendingPathComponent("second").path, + identity: identity, + operation: .cleaner + ) + let requests = [first, second] + let firstResponse = PrivilegedDeletionResponse( + requestID: first.id, + path: first.path, + status: .deleted, + message: nil + ) + let secondResponse = PrivilegedDeletionResponse( + requestID: second.id, + path: second.path, + status: .deleted, + message: nil + ) + let wrongPath = PrivilegedDeletionResponse( + requestID: second.id, + path: first.path, + status: .deleted, + message: nil + ) + + XCTAssertTrue(PrivilegedCleaningClient.responsesAreValid( + [firstResponse, secondResponse], + for: requests + )) + XCTAssertFalse(PrivilegedCleaningClient.responsesAreValid( + [secondResponse, firstResponse], + for: requests + )) + XCTAssertFalse(PrivilegedCleaningClient.responsesAreValid( + [firstResponse, firstResponse], + for: requests + )) + XCTAssertFalse(PrivilegedCleaningClient.responsesAreValid( + [firstResponse, wrongPath], + for: requests + )) + } + + func testPrivilegedClientValidatesBatchCorrelationAndDispositionMatrix() throws { + let identity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let request = PrivilegedDeletionRequest( + path: allowedURL.appendingPathComponent("item").path, + identity: identity, + operation: .cleaner + ) + let batchID = UUID() + let terminal = PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .deleted, + message: nil + ) + let unknown = PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .unknown, + message: "lost reply" + ) + + XCTAssertTrue(PrivilegedCleaningClient.batchResponseIsValid( + PrivilegedDeletionBatchResponse(batchID: batchID, responses: [terminal]), + batchID: batchID, + requests: [request] + )) + XCTAssertFalse(PrivilegedCleaningClient.batchResponseIsValid( + PrivilegedDeletionBatchResponse(batchID: UUID(), responses: [terminal]), + batchID: batchID, + requests: [request] + )) + XCTAssertFalse(PrivilegedCleaningClient.batchResponseIsValid( + PrivilegedDeletionBatchResponse(batchID: batchID, responses: [unknown]), + batchID: batchID, + requests: [request] + )) + XCTAssertTrue(PrivilegedCleaningClient.batchResponseIsValid( + PrivilegedDeletionBatchResponse( + batchID: batchID, + disposition: .notAccepted, + message: "busy", + responses: [] + ), + batchID: batchID, + requests: [request] + )) + XCTAssertFalse(PrivilegedCleaningClient.batchResponseIsValid( + PrivilegedDeletionBatchResponse( + batchID: batchID, + disposition: .notAccepted, + message: "busy", + responses: [terminal] + ), + batchID: batchID, + requests: [request] + )) + } + + func testPrivilegedDTOsRoundTripSecurityPolicyVersionAndUnknownStatus() throws { + let identity = try XCTUnwrap(FileIdentity.capture(path: allowedURL.path)) + let request = PrivilegedDeletionRequest( + path: allowedURL.appendingPathComponent("item").path, + identity: identity, + operation: .cleaner + ) + let batch = PrivilegedDeletionBatch( + requests: [request], + authorization: Data(repeating: 7, count: 32), + deadline: Date().addingTimeInterval(30) + ) + let decodedBatch = try PropertyListDecoder().decode( + PrivilegedDeletionBatch.self, + from: PropertyListEncoder().encode(batch) + ) + XCTAssertEqual( + decodedBatch.securityPolicyVersion, + PrivilegedCleaningConstants.securityPolicyVersion + ) + XCTAssertEqual(decodedBatch.id, batch.id) + + let response = PrivilegedDeletionBatchResponse( + batchID: batch.id, + responses: [ + PrivilegedDeletionResponse( + requestID: request.id, + path: request.path, + status: .unknown, + message: "lost reply" + ), + ] + ) + let decodedResponse = try PropertyListDecoder().decode( + PrivilegedDeletionBatchResponse.self, + from: PropertyListEncoder().encode(response) + ) + XCTAssertEqual(decodedResponse.batchID, batch.id) + XCTAssertEqual(decodedResponse.disposition.rawValue, "completed") + XCTAssertEqual(decodedResponse.responses.first?.status, .unknown) + XCTAssertEqual( + decodedResponse.securityPolicyVersion, + PrivilegedCleaningConstants.securityPolicyVersion + ) + + let info = PrivilegedCleaningServiceInfo( + recoveryState: .recovering, + recoveryMessage: "restoring a prepared transaction" + ) + let decodedInfo = try PropertyListDecoder().decode( + PrivilegedCleaningServiceInfo.self, + from: PropertyListEncoder().encode(info) + ) + XCTAssertEqual( + decodedInfo.securityPolicyVersion, + PrivilegedCleaningConstants.securityPolicyVersion + ) + XCTAssertEqual( + decodedInfo.helperBundleIdentifier, + PrivilegedCleaningConstants.helperBundleIdentifier + ) + XCTAssertEqual( + decodedInfo.protocolVersion, + PrivilegedCleaningConstants.protocolVersion + ) + XCTAssertEqual(decodedInfo.recoveryState, .recovering) + XCTAssertEqual( + decodedInfo.recoveryMessage, + "restoring a prepared transaction" + ) + } + + private func request(for url: URL) throws -> PrivilegedDeletionRequest { + let identity = try XCTUnwrap(identity(for: url), "Missing lstat identity for \(url.path)") + return PrivilegedDeletionRequest(path: url.path, identity: identity, operation: .cleaner) + } + + private func identity(for url: URL) -> FileIdentity? { + guard let path = try? policy.canonicalPath(url.path) else { return nil } + return FileIdentity.capture(path: path) + } + + private func makeRecoveryFixture( + state: PrivilegedQuarantineJournal.State, + victim: URL + ) throws -> (deleter: SecureFileDeleter, quarantineURL: URL) { + let deleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + privilegedStagingRootPathOverride: stagingURL.path + ) + try deleter.recoverPendingQuarantines() + + let canonicalPath = try policy.canonicalPath(victim.path) + let scannedIdentity = try XCTUnwrap(FileIdentity.capture(path: canonicalPath)) + let request = PrivilegedDeletionRequest( + path: canonicalPath, + identity: scannedIdentity, + operation: .cleaner + ) + let validated = try policy.validatedRequest(request) + let sourceParentPath = (canonicalPath as NSString).deletingLastPathComponent + let sourceParentIdentity = try XCTUnwrap( + FileIdentity.capture(path: sourceParentPath) + ) + let transactionID = UUID() + let quarantineName = "\(PrivilegedCleaningConstants.quarantineDirectoryPrefix)\(getuid())-\(transactionID.uuidString)" + let quarantineURL = stagingURL.appendingPathComponent( + quarantineName, + isDirectory: true + ) + try FileManager.default.createDirectory( + at: quarantineURL, + withIntermediateDirectories: false, + attributes: [.posixPermissions: 0o700] + ) + + let journal = PrivilegedQuarantineJournal( + state: state, + transactionID: transactionID, + operationID: UUID(), + initiatingUserID: getuid(), + request: request, + sourceParentPath: sourceParentPath, + sourceParentIdentity: sourceParentIdentity, + sourceName: (canonicalPath as NSString).lastPathComponent, + boundaryPath: validated.boundaryPath, + boundaryDevice: scannedIdentity.device + ) + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let journalURL = quarantineURL.appendingPathComponent( + PrivilegedQuarantineJournal.fileName + ) + try encoder.encode(journal).write(to: journalURL) + XCTAssertEqual(Darwin.chmod(journalURL.path, mode_t(0o600)), 0) + try FileManager.default.moveItem( + at: victim, + to: quarantineURL.appendingPathComponent("item") + ) + return (deleter, quarantineURL) + } + + private func runChmod(_ arguments: [String]) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/chmod") + process.arguments = arguments + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0) + } +} + +private enum InjectedDeletionError: Error { + case afterRename +} + +private final class OneShotGate: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !claimed else { return false } + claimed = true + return true + } +} diff --git a/Shared/SecureDeletion.swift b/Shared/SecureDeletion.swift new file mode 100644 index 0000000..a938a3c --- /dev/null +++ b/Shared/SecureDeletion.swift @@ -0,0 +1,2396 @@ +import Darwin +import Foundation + +enum PrivilegedDeletionOperation: String, Codable, Sendable { + case cleaner + case largeFile + case uninstall +} + +struct FileIdentity: Codable, Equatable, Sendable { + enum LookupResult: Sendable { + case found(FileIdentity) + case missing + case failed(Int32) + } + + let device: UInt64 + let inode: UInt64 + let fileType: UInt32 + let owner: UInt32 + let generation: UInt32 + let birthtimeSeconds: Int64 + let birthtimeNanoseconds: Int64 + + init( + device: UInt64, + inode: UInt64, + fileType: UInt32, + owner: UInt32, + generation: UInt32, + birthtimeSeconds: Int64, + birthtimeNanoseconds: Int64 + ) { + self.device = device + self.inode = inode + self.fileType = fileType + self.owner = owner + self.generation = generation + self.birthtimeSeconds = birthtimeSeconds + self.birthtimeNanoseconds = birthtimeNanoseconds + } + + init(stat info: stat) { + device = UInt64(bitPattern: Int64(info.st_dev)) + inode = UInt64(info.st_ino) + fileType = UInt32(info.st_mode) & UInt32(S_IFMT) + owner = UInt32(info.st_uid) + generation = UInt32(info.st_gen) + birthtimeSeconds = Int64(info.st_birthtimespec.tv_sec) + birthtimeNanoseconds = Int64(info.st_birthtimespec.tv_nsec) + } + + static func capture(path: String) -> FileIdentity? { + guard case let .found(identity) = lookup(path: path) else { + return nil + } + return identity + } + + static func lookup(path: String) -> LookupResult { + var info = stat() + let status = path.withCString { pointer in + Darwin.lstat(pointer, &info) + } + guard status == 0 else { + let code = errno + return code == ENOENT ? .missing : .failed(code) + } + return .found(FileIdentity(stat: info)) + } + + var isDirectory: Bool { fileType == UInt32(S_IFDIR) } + var isRegularFile: Bool { fileType == UInt32(S_IFREG) } + var isSymbolicLink: Bool { fileType == UInt32(S_IFLNK) } + var isFIFO: Bool { fileType == UInt32(S_IFIFO) } + var isSocket: Bool { fileType == UInt32(S_IFSOCK) } + + var isSupportedType: Bool { + isDirectory || isRegularFile || isSymbolicLink || isFIFO || isSocket + } +} + +struct PrivilegedDeletionRequest: Codable, Sendable { + let id: UUID + let path: String + let identity: FileIdentity + let operation: PrivilegedDeletionOperation + + init( + id: UUID = UUID(), + path: String, + identity: FileIdentity, + operation: PrivilegedDeletionOperation + ) { + self.id = id + self.path = path + self.identity = identity + self.operation = operation + } +} + +enum PrivilegedDeletionStatus: String, Codable, Equatable, Sendable { + case deleted + case missing + case rejected + case failed + /// The transport failed after submission, so the client cannot know + /// whether this identity-bound request ran. The client waits for a helper + /// reconciliation barrier before exposing this status, ensuring a retry + /// cannot observe a temporary quarantine absence. + case unknown +} + +struct PrivilegedDeletionResponse: Codable, Sendable { + let requestID: UUID + let path: String + let status: PrivilegedDeletionStatus + let message: String? +} + +struct PrivilegedDeletionBatch: Codable, Sendable { + let id: UUID + let protocolVersion: Int + let securityPolicyVersion: Int + let requests: [PrivilegedDeletionRequest] + /// Opaque Authorization Services external form. It is created only in + /// memory by the app and revalidated by the root helper for every batch. + let authorization: Data + let deadline: Date + + init( + id: UUID = UUID(), + protocolVersion: Int = PrivilegedCleaningConstants.protocolVersion, + securityPolicyVersion: Int = PrivilegedCleaningConstants.securityPolicyVersion, + requests: [PrivilegedDeletionRequest], + authorization: Data, + deadline: Date + ) { + self.id = id + self.protocolVersion = protocolVersion + self.securityPolicyVersion = securityPolicyVersion + self.requests = requests + self.authorization = authorization + self.deadline = deadline + } +} + +enum PrivilegedDeletionBatchDisposition: String, Codable, Sendable { + case completed + /// The helper proves this operation ID was never admitted for mutation. + case notAccepted +} + +struct PrivilegedDeletionBatchResponse: Codable, Sendable { + let batchID: UUID + let protocolVersion: Int + let securityPolicyVersion: Int + let disposition: PrivilegedDeletionBatchDisposition + let message: String? + let responses: [PrivilegedDeletionResponse] + + init( + batchID: UUID, + protocolVersion: Int = PrivilegedCleaningConstants.protocolVersion, + securityPolicyVersion: Int = PrivilegedCleaningConstants.securityPolicyVersion, + disposition: PrivilegedDeletionBatchDisposition = .completed, + message: String? = nil, + responses: [PrivilegedDeletionResponse] + ) { + self.batchID = batchID + self.protocolVersion = protocolVersion + self.securityPolicyVersion = securityPolicyVersion + self.disposition = disposition + self.message = message + self.responses = responses + } +} + +enum PrivilegedDeletionReconciliationState: String, Codable, Sendable { + /// The operation completed or failed with its namespace fully settled. + case settled + /// Reconciliation arrived first and permanently tombstoned this ID, or + /// the batch was explicitly rejected before any mutation. + case notAccepted + case pending + case unavailable + case recoveryFailed +} + +struct PrivilegedDeletionReconciliationResponse: Codable, Sendable { + let protocolVersion: Int + let securityPolicyVersion: Int + let batchID: UUID + let state: PrivilegedDeletionReconciliationState + let message: String? + + init( + protocolVersion: Int = PrivilegedCleaningConstants.protocolVersion, + securityPolicyVersion: Int = PrivilegedCleaningConstants.securityPolicyVersion, + batchID: UUID, + state: PrivilegedDeletionReconciliationState, + message: String? = nil + ) { + self.protocolVersion = protocolVersion + self.securityPolicyVersion = securityPolicyVersion + self.batchID = batchID + self.state = state + self.message = message + } +} + +struct PrivilegedQuarantineJournal: Codable, Sendable { + enum State: String, Codable, Sendable { + case prepared + case committed + } + + static let formatVersion = 2 + static let fileName = ".puremac-journal" + static let temporaryFilePrefix = ".puremac-journal-new-" + static let maximumEncodedSize = 65_536 + + let formatVersion: Int + let protocolVersion: Int + let securityPolicyVersion: Int + let state: State + let transactionID: UUID + let operationID: UUID + let initiatingUserID: UInt32 + let request: PrivilegedDeletionRequest + let sourceParentPath: String + let sourceParentIdentity: FileIdentity + let sourceName: String + let boundaryPath: String + let boundaryDevice: UInt64 + + init( + state: State, + transactionID: UUID, + operationID: UUID, + initiatingUserID: uid_t, + request: PrivilegedDeletionRequest, + sourceParentPath: String, + sourceParentIdentity: FileIdentity, + sourceName: String, + boundaryPath: String, + boundaryDevice: UInt64 + ) { + formatVersion = Self.formatVersion + protocolVersion = PrivilegedCleaningConstants.protocolVersion + securityPolicyVersion = PrivilegedCleaningConstants.securityPolicyVersion + self.state = state + self.transactionID = transactionID + self.operationID = operationID + self.initiatingUserID = UInt32(initiatingUserID) + self.request = request + self.sourceParentPath = sourceParentPath + self.sourceParentIdentity = sourceParentIdentity + self.sourceName = sourceName + self.boundaryPath = boundaryPath + self.boundaryDevice = boundaryDevice + } + + func changingState(to state: State) -> PrivilegedQuarantineJournal { + PrivilegedQuarantineJournal( + state: state, + transactionID: transactionID, + operationID: operationID, + initiatingUserID: uid_t(initiatingUserID), + request: request, + sourceParentPath: sourceParentPath, + sourceParentIdentity: sourceParentIdentity, + sourceName: sourceName, + boundaryPath: boundaryPath, + boundaryDevice: boundaryDevice + ) + } +} + +enum PrivilegedCleaningRecoveryState: String, Codable, Sendable { + case recovering + case ready + case failed +} + +struct PrivilegedCleaningServiceInfo: Codable, Sendable { + let protocolVersion: Int + let securityPolicyVersion: Int + let helperBundleIdentifier: String + /// Optional so a service-info reply from an older protocol can still be + /// decoded far enough to identify an explicit version mismatch. A helper + /// advertising the current protocol must always provide this field. + let recoveryState: PrivilegedCleaningRecoveryState? + let recoveryMessage: String? + + init( + protocolVersion: Int = PrivilegedCleaningConstants.protocolVersion, + securityPolicyVersion: Int = PrivilegedCleaningConstants.securityPolicyVersion, + helperBundleIdentifier: String = PrivilegedCleaningConstants.helperBundleIdentifier, + recoveryState: PrivilegedCleaningRecoveryState = .ready, + recoveryMessage: String? = nil + ) { + self.protocolVersion = protocolVersion + self.securityPolicyVersion = securityPolicyVersion + self.helperBundleIdentifier = helperBundleIdentifier + self.recoveryState = recoveryState + self.recoveryMessage = recoveryMessage + } +} + +enum PrivilegedCleaningConstants { + static let machServiceName = "com.puremac.privileged-cleaning" + static let launchDaemonPlistName = "com.puremac.privileged-cleaning.plist" + static let appBundleIdentifier = "com.puremac.app" + static let helperBundleIdentifier = "com.puremac.privileged-helper" + static let teamIdentifier = "H3WXHVTP97" + static let authorizationRight = "com.puremac.app.delete-items" + static let authorizationRightTimeout = 300 + static let protocolVersion = 3 + /// Increment this whenever privileged allowlists or deletion semantics + /// change. The app checks it before sending any paths, so an older daemon + /// cannot keep applying stale root policy after an application update. + static let securityPolicyVersion = 3 + static let maximumBatchCount = 128 + static let maximumEncodedSize = 1_048_576 + static let quarantineDirectoryPrefix = ".puremac-delete-" + static let quarantineRootPath = "/private/var/db/com.puremac/quarantine" + + static let appCodeSigningRequirement = "anchor apple generic and identifier \"\(appBundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\"" + static let helperCodeSigningRequirement = "anchor apple generic and identifier \"\(helperBundleIdentifier)\" and certificate leaf[subject.OU] = \"\(teamIdentifier)\"" +} + +@objc protocol PrivilegedCleaningXPCProtocol { + func serviceInfo(withReply reply: @escaping (NSData) -> Void) + func reconcileDeletion( + _ batchID: NSUUID, + withReply reply: @escaping (NSData) -> Void + ) + func deleteItems( + _ batchID: NSUUID, + encodedBatch: NSData, + withReply reply: @escaping (NSData) -> Void + ) +} + +enum SecureDeletionError: LocalizedError { + case invalidPath(String) + case outsideAllowlist(String) + case unsupportedType(String) + case ownerMismatch(path: String, owner: uid_t) + case identityChanged(String) + case topLevelMissing(String) + case crossedDeviceBoundary(String) + case traversalLimitExceeded(String) + case quarantineRecoveryFailed(String) + case operationCancelled(String) + case deadlineExceeded(String) + case posix(operation: String, path: String, code: Int32) + + var errorDescription: String? { + switch self { + case let .invalidPath(path): + return "Invalid deletion path: \(path)" + case let .outsideAllowlist(path): + return "Path is outside the deletion allowlist: \(path)" + case let .unsupportedType(path): + return "Unsupported filesystem object type: \(path)" + case let .ownerMismatch(path, owner): + return "Refusing object owned by uid \(owner): \(path)" + case let .identityChanged(path): + return "Filesystem identity changed before deletion: \(path)" + case let .topLevelMissing(path): + return "Filesystem object disappeared before deletion: \(path)" + case let .crossedDeviceBoundary(path): + return "Refusing to cross a mounted filesystem while deleting: \(path)" + case let .traversalLimitExceeded(path): + return "Deletion traversal safety limit exceeded: \(path)" + case let .quarantineRecoveryFailed(path): + return "A raced filesystem object could not be restored safely: \(path)" + case let .operationCancelled(path): + return "Privileged deletion was canceled: \(path)" + case let .deadlineExceeded(path): + return "Privileged deletion deadline exceeded: \(path)" + case let .posix(operation, path, code): + return "\(operation) failed for \(path): \(String(cString: strerror(code)))" + } + } +} + +struct SecureDeletionPolicy: Sendable { + struct Root: Sendable { + let path: String + let mayDeleteRoot: Bool + } + + struct ValidatedRequest: Sendable { + let path: String + /// The structural allowlist root. Its device is captured while the + /// descriptor walk crosses it, so a mountpoint below the root cannot + /// redirect recursive deletion onto another filesystem. + let boundaryPath: String + } + + let userID: uid_t + let homeDirectory: String + private let cleanerRoots: [Root] + private let largeFileRoots: [String] + + init( + userID: uid_t, + homeDirectory: String, + cleanerRootsOverride: [Root]? = nil, + largeFileRootsOverride: [String]? = nil + ) { + self.userID = userID + self.homeDirectory = (homeDirectory as NSString).standardizingPath + + if let cleanerRootsOverride { + cleanerRoots = cleanerRootsOverride.map { root in + Root(path: Self.normalizeSystemAliases(root.path), mayDeleteRoot: root.mayDeleteRoot) + } + } else { + let home = self.homeDirectory + cleanerRoots = [ + Root(path: "\(home)/Library/Caches", mayDeleteRoot: false), + Root(path: "\(home)/Library/Logs", mayDeleteRoot: false), + Root(path: "\(home)/Library/Saved Application State", mayDeleteRoot: false), + Root(path: "\(home)/Library/HTTPStorages", mayDeleteRoot: false), + Root(path: "\(home)/Library/WebKit", mayDeleteRoot: false), + Root(path: "\(home)/Library/Containers", mayDeleteRoot: false), + Root(path: "\(home)/Library/Group Containers", mayDeleteRoot: false), + Root(path: "\(home)/Library/Application Support", mayDeleteRoot: false), + Root(path: "\(home)/Library/Preferences", mayDeleteRoot: false), + Root(path: "\(home)/Library/LaunchAgents", mayDeleteRoot: false), + Root(path: "\(home)/Library/Mail Downloads", mayDeleteRoot: false), + Root(path: "\(home)/Library/Developer/Xcode/DerivedData", mayDeleteRoot: true), + Root(path: "\(home)/Library/Developer/Xcode/Archives", mayDeleteRoot: true), + Root(path: "\(home)/Library/Developer/CoreSimulator/Caches", mayDeleteRoot: true), + Root(path: "\(home)/.Trash", mayDeleteRoot: false), + Root(path: "\(home)/.npm", mayDeleteRoot: true), + Root(path: "\(home)/.cache", mayDeleteRoot: false), + Root(path: "\(home)/Library/Containers/com.docker.docker", mayDeleteRoot: false), + Root(path: "/Library/Caches", mayDeleteRoot: false), + Root(path: "/Library/Logs", mayDeleteRoot: false), + Root(path: "/private/var/log", mayDeleteRoot: false), + Root(path: "/private/var/tmp", mayDeleteRoot: false), + Root(path: "/private/tmp", mayDeleteRoot: false), + ] + } + + if let largeFileRootsOverride { + largeFileRoots = largeFileRootsOverride.map(Self.normalizeSystemAliases) + } else { + let home = self.homeDirectory + largeFileRoots = [ + "\(home)/Downloads", + "\(home)/Documents", + "\(home)/Desktop", + ] + } + } + + func canonicalPath(_ untrustedPath: String) throws -> String { + guard !untrustedPath.isEmpty, + untrustedPath.hasPrefix("/"), + !untrustedPath.utf8.contains(0), + untrustedPath != "/", + untrustedPath.utf8.count < Int(PATH_MAX) + else { + throw SecureDeletionError.invalidPath(untrustedPath) + } + + let rawComponents = untrustedPath.split(separator: "/", omittingEmptySubsequences: false) + guard rawComponents.first == "", + rawComponents.count <= 129, + !rawComponents.dropFirst().contains(where: { + $0.isEmpty || $0 == "." || $0 == ".." || $0.utf8.count > Int(NAME_MAX) + }) + else { + throw SecureDeletionError.invalidPath(untrustedPath) + } + + let normalized = Self.normalizeSystemAliases(untrustedPath) + guard normalized.utf8.count < Int(PATH_MAX) else { + throw SecureDeletionError.invalidPath(untrustedPath) + } + return normalized + } + + func validate(_ request: PrivilegedDeletionRequest) throws -> String { + try validatedRequest(request).path + } + + func validatedRequest(_ request: PrivilegedDeletionRequest) throws -> ValidatedRequest { + let path = try canonicalPath(request.path) + guard request.identity.isSupportedType else { + throw SecureDeletionError.unsupportedType(path) + } + guard request.identity.owner == UInt32(userID) || request.identity.owner == 0 else { + throw SecureDeletionError.ownerMismatch(path: path, owner: uid_t(request.identity.owner)) + } + + let boundaryPath: String? + switch request.operation { + case .cleaner: + let matchedRoot = cleanerRoots + .filter { isInside(path, root: $0.path, includeRoot: true) } + .max { $0.path.utf8.count < $1.path.utf8.count } + if let matchedRoot, + path != matchedRoot.path || matchedRoot.mayDeleteRoot { + boundaryPath = matchedRoot.path + } else { + boundaryPath = nil + } + case .largeFile: + guard request.identity.isRegularFile else { + throw SecureDeletionError.unsupportedType(path) + } + boundaryPath = largeFileRoots + .filter { isInside(path, root: $0, includeRoot: false) } + .max { $0.utf8.count < $1.utf8.count } + case .uninstall: + boundaryPath = safeUninstallBoundary(path, identity: request.identity) + } + + guard let boundaryPath else { + throw SecureDeletionError.outsideAllowlist(path) + } + return ValidatedRequest(path: path, boundaryPath: boundaryPath) + } + + func quarantineParentPath(for validated: ValidatedRequest) -> String { + validated.path == validated.boundaryPath + ? (validated.boundaryPath as NSString).deletingLastPathComponent + : validated.boundaryPath + } + + var quarantineParentPaths: [String] { + var paths = cleanerRoots.map(\.path) + paths.append(contentsOf: cleanerRoots.compactMap { root in + root.mayDeleteRoot ? (root.path as NSString).deletingLastPathComponent : nil + }) + paths.append(contentsOf: largeFileRoots) + paths.append(contentsOf: [ + "/Applications", + "\(homeDirectory)/Applications", + "/private/var/db/receipts", + "/Library/LaunchDaemons", + "/Library/LaunchAgents", + ]) + return Array(Set(paths.map(Self.normalizeSystemAliases))).sorted() + } + + private func safeUninstallBoundary(_ path: String, identity: FileIdentity) -> String? { + let systemApplications = "/Applications" + let userApplications = "\(homeDirectory)/Applications" + let receipts = "/private/var/db/receipts" + let launchDaemons = "/Library/LaunchDaemons" + let launchAgents = "/Library/LaunchAgents" + + if identity.isDirectory && isAppBundlePath(path, rootedAt: systemApplications) { + return systemApplications + } + if identity.isDirectory && isAppBundlePath(path, rootedAt: userApplications) { + return userApplications + } + if identity.isRegularFile && isReceiptPath(path, rootedAt: receipts) { + return receipts + } + if identity.isRegularFile && isPlist(path, directlyUnder: launchDaemons) { + return launchDaemons + } + if identity.isRegularFile && isPlist(path, directlyUnder: launchAgents) { + return launchAgents + } + return nil + } + + private func isAppBundlePath(_ path: String, rootedAt root: String) -> Bool { + guard isInside(path, root: root, includeRoot: false) else { return false } + let relative = String(path.dropFirst(root.count + 1)) + let components = relative.split(separator: "/").map(String.init) + guard let bundleName = components.last, + bundleName.lowercased().hasSuffix(".app") + else { + return false + } + // A selected app may live in an Applications subfolder, but never + // authorize a nested helper app or other bundle inside another .app. + return !components.dropLast().contains { + $0.lowercased().hasSuffix(".app") + } + } + + private func isReceiptPath(_ path: String, rootedAt root: String) -> Bool { + let parent = (path as NSString).deletingLastPathComponent + let ext = (path as NSString).pathExtension.lowercased() + return parent == root && (ext == "plist" || ext == "bom") + } + + private func isPlist(_ path: String, directlyUnder root: String) -> Bool { + (path as NSString).deletingLastPathComponent == root + && (path as NSString).pathExtension.lowercased() == "plist" + } + + private func isInside(_ path: String, root: String, includeRoot: Bool) -> Bool { + let normalizedRoot = Self.normalizeSystemAliases(root) + if path == normalizedRoot { return includeRoot } + return path.hasPrefix(normalizedRoot + "/") + } + + private static func normalizeSystemAliases(_ path: String) -> String { + var normalized = (path as NSString).standardizingPath + if normalized == "/tmp" || normalized.hasPrefix("/tmp/") { + normalized = "/private" + normalized + } else if normalized == "/var" || normalized.hasPrefix("/var/") { + normalized = "/private" + normalized + } + return normalized + } +} + +enum SecureDeletionIsolation: Sendable { + /// Used by the unprivileged process. The entry is revalidated immediately + /// before unlinkat, and every path component is descriptor-walked. + case direct + /// Used only by the root helper. The selected entry is atomically moved + /// into a root-owned directory and its identity is checked *after* that + /// move, closing the final check-to-unlink race for privileged deletion. + case privilegedQuarantine +} + +struct SecureFileDeleter: Sendable { + private struct PrivilegedStagingRoot { + let descriptor: Int32 + let path: String + let identity: FileIdentity + let expectedOwner: uid_t + } + + let policy: SecureDeletionPolicy + let isolation: SecureDeletionIsolation + /// Production always uses the fixed root-owned staging directory. Tests + /// may inject a private directory owned by the effective test uid; the + /// same-device, no-follow and mode checks remain mandatory. + let privilegedStagingRootPathOverride: String? + /// The helper may bind journal records to its batch/operation identifier. + /// Existing direct and test callers fall back to the request identifier. + let privilegedOperationID: UUID? + /// Deterministic race injection point used by the security tests. Shipping + /// callers leave it nil. + let beforeQuarantineRename: (@Sendable () throws -> Void)? + let afterQuarantineRename: (@Sendable () throws -> Void)? + let beforeDirectoryUnlink: (@Sendable (String) throws -> Void)? + let cancellationCheck: (@Sendable () throws -> Void)? + private let maximumDepth = 64 + private let maximumEntries = 100_000 + private let maximumTransactions = 4_096 + + init( + policy: SecureDeletionPolicy, + isolation: SecureDeletionIsolation = .direct, + beforeQuarantineRename: (@Sendable () throws -> Void)? = nil, + afterQuarantineRename: (@Sendable () throws -> Void)? = nil, + beforeDirectoryUnlink: (@Sendable (String) throws -> Void)? = nil, + cancellationCheck: (@Sendable () throws -> Void)? = nil, + privilegedStagingRootPathOverride: String? = nil, + privilegedOperationID: UUID? = nil + ) { + self.policy = policy + self.isolation = isolation + self.privilegedStagingRootPathOverride = privilegedStagingRootPathOverride + self.privilegedOperationID = privilegedOperationID + self.beforeQuarantineRename = beforeQuarantineRename + self.afterQuarantineRename = afterQuarantineRename + self.beforeDirectoryUnlink = beforeDirectoryUnlink + self.cancellationCheck = cancellationCheck + } + + /// Synchronous production startup recovery. The privileged helper calls + /// this before accepting XPC connections. Every uid represented in the + /// fixed root-owned staging root must settle successfully or startup stays + /// fail-closed. + static func recoverInterruptedQuarantines() throws { + guard geteuid() == 0 else { + throw SecureDeletionError.quarantineRecoveryFailed( + PrivilegedCleaningConstants.quarantineRootPath + ) + } + + let scanner = SecureFileDeleter( + policy: SecureDeletionPolicy(userID: 0, homeDirectory: "/var/root"), + isolation: .privilegedQuarantine + ) + let userIDs = try scanner.pendingTransactionUserIDs() + for userID in userIDs.sorted() { + guard let passwordEntry = Darwin.getpwuid(userID), + let homePointer = passwordEntry.pointee.pw_dir + else { + throw SecureDeletionError.quarantineRecoveryFailed( + PrivilegedCleaningConstants.quarantineRootPath + ) + } + let homeDirectory = String(cString: homePointer) + let deleter = SecureFileDeleter( + policy: SecureDeletionPolicy( + userID: userID, + homeDirectory: homeDirectory + ), + isolation: .privilegedQuarantine + ) + try deleter.recoverPendingQuarantines() + } + } + + /// Instance recovery also supports the explicitly injected private + /// staging root used by XCTest. Production callers should use the static + /// startup API above. + func recoverPendingQuarantines() throws { + guard case .privilegedQuarantine = isolation else { return } + // Recovery is an internal durability obligation. A deadline or test + // callback belonging to a newly submitted request must not interrupt + // or mutate recovery of a previous committed transaction. + let recoveryDeleter = SecureFileDeleter( + policy: policy, + isolation: .privilegedQuarantine, + privilegedStagingRootPathOverride: privilegedStagingRootPathOverride + ) + try recoveryDeleter.recoverPendingQuarantinesWithoutCallbacks() + } + + private func recoverPendingQuarantinesWithoutCallbacks() throws { + let stagingRoot = try openPrivilegedStagingRoot(createIfMissing: true) + defer { Darwin.close(stagingRoot.descriptor) } + + let names = try directoryEntryNames( + directoryFD: stagingRoot.descriptor, + displayPath: stagingRoot.path, + maximumCount: maximumTransactions + ).sorted() + for name in names { + guard let parsed = parseQuarantineDirectoryName(name) else { + throw SecureDeletionError.quarantineRecoveryFailed(stagingRoot.path) + } + guard parsed.userID == policy.userID else { continue } + do { + try recoverQuarantineTransaction( + stagingRoot: stagingRoot, + quarantineName: name, + transactionID: parsed.transactionID + ) + } catch { + let path = (stagingRoot.path as NSString).appendingPathComponent(name) + throw SecureDeletionError.quarantineRecoveryFailed(path) + } + } + } + + private func pendingTransactionUserIDs() throws -> Set { + let stagingRoot = try openPrivilegedStagingRoot(createIfMissing: true) + defer { Darwin.close(stagingRoot.descriptor) } + let names = try directoryEntryNames( + directoryFD: stagingRoot.descriptor, + displayPath: stagingRoot.path, + maximumCount: maximumTransactions + ) + var result = Set() + for name in names { + guard let parsed = parseQuarantineDirectoryName(name) else { + throw SecureDeletionError.quarantineRecoveryFailed(stagingRoot.path) + } + result.insert(parsed.userID) + } + return result + } + + private func recoverQuarantineTransaction( + stagingRoot: PrivilegedStagingRoot, + quarantineName: String, + transactionID: UUID + ) throws { + let quarantineFD = quarantineName.withCString { pointer in + Darwin.openat( + stagingRoot.descriptor, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + guard quarantineFD >= 0 else { + throw SecureDeletionError.quarantineRecoveryFailed(stagingRoot.path) + } + defer { Darwin.close(quarantineFD) } + + let quarantinePath = (stagingRoot.path as NSString) + .appendingPathComponent(quarantineName) + let quarantineIdentity = try validatePrivateDirectoryDescriptor( + quarantineFD, + expectedOwner: stagingRoot.expectedOwner, + expectedDevice: stagingRoot.identity.device, + displayPath: quarantinePath + ) + + let journal: PrivilegedQuarantineJournal + do { + journal = try readQuarantineJournal( + quarantineFD: quarantineFD, + expectedOwner: stagingRoot.expectedOwner, + boundaryDevice: stagingRoot.identity.device, + displayPath: quarantinePath + ) + } catch let SecureDeletionError.posix(_, _, code) where code == ENOENT { + try recoverUnjournaledTransaction( + stagingRoot: stagingRoot, + quarantineFD: quarantineFD, + quarantineName: quarantineName, + quarantineIdentity: quarantineIdentity, + displayPath: quarantinePath + ) + return + } catch { + throw SecureDeletionError.quarantineRecoveryFailed(quarantinePath) + } + + try validateRecoveredJournal( + journal, + transactionID: transactionID, + stagingDevice: stagingRoot.identity.device, + displayPath: quarantinePath + ) + try validateTransactionEntrySet( + quarantineFD: quarantineFD, + displayPath: quarantinePath, + journalRequired: true + ) + + let stagedIdentity = try optionalIdentityAt( + parentFD: quarantineFD, + name: "item", + displayPath: journal.request.path + ) + switch journal.state { + case .prepared: + if let stagedIdentity { + guard stagedIdentity.device == journal.boundaryDevice, + stagedIdentity.isSupportedType + else { + throw SecureDeletionError.quarantineRecoveryFailed(quarantinePath) + } + let sourceParentFD = try openRecoverySourceParent(journal) + defer { Darwin.close(sourceParentFD) } + try restoreQuarantinedEntry( + quarantineFD: quarantineFD, + stagedName: "item", + destinationParentFD: sourceParentFD, + destinationName: journal.sourceName, + displayPath: journal.request.path + ) + try synchronizeDirectory( + sourceParentFD, + displayPath: journal.sourceParentPath + ) + try synchronizeDirectory(quarantineFD, displayPath: quarantinePath) + } + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantineFD, + quarantineName: quarantineName, + quarantineIdentity: quarantineIdentity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: quarantinePath + ) + + case .committed: + if let stagedIdentity { + guard stagedIdentity == journal.request.identity else { + throw SecureDeletionError.quarantineRecoveryFailed(quarantinePath) + } + var remainingEntries = maximumEntries + try removeEntry( + parentFD: quarantineFD, + name: "item", + displayPath: journal.request.path, + expectedIdentity: journal.request.identity, + observedIdentity: stagedIdentity, + boundaryDevice: journal.boundaryDevice, + depth: 0, + remainingEntries: &remainingEntries + ) + try synchronizeDirectory(quarantineFD, displayPath: quarantinePath) + } + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantineFD, + quarantineName: quarantineName, + quarantineIdentity: quarantineIdentity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: quarantinePath + ) + } + } + + private func recoverUnjournaledTransaction( + stagingRoot: PrivilegedStagingRoot, + quarantineFD: Int32, + quarantineName: String, + quarantineIdentity: FileIdentity, + displayPath: String + ) throws { + // A crash before the initial atomic journal install can leave only a + // root-owned temporary journal file. `item` is never renamed until + // PREPARED is installed and synced, so any other entry is unresolved. + try validateTransactionEntrySet( + quarantineFD: quarantineFD, + displayPath: displayPath, + journalRequired: false + ) + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantineFD, + quarantineName: quarantineName, + quarantineIdentity: quarantineIdentity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: displayPath + ) + } + + private func openPrivilegedStagingRoot( + createIfMissing: Bool + ) throws -> PrivilegedStagingRoot { + let isOverride = privilegedStagingRootPathOverride != nil + if !isOverride, geteuid() != 0 { + throw SecureDeletionError.quarantineRecoveryFailed( + PrivilegedCleaningConstants.quarantineRootPath + ) + } + + let rawPath = privilegedStagingRootPathOverride + ?? PrivilegedCleaningConstants.quarantineRootPath + let path = try policy.canonicalPath(rawPath) + if !isOverride, path != PrivilegedCleaningConstants.quarantineRootPath { + throw SecureDeletionError.quarantineRecoveryFailed(path) + } + let components = path.split(separator: "/").map(String.init) + guard !components.isEmpty else { + throw SecureDeletionError.quarantineRecoveryFailed(path) + } + + let firstCreatableIndex: Int + let expectedOwner: uid_t + if isOverride { + // The caller creates the unique test parent; only its final private + // staging component may be created here. + firstCreatableIndex = components.count - 1 + expectedOwner = geteuid() + } else { + let baseComponents = ["private", "var", "db"] + guard components.starts(with: baseComponents), + components.count > baseComponents.count + else { + throw SecureDeletionError.quarantineRecoveryFailed(path) + } + firstCreatableIndex = baseComponents.count + expectedOwner = 0 + } + + var currentFD = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard currentFD >= 0 else { throw posixError("open", path: "/") } + var traversed = "" + + do { + for (index, component) in components.enumerated() { + let parentPath = traversed.isEmpty ? "/" : traversed + traversed += "/" + component + var created = false + var nextFD = component.withCString { pointer in + Darwin.openat( + currentFD, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + if nextFD < 0, errno == ENOENT, createIfMissing, + index >= firstCreatableIndex { + let createStatus = component.withCString { pointer in + Darwin.mkdirat(currentFD, pointer, mode_t(S_IRWXU)) + } + if createStatus != 0, errno != EEXIST { + throw posixError("mkdirat", path: traversed) + } + created = createStatus == 0 + nextFD = component.withCString { pointer in + Darwin.openat( + currentFD, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + } + guard nextFD >= 0 else { + throw posixError("openat", path: traversed) + } + + do { + if created { + try hardenQuarantineDirectory(nextFD, path: traversed) + } + if index >= firstCreatableIndex { + _ = try validatePrivateDirectoryDescriptor( + nextFD, + expectedOwner: expectedOwner, + expectedDevice: nil, + displayPath: traversed + ) + } else { + let ancestor = try descriptorIdentity(nextFD, displayPath: traversed) + let allowedOwner = isOverride + ? ancestor.owner == 0 || ancestor.owner == UInt32(expectedOwner) + : ancestor.owner == 0 + guard ancestor.isDirectory, allowedOwner else { + throw SecureDeletionError.quarantineRecoveryFailed(traversed) + } + } + if created { + try synchronizeDirectory(nextFD, displayPath: traversed) + try synchronizeDirectory(currentFD, displayPath: parentPath) + } + } catch { + Darwin.close(nextFD) + throw error + } + + Darwin.close(currentFD) + currentFD = nextFD + } + + let identity = try validatePrivateDirectoryDescriptor( + currentFD, + expectedOwner: expectedOwner, + expectedDevice: nil, + displayPath: path + ) + return PrivilegedStagingRoot( + descriptor: currentFD, + path: path, + identity: identity, + expectedOwner: expectedOwner + ) + } catch { + Darwin.close(currentFD) + throw error + } + } + + private func quarantineDirectoryName( + transactionID: UUID, + userID: uid_t + ) -> String { + "\(PrivilegedCleaningConstants.quarantineDirectoryPrefix)\(userID)-\(transactionID.uuidString)" + } + + private func parseQuarantineDirectoryName( + _ name: String + ) -> (userID: uid_t, transactionID: UUID)? { + let prefix = PrivilegedCleaningConstants.quarantineDirectoryPrefix + guard name.hasPrefix(prefix) else { return nil } + let suffix = name.dropFirst(prefix.count) + guard let separator = suffix.firstIndex(of: "-") else { return nil } + let userPart = suffix[.. Int32 { + let components = journal.sourceParentPath.split(separator: "/").map(String.init) + var parentFD = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard parentFD >= 0 else { throw posixError("open", path: "/") } + var traversed = "" + + do { + for component in components { + traversed += "/" + component + let nextFD = component.withCString { pointer in + Darwin.openat( + parentFD, + pointer, + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + } + guard nextFD >= 0 else { + throw SecureDeletionError.quarantineRecoveryFailed( + journal.request.path + ) + } + do { + let identity = try validateDirectoryDescriptor(nextFD, path: traversed) + if traversed == journal.boundaryPath + || traversed.hasPrefix(journal.boundaryPath + "/") { + guard identity.device == journal.boundaryDevice else { + throw SecureDeletionError.quarantineRecoveryFailed( + journal.request.path + ) + } + } + } catch { + Darwin.close(nextFD) + throw error + } + Darwin.close(parentFD) + parentFD = nextFD + } + + let observed = try descriptorIdentity( + parentFD, + displayPath: journal.sourceParentPath + ) + guard observed == journal.sourceParentIdentity else { + throw SecureDeletionError.quarantineRecoveryFailed(journal.request.path) + } + return parentFD + } catch { + Darwin.close(parentFD) + throw error + } + } + + private func validateTransactionEntrySet( + quarantineFD: Int32, + displayPath: String, + journalRequired: Bool + ) throws { + let entries = try directoryEntryNames( + directoryFD: quarantineFD, + displayPath: displayPath, + maximumCount: maximumEntries + ) + let hasJournal = entries.contains(PrivilegedQuarantineJournal.fileName) + guard hasJournal == journalRequired else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + if !journalRequired, entries.contains("item") { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + + let quarantineIdentity = try descriptorIdentity( + quarantineFD, + displayPath: displayPath + ) + for entry in entries { + if entry == "item" { continue } + guard entry == PrivilegedQuarantineJournal.fileName + || entry.hasPrefix(PrivilegedQuarantineJournal.temporaryFilePrefix) + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + try validateMetadataFile( + parentFD: quarantineFD, + name: entry, + expectedOwner: uid_t(quarantineIdentity.owner), + expectedDevice: quarantineIdentity.device, + displayPath: displayPath + ) + } + } + + private func cleanupQuarantineTransaction( + stagingRootFD: Int32, + stagingRootPath: String, + quarantineFD: Int32, + quarantineName: String, + quarantineIdentity: FileIdentity, + expectedOwner: uid_t, + displayPath: String + ) throws { + let entries = try directoryEntryNames( + directoryFD: quarantineFD, + displayPath: displayPath, + maximumCount: maximumEntries + ) + guard !entries.contains("item") else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + for entry in entries where entry != PrivilegedQuarantineJournal.fileName { + guard entry.hasPrefix(PrivilegedQuarantineJournal.temporaryFilePrefix) else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + try unlinkMetadataFile( + parentFD: quarantineFD, + name: entry, + expectedOwner: expectedOwner, + expectedDevice: quarantineIdentity.device, + displayPath: displayPath + ) + } + if entries.contains(PrivilegedQuarantineJournal.fileName) { + try unlinkMetadataFile( + parentFD: quarantineFD, + name: PrivilegedQuarantineJournal.fileName, + expectedOwner: expectedOwner, + expectedDevice: quarantineIdentity.device, + displayPath: displayPath + ) + } + try synchronizeDirectory(quarantineFD, displayPath: displayPath) + let remaining = try directoryEntryNames( + directoryFD: quarantineFD, + displayPath: displayPath, + maximumCount: 1 + ) + guard remaining.isEmpty else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + try verifyNamedIdentity( + parentFD: stagingRootFD, + name: quarantineName, + displayPath: displayPath, + expectedIdentity: quarantineIdentity + ) + let removeStatus = quarantineName.withCString { pointer in + Darwin.unlinkat(stagingRootFD, pointer, AT_REMOVEDIR) + } + guard removeStatus == 0 else { + throw posixError("unlinkat", path: displayPath) + } + try synchronizeDirectory(stagingRootFD, displayPath: stagingRootPath) + } + + private func validateMetadataFile( + parentFD: Int32, + name: String, + expectedOwner: uid_t, + expectedDevice: UInt64, + displayPath: String + ) throws { + var info = stat() + let status = name.withCString { pointer in + Darwin.fstatat(parentFD, pointer, &info, AT_SYMLINK_NOFOLLOW) + } + guard status == 0 else { throw posixError("fstatat", path: displayPath) } + let identity = FileIdentity(stat: info) + guard identity.isRegularFile, + identity.owner == UInt32(expectedOwner), + identity.device == expectedDevice, + info.st_nlink == 1, + (info.st_mode & mode_t(0o777)) == mode_t(0o600) + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + } + + private func unlinkMetadataFile( + parentFD: Int32, + name: String, + expectedOwner: uid_t, + expectedDevice: UInt64, + displayPath: String + ) throws { + try validateMetadataFile( + parentFD: parentFD, + name: name, + expectedOwner: expectedOwner, + expectedDevice: expectedDevice, + displayPath: displayPath + ) + let status = name.withCString { pointer in + Darwin.unlinkat(parentFD, pointer, 0) + } + guard status == 0 else { throw posixError("unlinkat", path: displayPath) } + } + + func remove(_ request: PrivilegedDeletionRequest) throws { + if case .privilegedQuarantine = isolation { + // No new namespace mutation is admitted while a durable prior + // transaction for this uid remains unresolved. + try recoverPendingQuarantines() + } + try cancellationCheck?() + let validated = try policy.validatedRequest(request) + let path = validated.path + let components = path.split(separator: "/").map(String.init) + guard let leafName = components.last else { + throw SecureDeletionError.invalidPath(path) + } + + var parentFD = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard parentFD >= 0 else { + throw posixError("open", path: "/") + } + defer { Darwin.close(parentFD) } + + var boundaryDevice: UInt64? + var traversed = "" + + for component in components.dropLast() { + try cancellationCheck?() + traversed += "/" + component + let nextFD = component.withCString { pointer in + Darwin.openat(parentFD, pointer, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard nextFD >= 0 else { + if errno == ENOENT { + // No mutation has begun yet. If an ancestor disappeared, + // the exact scanned object is unreachable and therefore + // already gone just like a missing final component. + throw SecureDeletionError.topLevelMissing(path) + } + throw posixError("openat", path: traversed) + } + + do { + let identity = try validateDirectoryDescriptor(nextFD, path: traversed) + if traversed == validated.boundaryPath { + boundaryDevice = identity.device + } else if let boundaryDevice, identity.device != boundaryDevice { + throw SecureDeletionError.crossedDeviceBoundary(traversed) + } + } catch { + Darwin.close(nextFD) + throw error + } + Darwin.close(parentFD) + parentFD = nextFD + } + + let observed: FileIdentity + do { + observed = try identityAt(parentFD: parentFD, name: leafName, displayPath: path) + } catch let SecureDeletionError.posix(_, _, code) where code == ENOENT { + // Absence during the initial descriptor walk is an "already gone" + // result. Nested races after mutation begins are handled inside + // the recursive walker and never promote the whole request. + throw SecureDeletionError.topLevelMissing(path) + } + + if path == validated.boundaryPath { + boundaryDevice = observed.device + } + guard let boundaryDevice else { + throw SecureDeletionError.outsideAllowlist(path) + } + guard observed.device == boundaryDevice else { + throw SecureDeletionError.crossedDeviceBoundary(path) + } + + var remainingEntries = maximumEntries + switch isolation { + case .direct: + try removeEntry( + parentFD: parentFD, + name: leafName, + displayPath: path, + expectedIdentity: request.identity, + observedIdentity: observed, + boundaryDevice: boundaryDevice, + depth: 0, + remainingEntries: &remainingEntries + ) + case .privilegedQuarantine: + try removeViaQuarantine( + sourceParentFD: parentFD, + sourceName: leafName, + displayPath: path, + request: request, + validated: validated, + initiallyObservedIdentity: observed, + boundaryDevice: boundaryDevice, + remainingEntries: &remainingEntries + ) + } + } + + private func removeViaQuarantine( + sourceParentFD: Int32, + sourceName: String, + displayPath: String, + request: PrivilegedDeletionRequest, + validated: SecureDeletionPolicy.ValidatedRequest, + initiallyObservedIdentity: FileIdentity, + boundaryDevice: UInt64, + remainingEntries: inout Int + ) throws { + let expectedIdentity = request.identity + guard initiallyObservedIdentity == expectedIdentity else { + throw SecureDeletionError.identityChanged(displayPath) + } + + let sourceParentPath = (displayPath as NSString).deletingLastPathComponent + let sourceParentIdentity = try descriptorIdentity( + sourceParentFD, + displayPath: sourceParentPath + ) + guard sourceParentIdentity.isDirectory, + sourceParentIdentity.device == boundaryDevice + else { + throw SecureDeletionError.identityChanged(displayPath) + } + + let stagingRoot = try openPrivilegedStagingRoot(createIfMissing: true) + defer { Darwin.close(stagingRoot.descriptor) } + guard stagingRoot.identity.device == boundaryDevice else { + throw SecureDeletionError.crossedDeviceBoundary(displayPath) + } + + let transactionID = UUID() + let quarantine = try createQuarantineDirectory( + parentFD: stagingRoot.descriptor, + transactionID: transactionID, + expectedOwner: stagingRoot.expectedOwner, + boundaryDevice: boundaryDevice, + displayPath: displayPath + ) + defer { Darwin.close(quarantine.descriptor) } + do { + try synchronizeDirectory(stagingRoot.descriptor, displayPath: stagingRoot.path) + } catch let synchronizationError { + do { + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantine.descriptor, + quarantineName: quarantine.name, + quarantineIdentity: quarantine.identity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: displayPath + ) + } catch { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + throw synchronizationError + } + + let canonicalRequest = PrivilegedDeletionRequest( + id: request.id, + path: displayPath, + identity: request.identity, + operation: request.operation + ) + let preparedJournal = PrivilegedQuarantineJournal( + state: .prepared, + transactionID: transactionID, + operationID: privilegedOperationID ?? request.id, + initiatingUserID: policy.userID, + request: canonicalRequest, + sourceParentPath: sourceParentPath, + sourceParentIdentity: sourceParentIdentity, + sourceName: sourceName, + boundaryPath: validated.boundaryPath, + boundaryDevice: boundaryDevice + ) + do { + try installQuarantineJournal( + preparedJournal, + quarantineFD: quarantine.descriptor, + expectedOwner: stagingRoot.expectedOwner, + boundaryDevice: boundaryDevice, + replacingExisting: false, + displayPath: displayPath + ) + } catch let journalError { + do { + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantine.descriptor, + quarantineName: quarantine.name, + quarantineIdentity: quarantine.identity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: displayPath + ) + } catch { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + throw journalError + } + + let stagedName = "item" + var didRename = false + do { + try beforeQuarantineRename?() + // PREPARED is durable before the first namespace mutation. + try cancellationCheck?() + let renameStatus = sourceName.withCString { sourcePointer in + stagedName.withCString { stagedPointer in + Darwin.renameatx_np( + sourceParentFD, + sourcePointer, + quarantine.descriptor, + stagedPointer, + UInt32(RENAME_EXCL) + ) + } + } + guard renameStatus == 0 else { + if errno == ENOENT { + throw SecureDeletionError.identityChanged(displayPath) + } + throw posixError("renameatx_np", path: displayPath) + } + didRename = true + try synchronizeDirectory(sourceParentFD, displayPath: sourceParentPath) + try synchronizeDirectory(quarantine.descriptor, displayPath: displayPath) + + try afterQuarantineRename?() + let stagedIdentity = try identityAt( + parentFD: quarantine.descriptor, + name: stagedName, + displayPath: displayPath + ) + guard stagedIdentity == expectedIdentity else { + throw SecureDeletionError.identityChanged(displayPath) + } + } catch let preparationError { + do { + if didRename { + try restoreQuarantinedEntry( + quarantineFD: quarantine.descriptor, + stagedName: stagedName, + destinationParentFD: sourceParentFD, + destinationName: sourceName, + displayPath: displayPath + ) + try synchronizeDirectory(sourceParentFD, displayPath: sourceParentPath) + try synchronizeDirectory(quarantine.descriptor, displayPath: displayPath) + } + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantine.descriptor, + quarantineName: quarantine.name, + quarantineIdentity: quarantine.identity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: displayPath + ) + } catch { + // Keep PREPARED and any staged entry intact when durable + // restore/cleanup cannot be proven. The next recovery pass + // must settle it before admitting another deletion. + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + throw preparationError + } + + // Atomically replacing the complete journal is the commit point. If + // this transition reports an error, leave the transaction untouched: + // recovery will observe either the complete PREPARED generation and + // restore it, or the complete COMMITTED generation and finish it. + let committedJournal = preparedJournal.changingState(to: .committed) + do { + try installQuarantineJournal( + committedJournal, + quarantineFD: quarantine.descriptor, + expectedOwner: stagingRoot.expectedOwner, + boundaryDevice: boundaryDevice, + replacingExisting: true, + displayPath: displayPath + ) + } catch { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + + do { + let stagedIdentity = try identityAt( + parentFD: quarantine.descriptor, + name: stagedName, + displayPath: displayPath + ) + guard stagedIdentity == expectedIdentity else { + throw SecureDeletionError.identityChanged(displayPath) + } + try removeEntry( + parentFD: quarantine.descriptor, + name: stagedName, + displayPath: displayPath, + expectedIdentity: expectedIdentity, + observedIdentity: stagedIdentity, + boundaryDevice: boundaryDevice, + depth: 0, + remainingEntries: &remainingEntries + ) + try synchronizeDirectory(quarantine.descriptor, displayPath: displayPath) + try cleanupQuarantineTransaction( + stagingRootFD: stagingRoot.descriptor, + stagingRootPath: stagingRoot.path, + quarantineFD: quarantine.descriptor, + quarantineName: quarantine.name, + quarantineIdentity: quarantine.identity, + expectedOwner: stagingRoot.expectedOwner, + displayPath: displayPath + ) + } catch { + // A COMMITTED tree is never restored after recursive deletion may + // have started. Its exact top-level identity and journal remain + // available for descriptor-relative startup recovery. + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + } + + private func createQuarantineDirectory( + parentFD: Int32, + transactionID: UUID, + expectedOwner: uid_t, + boundaryDevice: UInt64, + displayPath: String + ) throws -> (descriptor: Int32, name: String, identity: FileIdentity) { + let name = quarantineDirectoryName(transactionID: transactionID, userID: policy.userID) + let createStatus = name.withCString { pointer in + Darwin.mkdirat(parentFD, pointer, mode_t(S_IRWXU)) + } + guard createStatus == 0 else { + throw posixError("mkdirat", path: displayPath) + } + + let descriptor = name.withCString { pointer in + Darwin.openat(parentFD, pointer, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard descriptor >= 0 else { + throw posixError("openat", path: displayPath) + } + + var initialInfo = stat() + guard Darwin.fstat(descriptor, &initialInfo) == 0 else { + Darwin.close(descriptor) + throw posixError("fstat", path: displayPath) + } + let initialIdentity = FileIdentity(stat: initialInfo) + guard initialIdentity.isDirectory, + initialIdentity.device == boundaryDevice, + initialIdentity.owner == UInt32(expectedOwner) + else { + Darwin.close(descriptor) + throw SecureDeletionError.identityChanged(displayPath) + } + + do { + try hardenQuarantineDirectory(descriptor, path: displayPath) + let inheritedEntries = try directoryEntryNames( + directoryFD: descriptor, + displayPath: displayPath, + maximumCount: 1 + ) + guard inheritedEntries.isEmpty else { + throw SecureDeletionError.identityChanged(displayPath) + } + } catch { + Darwin.close(descriptor) + removeEmptyDirectoryIfIdentityMatches( + parentFD: parentFD, + name: name, + expectedIdentity: initialIdentity, + displayPath: displayPath + ) + throw error + } + + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + Darwin.close(descriptor) + name.withCString { pointer in + _ = Darwin.unlinkat(parentFD, pointer, AT_REMOVEDIR) + } + throw posixError("fstat", path: displayPath) + } + let identity = FileIdentity(stat: info) + let hasPrivateMode = (info.st_mode & mode_t(0o777)) == mode_t(0o700) + guard identity == initialIdentity, + identity.owner == UInt32(expectedOwner), + hasPrivateMode + else { + Darwin.close(descriptor) + removeEmptyDirectoryIfIdentityMatches( + parentFD: parentFD, + name: name, + expectedIdentity: initialIdentity, + displayPath: displayPath + ) + throw SecureDeletionError.identityChanged(displayPath) + } + return (descriptor, name, identity) + } + + private func hardenQuarantineDirectory(_ descriptor: Int32, path: String) throws { + guard let fileSecurity = filesec_init() else { + throw posixError("filesec_init", path: path) + } + defer { filesec_free(fileSecurity) } + + // `_FILESEC_REMOVE_ACL` is the sentinel pointer value 1 in Darwin's + // fcntl.h, but that macro is not imported into Swift. + let removeACL = UnsafeRawPointer(bitPattern: 1) + guard filesec_set_property(fileSecurity, FILESEC_ACL, removeACL) == 0, + Darwin.fchmodx_np(descriptor, fileSecurity) == 0 + else { + throw posixError("fchmodx_np", path: path) + } + guard Darwin.fchmod(descriptor, mode_t(S_IRWXU)) == 0 else { + throw posixError("fchmod", path: path) + } + + errno = 0 + if let inheritedACL = Darwin.acl_get_fd_np(descriptor, ACL_TYPE_EXTENDED) { + Darwin.acl_free(UnsafeMutableRawPointer(inheritedACL)) + throw SecureDeletionError.identityChanged(path) + } + guard errno == ENOENT || errno == EOPNOTSUPP else { + throw posixError("acl_get_fd_np", path: path) + } + } + + private func installQuarantineJournal( + _ journal: PrivilegedQuarantineJournal, + quarantineFD: Int32, + expectedOwner: uid_t, + boundaryDevice: UInt64, + replacingExisting: Bool, + displayPath: String + ) throws { + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + let encoded = try encoder.encode(journal) + guard !encoded.isEmpty, + encoded.count <= PrivilegedQuarantineJournal.maximumEncodedSize + else { + throw SecureDeletionError.traversalLimitExceeded(displayPath) + } + + if replacingExisting { + let existing = try readQuarantineJournal( + quarantineFD: quarantineFD, + expectedOwner: expectedOwner, + boundaryDevice: boundaryDevice, + displayPath: displayPath + ) + guard existing.state == .prepared, + existing.transactionID == journal.transactionID, + existing.request.id == journal.request.id, + journal.state == .committed + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + } + + let temporaryName = "\(PrivilegedQuarantineJournal.temporaryFilePrefix)\(UUID().uuidString)" + let descriptor = temporaryName.withCString { pointer in + Darwin.openat( + quarantineFD, + pointer, + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + mode_t(S_IRUSR | S_IWUSR) + ) + } + guard descriptor >= 0 else { + throw posixError("openat", path: displayPath) + } + var shouldRemove = true + defer { + Darwin.close(descriptor) + if shouldRemove { + temporaryName.withCString { pointer in + _ = Darwin.unlinkat(quarantineFD, pointer, 0) + } + } + } + + guard Darwin.fchmod(descriptor, mode_t(S_IRUSR | S_IWUSR)) == 0 else { + throw posixError("fchmod", path: displayPath) + } + try writeAll(encoded, descriptor: descriptor, displayPath: displayPath) + + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw posixError("fstat", path: displayPath) + } + let identity = FileIdentity(stat: info) + guard identity.isRegularFile, + identity.device == boundaryDevice, + identity.owner == UInt32(expectedOwner), + info.st_nlink == 1, + (info.st_mode & mode_t(0o777)) == mode_t(0o600) + else { + throw SecureDeletionError.identityChanged(displayPath) + } + try validateNoExtendedACL(descriptor, displayPath: displayPath) + try synchronizeFile(descriptor, displayPath: displayPath) + + let renameStatus: Int32 + if replacingExisting { + renameStatus = temporaryName.withCString { temporaryPointer in + PrivilegedQuarantineJournal.fileName.withCString { journalPointer in + Darwin.renameat( + quarantineFD, + temporaryPointer, + quarantineFD, + journalPointer + ) + } + } + } else { + renameStatus = temporaryName.withCString { temporaryPointer in + PrivilegedQuarantineJournal.fileName.withCString { journalPointer in + Darwin.renameatx_np( + quarantineFD, + temporaryPointer, + quarantineFD, + journalPointer, + UInt32(RENAME_EXCL) + ) + } + } + } + guard renameStatus == 0 else { + throw posixError("renameat", path: displayPath) + } + + shouldRemove = false + try synchronizeDirectory(quarantineFD, displayPath: displayPath) + + let installed = try readQuarantineJournal( + quarantineFD: quarantineFD, + expectedOwner: expectedOwner, + boundaryDevice: boundaryDevice, + displayPath: displayPath + ) + guard installed.transactionID == journal.transactionID, + installed.state == journal.state, + installed.request.id == journal.request.id + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + } + + private func readQuarantineJournal( + quarantineFD: Int32, + expectedOwner: uid_t, + boundaryDevice: UInt64, + displayPath: String + ) throws -> PrivilegedQuarantineJournal { + let descriptor = PrivilegedQuarantineJournal.fileName.withCString { pointer in + Darwin.openat( + quarantineFD, + pointer, + O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK + ) + } + guard descriptor >= 0 else { throw posixError("openat", path: displayPath) } + defer { Darwin.close(descriptor) } + + var initialInfo = stat() + guard Darwin.fstat(descriptor, &initialInfo) == 0 else { + throw posixError("fstat", path: displayPath) + } + let initialIdentity = FileIdentity(stat: initialInfo) + guard initialIdentity.isRegularFile, + initialIdentity.device == boundaryDevice, + initialIdentity.owner == UInt32(expectedOwner), + initialInfo.st_nlink == 1, + (initialInfo.st_mode & mode_t(0o777)) == mode_t(0o600), + initialInfo.st_size > 0, + initialInfo.st_size <= off_t(PrivilegedQuarantineJournal.maximumEncodedSize) + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + try validateNoExtendedACL(descriptor, displayPath: displayPath) + + let byteCount = Int(initialInfo.st_size) + var encoded = Data(count: byteCount) + try encoded.withUnsafeMutableBytes { (rawBuffer: UnsafeMutableRawBufferPointer) in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < byteCount { + let count = Darwin.pread( + descriptor, + baseAddress.advanced(by: offset), + byteCount - offset, + off_t(offset) + ) + if count < 0 { + if errno == EINTR { continue } + throw posixError("pread", path: displayPath) + } + guard count > 0 else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + offset += count + } + } + + var finalInfo = stat() + guard Darwin.fstat(descriptor, &finalInfo) == 0 else { + throw posixError("fstat", path: displayPath) + } + guard FileIdentity(stat: finalInfo) == initialIdentity, + finalInfo.st_size == initialInfo.st_size, + finalInfo.st_nlink == 1 + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + return try PropertyListDecoder().decode( + PrivilegedQuarantineJournal.self, + from: encoded + ) + } + + private func writeAll( + _ data: Data, + descriptor: Int32, + displayPath: String + ) throws { + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var offset = 0 + while offset < rawBuffer.count { + let written = Darwin.write( + descriptor, + baseAddress.advanced(by: offset), + rawBuffer.count - offset + ) + if written < 0 { + if errno == EINTR { continue } + throw posixError("write", path: displayPath) + } + guard written > 0 else { + throw SecureDeletionError.posix( + operation: "write", + path: displayPath, + code: EIO + ) + } + offset += written + } + } + } + + private func synchronizeFile(_ descriptor: Int32, displayPath: String) throws { + guard Darwin.fsync(descriptor) == 0 else { + throw posixError("fsync", path: displayPath) + } + guard Darwin.fcntl(descriptor, F_FULLFSYNC) == 0 else { + throw posixError("F_FULLFSYNC", path: displayPath) + } + } + + private func synchronizeDirectory(_ descriptor: Int32, displayPath: String) throws { + guard Darwin.fsync(descriptor) == 0 else { + throw posixError("fsync", path: displayPath) + } + guard Darwin.fcntl(descriptor, F_FULLFSYNC) == 0 else { + throw posixError("F_FULLFSYNC", path: displayPath) + } + } + + private func descriptorIdentity( + _ descriptor: Int32, + displayPath: String + ) throws -> FileIdentity { + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw posixError("fstat", path: displayPath) + } + return FileIdentity(stat: info) + } + + private func validatePrivateDirectoryDescriptor( + _ descriptor: Int32, + expectedOwner: uid_t, + expectedDevice: UInt64?, + displayPath: String + ) throws -> FileIdentity { + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw posixError("fstat", path: displayPath) + } + let identity = FileIdentity(stat: info) + guard identity.isDirectory, + identity.owner == UInt32(expectedOwner), + expectedDevice == nil || identity.device == expectedDevice, + (info.st_mode & mode_t(0o777)) == mode_t(0o700) + else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + try validateNoExtendedACL(descriptor, displayPath: displayPath) + return identity + } + + private func validateNoExtendedACL( + _ descriptor: Int32, + displayPath: String + ) throws { + errno = 0 + if let accessControlList = Darwin.acl_get_fd_np(descriptor, ACL_TYPE_EXTENDED) { + Darwin.acl_free(UnsafeMutableRawPointer(accessControlList)) + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + guard errno == ENOENT || errno == EOPNOTSUPP else { + throw posixError("acl_get_fd_np", path: displayPath) + } + } + + private func optionalIdentityAt( + parentFD: Int32, + name: String, + displayPath: String + ) throws -> FileIdentity? { + do { + return try identityAt( + parentFD: parentFD, + name: name, + displayPath: displayPath + ) + } catch let SecureDeletionError.posix(_, _, code) where code == ENOENT { + return nil + } + } + + private func removeFileIfIdentityMatches( + parentFD: Int32, + name: String, + expectedIdentity: FileIdentity, + displayPath: String + ) { + guard let observed = try? identityAt( + parentFD: parentFD, + name: name, + displayPath: displayPath + ), observed == expectedIdentity, observed.isRegularFile + else { + return + } + name.withCString { pointer in + _ = Darwin.unlinkat(parentFD, pointer, 0) + } + } + + private func removeEmptyDirectoryIfIdentityMatches( + parentFD: Int32, + name: String, + expectedIdentity: FileIdentity, + displayPath: String + ) { + guard let observed = try? identityAt( + parentFD: parentFD, + name: name, + displayPath: displayPath + ), observed == expectedIdentity, observed.isDirectory + else { + return + } + name.withCString { pointer in + _ = Darwin.unlinkat(parentFD, pointer, AT_REMOVEDIR) + } + } + + private func restoreQuarantinedEntry( + quarantineFD: Int32, + stagedName: String, + destinationParentFD: Int32, + destinationName: String, + displayPath: String + ) throws { + for _ in 0..<4 { + let restoreStatus = stagedName.withCString { stagedPointer in + destinationName.withCString { destinationPointer in + Darwin.renameatx_np( + quarantineFD, + stagedPointer, + destinationParentFD, + destinationPointer, + UInt32(RENAME_EXCL) + ) + } + } + if restoreStatus == 0 { return } + + if errno == ENOENT { continue } + guard errno == EEXIST else { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + + // Preserve both entries if something raced into the original + // name. Swap atomically, then give the interfering entry a fresh + // recovery name instead of deleting or overwriting it. + let swapStatus = stagedName.withCString { stagedPointer in + destinationName.withCString { destinationPointer in + Darwin.renameatx_np( + quarantineFD, + stagedPointer, + destinationParentFD, + destinationPointer, + UInt32(RENAME_SWAP) + ) + } + } + if swapStatus != 0 { + if errno == ENOENT { continue } + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + + for _ in 0..<8 { + let recoveryName = ".puremac-recovered-\(UUID().uuidString)" + let recoveryStatus = stagedName.withCString { stagedPointer in + recoveryName.withCString { recoveryPointer in + Darwin.renameatx_np( + quarantineFD, + stagedPointer, + destinationParentFD, + recoveryPointer, + UInt32(RENAME_EXCL) + ) + } + } + if recoveryStatus == 0 { return } + if errno != EEXIST { + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + } + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + throw SecureDeletionError.quarantineRecoveryFailed(displayPath) + } + + private func removeEntry( + parentFD: Int32, + name: String, + displayPath: String, + expectedIdentity: FileIdentity, + observedIdentity: FileIdentity, + boundaryDevice: UInt64, + depth: Int, + remainingEntries: inout Int + ) throws { + try cancellationCheck?() + guard depth <= maximumDepth, remainingEntries > 0 else { + throw SecureDeletionError.traversalLimitExceeded(displayPath) + } + remainingEntries -= 1 + + guard observedIdentity == expectedIdentity else { + throw SecureDeletionError.identityChanged(displayPath) + } + guard observedIdentity.device == boundaryDevice else { + throw SecureDeletionError.crossedDeviceBoundary(displayPath) + } + try validateOwnerAndType(observedIdentity, path: displayPath) + + if observedIdentity.isDirectory { + let directoryFD = name.withCString { pointer in + Darwin.openat(parentFD, pointer, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + } + guard directoryFD >= 0 else { + throw posixError("openat", path: displayPath) + } + defer { Darwin.close(directoryFD) } + + var openedInfo = stat() + guard Darwin.fstat(directoryFD, &openedInfo) == 0 else { + throw posixError("fstat", path: displayPath) + } + guard FileIdentity(stat: openedInfo) == observedIdentity else { + throw SecureDeletionError.identityChanged(displayPath) + } + + while true { + try removeDirectoryContents( + directoryFD: directoryFD, + displayPath: displayPath, + boundaryDevice: boundaryDevice, + depth: depth, + remainingEntries: &remainingEntries + ) + try verifyNamedIdentity( + parentFD: parentFD, + name: name, + displayPath: displayPath, + expectedIdentity: observedIdentity + ) + try beforeDirectoryUnlink?(displayPath) + + let status = name.withCString { pointer in + Darwin.unlinkat(parentFD, pointer, AT_REMOVEDIR) + } + if status == 0 { return } + if errno != ENOTEMPTY { + throw posixError("unlinkat", path: displayPath) + } + } + } + + try verifyNamedIdentity( + parentFD: parentFD, + name: name, + displayPath: displayPath, + expectedIdentity: observedIdentity + ) + let status = name.withCString { pointer in + Darwin.unlinkat(parentFD, pointer, 0) + } + guard status == 0 else { + throw posixError("unlinkat", path: displayPath) + } + } + + private func removeDirectoryContents( + directoryFD: Int32, + displayPath: String, + boundaryDevice: UInt64, + depth: Int, + remainingEntries: inout Int + ) throws { + while true { + try cancellationCheck?() + let names = try directoryEntryNames( + directoryFD: directoryFD, + displayPath: displayPath, + maximumCount: remainingEntries + ) + if names.isEmpty { return } + + for name in names { + try cancellationCheck?() + let childPath = displayPath + "/" + name + do { + let childIdentity = try identityAt( + parentFD: directoryFD, + name: name, + displayPath: childPath + ) + guard childIdentity.device == boundaryDevice else { + throw SecureDeletionError.crossedDeviceBoundary(childPath) + } + try validateOwnerAndType(childIdentity, path: childPath) + try removeEntry( + parentFD: directoryFD, + name: name, + displayPath: childPath, + expectedIdentity: childIdentity, + observedIdentity: childIdentity, + boundaryDevice: boundaryDevice, + depth: depth + 1, + remainingEntries: &remainingEntries + ) + } catch let SecureDeletionError.posix(_, _, code) where code == ENOENT { + // A child can disappear concurrently. It no longer resides + // in the selected tree, so continue; only the top-level + // initial lookup may produce the public `missing` status. + continue + } + } + } + } + + private func directoryEntryNames( + directoryFD: Int32, + displayPath: String, + maximumCount: Int + ) throws -> [String] { + // `dup` would share the directory offset with the original open file + // description. A second pass after ENOTEMPTY could then stay at EOF + // forever while a concurrently added entry remains. Opening `.` + // creates an independent description whose offset starts at zero. + let iterationFD = Darwin.openat( + directoryFD, + ".", + O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC + ) + guard iterationFD >= 0 else { + throw posixError("openat", path: displayPath) + } + guard let directory = Darwin.fdopendir(iterationFD) else { + Darwin.close(iterationFD) + throw posixError("fdopendir", path: displayPath) + } + defer { Darwin.closedir(directory) } + + var names: [String] = [] + while true { + errno = 0 + guard let entry = Darwin.readdir(directory) else { + if errno != 0 { throw posixError("readdir", path: displayPath) } + return names + } + let name = withUnsafePointer(to: &entry.pointee.d_name) { pointer -> String? in + pointer.withMemoryRebound(to: CChar.self, capacity: Int(MAXNAMLEN) + 1) { + String(validatingUTF8: $0) + } + } + guard let name else { + throw SecureDeletionError.invalidPath(displayPath + "/") + } + if name == "." || name == ".." { continue } + guard names.count < maximumCount else { + throw SecureDeletionError.traversalLimitExceeded(displayPath) + } + names.append(name) + } + } + + private func identityAt(parentFD: Int32, name: String, displayPath: String) throws -> FileIdentity { + var info = stat() + let status = name.withCString { pointer in + Darwin.fstatat(parentFD, pointer, &info, AT_SYMLINK_NOFOLLOW) + } + guard status == 0 else { + throw posixError("fstatat", path: displayPath) + } + return FileIdentity(stat: info) + } + + private func verifyNamedIdentity( + parentFD: Int32, + name: String, + displayPath: String, + expectedIdentity: FileIdentity + ) throws { + let observed = try identityAt(parentFD: parentFD, name: name, displayPath: displayPath) + guard observed == expectedIdentity else { + throw SecureDeletionError.identityChanged(displayPath) + } + } + + private func validateDirectoryDescriptor(_ descriptor: Int32, path: String) throws -> FileIdentity { + var info = stat() + guard Darwin.fstat(descriptor, &info) == 0 else { + throw posixError("fstat", path: path) + } + let identity = FileIdentity(stat: info) + guard identity.isDirectory else { + throw SecureDeletionError.unsupportedType(path) + } + guard identity.owner == UInt32(policy.userID) || identity.owner == 0 else { + throw SecureDeletionError.ownerMismatch(path: path, owner: uid_t(identity.owner)) + } + return identity + } + + private func validateOwnerAndType(_ identity: FileIdentity, path: String) throws { + guard identity.isSupportedType else { + throw SecureDeletionError.unsupportedType(path) + } + guard identity.owner == UInt32(policy.userID) || identity.owner == 0 else { + throw SecureDeletionError.ownerMismatch(path: path, owner: uid_t(identity.owner)) + } + } + + private func posixError(_ operation: String, path: String) -> SecureDeletionError { + SecureDeletionError.posix(operation: operation, path: path, code: errno) + } +} diff --git a/project.yml b/project.yml index f698d9d..3d7af94 100644 --- a/project.yml +++ b/project.yml @@ -20,7 +20,7 @@ settings: CODE_SIGN_IDENTITY: "Apple Development" CODE_SIGN_STYLE: "Automatic" DEVELOPMENT_TEAM: "H3WXHVTP97" - CODE_SIGN_ENTITLEMENTS: "PureMac/PureMac.entitlements" + CODE_SIGN_ENTITLEMENTS: "" INFOPLIST_FILE: "PureMac/Info.plist" PRODUCT_BUNDLE_IDENTIFIER: "com.puremac.app" MARKETING_VERSION: "2.8.5" @@ -34,19 +34,56 @@ targets: type: application platform: macOS sources: - - PureMac + - path: PureMac + excludes: + - Info.plist + - PureMac.entitlements + - Shared + - path: PureMacPrivilegedHelper/com.puremac.privileged-cleaning.plist + buildPhase: + copyFiles: + destination: wrapper + subpath: Contents/Library/LaunchDaemons dependencies: - package: Sparkle product: Sparkle + - sdk: ServiceManagement.framework + - sdk: Security.framework + - target: PureMacPrivilegedHelper + embed: true + link: false + codeSign: true + copy: + destination: wrapper + subpath: Contents/Library/LaunchServices settings: base: PRODUCT_NAME: PureMac SWIFT_EMIT_LOC_STRINGS: "YES" + CODE_SIGN_ENTITLEMENTS: "PureMac/PureMac.entitlements" + ENABLE_HARDENED_RUNTIME: "YES" LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/../Frameworks" - configs: - Debug: - CODE_SIGNING_ALLOWED: "NO" - CODE_SIGNING_REQUIRED: "NO" + + PureMacPrivilegedHelper: + type: tool + platform: macOS + sources: + - path: PureMacPrivilegedHelper + excludes: + - "*.plist" + - Shared + dependencies: + - sdk: Security.framework + settings: + base: + PRODUCT_NAME: com.puremac.privileged-helper + PRODUCT_BUNDLE_IDENTIFIER: com.puremac.privileged-helper + CODE_SIGN_ENTITLEMENTS: "" + INFOPLIST_FILE: "" + GENERATE_INFOPLIST_FILE: "NO" + ASSETCATALOG_COMPILER_APPICON_NAME: "" + SKIP_INSTALL: "YES" + ENABLE_HARDENED_RUNTIME: "YES" PureMacTests: type: bundle.unit-test diff --git a/scripts/release-local.sh b/scripts/release-local.sh index 8be8eb4..ac8f808 100755 --- a/scripts/release-local.sh +++ b/scripts/release-local.sh @@ -22,6 +22,8 @@ SIGN_ID="Developer ID Application: Moamen Basel (${TEAM_ID})" SCHEME="PureMac" PROJECT="PureMac.xcodeproj" APP="build/export/PureMac.app" +HELPER="${APP}/Contents/Library/LaunchServices/com.puremac.privileged-helper" +DAEMON_PLIST="${APP}/Contents/Library/LaunchDaemons/com.puremac.privileged-cleaning.plist" DMG="build/PureMac-${VERSION}.dmg" ZIP="build/PureMac-${VERSION}.zip" @@ -74,10 +76,32 @@ xcodebuild -exportArchive \ -exportOptionsPlist build/ExportOptions.plist echo "==> verify codesign" +APP_REQUIREMENT='anchor apple generic and identifier "com.puremac.app" and certificate leaf[subject.OU] = "H3WXHVTP97"' +HELPER_REQUIREMENT='anchor apple generic and identifier "com.puremac.privileged-helper" and certificate leaf[subject.OU] = "H3WXHVTP97"' +test -x "${HELPER}" +test -f "${DAEMON_PLIST}" +plutil -lint "${DAEMON_PLIST}" +test "$(/usr/libexec/PlistBuddy -c 'Print :Label' "${DAEMON_PLIST}")" = "com.puremac.privileged-cleaning" +test "$(/usr/libexec/PlistBuddy -c 'Print :BundleProgram' "${DAEMON_PLIST}")" = "Contents/Library/LaunchServices/com.puremac.privileged-helper" +test "$(/usr/libexec/PlistBuddy -c 'Print :AssociatedBundleIdentifiers:0' "${DAEMON_PLIST}")" = "com.puremac.app" +test "$(/usr/libexec/PlistBuddy -c 'Print :MachServices:com.puremac.privileged-cleaning' "${DAEMON_PLIST}")" = "true" codesign --verify --deep --strict --verbose=2 "${APP}" +codesign --verify --strict --verbose=2 "${HELPER}" +codesign --verify --strict --verbose=2 -R="${APP_REQUIREMENT}" "${APP}" +codesign --verify --strict --verbose=2 -R="${HELPER_REQUIREMENT}" "${HELPER}" +codesign -d --entitlements :- "${APP}" > /tmp/puremac-app-entitlements.plist +codesign -d --entitlements :- "${HELPER}" > /tmp/puremac-helper-entitlements.plist +! plutil -p /tmp/puremac-app-entitlements.plist | grep -E 'get-task-allow|disable-library-validation|allow-dyld-environment-variables' +! plutil -p /tmp/puremac-helper-entitlements.plist | grep -E 'get-task-allow|disable-library-validation|allow-dyld-environment-variables' codesign -dvv "${APP}" 2>&1 | grep -E "Identifier|TeamIdentifier|flags|Authority" codesign -dvv "${APP}" 2>&1 | grep -q "flags=0x10000(runtime)" || { echo "Hardened runtime missing"; exit 1; } +codesign -dvv "${HELPER}" 2>&1 | grep -E "Identifier|TeamIdentifier|flags|Authority" +codesign -dvv "${HELPER}" 2>&1 | grep -q "Identifier=com.puremac.privileged-helper" || { echo "Unexpected privileged helper identifier"; exit 1; } +codesign -dvv "${HELPER}" 2>&1 | grep -q "TeamIdentifier=${TEAM_ID}" || { echo "Unexpected privileged helper team"; exit 1; } +codesign -dvv "${HELPER}" 2>&1 | grep -q "flags=0x10000(runtime)" || { echo "Privileged helper hardened runtime missing"; exit 1; } lipo -archs "${APP}/Contents/MacOS/PureMac" +lipo -archs "${HELPER}" | grep -q "x86_64" +lipo -archs "${HELPER}" | grep -q "arm64" echo "==> dmg" create-dmg \