diff --git a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift index b588f48..9931ed0 100644 --- a/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift +++ b/Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift @@ -37,6 +37,7 @@ final class AtlasAppModel: ObservableObject { @Published private(set) var updateCheckNotice: String? @Published private(set) var updateCheckError: String? + private let repository: AtlasWorkspaceRepository private let workspaceController: AtlasWorkspaceController private let updateChecker = AtlasUpdateChecker() private let notificationPermissionRequester: @Sendable () async -> Bool @@ -53,6 +54,7 @@ final class AtlasAppModel: ObservableObject { notificationPermissionRequester: (@Sendable () async -> Bool)? = nil ) { let state = repository.loadState() + self.repository = repository self.snapshot = state.snapshot self.currentPlan = state.currentPlan self.settings = state.settings @@ -482,6 +484,12 @@ final class AtlasAppModel: ObservableObject { } await refreshPlanPreview() } catch { + let persistedState = repository.loadState() + withAnimation(.snappy(duration: 0.24)) { + snapshot = persistedState.snapshot + currentPlan = persistedState.currentPlan + settings = persistedState.settings + } latestScanSummary = error.localizedDescription } diff --git a/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift b/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift index 1ce9031..f63b8ce 100644 --- a/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift +++ b/Apps/AtlasApp/Tests/AtlasAppTests/AtlasAppModelTests.swift @@ -189,6 +189,62 @@ final class AtlasAppModelTests: XCTestCase { XCTAssertNil(model.smartCleanExecutionIssue) } + func testRestoreExpiredRecoveryItemReloadsPersistedState() async throws { + let baseDate = Date(timeIntervalSince1970: 1_710_000_000) + let clock = TestClock(now: baseDate) + let repository = makeRepository(nowProvider: { clock.now }) + let finding = Finding( + id: UUID(), + title: "Expiring fixture", + detail: "Expires soon", + bytes: 5, + risk: .safe, + category: "Developer tools" + ) + let recoveryItem = RecoveryItem( + id: UUID(), + title: finding.title, + detail: finding.detail, + originalPath: "~/Library/Caches/AtlasOnly", + bytes: 5, + deletedAt: baseDate, + expiresAt: baseDate.addingTimeInterval(10), + payload: .finding(finding), + restoreMappings: nil + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [recoveryItem], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService( + repository: repository, + nowProvider: { clock.now }, + allowStateOnlyCleanExecution: true + ) + let model = AtlasAppModel(repository: repository, workerService: worker) + XCTAssertTrue(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id })) + + clock.now = baseDate.addingTimeInterval(60) + await model.restoreRecoveryItem(recoveryItem.id) + + XCTAssertFalse(model.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id })) + XCTAssertEqual( + model.latestScanSummary, + AtlasL10n.string("application.error.restoreExpired", "One or more selected recovery items have expired and can no longer be restored.") + ) + } + func testSettingsUpdatePersistsThroughWorker() async throws { let repository = makeRepository() let permissionInspector = AtlasPermissionInspector( @@ -309,11 +365,12 @@ final class AtlasAppModelTests: XCTestCase { XCTAssertEqual(AtlasRoute.overview.title, "Overview") } - private func makeRepository() -> AtlasWorkspaceRepository { + private func makeRepository(nowProvider: @escaping @Sendable () -> Date = { Date() }) -> AtlasWorkspaceRepository { AtlasWorkspaceRepository( stateFileURL: FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) - .appendingPathComponent("workspace-state.json") + .appendingPathComponent("workspace-state.json"), + nowProvider: nowProvider ) } } @@ -415,3 +472,11 @@ private actor ExecuteRejectingRestoreDelegatingWorker: AtlasWorkerServing { } } } + +private final class TestClock: @unchecked Sendable { + var now: Date + + init(now: Date) { + self.now = now + } +} diff --git a/Atlas.xcodeproj/project.pbxproj b/Atlas.xcodeproj/project.pbxproj index 353ed13..9ba71b7 100644 --- a/Atlas.xcodeproj/project.pbxproj +++ b/Atlas.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 18248DDA2E6242D30B2FF84B /* AtlasFeaturesSettings in Frameworks */ = {isa = PBXBuildFile; productRef = FE51513F5C3746B2C3DA5E9A /* AtlasFeaturesSettings */; }; 18361B20FDB815F8F80A8D89 /* AtlasCoreAdapters in Frameworks */ = {isa = PBXBuildFile; productRef = A110B5FE410BD691B10F4338 /* AtlasCoreAdapters */; }; 1FD68E8A5DFA42F86C474290 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10ECA6F0B2C093A4FDBA60A5 /* main.swift */; }; + 3F61098A3E68EB5B385D676C /* AtlasAppModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */; }; 568260A734C660E1C1E29EEF /* AtlasApplication in Frameworks */ = {isa = PBXBuildFile; productRef = 07F92560DDAA3271466226A0 /* AtlasApplication */; }; 5E17D3D1A8B2B6844C11E4A0 /* AppShellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */; }; 69A95E0759F67A6749B13268 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7514A7238A2A0C3B19F6D967 /* Assets.xcassets */; }; @@ -50,6 +51,13 @@ remoteGlobalIDString = 6554EF197FBC626F52F4BA4B; remoteInfo = AtlasWorkerXPC; }; + 4D8C0366067B16B0EDEFF06F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 43D55555CA7BCC7C87E44A39 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 98260B956C6EC40DBBEEC103; + remoteInfo = AtlasApp; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -68,12 +76,14 @@ /* Begin PBXFileReference section */ 10ECA6F0B2C093A4FDBA60A5 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 13B5E85855AB6534C486F6AB /* AtlasAppUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppUITests.swift; sourceTree = ""; }; + 1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppModelTests.swift; sourceTree = ""; }; 31527C6248CC4F0D354F6593 /* TaskCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskCenterView.swift; sourceTree = ""; }; 67FF6A7D6D44C9F4789DA0FF /* Packages */ = {isa = PBXFileReference; lastKnownFileType = folder; name = Packages; path = Packages; sourceTree = SOURCE_ROOT; }; 6A363C421B6DA2EFE09AE3D7 /* ReadmeAssetExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadmeAssetExporter.swift; sourceTree = ""; }; 6D6F7E5AF1DB77BD9455C253 /* AppShellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppShellView.swift; sourceTree = ""; }; 7514A7238A2A0C3B19F6D967 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtlasAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AtlasAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */ = {isa = PBXFileReference; explicitFileType = "wrapper.xpc-service"; includeInIndex = 0; path = AtlasWorkerXPC.xpc; sourceTree = BUILT_PRODUCTS_DIR; }; 9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AtlasApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; A57454A431BBD25C9D9F7ACA /* AtlasAppCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtlasAppCommands.swift; sourceTree = ""; }; @@ -115,6 +125,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 239A9C84704A886CD4AF1BE3 /* AtlasAppTests */ = { + isa = PBXGroup; + children = ( + 1E3F033599CA5CB41CC01A2D /* AtlasAppModelTests.swift */, + ); + path = AtlasAppTests; + sourceTree = ""; + }; 33096698F9C248F87E324810 /* Packages */ = { isa = PBXGroup; children = ( @@ -135,6 +153,7 @@ isa = PBXGroup; children = ( 3FA23CAAED1482D5CD7DBA21 /* Sources */, + 6E9CA333F7EF9B53949FFE51 /* Tests */, ); path = AtlasApp; sourceTree = ""; @@ -147,6 +166,14 @@ path = AtlasWorkerXPC; sourceTree = ""; }; + 6E9CA333F7EF9B53949FFE51 /* Tests */ = { + isa = PBXGroup; + children = ( + 239A9C84704A886CD4AF1BE3 /* AtlasAppTests */, + ); + path = Tests; + sourceTree = ""; + }; 70A20106B8807AFCC4851B2C = { isa = PBXGroup; children = ( @@ -177,6 +204,7 @@ isa = PBXGroup; children = ( 9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */, + 7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */, 7B60D354F907D973C9D78524 /* AtlasAppUITests.xctest */, 97A2723BA50C375AFC3C9321 /* AtlasWorkerXPC.xpc */, ); @@ -280,6 +308,24 @@ productReference = 9AB1D202267B7A0E93C4D7A4 /* AtlasApp.app */; productType = "com.apple.product-type.application"; }; + BC1D0FBB35276C2AE56A0268 /* AtlasAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AFB6375FA8AB26AE68C66925 /* Build configuration list for PBXNativeTarget "AtlasAppTests" */; + buildPhases = ( + E0B1CC6AEF9B151A45B8A49E /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 1402ADF5C02FD65B5B68E38C /* PBXTargetDependency */, + ); + name = AtlasAppTests; + packageProductDependencies = ( + ); + productName = AtlasAppTests; + productReference = 7FF49AAA8C253DBEE96EC8D3 /* AtlasAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; DC24C4DDD452116007066447 /* AtlasAppUITests */ = { isa = PBXNativeTarget; buildConfigurationList = E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */; @@ -330,6 +376,7 @@ projectRoot = ""; targets = ( 98260B956C6EC40DBBEEC103 /* AtlasApp */, + BC1D0FBB35276C2AE56A0268 /* AtlasAppTests */, DC24C4DDD452116007066447 /* AtlasAppUITests */, 6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */, ); @@ -377,9 +424,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E0B1CC6AEF9B151A45B8A49E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 3F61098A3E68EB5B385D676C /* AtlasAppModelTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 1402ADF5C02FD65B5B68E38C /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 98260B956C6EC40DBBEEC103 /* AtlasApp */; + targetProxy = 4D8C0366067B16B0EDEFF06F /* PBXContainerItemProxy */; + }; 4561468E37A46EEB82269AB0 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 6554EF197FBC626F52F4BA4B /* AtlasWorkerXPC */; @@ -511,6 +571,7 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app; + PRODUCT_MODULE_NAME = AtlasApp; PRODUCT_NAME = "Atlas for Mac"; SDKROOT = macosx; }; @@ -579,6 +640,25 @@ }; name = Debug; }; + A91320B2152453D347819B2E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + AD_HOC_CODE_SIGNING_ALLOWED = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.tests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Atlas for Mac.app/Contents/MacOS/Atlas for Mac"; + }; + name = Debug; + }; CBD20A4D8B026FF2EDBFF1DC /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -599,11 +679,31 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app; + PRODUCT_MODULE_NAME = AtlasApp; PRODUCT_NAME = "Atlas for Mac"; SDKROOT = macosx; }; name = Release; }; + D2CF1D133D0E5FFAEFA90E2F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + AD_HOC_CODE_SIGNING_ALLOWED = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.tests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Atlas for Mac.app/Contents/MacOS/Atlas for Mac"; + }; + name = Release; + }; E15130E42E1B6078B50A4F28 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -672,6 +772,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + AFB6375FA8AB26AE68C66925 /* Build configuration list for PBXNativeTarget "AtlasAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A91320B2152453D347819B2E /* Debug */, + D2CF1D133D0E5FFAEFA90E2F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; E425825D3882044CA19B8446 /* Build configuration list for PBXNativeTarget "AtlasAppUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/Atlas.xcodeproj/xcshareddata/xcschemes/AtlasApp.xcscheme b/Atlas.xcodeproj/xcshareddata/xcschemes/AtlasApp.xcscheme index abde99e..0a0216b 100644 --- a/Atlas.xcodeproj/xcshareddata/xcschemes/AtlasApp.xcscheme +++ b/Atlas.xcodeproj/xcshareddata/xcschemes/AtlasApp.xcscheme @@ -53,6 +53,17 @@ + + + + diff --git a/Docs/ADR/ADR-007-Recovery-Retention-Enforcement.md b/Docs/ADR/ADR-007-Recovery-Retention-Enforcement.md new file mode 100644 index 0000000..5a7ddc8 --- /dev/null +++ b/Docs/ADR/ADR-007-Recovery-Retention-Enforcement.md @@ -0,0 +1,32 @@ +# ADR-007: Recovery Retention Enforcement + +## Status + +Accepted + +## Context + +Atlas already documents a retention-window recovery model, including `RecoveryItem.expiresAt`, the `expired` task-state concept, and `restore_expired` in the error-code registry. The shipped worker, however, still restores items solely by presence in `snapshot.recoveryItems`. That means an expired entry can remain visible and restorable if it has not yet been pruned from persisted state. + +This creates a trust gap in a release-sensitive area: History and Recovery can claim that items are available only while the retention window remains open, while the implementation still allows restore after expiry. + +## Decision + +- Atlas must treat expiry as an enforced worker and persistence boundary, not only as UI copy. +- `AtlasWorkspaceRepository` must prune expired `RecoveryItem`s on load and save so stale entries do not remain in active recovery state across launches. +- `AtlasScaffoldWorkerService.restoreItems` must recheck expiry at request time and fail closed before any restore side effect. +- Restore rejections must use stable restore-specific protocol codes for expiry and restore conflicts. +- Presentation may add defensive restore disabling for expired entries, but worker enforcement remains authoritative. + +## Consequences + +- Recovery behavior now matches the documented retention contract. +- Expired entries stop appearing as active recovery inventory after repository normalization. +- Restore batches remain fail closed: if any selected item is expired, the batch is rejected before mutation. +- Protocol consumers must handle the additional restore-specific rejection codes. + +## Alternatives Considered + +- Narrow docs to match current behavior: rejected because it preserves an avoidable trust gap. +- Enforce expiry only in the restore command: rejected because stale entries would still persist in active recovery state. +- Fix only in UI: rejected because restore is ultimately a worker-boundary guarantee. diff --git a/Docs/Architecture.md b/Docs/Architecture.md index 7a43e65..e5075de 100644 --- a/Docs/Architecture.md +++ b/Docs/Architecture.md @@ -40,6 +40,7 @@ - XPC transport - JSON-backed workspace state persistence +- Recovery-state normalization that prunes expired recovery entries on load/save - Logging and audit events - Best-effort permission inspection - Helper executable client @@ -54,6 +55,7 @@ - Allowlisted helper actions for bundle trashing, restoration, and launch-service removal - Release-facing execution must fail closed when real worker/adapter/helper capability is unavailable; scaffold fallback is development-only by opt-in - Smart Clean now supports a real Trash-based execution path for a safe structured subset of user-owned targets, plus physical restoration when recovery mappings are present +- Restore requests recheck expiry and destination conflicts before side effects, so expired or conflicting recovery items fail closed ## Process Boundaries diff --git a/Docs/ErrorCodes.md b/Docs/ErrorCodes.md index f1522a8..96f854f 100644 --- a/Docs/ErrorCodes.md +++ b/Docs/ErrorCodes.md @@ -31,6 +31,11 @@ - Use sheets for permission and destructive confirmation flows. - Use result pages for partial success, cancellation, and recovery outcomes. +## Recovery Semantics + +- `restore_expired` — the recovery retention window has closed; the item must no longer be restorable and should disappear from active recovery state on the next refresh. +- `restore_conflict` — the original destination already exists; the restore request must fail closed without moving the trashed source. + ## Format - User-visible format recommendation: `ATLAS--` diff --git a/Docs/Execution/Recovery-Contract-2026-03-13.md b/Docs/Execution/Recovery-Contract-2026-03-13.md new file mode 100644 index 0000000..04ce75c --- /dev/null +++ b/Docs/Execution/Recovery-Contract-2026-03-13.md @@ -0,0 +1,145 @@ +# Recovery Contract — 2026-03-13 + +## Goal + +Freeze Atlas recovery semantics against the behavior that is actually shipped today. + +## Scope + +- `ATL-221` physical restore for file-backed recoverable actions where safe +- `ATL-222` restore validation on real file-backed test cases +- `ATL-223` README, in-app, and release-facing recovery claim audit +- `ATL-224` recovery contract and acceptance evidence freeze + +## Canonical Contract + +### 1. What a recovery item means + +- Every recoverable destructive flow must produce a structured `RecoveryItem`. +- A `RecoveryItem` may carry `restoreMappings` that pair the original path with the actual path returned from Trash. +- `restoreMappings` are the only shipped proof that Atlas can claim an on-disk return path for that item. + +### 2. When Atlas can claim physical restore + +Atlas can claim physical on-disk restore only when all of the following are true: + +- the recovery item still exists in active Atlas recovery state +- its retention window is still open +- the recovery item contains at least one `restoreMappings` entry +- the trashed source still exists on disk +- the original destination path does not already exist +- the required execution capability is available: + - direct move for supported user-trashable targets such as `~/Library/Caches/*` and `~/Library/pnpm/store/*` + - helper-backed restore for protected targets such as app bundles under `/Applications` or `~/Applications` + +### 3. When Atlas restores state only + +If a recovery item has no `restoreMappings`, Atlas may still restore Atlas workspace state by rehydrating the saved `Finding` or `AppFootprint` payload. + +State-only restore means: + +- the item reappears in Atlas UI state +- the action remains auditable in History +- Atlas does not claim the underlying file or bundle returned on disk + +### 4. Failure behavior + +Restore remains fail-closed. Atlas rejects the restore request instead of claiming success when: + +- the recovery item has expired and is no longer active in recovery state (`restoreExpired`) +- the trash source no longer exists +- the original destination already exists (`restoreConflict`) +- the target falls outside the supported direct/helper allowlist +- a required helper capability is unavailable (`helperUnavailable`) +- another restore precondition fails after validation (`executionUnavailable`) + +### 5. History and completion wording + +- Disk-backed restores must use the disk-specific completion summary. +- State-only restores must use the Atlas-only completion summary. +- Mixed restore batches must report both clauses instead of collapsing everything into a physical-restore claim. + +## Accepted Physical Restore Surface + +### Direct restore paths + +The currently proven direct restore surface is the same safe structured subset used by Smart Clean execution: + +- `~/Library/Caches/*` +- `~/Library/pnpm/store/*` +- other targets explicitly allowed by `AtlasSmartCleanExecutionSupport.isDirectlyTrashable` + +### Helper-backed restore paths + +The currently proven helper-backed restore surface includes app bundles that require the privileged helper path: + +- `/Applications/*.app` +- `~/Applications/*.app` + +## Claim Audit + +The following surfaces now match the frozen contract and must stay aligned: + +- `README.md` +- `README.zh-CN.md` +- `Docs/Protocol.md` +- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings` + +No additional README or in-app narrowing is required in this slice because the shipped wording already distinguishes: + +- supported on-disk restore +- Atlas-only state restoration +- fail-closed behavior for unsupported or unprovable actions + +## Release-Note-Safe Wording + +Future release notes must stay within these statements unless the restore surface expands and new evidence is added: + +- `Recoverable items can be restored when a supported recovery path is available.` +- `Some recoverable items restore on disk, while older or unstructured records restore Atlas state only.` +- `Atlas only claims physical return when it recorded a supported restore path for that item.` +- `Unsupported or unavailable restore paths fail closed instead of being reported as restored.` + +Avoid saying: + +- `All recoverable items restore physically.` +- `History always returns deleted files to disk.` +- `Restore succeeded` when Atlas only rehydrated workspace state. + +## Acceptance Evidence + +### Automated proof points + +- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` + - `testRepositorySaveStatePrunesExpiredRecoveryItems` + - `testRestoreRecoveryItemPhysicallyRestoresRealTargets` + - `testExecuteAppUninstallRestorePhysicallyRestoresAppBundle` + - `testRestoreItemsStateOnlySummaryDoesNotClaimOnDiskRestore` + - `testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses` + - `testRestoreItemsRejectsExpiredRecoveryItemsAndPrunesThem` + - `testRestoreItemsRejectsWhenDestinationAlreadyExists` + - `testScanExecuteRescanRemovesExecutedTargetFromRealResults` + - `testScanExecuteRescanRemovesExecutedPnpmStoreTargetFromRealResults` +- `Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift` + - `testRestoreItemsMapsRestoreExpiredToLocalizedError` + - `testRestoreItemsMapsRestoreConflictToLocalizedError` +- `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md` +- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md` + +### What the evidence proves + +- direct-trash file-backed recovery physically returns a real file to its original path +- helper-backed app uninstall recovery physically returns a real app bundle to its original path +- Atlas-only recovery records do not overclaim on-disk restore +- mixed restore batches preserve truthful summaries when disk-backed and Atlas-only items are restored together +- expired recovery items are pruned from active recovery state and fail closed if a restore arrives after expiry +- destination collisions return a stable restore-specific rejection instead of claiming completion +- supported file-backed targets still satisfy `scan -> execute -> rescan` credibility checks + +## Remaining Limits + +- Physical restore is intentionally partial, not universal. +- Older or unstructured recovery entries remain Atlas-state-only unless they carry `restoreMappings`. +- Broader restore scope must not ship without new allowlist review, automated coverage, and matching copy updates. diff --git a/Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md b/Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md new file mode 100644 index 0000000..bb3d9bf --- /dev/null +++ b/Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md @@ -0,0 +1,96 @@ +# Recovery Credibility Gate Review + +## Gate + +- `Recovery Credibility` + +## Review Date + +- `2026-03-13` + +## Scope Reviewed + +- `ATL-221` implement physical restore for file-backed recoverable actions where safe +- `ATL-222` validate shipped restore behavior on real file-backed test cases +- `ATL-223` narrow README, in-app, and release-note recovery claims if needed +- `ATL-224` freeze recovery contract and acceptance evidence +- `ATL-225` recovery credibility gate review + +## Readiness Checklist + +- [x] Required P0 tasks complete +- [x] Docs updated +- [x] Risks reviewed +- [x] Open questions below threshold +- [x] Next-stage inputs available + +## Evidence Reviewed + +- `Docs/Protocol.md` +- `Docs/Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` +- `Docs/Execution/Smart-Clean-QA-Checklist-2026-03-09.md` +- `Docs/Execution/Smart-Clean-Manual-Verification-2026-03-09.md` +- `Docs/Execution/Recovery-Contract-2026-03-13.md` +- `README.md` +- `README.zh-CN.md` +- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings` +- `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings` + +## Automated Validation Summary + +- `swift test --package-path Packages --filter AtlasInfrastructureTests` — pass +- `swift test --package-path Packages --filter AtlasApplicationTests` — pass +- `swift test --package-path Packages` — pass + +## Gate Assessment + +### ATL-221 Physical Restore Surface + +- File-backed recovery items now restore physically when Atlas recorded `restoreMappings` from a real Trash move. +- Supported direct-trash targets restore back to their original on-disk path. +- Protected app-bundle targets restore through the helper-backed path instead of claiming an unproven direct move. +- Restore remains fail-closed when the source, destination, or capability contract is not satisfied. + +### ATL-222 Shipped Restore Evidence + +- Automated tests now cover both proven physical restore classes: + - direct-trash file-backed Smart Clean targets + - helper-backed app uninstall targets +- State-only recovery remains explicitly covered so Atlas does not regress into overclaiming physical restore. +- Mixed restore summaries are covered so a batch containing both kinds of items stays truthful. +- Expired recovery items are now covered as a fail-closed path and are pruned from active recovery state. +- Restore destination conflicts now return a stable restore-specific rejection instead of being reported as generic success. + +### ATL-223 Claim Audit + +- README and localized in-app strings already reflect the narrowed recovery promise. +- No new copy narrowing was required in this slice. +- This gate freezes a release-note-safe wording set in `Docs/Execution/Recovery-Contract-2026-03-13.md` so future release notes cannot overstate restore behavior. + +### ATL-224 Contract Freeze + +- The recovery contract is now explicit, evidence-backed, and tied to shipped protocol fields and worker behavior. +- The contract distinguishes physical restore from Atlas-only state rehydration and documents the exact failure conditions, including expiry and destination conflicts. + +## Remaining Limits + +- Physical restore is still partial and depends on supported `restoreMappings`. +- Older or unstructured recovery items still restore Atlas state only. +- Broader restore coverage, including additional protected or system-managed targets, must not be described as shipped until new allowlist and QA evidence exist. + +## Decision + +- `Pass with Conditions` + +## Conditions + +- Release-facing copy must continue to use the frozen wording in `Docs/Execution/Recovery-Contract-2026-03-13.md`. +- Any future restore-surface expansion must add automated proof for the new target class before copy is widened. +- Candidate-build QA should still rerun the manual restore checklist on packaged artifacts before external distribution. + +## Follow-up Actions + +- Reuse the frozen recovery contract in future release notes and internal beta notices. +- Add new restore targets only after allowlist review, helper-path review, and contract tests land together. +- Re-run packaged-app manual restore verification when signed distribution work resumes. diff --git a/Docs/Protocol.md b/Docs/Protocol.md index c88d67c..49def55 100644 --- a/Docs/Protocol.md +++ b/Docs/Protocol.md @@ -8,7 +8,7 @@ ## Protocol Version -- Current implementation version: `0.3.0` +- Current implementation version: `0.3.1` ## UI ↔ Worker Commands @@ -56,6 +56,8 @@ - `permissionRequired` - `helperUnavailable` - `executionUnavailable` +- `restoreExpired` +- `restoreConflict` - `invalidSelection` ## Event Payloads @@ -146,6 +148,7 @@ - `scan.start` is backed by `bin/clean.sh --dry-run` through `MoleSmartCleanAdapter` when the upstream workflow succeeds. If it cannot complete, the worker now rejects the request instead of silently fabricating scan results. - `apps.list` is backed by `MacAppsInventoryAdapter`, which scans local app bundles and derives leftover counts. - The worker persists a local JSON-backed workspace state containing the latest snapshot, current Smart Clean plan, and settings, including the persisted app-language preference. +- The repository and worker normalize recovery state by pruning expired `RecoveryItem`s and rejecting restore requests that arrive after the retention window has closed. - Atlas localizes user-facing shell copy through a package-scoped resource bundle and uses the persisted language to keep summaries and settings text aligned. - App uninstall can invoke the packaged or development helper executable through structured JSON actions. - Structured Smart Clean findings can now carry executable target paths, and a safe subset of those targets can be moved to Trash and physically restored later. @@ -154,3 +157,4 @@ - `executePlan` is fail-closed for unsupported targets, but now supports a real Trash-based execution path for a safe structured subset of Smart Clean items. - `recovery.restore` can physically restore items when `restoreMappings` are present; otherwise it falls back to model rehydration only. +- `recovery.restore` rejects expired recovery items with `restoreExpired` and rejects destination collisions with `restoreConflict`. diff --git a/Docs/README.md b/Docs/README.md index 73fd2bf..88c8ecf 100644 --- a/Docs/README.md +++ b/Docs/README.md @@ -30,6 +30,8 @@ This directory contains the working product, design, engineering, and compliance - `Execution/Execution-Chain-Audit-2026-03-09.md` — end-to-end review of real vs scaffold execution paths and release-facing trust gaps - `Execution/Implementation-Plan-ATL-201-202-205-2026-03-12.md` — implementation plan for internal-beta hardening tasks ATL-201, ATL-202, and ATL-205 - `Execution/Execution-Credibility-Gate-Review-2026-03-12.md` — gate review for ATL-211, ATL-212, and ATL-215 Smart Clean execution credibility work +- `Execution/Recovery-Contract-2026-03-13.md` — frozen recovery semantics, claim boundaries, and acceptance evidence for ATL-221 through ATL-224 +- `Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` — gate review for ATL-221 through ATL-225 recovery credibility work - `Execution/Smart-Clean-Execution-Coverage-2026-03-09.md` — user-facing summary of what Smart Clean can execute for real today - `Execution/Smart-Clean-QA-Checklist-2026-03-09.md` — QA checklist for scan, execute, rescan, and physical restore validation - `Execution/Smart-Clean-Manual-Verification-2026-03-09.md` — local-machine fixture workflow for validating real Smart Clean execution and restore diff --git a/Docs/TaskStateMachine.md b/Docs/TaskStateMachine.md index c3026e9..a8f25ba 100644 --- a/Docs/TaskStateMachine.md +++ b/Docs/TaskStateMachine.md @@ -57,6 +57,7 @@ - Progress must not move backwards. - Destructive tasks must be audited. - Recoverable tasks must leave structured recovery entries until restored or expired. +- Expired recovery entries must no longer remain actionable in active recovery state. - Repeated write requests must honor idempotency rules when those flows become externally reentrant. ## Current MVP Notes @@ -65,4 +66,5 @@ - `execute_clean` must not report completion in release-facing flows unless real cleanup side effects have been applied. Fresh preview plans now carry structured execution targets, and unsupported or unstructured targets should fail closed. - `execute_uninstall` removes an app from the current workspace view and creates a recovery entry. - `restore` can physically restore items when structured recovery mappings are present, and can still rehydrate a `Finding` or an `AppFootprint` into Atlas state from the recovery payload. +- `restore` must reject expired recovery items before side effects and must fail closed when the original destination already exists. - User-visible task summaries and settings-driven text should reflect the persisted app-language preference when generated. diff --git a/Docs/plans/2026-03-13-recovery-credibility.md b/Docs/plans/2026-03-13-recovery-credibility.md new file mode 100644 index 0000000..00671db --- /dev/null +++ b/Docs/plans/2026-03-13-recovery-credibility.md @@ -0,0 +1,172 @@ +# Recovery Credibility Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Freeze Atlas recovery semantics against shipped behavior by adding missing restore coverage and publishing explicit acceptance evidence and a gate review for ATL-221 through ATL-225. + +**Architecture:** The worker already supports restore mappings for file-backed recovery items and Atlas-only rehydration for older/state-only records. This slice should avoid widening restore scope; instead it should prove the current contract with focused automated tests, then freeze that contract in execution docs and a recovery gate review. + +**Tech Stack:** Swift Package Manager, XCTest, Markdown docs + +--- + +### Task 1: Add helper-backed app restore coverage + +**Files:** +- Modify: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- Check: `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift` + +**Step 1: Write the failing test** + +Add a test that: +- creates a fake installed app under `~/Applications/AtlasExecutionTests/...` +- injects a stub `AtlasPrivilegedActionExecuting` +- executes app uninstall +- restores the resulting recovery item +- asserts the app bundle returns to its original path and the restore summary uses the disk-backed wording + +**Step 2: Run test to verify it fails** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testExecuteAppUninstallRestorePhysicallyRestoresAppBundle` +Expected: FAIL until the stub/helper-backed path is wired correctly in the test. + +**Step 3: Write minimal implementation** + +Implement only the test support needed: +- a stub helper executor that handles `.trashItems` and `.restoreItem` +- deterministic assertions for returned `restoreMappings` + +**Step 4: Run test to verify it passes** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testExecuteAppUninstallRestorePhysicallyRestoresAppBundle` +Expected: PASS + +**Step 5: Commit** + +```bash +git add Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +git commit -m "test: cover helper-backed app restore" +``` + +### Task 2: Add mixed recovery summary coverage + +**Files:** +- Modify: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- Check: `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift:1086` + +**Step 1: Write the failing test** + +Add a test that restores: +- one recovery item with `restoreMappings` +- one recovery item without `restoreMappings` + +Assert the task summary contains both: +- disk restore wording +- Atlas-only restore wording + +**Step 2: Run test to verify it fails** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses` +Expected: FAIL if the combined contract is not proven yet. + +**Step 3: Write minimal implementation** + +If needed, adjust only test fixtures or summary generation so mixed restores preserve both clauses without overstating physical restore. + +**Step 4: Run test to verify it passes** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests/testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses` +Expected: PASS + +**Step 5: Commit** + +```bash +git add Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +git commit -m "test: cover mixed recovery summaries" +``` + +### Task 3: Freeze recovery contract and evidence + +**Files:** +- Create: `Docs/Execution/Recovery-Contract-2026-03-13.md` +- Create: `Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` +- Modify: `Docs/README.md` +- Check: `Docs/Protocol.md` +- Check: `README.md` +- Check: `README.zh-CN.md` +- Check: `Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings` +- Check: `Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings` + +**Step 1: Write the contract doc** + +Document exactly what Atlas promises today: +- file-backed recovery physically restores only when `restoreMappings` exist +- Atlas-only recovery rehydrates workspace state without claiming on-disk return +- helper-backed restore is required for protected paths like app bundles +- restore fails closed when the trash source is gone, the destination already exists, or helper capability is unavailable + +**Step 2: Write the evidence section** + +Reference automated proof points: +- direct-trash cache restore test +- helper-backed app uninstall restore test +- mixed summary/state-only tests +- existing `scan -> execute -> rescan` coverage for supported targets + +**Step 3: Write the gate review** + +Mirror the existing execution gate format and record: +- scope reviewed (`ATL-221` to `ATL-225`) +- evidence reviewed +- automated validation summary +- remaining limits +- decision and follow-up conditions + +**Step 4: Update docs index** + +Add the new recovery contract and gate review docs to `Docs/README.md`. + +**Step 5: Commit** + +```bash +git add Docs/Execution/Recovery-Contract-2026-03-13.md Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md Docs/README.md +git commit -m "docs: freeze recovery contract and gate evidence" +``` + +### Task 4: Run focused validation + +**Files:** +- Check: `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` +- Check: `Docs/Execution/Recovery-Contract-2026-03-13.md` +- Check: `Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md` + +**Step 1: Run targeted infrastructure tests** + +Run: `swift test --package-path Packages --filter AtlasInfrastructureTests` +Expected: PASS + +**Step 2: Run broader package tests** + +Run: `swift test --package-path Packages` +Expected: PASS + +**Step 3: Sanity-check docs claims** + +Verify every new doc line matches one of: +- protocol contract +- localized UI copy +- automated test evidence + +**Step 4: Summarize remaining limits** + +Call out that: +- physical restore is still partial by design +- unsupported or older recovery items remain Atlas-state-only +- broader restore scope should not expand without new allowlist and QA evidence + +**Step 5: Commit** + +```bash +git add Docs/README.md Docs/Execution/Recovery-Contract-2026-03-13.md Docs/Execution/Recovery-Credibility-Gate-Review-2026-03-13.md Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +git commit -m "chore: validate recovery credibility slice" +``` diff --git a/Docs/plans/2026-03-13-recovery-retention-enforcement.md b/Docs/plans/2026-03-13-recovery-retention-enforcement.md new file mode 100644 index 0000000..4608126 --- /dev/null +++ b/Docs/plans/2026-03-13-recovery-retention-enforcement.md @@ -0,0 +1,62 @@ +# Recovery Retention Enforcement Plan + +## Goal + +Align shipped recovery behavior with the existing Atlas retention contract so expired recovery items are no longer restorable, no longer linger as active recovery entries, and return stable restore-specific protocol errors. + +## Problem + +The current worker restores any `RecoveryItem` still present in state, even when `expiresAt` is already in the past. The app also keeps the restore action available as long as the item remains selected. This breaks the retention-window claim already present in the protocol, task-state, and recovery docs. + +## Options + +### Option A: Narrow docs to match current code + +- Remove the retention-window restore claim from docs and gate reviews. +- Keep restore behavior unchanged. + +Why not: + +- It weakens an existing product promise instead of fixing the trust gap. +- It leaves expired recovery items actionable in UI and worker flows. + +### Option B: Enforce expiry only inside `restoreItems` + +- Reject restore requests when any selected `RecoveryItem.expiresAt` is in the past. +- Leave repository state unchanged. + +Why not: + +- Expired entries would still linger in active recovery state across launches. +- The app could still display stale recovery items until the user attempts restore. + +### Option C: Enforce expiry centrally and prune expired recovery items + +- Normalize persisted workspace state so expired recovery items are removed on load/save. +- Recheck expiry in the worker restore path to fail closed for items that expire while the app is open. +- Return stable restore-specific error codes for expiry and restore conflicts. +- Disable restore UI when the selected entry is already expired. + +## Decision + +Choose Option C. + +## Implementation Outline + +1. Extend `AtlasProtocolErrorCode` with restore-specific cases used by this flow. +2. Normalize workspace state in `AtlasWorkspaceRepository` by pruning expired `RecoveryItem`s. +3. Recheck expiry in `AtlasScaffoldWorkerService.restoreItems` before side effects. +4. Map restore conflicts such as an already-existing destination to a stable restore-specific rejection. +5. Disable restore actions for expired entries in History UI. +6. Add tests for: + - expired recovery rejection + - repository pruning of expired recovery items + - restore conflict rejection + - controller localization for restore-specific rejections +7. Update protocol, architecture, task-state, recovery contract, and gate review docs to match the shipped behavior. + +## Validation + +- `swift test --package-path Packages --filter AtlasInfrastructureTests` +- `swift test --package-path Packages --filter AtlasApplicationTests` +- `swift test --package-path Packages` diff --git a/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift b/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift index 3faee3f..42f7559 100644 --- a/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift +++ b/Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift @@ -161,6 +161,10 @@ public enum AtlasWorkspaceControllerError: LocalizedError, Sendable { return AtlasL10n.string("application.error.executionUnavailable", reason) case .helperUnavailable: return AtlasL10n.string("application.error.helperUnavailable", reason) + case .restoreExpired: + return AtlasL10n.string("application.error.restoreExpired", reason) + case .restoreConflict: + return AtlasL10n.string("application.error.restoreConflict", reason) default: return AtlasL10n.string("application.error.workerRejected", code.rawValue, reason) } diff --git a/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift b/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift index d261d85..adba7c2 100644 --- a/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift +++ b/Packages/AtlasApplication/Tests/AtlasApplicationTests/AtlasApplicationTests.swift @@ -209,6 +209,52 @@ final class AtlasApplicationTests: XCTestCase { XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.helperUnavailable", "Privileged helper missing")) } } + + func testRestoreItemsMapsRestoreExpiredToLocalizedError() async throws { + let itemID = UUID() + let request = AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [itemID])) + let result = AtlasWorkerCommandResult( + request: request, + response: AtlasResponseEnvelope( + requestID: request.id, + response: .rejected(code: .restoreExpired, reason: "Recovery retention expired") + ), + events: [], + snapshot: AtlasScaffoldWorkspace.snapshot(), + previewPlan: nil + ) + let controller = AtlasWorkspaceController(worker: FakeWorker(result: result)) + + do { + _ = try await controller.restoreItems(itemIDs: [itemID]) + XCTFail("Expected restoreItems to throw") + } catch { + XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.restoreExpired", "Recovery retention expired")) + } + } + + func testRestoreItemsMapsRestoreConflictToLocalizedError() async throws { + let itemID = UUID() + let request = AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [itemID])) + let result = AtlasWorkerCommandResult( + request: request, + response: AtlasResponseEnvelope( + requestID: request.id, + response: .rejected(code: .restoreConflict, reason: "Original path already exists") + ), + events: [], + snapshot: AtlasScaffoldWorkspace.snapshot(), + previewPlan: nil + ) + let controller = AtlasWorkspaceController(worker: FakeWorker(result: result)) + + do { + _ = try await controller.restoreItems(itemIDs: [itemID]) + XCTFail("Expected restoreItems to throw") + } catch { + XCTAssertEqual(error.localizedDescription, AtlasL10n.string("application.error.restoreConflict", "Original path already exists")) + } + } } private actor FakeWorker: AtlasWorkerServing { diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings index 27b1bf2..3da3ddb 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings +++ b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/en.lproj/Localizable.strings @@ -92,6 +92,8 @@ "application.error.workerRejected" = "Worker rejected request (%@): %@"; "application.error.executionUnavailable" = "Atlas could not run this action with the real worker path: %@"; "application.error.helperUnavailable" = "Atlas could not complete this action because the privileged helper is unavailable: %@"; +"application.error.restoreExpired" = "Atlas can no longer restore this item because its recovery retention window has expired: %@"; +"application.error.restoreConflict" = "Atlas could not restore this item because its original destination already exists: %@"; "xpc.error.encodingFailed" = "Could not encode the background worker request: %@"; "xpc.error.decodingFailed" = "Could not decode the background worker response: %@"; "xpc.error.invalidResponse" = "The background worker returned an invalid response. Fully quit and reopen Atlas; if it still fails, reinstall the current build."; diff --git a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings index be790c6..7d0a6fc 100644 --- a/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/AtlasDomain/Sources/AtlasDomain/Resources/zh-Hans.lproj/Localizable.strings @@ -92,6 +92,8 @@ "application.error.workerRejected" = "后台服务拒绝了请求(%@):%@"; "application.error.executionUnavailable" = "Atlas 当前无法通过真实工作链路执行这项操作:%@"; "application.error.helperUnavailable" = "Atlas 当前无法完成这项操作,因为特权辅助组件不可用:%@"; +"application.error.restoreExpired" = "这个项目已经超出恢复保留窗口,Atlas 不能再恢复它:%@"; +"application.error.restoreConflict" = "Atlas 无法恢复这个项目,因为它的原始目标位置已经存在内容:%@"; "xpc.error.encodingFailed" = "无法编码后台请求:%@"; "xpc.error.decodingFailed" = "无法解析后台响应:%@"; "xpc.error.invalidResponse" = "后台工作组件返回了无效响应。请完全退出并重新打开 Atlas;若仍失败,请重新安装当前版本。"; diff --git a/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift b/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift index ed3a8f2..a9c3e0f 100644 --- a/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift +++ b/Packages/AtlasFeaturesHistory/Sources/AtlasFeaturesHistory/HistoryFeatureView.swift @@ -536,7 +536,7 @@ public struct HistoryFeatureView: View { HistoryRecoveryDetailView( item: item, isRestoring: restoringItemID == item.id, - canRestore: restoringItemID == nil, + canRestore: restoringItemID == nil && !item.isExpired, onRestore: { onRestoreItem(item.id) } ) } else { @@ -1115,6 +1115,13 @@ private extension RecoveryItem { !(restoreMappings ?? []).isEmpty } + var isExpired: Bool { + guard let expiresAt else { + return false + } + return expiresAt <= Date() + } + var isExpiringSoon: Bool { guard let expiresAt else { return false diff --git a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift index dae523f..d2d72b7 100644 --- a/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift +++ b/Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift @@ -297,9 +297,14 @@ public enum AtlasSmartCleanExecutionSupport { public struct AtlasWorkspaceRepository: Sendable { private let stateFileURL: URL + private let nowProvider: @Sendable () -> Date - public init(stateFileURL: URL? = nil) { + public init( + stateFileURL: URL? = nil, + nowProvider: @escaping @Sendable () -> Date = { Date() } + ) { self.stateFileURL = stateFileURL ?? Self.defaultStateFileURL + self.nowProvider = nowProvider } public func loadState() -> AtlasWorkspaceState { @@ -308,7 +313,12 @@ public struct AtlasWorkspaceRepository: Sendable { if FileManager.default.fileExists(atPath: stateFileURL.path) { do { let data = try Data(contentsOf: stateFileURL) - return try decoder.decode(AtlasWorkspaceState.self, from: data) + let decoded = try decoder.decode(AtlasWorkspaceState.self, from: data) + let normalized = normalizedState(decoded) + if normalized != decoded { + _ = try? saveState(normalized) + } + return normalized } catch let repositoryError as AtlasWorkspaceRepositoryError { reportFailure(repositoryError, operation: "load existing workspace state from \(stateFileURL.path)") } catch { @@ -332,6 +342,7 @@ public struct AtlasWorkspaceRepository: Sendable { public func saveState(_ state: AtlasWorkspaceState) throws -> AtlasWorkspaceState { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let normalizedState = normalizedState(state) do { try FileManager.default.createDirectory( @@ -344,7 +355,7 @@ public struct AtlasWorkspaceRepository: Sendable { let data: Data do { - data = try encoder.encode(state) + data = try encoder.encode(normalizedState) } catch { throw AtlasWorkspaceRepositoryError.encodeFailed(error.localizedDescription) } @@ -355,7 +366,7 @@ public struct AtlasWorkspaceRepository: Sendable { throw AtlasWorkspaceRepositoryError.writeFailed(error.localizedDescription) } - return state + return normalizedState } public func loadScaffoldSnapshot() -> AtlasWorkspaceSnapshot { @@ -377,6 +388,15 @@ public struct AtlasWorkspaceRepository: Sendable { } } + private func normalizedState(_ state: AtlasWorkspaceState) -> AtlasWorkspaceState { + var normalized = state + let now = nowProvider() + normalized.snapshot.recoveryItems.removeAll { item in + item.isExpired(asOf: now) + } + return normalized + } + private static var defaultStateFileURL: URL { if let explicit = ProcessInfo.processInfo.environment["ATLAS_STATE_FILE"], !explicit.isEmpty { return URL(fileURLWithPath: explicit) @@ -403,6 +423,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { private let smartCleanScanProvider: (any AtlasSmartCleanScanProviding)? private let appsInventoryProvider: (any AtlasAppInventoryProviding)? private let helperExecutor: (any AtlasPrivilegedActionExecuting)? + private let nowProvider: @Sendable () -> Date private let allowProviderFailureFallback: Bool private let allowStateOnlyCleanExecution: Bool private var state: AtlasWorkspaceState @@ -415,6 +436,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { appsInventoryProvider: (any AtlasAppInventoryProviding)? = nil, helperExecutor: (any AtlasPrivilegedActionExecuting)? = nil, auditStore: AtlasAuditStore = AtlasAuditStore(), + nowProvider: @escaping @Sendable () -> Date = { Date() }, allowProviderFailureFallback: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_PROVIDER_FAILURE_FALLBACK"] == "1", allowStateOnlyCleanExecution: Bool = ProcessInfo.processInfo.environment["ATLAS_ALLOW_STATE_ONLY_CLEAN_EXECUTION"] == "1" ) { @@ -425,6 +447,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { self.smartCleanScanProvider = smartCleanScanProvider self.appsInventoryProvider = appsInventoryProvider self.helperExecutor = helperExecutor + self.nowProvider = nowProvider self.allowProviderFailureFallback = allowProviderFailureFallback self.allowStateOnlyCleanExecution = allowStateOnlyCleanExecution self.state = repository.loadState() @@ -433,6 +456,11 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { public func submit(_ request: AtlasRequestEnvelope) async throws -> AtlasWorkerCommandResult { AtlasL10n.setCurrentLanguage(state.settings.language) + if case .restoreItems = request.command { + // Restore needs selected-item expiry reporting before the general prune. + } else { + await pruneExpiredRecoveryItemsIfNeeded(context: "process request \(request.id.uuidString)") + } switch request.command { case .healthSnapshot: return try await healthSnapshot(using: request) @@ -695,7 +723,20 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { } private func restoreItems(using request: AtlasRequestEnvelope, taskID: UUID, itemIDs: [UUID]) async -> AtlasWorkerCommandResult { - let itemsToRestore = state.snapshot.recoveryItems.filter { itemIDs.contains($0.id) } + let requestedItemIDs = Set(itemIDs) + let expiredSelectionIDs = requestedItemIDs.intersection(expiredRecoveryItemIDs()) + + if !expiredSelectionIDs.isEmpty { + await pruneExpiredRecoveryItemsIfNeeded(context: "prune expired recovery items before rejected restore") + return rejectedResult( + for: request, + code: .restoreExpired, + reason: "One or more selected recovery items have expired and can no longer be restored." + ) + } + + await pruneExpiredRecoveryItemsIfNeeded(context: "refresh recovery retention before restore") + let itemsToRestore = state.snapshot.recoveryItems.filter { requestedItemIDs.contains($0.id) } guard !itemsToRestore.isEmpty else { return rejectedResult( @@ -713,6 +754,12 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { do { try await restoreRecoveryMappings(restoreMappings) physicalRestoreCount += 1 + } catch let failure as RecoveryRestoreFailure { + return rejectedResult( + for: request, + code: failure.code, + reason: failure.localizedDescription + ) } catch { return rejectedResult( for: request, @@ -885,6 +932,25 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { ) } + private func expiredRecoveryItemIDs(asOf now: Date? = nil) -> Set { + let cutoff = now ?? nowProvider() + return Set(state.snapshot.recoveryItems.compactMap { item in + item.isExpired(asOf: cutoff) ? item.id : nil + }) + } + + private func pruneExpiredRecoveryItemsIfNeeded(context: String, now: Date? = nil) async { + let cutoff = now ?? nowProvider() + let expiredIDs = expiredRecoveryItemIDs(asOf: cutoff) + guard !expiredIDs.isEmpty else { + return + } + + state.snapshot.recoveryItems.removeAll { expiredIDs.contains($0.id) } + await persistState(context: context) + await auditStore.append("Pruned \(expiredIDs.count) expired recovery item(s)") + } + private func persistState(context: String) async { do { _ = try repository.saveState(state) @@ -1003,23 +1069,23 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing { let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath() let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath() guard FileManager.default.fileExists(atPath: sourceURL.path) else { - throw AtlasWorkspaceRepositoryError.writeFailed("Recovery source is no longer available on disk: \(sourceURL.path)") + throw RecoveryRestoreFailure.executionUnavailable("Recovery source is no longer available on disk: \(sourceURL.path)") } if shouldUseHelperForSmartCleanTarget(destinationURL) { guard let helperExecutor else { - throw AtlasWorkspaceRepositoryError.writeFailed("Bundled helper unavailable for recovery target: \(destinationURL.path)") + throw RecoveryRestoreFailure.helperUnavailable("Bundled helper unavailable for recovery target: \(destinationURL.path)") } let result = try await helperExecutor.perform(AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path)) guard result.success else { - throw AtlasWorkspaceRepositoryError.writeFailed(result.message) + throw RecoveryRestoreFailure.executionUnavailable(result.message) } return } guard isDirectlyTrashableSmartCleanTarget(destinationURL) else { - throw AtlasWorkspaceRepositoryError.writeFailed("Recovery target is outside the supported execution allowlist: \(destinationURL.path)") + throw RecoveryRestoreFailure.executionUnavailable("Recovery target is outside the supported execution allowlist: \(destinationURL.path)") } if FileManager.default.fileExists(atPath: destinationURL.path) { - throw AtlasWorkspaceRepositoryError.writeFailed("Recovery target already exists: \(destinationURL.path)") + throw RecoveryRestoreFailure.restoreConflict("Recovery target already exists: \(destinationURL.path)") } try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.moveItem(at: sourceURL, to: destinationURL) @@ -1284,6 +1350,41 @@ private struct SmartCleanExecutionFailure: LocalizedError { } } +private enum RecoveryRestoreFailure: LocalizedError { + case helperUnavailable(String) + case restoreConflict(String) + case executionUnavailable(String) + + var code: AtlasProtocolErrorCode { + switch self { + case .helperUnavailable: + return .helperUnavailable + case .restoreConflict: + return .restoreConflict + case .executionUnavailable: + return .executionUnavailable + } + } + + var errorDescription: String? { + switch self { + case let .helperUnavailable(reason), + let .restoreConflict(reason), + let .executionUnavailable(reason): + return reason + } + } +} + +private extension RecoveryItem { + func isExpired(asOf date: Date) -> Bool { + guard let expiresAt else { + return false + } + return expiresAt <= date + } +} + import AtlasProtocol import Foundation diff --git a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift index 747764f..0405545 100644 --- a/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift +++ b/Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift @@ -27,6 +27,55 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertThrowsError(try repository.saveState(AtlasScaffoldWorkspace.state())) } + func testRepositorySaveStatePrunesExpiredRecoveryItems() throws { + let baseDate = Date(timeIntervalSince1970: 1_710_000_000) + let clock = TestClock(now: baseDate) + let repository = AtlasWorkspaceRepository( + stateFileURL: temporaryStateFileURL(), + nowProvider: { clock.now } + ) + let activeItem = RecoveryItem( + id: UUID(), + title: "Active recovery", + detail: "Still valid", + originalPath: "~/Library/Caches/Active", + bytes: 5, + deletedAt: baseDate.addingTimeInterval(-120), + expiresAt: baseDate.addingTimeInterval(3600), + payload: nil, + restoreMappings: nil + ) + let expiredItem = RecoveryItem( + id: UUID(), + title: "Expired recovery", + detail: "Expired", + originalPath: "~/Library/Caches/Expired", + bytes: 7, + deletedAt: baseDate.addingTimeInterval(-7200), + expiresAt: baseDate.addingTimeInterval(-1), + payload: nil, + restoreMappings: nil + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [activeItem, expiredItem], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + + let saved = try repository.saveState(state) + + XCTAssertEqual(saved.snapshot.recoveryItems.map(\.id), [activeItem.id]) + XCTAssertEqual(repository.loadState().snapshot.recoveryItems.map(\.id), [activeItem.id]) + } + func testExecutePlanMovesSupportedFindingsIntoRecoveryWhileKeepingInspectionOnlyItems() async throws { let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let home = FileManager.default.homeDirectoryForCurrentUser @@ -709,6 +758,237 @@ final class AtlasInfrastructureTests: XCTestCase { ) } + func testRestoreItemsRejectsExpiredRecoveryItemsAndPrunesThem() async throws { + let baseDate = Date(timeIntervalSince1970: 1_710_000_000) + let clock = TestClock(now: baseDate) + let repository = AtlasWorkspaceRepository( + stateFileURL: temporaryStateFileURL(), + nowProvider: { clock.now } + ) + let finding = Finding( + id: UUID(), + title: "Atlas-only fixture", + detail: "Expires soon", + bytes: 5, + risk: .safe, + category: "Developer tools" + ) + let recoveryItem = RecoveryItem( + id: UUID(), + title: finding.title, + detail: finding.detail, + originalPath: "~/Library/Caches/AtlasOnly", + bytes: 5, + deletedAt: baseDate, + expiresAt: baseDate.addingTimeInterval(10), + payload: .finding(finding), + restoreMappings: nil + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [recoveryItem], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService( + repository: repository, + nowProvider: { clock.now }, + allowStateOnlyCleanExecution: false + ) + clock.now = baseDate.addingTimeInterval(60) + + let restore = try await worker.submit( + AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id])) + ) + + guard case let .rejected(code, reason) = restore.response.response else { + return XCTFail("Expected rejected restore response") + } + XCTAssertEqual(code, .restoreExpired) + XCTAssertTrue(reason.contains("expired")) + XCTAssertFalse(restore.snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id })) + XCTAssertFalse(repository.loadState().snapshot.recoveryItems.contains(where: { $0.id == recoveryItem.id })) + } + + func testRestoreItemsMixedSummaryIncludesDiskAndStateOnlyClauses() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let home = FileManager.default.homeDirectoryForCurrentUser + let targetDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + let targetFile = targetDirectory.appendingPathComponent("sample.cache") + try Data("cache".utf8).write(to: targetFile) + + var trashedURL: NSURL? + try FileManager.default.trashItem(at: targetFile, resultingItemURL: &trashedURL) + let trashedPath = try XCTUnwrap((trashedURL as URL?)?.path) + + addTeardownBlock { + try? FileManager.default.removeItem(at: targetDirectory) + if let trashedURL { + try? FileManager.default.removeItem(at: trashedURL as URL) + } + } + + let fileBackedFinding = Finding( + id: UUID(), + title: "Disk-backed fixture", + detail: targetFile.path, + bytes: 5, + risk: .safe, + category: "Developer tools", + targetPaths: [targetFile.path] + ) + let stateOnlyFinding = Finding( + id: UUID(), + title: "Atlas-only fixture", + detail: "State-only recovery item", + bytes: 7, + risk: .safe, + category: "Developer tools" + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [ + RecoveryItem( + id: UUID(), + title: fileBackedFinding.title, + detail: fileBackedFinding.detail, + originalPath: targetFile.path, + bytes: fileBackedFinding.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .finding(fileBackedFinding), + restoreMappings: [RecoveryPathMapping(originalPath: targetFile.path, trashedPath: trashedPath)] + ), + RecoveryItem( + id: UUID(), + title: stateOnlyFinding.title, + detail: stateOnlyFinding.detail, + originalPath: "~/Library/Caches/AtlasOnly", + bytes: stateOnlyFinding.bytes, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .finding(stateOnlyFinding), + restoreMappings: nil + ), + ], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let restoreItemIDs = state.snapshot.recoveryItems.map(\.id) + let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: false) + let restore = try await worker.submit(AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: restoreItemIDs))) + + if case let .accepted(task) = restore.response.response { + XCTAssertEqual(task.kind, .restore) + } else { + XCTFail("Expected accepted restore response") + } + XCTAssertTrue(FileManager.default.fileExists(atPath: targetFile.path)) + XCTAssertTrue(restore.snapshot.findings.contains(where: { $0.id == fileBackedFinding.id })) + XCTAssertTrue(restore.snapshot.findings.contains(where: { $0.id == stateOnlyFinding.id })) + XCTAssertEqual( + restore.snapshot.taskRuns.first?.summary, + [ + AtlasL10n.string("infrastructure.restore.summary.disk.one", language: state.settings.language), + AtlasL10n.string("infrastructure.restore.summary.state.one", language: state.settings.language), + ].joined(separator: " ") + ) + } + + func testRestoreItemsRejectsWhenDestinationAlreadyExists() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let fileManager = FileManager.default + let home = fileManager.homeDirectoryForCurrentUser + let sourceDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + let destinationDirectory = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + try fileManager.createDirectory(at: sourceDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: destinationDirectory, withIntermediateDirectories: true) + + let trashedCandidate = sourceDirectory.appendingPathComponent("trashed.cache") + try Data("trashed".utf8).write(to: trashedCandidate) + var trashedURL: NSURL? + try fileManager.trashItem(at: trashedCandidate, resultingItemURL: &trashedURL) + let trashedPath = try XCTUnwrap((trashedURL as URL?)?.path) + + let destinationURL = destinationDirectory.appendingPathComponent("trashed.cache") + try Data("existing".utf8).write(to: destinationURL) + + addTeardownBlock { + try? FileManager.default.removeItem(at: sourceDirectory) + try? FileManager.default.removeItem(at: destinationDirectory) + if let trashedURL { + try? FileManager.default.removeItem(at: trashedURL as URL) + } + } + + let finding = Finding( + id: UUID(), + title: "Conflicting restore", + detail: destinationURL.path, + bytes: 7, + risk: .safe, + category: "Developer tools", + targetPaths: [destinationURL.path] + ) + let recoveryItem = RecoveryItem( + id: UUID(), + title: finding.title, + detail: finding.detail, + originalPath: destinationURL.path, + bytes: 7, + deletedAt: Date(), + expiresAt: Date().addingTimeInterval(3600), + payload: .finding(finding), + restoreMappings: [RecoveryPathMapping(originalPath: destinationURL.path, trashedPath: trashedPath)] + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: 0, + findings: [], + apps: [], + taskRuns: [], + recoveryItems: [recoveryItem], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: false) + let restore = try await worker.submit( + AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id])) + ) + + guard case let .rejected(code, reason) = restore.response.response else { + return XCTFail("Expected rejected restore response") + } + XCTAssertEqual(code, .restoreConflict) + XCTAssertTrue(reason.contains(destinationURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: destinationURL.path)) + XCTAssertTrue(fileManager.fileExists(atPath: trashedPath)) + } + func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws { let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true) @@ -724,6 +1004,84 @@ final class AtlasInfrastructureTests: XCTestCase { XCTAssertEqual(result.snapshot.taskRuns.first?.kind, .uninstallApp) } + func testExecuteAppUninstallRestorePhysicallyRestoresAppBundle() async throws { + let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) + let fileManager = FileManager.default + let appRoot = fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true) + let appBundleURL = appRoot.appendingPathComponent("Atlas Restore Test.app", isDirectory: true) + try fileManager.createDirectory(at: appBundleURL, withIntermediateDirectories: true) + let executableURL = appBundleURL.appendingPathComponent("Contents/MacOS/AtlasRestoreTest") + try fileManager.createDirectory(at: executableURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("#!/bin/sh\nexit 0\n".utf8).write(to: executableURL) + + addTeardownBlock { + try? FileManager.default.removeItem(at: appRoot) + } + + let app = AppFootprint( + id: UUID(), + name: "Atlas Restore Test", + bundleIdentifier: "com.atlas.restore-test", + bundlePath: appBundleURL.path, + bytes: 17, + leftoverItems: 1 + ) + let state = AtlasWorkspaceState( + snapshot: AtlasWorkspaceSnapshot( + reclaimableSpaceBytes: app.bytes, + findings: [], + apps: [app], + taskRuns: [], + recoveryItems: [], + permissions: [], + healthSnapshot: nil + ), + currentPlan: ActionPlan(title: "Review 0 selected findings", items: [], estimatedBytes: 0), + settings: AtlasScaffoldWorkspace.state().settings + ) + _ = try repository.saveState(state) + + let worker = AtlasScaffoldWorkerService( + repository: repository, + helperExecutor: StubPrivilegedHelperExecutor(), + allowStateOnlyCleanExecution: false + ) + + let execute = try await worker.submit( + AtlasRequestEnvelope(command: .executeAppUninstall(appID: app.id)) + ) + + if case let .accepted(task) = execute.response.response { + XCTAssertEqual(task.kind, .uninstallApp) + } else { + XCTFail("Expected accepted uninstall response") + } + + XCTAssertFalse(fileManager.fileExists(atPath: appBundleURL.path)) + XCTAssertFalse(execute.snapshot.apps.contains(where: { $0.id == app.id })) + + let recoveryItem = try XCTUnwrap(execute.snapshot.recoveryItems.first) + XCTAssertEqual(recoveryItem.restoreMappings?.first?.originalPath, appBundleURL.path) + XCTAssertNotNil(recoveryItem.restoreMappings?.first?.trashedPath) + + let restore = try await worker.submit( + AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [recoveryItem.id])) + ) + + if case let .accepted(task) = restore.response.response { + XCTAssertEqual(task.kind, .restore) + } else { + XCTFail("Expected accepted restore response") + } + + XCTAssertTrue(fileManager.fileExists(atPath: appBundleURL.path)) + XCTAssertTrue(restore.snapshot.apps.contains(where: { $0.id == app.id })) + XCTAssertEqual( + restore.snapshot.taskRuns.first?.summary, + AtlasL10n.string("infrastructure.restore.summary.disk.one", language: state.settings.language) + ) + } + private func temporaryStateFileURL() -> URL { FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) @@ -761,3 +1119,43 @@ private struct FileBackedSmartCleanProvider: AtlasSmartCleanScanProviding { return AtlasSmartCleanScanResult(findings: [finding], summary: "Found 1 reclaimable item.") } } + +private actor StubPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting { + func perform(_ action: AtlasHelperAction) async throws -> AtlasHelperActionResult { + let fileManager = FileManager.default + let targetURL = URL(fileURLWithPath: action.targetPath) + + switch action.kind { + case .trashItems: + var trashedURL: NSURL? + try fileManager.trashItem(at: targetURL, resultingItemURL: &trashedURL) + return AtlasHelperActionResult( + action: action, + success: true, + message: "Moved item to Trash.", + resolvedPath: (trashedURL as URL?)?.path + ) + case .restoreItem: + let destinationPath = try XCTUnwrap(action.destinationPath) + let destinationURL = URL(fileURLWithPath: destinationPath) + try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try fileManager.moveItem(at: targetURL, to: destinationURL) + return AtlasHelperActionResult( + action: action, + success: true, + message: "Restored item from Trash.", + resolvedPath: destinationURL.path + ) + case .removeLaunchService, .repairOwnership: + throw NSError(domain: "StubPrivilegedHelperExecutor", code: 1, userInfo: [NSLocalizedDescriptionKey: "Unsupported test helper action: \(action.kind)"]) + } + } +} + +private final class TestClock: @unchecked Sendable { + var now: Date + + init(now: Date) { + self.now = now + } +} diff --git a/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift b/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift index b4f1101..4eb0457 100644 --- a/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift +++ b/Packages/AtlasProtocol/Sources/AtlasProtocol/AtlasProtocol.swift @@ -2,7 +2,7 @@ import AtlasDomain import Foundation public enum AtlasProtocolVersion { - public static let current = "0.3.0" + public static let current = "0.3.1" } public enum AtlasCommand: Codable, Hashable, Sendable { @@ -46,6 +46,8 @@ public enum AtlasProtocolErrorCode: String, Codable, CaseIterable, Hashable, Sen case permissionRequired case helperUnavailable case executionUnavailable + case restoreExpired + case restoreConflict case invalidSelection } diff --git a/project.yml b/project.yml index 2f6538c..00b7b5d 100644 --- a/project.yml +++ b/project.yml @@ -21,6 +21,7 @@ schemes: config: Debug gatherCoverageData: false targets: + - name: AtlasAppTests - name: AtlasAppUITests run: config: Debug @@ -62,6 +63,7 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app PRODUCT_NAME: Atlas for Mac + PRODUCT_MODULE_NAME: AtlasApp MARKETING_VERSION: "1.0.0" CURRENT_PROJECT_VERSION: 1 GENERATE_INFOPLIST_FILE: YES @@ -99,6 +101,21 @@ targets: product: AtlasFeaturesSmartClean - package: Packages product: AtlasInfrastructure + AtlasAppTests: + type: bundle.unit-test + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Apps/AtlasApp/Tests/AtlasAppTests + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app.tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Atlas for Mac.app/Contents/MacOS/Atlas for Mac" + BUNDLE_LOADER: "$(TEST_HOST)" + AD_HOC_CODE_SIGNING_ALLOWED: YES + dependencies: + - target: AtlasApp AtlasAppUITests: type: bundle.ui-testing platform: macOS