fix(recovery): preflight restore items before mutating state

Add validation pass that checks all selected recovery items before any
restore operations begin. This prevents partial in-memory restore success
when a later item fails.

Map helper-backed restore destination conflicts to restore-specific
rejection paths instead of falling back to generic execution-unavailable
messages.

Bump version to 1.0.1 and update CHANGELOG with release notes.
This commit is contained in:
zhukang
2026-03-13 16:40:31 +08:00
parent 1cb9a42c7b
commit 86e6ea1d80
7 changed files with 251 additions and 40 deletions

View File

@@ -100,11 +100,11 @@ final class AtlasAppModel: ObservableObject {
} }
var appVersion: String { var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.1"
} }
var appBuild: String { var appBuild: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1" Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "2"
} }
func checkForUpdate() async { func checkForUpdate() async {

View File

@@ -458,7 +458,7 @@
buildSettings = { buildSettings = {
AD_HOC_CODE_SIGNING_ALLOWED = YES; AD_HOC_CODE_SIGNING_ALLOWED = YES;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)";
@@ -467,7 +467,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker;
PRODUCT_NAME = AtlasWorkerXPC; PRODUCT_NAME = AtlasWorkerXPC;
SDKROOT = macosx; SDKROOT = macosx;
@@ -535,7 +535,7 @@
buildSettings = { buildSettings = {
AD_HOC_CODE_SIGNING_ALLOWED = YES; AD_HOC_CODE_SIGNING_ALLOWED = YES;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)"; INFOPLIST_KEY_CFBundleVersion = "$(CURRENT_PROJECT_VERSION)";
@@ -544,7 +544,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app.worker;
PRODUCT_NAME = AtlasWorkerXPC; PRODUCT_NAME = AtlasWorkerXPC;
SDKROOT = macosx; SDKROOT = macosx;
@@ -557,7 +557,7 @@
AD_HOC_CODE_SIGNING_ALLOWED = YES; AD_HOC_CODE_SIGNING_ALLOWED = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac"; INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac";
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
@@ -569,7 +569,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
PRODUCT_MODULE_NAME = AtlasApp; PRODUCT_MODULE_NAME = AtlasApp;
PRODUCT_NAME = "Atlas for Mac"; PRODUCT_NAME = "Atlas for Mac";
@@ -665,7 +665,7 @@
AD_HOC_CODE_SIGNING_ALLOWED = YES; AD_HOC_CODE_SIGNING_ALLOWED = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
COMBINE_HIDPI_IMAGES = YES; COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac"; INFOPLIST_KEY_CFBundleDisplayName = "Atlas for Mac";
INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)"; INFOPLIST_KEY_CFBundleShortVersionString = "$(MARKETING_VERSION)";
@@ -677,7 +677,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0.0; MARKETING_VERSION = 1.0.1;
PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app; PRODUCT_BUNDLE_IDENTIFIER = com.atlasformac.app;
PRODUCT_MODULE_NAME = AtlasApp; PRODUCT_MODULE_NAME = AtlasApp;
PRODUCT_NAME = "Atlas for Mac"; PRODUCT_NAME = "Atlas for Mac";

View File

@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
## [1.0.1] - 2026-03-13
### Added ### Added
- Native macOS app with 7 MVP modules: Overview, Smart Clean, Apps, History, Recovery, Permissions, Settings - Native macOS app with 7 MVP modules: Overview, Smart Clean, Apps, History, Recovery, Permissions, Settings
@@ -22,6 +24,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- Go-based TUI tools inherited from upstream Mole: disk analyzer (`analyze`) and system monitor (`status`) - Go-based TUI tools inherited from upstream Mole: disk analyzer (`analyze`) and system monitor (`status`)
- CI/CD: GitHub Actions for formatting, linting, testing, CodeQL scanning, and release packaging - CI/CD: GitHub Actions for formatting, linting, testing, CodeQL scanning, and release packaging
### Fixed
- Recovery restore requests now preflight every selected item before Atlas mutates local recovery state, preventing partial in-memory restore success when a later item fails.
- Helper-backed restore destination conflicts now surface restore-specific errors instead of falling back to a generic execution-unavailable message.
- Expired recovery items are pruned from persisted state and rejected with explicit restore-expired messaging.
- Revalidated the current native release candidate with package tests, app tests, DMG install verification, launch smoke, and native UI automation.
### Attribution ### Attribution
- Built in part on the open-source [Mole](https://github.com/tw93/mole) project (MIT) by tw93 and contributors - Built in part on the open-source [Mole](https://github.com/tw93/mole) project (MIT) by tw93 and contributors

View File

@@ -119,6 +119,8 @@ Do not expand into:
- Added one new real Smart Clean execute target class for `~/Library/pnpm/store/*`. - Added one new real Smart Clean execute target class for `~/Library/pnpm/store/*`.
- Added stronger worker-side truthfulness so Atlas only records recovery/history side effects when a real file move happened. - Added stronger worker-side truthfulness so Atlas only records recovery/history side effects when a real file move happened.
- Hardened recovery restore semantics so batched restore requests preflight all selected items before mutating Atlas state.
- Mapped helper-backed restore destination conflicts back to the restore-specific rejection path instead of the generic execution-unavailable path.
- Split History recovery messaging between: - Split History recovery messaging between:
- file-backed restore entries with `restoreMappings` - file-backed restore entries with `restoreMappings`
- Atlas-only recovery entries with no supported on-disk restore path - Atlas-only recovery entries with no supported on-disk restore path
@@ -126,17 +128,17 @@ Do not expand into:
### Current blocker ### Current blocker
- Interactive bilingual UI automation on this machine is **blocked** by macOS Accessibility trust for the current terminal process. - Local UI automation is no longer a hard blocker on this Mac.
- `./scripts/atlas/ui-automation-preflight.sh` reported `Accessibility trusted for current process: false` on **2026-03-12**. - `./scripts/atlas/full-acceptance.sh` passed on **2026-03-13** for the latest recovery-fix candidate build.
This means the packaged-build install and fresh-state launch checks below are complete, but a full click-through clean-machine bilingual UI walkthrough still requires either: The remaining gap is narrower:
- Accessibility trust to be granted on this Mac, or - a dedicated clean-machine bilingual manual walkthrough is still outstanding
- a separate clean machine for the final interactive pass. - current packaged-build evidence is still machine-local, not evidence from a second physical clean Mac
## Packaged-Build Evidence ## Packaged-Build Evidence
### Latest artifacts built on 2026-03-12 ### Latest artifacts built on 2026-03-13
- App: `dist/native/Atlas for Mac.app` - App: `dist/native/Atlas for Mac.app`
- DMG: `dist/native/Atlas-for-Mac.dmg` - DMG: `dist/native/Atlas-for-Mac.dmg`
@@ -147,16 +149,19 @@ This means the packaged-build install and fresh-state launch checks below are co
### Checksum record ### Checksum record
```text ```text
b85425649c5d781f234cdf1690ce01f330e3216d963cbf7d8f720a2e66611ffa Atlas-for-Mac.zip 2d5988ac5f03d06b5f8ec2c94869752ad5c67de0f7eeb7b59aeb9e463f6cdee9 Atlas-for-Mac.zip
2d5f480110d13f83c38e2296fafaa72617fc122d694d78c2c32c3a260f0ae110 Atlas-for-Mac.dmg 2b5d1a1b6636edcf180b5639bcf62734d25e05adcb405419f7a234ade3870c1e Atlas-for-Mac.dmg
d71c45b0312ceeb045e390d851e246fe7f59e90961f2a482cfb21ee4f65d56ec Atlas-for-Mac.pkg e89f50a77d9148d18ca78d8a0fd005394fc5848e6ae6a5231700e1b180135495 Atlas-for-Mac.pkg
``` ```
### Verified commands ### Verified commands
- `./scripts/atlas/package-native.sh`**pass** on 2026-03-12 - `./scripts/atlas/full-acceptance.sh`**pass** on 2026-03-13
- `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh`**pass** on 2026-03-12 - `./scripts/atlas/package-native.sh`**pass** on 2026-03-13
- `STATE_DIR="$PWD/.build/atlas-hardening-fresh-state-2026-03-12" ./scripts/atlas/verify-app-launch.sh`**pass** on 2026-03-12 - `./scripts/atlas/verify-bundle-contents.sh`**pass** on 2026-03-13
- `KEEP_INSTALLED_APP=1 ./scripts/atlas/verify-dmg-install.sh`**pass** on 2026-03-13
- `./scripts/atlas/verify-app-launch.sh`**pass** on 2026-03-13
- `./scripts/atlas/run-ui-automation.sh`**pass** on 2026-03-13 for the latest recovery-fix candidate build
### Fresh-state file evidence ### Fresh-state file evidence
@@ -170,19 +175,19 @@ This is a machine-local fresh-state packaged-build verification, not a claim of
### 1. Clean-machine bilingual QA ### 1. Clean-machine bilingual QA
**Status:** `Partially complete / locally blocked` **Status:** `Partially complete / manual clean-machine pass still pending`
Completed evidence: Completed evidence:
- Packaged install path verified to `~/Applications/Atlas for Mac.app` - Packaged install path verified to `~/Applications/Atlas for Mac.app`
- Fresh-state packaged launch verified with a brand-new workspace-state directory - Fresh-state packaged launch verified with a brand-new workspace-state directory
- Default first-launch language persisted as `zh-Hans` - Default first-launch language persisted as `zh-Hans`
- Language-switch persistence covered by app-model test evidence - Language-switch persistence covered by app-model test evidence and local UI automation
- Smart Clean, Apps, and Recovery trust paths covered by package and app tests listed below - Smart Clean, Apps, and Recovery trust paths covered by package and app tests listed below
Remaining blocker: Remaining blocker:
- Interactive packaged-app UI walkthrough for first launch + bilingual control verification is blocked on local Accessibility trust - A dedicated clean-machine bilingual manual walkthrough is still not recorded for this candidate build
### 2. Fresh-state verification with latest packaged build ### 2. Fresh-state verification with latest packaged build
@@ -231,6 +236,7 @@ Behavior tightened so that:
- `swift test --package-path Packages --filter MoleSmartCleanAdapterTests`**pass** on 2026-03-12 - `swift test --package-path Packages --filter MoleSmartCleanAdapterTests`**pass** on 2026-03-12
- `swift test --package-path Packages --filter AtlasInfrastructureTests`**pass** on 2026-03-12 - `swift test --package-path Packages --filter AtlasInfrastructureTests`**pass** on 2026-03-12
- `swift test --package-path Packages`**pass** on 2026-03-13 after the recovery restore atomicity and helper-conflict fix
Key tests: Key tests:
@@ -244,6 +250,7 @@ Key tests:
### App-model coverage ### App-model coverage
- `swift test --package-path Apps --filter AtlasAppModelTests`**pass** on 2026-03-12 - `swift test --package-path Apps --filter AtlasAppModelTests`**pass** on 2026-03-12
- `swift test --package-path Apps`**pass** on 2026-03-13 after the recovery restore atomicity and helper-conflict fix
Key tests: Key tests:
@@ -251,6 +258,7 @@ Key tests:
- `testPreferredXPCWorkerPathFailsClosedWhenScanIsRejected` - `testPreferredXPCWorkerPathFailsClosedWhenScanIsRejected`
- `testExecuteCurrentPlanExposesExplicitExecutionIssueWhenWorkerRejectsExecution` - `testExecuteCurrentPlanExposesExplicitExecutionIssueWhenWorkerRejectsExecution`
- `testExecuteCurrentPlanOnlyRecordsRecoveryForRealSideEffects` - `testExecuteCurrentPlanOnlyRecordsRecoveryForRealSideEffects`
- `testRestoreExpiredRecoveryItemReloadsPersistedState`
- `testRestoreRecoveryItemReturnsFindingToWorkspace` - `testRestoreRecoveryItemReturnsFindingToWorkspace`
## QA Matrix ## QA Matrix
@@ -260,7 +268,7 @@ Key tests:
| First launch | packaged app launch smoke with new state dir | Pass | | First launch | packaged app launch smoke with new state dir | Pass |
| Install path | DMG install validation to `~/Applications` | Pass | | Install path | DMG install validation to `~/Applications` | Pass |
| Default language | fresh packaged state file persisted `zh-Hans` | Pass | | Default language | fresh packaged state file persisted `zh-Hans` | Pass |
| Language switching | app-model persistence test; UI click-through still blocked locally | Partial | | Language switching | app-model persistence test plus local UI automation pass on 2026-03-13; clean-machine manual pass still pending | Partial |
| Smart Clean execute | package tests + real file-backed contract tests | Pass | | Smart Clean execute | package tests + real file-backed contract tests | Pass |
| Apps | app-model and infrastructure uninstall/recovery tests | Pass | | Apps | app-model and infrastructure uninstall/recovery tests | Pass |
| History / Recovery | file-backed vs Atlas-only summary/copy split + restore tests | Pass | | History / Recovery | file-backed vs Atlas-only summary/copy split + restore tests | Pass |
@@ -281,6 +289,8 @@ Do not say:
## Files Changed for Hardening ## Files Changed for Hardening
- `Apps/AtlasApp/Sources/AtlasApp/AtlasAppModel.swift`
- `Packages/AtlasApplication/Sources/AtlasApplication/AtlasApplication.swift`
- `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift` - `Packages/AtlasInfrastructure/Sources/AtlasInfrastructure/AtlasInfrastructure.swift`
- `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift` - `Packages/AtlasInfrastructure/Tests/AtlasInfrastructureTests/AtlasInfrastructureTests.swift`
- `Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift` - `Packages/AtlasCoreAdapters/Sources/AtlasCoreAdapters/MoleSmartCleanAdapter.swift`

View File

@@ -746,6 +746,22 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
) )
} }
do {
try validateRestoreItems(itemsToRestore)
} catch let failure as RecoveryRestoreFailure {
return rejectedResult(
for: request,
code: failure.code,
reason: failure.localizedDescription
)
} catch {
return rejectedResult(
for: request,
code: .executionUnavailable,
reason: error.localizedDescription
)
}
var physicalRestoreCount = 0 var physicalRestoreCount = 0
var atlasOnlyRestoreCount = 0 var atlasOnlyRestoreCount = 0
@@ -770,7 +786,9 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
} else { } else {
atlasOnlyRestoreCount += 1 atlasOnlyRestoreCount += 1
} }
}
for item in itemsToRestore {
switch item.payload { switch item.payload {
case let .finding(finding): case let .finding(finding):
if !state.snapshot.findings.contains(where: { $0.id == finding.id }) { if !state.snapshot.findings.contains(where: { $0.id == finding.id }) {
@@ -785,7 +803,7 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
} }
} }
state.snapshot.recoveryItems.removeAll { itemIDs.contains($0.id) } state.snapshot.recoveryItems.removeAll { requestedItemIDs.contains($0.id) }
recalculateReclaimableSpace() recalculateReclaimableSpace()
state.currentPlan = makePreviewPlan(findingIDs: state.snapshot.findings.map(\.id)) state.currentPlan = makePreviewPlan(findingIDs: state.snapshot.findings.map(\.id))
@@ -932,6 +950,21 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
) )
} }
private func validateRestoreItems(_ items: [RecoveryItem]) throws {
for item in items {
guard let restoreMappings = item.restoreMappings, !restoreMappings.isEmpty else {
continue
}
try validateRestoreMappings(restoreMappings)
}
}
private func validateRestoreMappings(_ restoreMappings: [RecoveryPathMapping]) throws {
for mapping in restoreMappings {
try validateRestoreTarget(mapping)
}
}
private func expiredRecoveryItemIDs(asOf now: Date? = nil) -> Set<UUID> { private func expiredRecoveryItemIDs(asOf now: Date? = nil) -> Set<UUID> {
let cutoff = now ?? nowProvider() let cutoff = now ?? nowProvider()
return Set(state.snapshot.recoveryItems.compactMap { item in return Set(state.snapshot.recoveryItems.compactMap { item in
@@ -1065,32 +1098,64 @@ public actor AtlasScaffoldWorkerService: AtlasWorkerServing {
} }
} }
private func restoreRecoveryTarget(_ mapping: RecoveryPathMapping) async throws { private func validateRestoreTarget(_ mapping: RecoveryPathMapping) throws {
let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath() let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath()
let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath() let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: sourceURL.path) else { guard FileManager.default.fileExists(atPath: sourceURL.path) else {
throw RecoveryRestoreFailure.executionUnavailable("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) { if shouldUseHelperForSmartCleanTarget(destinationURL) {
guard let helperExecutor else { guard helperExecutor != nil else {
throw RecoveryRestoreFailure.helperUnavailable("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)) } else if !isDirectlyTrashableSmartCleanTarget(destinationURL) {
guard result.success else {
throw RecoveryRestoreFailure.executionUnavailable(result.message)
}
return
}
guard isDirectlyTrashableSmartCleanTarget(destinationURL) else {
throw RecoveryRestoreFailure.executionUnavailable("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) { if FileManager.default.fileExists(atPath: destinationURL.path) {
throw RecoveryRestoreFailure.restoreConflict("Recovery target already exists: \(destinationURL.path)") throw RecoveryRestoreFailure.restoreConflict("Recovery target already exists: \(destinationURL.path)")
} }
}
private func restoreRecoveryTarget(_ mapping: RecoveryPathMapping) async throws {
try validateRestoreTarget(mapping)
let sourceURL = URL(fileURLWithPath: mapping.trashedPath).resolvingSymlinksInPath()
let destinationURL = URL(fileURLWithPath: mapping.originalPath).resolvingSymlinksInPath()
if shouldUseHelperForSmartCleanTarget(destinationURL) {
guard let helperExecutor else {
throw RecoveryRestoreFailure.helperUnavailable("Bundled helper unavailable for recovery target: \(destinationURL.path)")
}
do {
let result = try await helperExecutor.perform(
AtlasHelperAction(kind: .restoreItem, targetPath: sourceURL.path, destinationPath: destinationURL.path)
)
guard result.success else {
throw recoveryRestoreFailure(fromHelperMessage: result.message)
}
} catch let failure as RecoveryRestoreFailure {
throw failure
} catch let clientError as AtlasHelperClientError {
switch clientError {
case .helperUnavailable:
throw RecoveryRestoreFailure.helperUnavailable(clientError.localizedDescription)
case .encodingFailed, .decodingFailed, .invocationFailed:
throw RecoveryRestoreFailure.executionUnavailable(clientError.localizedDescription)
}
} catch {
throw RecoveryRestoreFailure.executionUnavailable(error.localizedDescription)
}
return
}
try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.moveItem(at: sourceURL, to: destinationURL) try FileManager.default.moveItem(at: sourceURL, to: destinationURL)
} }
private func recoveryRestoreFailure(fromHelperMessage message: String) -> RecoveryRestoreFailure {
if message.hasPrefix("Restore destination already exists:") {
return .restoreConflict(message)
}
return .executionUnavailable(message)
}
private func shouldUseHelperForSmartCleanTarget(_ targetURL: URL) -> Bool { private func shouldUseHelperForSmartCleanTarget(_ targetURL: URL) -> Bool {
AtlasSmartCleanExecutionSupport.requiresHelper(for: targetURL) AtlasSmartCleanExecutionSupport.requiresHelper(for: targetURL)
} }

View File

@@ -989,6 +989,123 @@ final class AtlasInfrastructureTests: XCTestCase {
XCTAssertTrue(fileManager.fileExists(atPath: trashedPath)) XCTAssertTrue(fileManager.fileExists(atPath: trashedPath))
} }
func testRestoreItemsKeepsStateUnchangedWhenLaterHelperRestoreFails() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let fileManager = FileManager.default
let home = fileManager.homeDirectoryForCurrentUser
let directRoot = home.appendingPathComponent("Library/Caches/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: directRoot, withIntermediateDirectories: true)
let directTargetURL = directRoot.appendingPathComponent("restored.cache")
try Data("restored".utf8).write(to: directTargetURL)
var directTrashedURL: NSURL?
try fileManager.trashItem(at: directTargetURL, resultingItemURL: &directTrashedURL)
let directTrashedPath = try XCTUnwrap((directTrashedURL as URL?)?.path)
let appRoot = home.appendingPathComponent("Applications/AtlasExecutionTests/" + UUID().uuidString, isDirectory: true)
let appBundleURL = appRoot.appendingPathComponent("Atlas Restore Conflict.app", isDirectory: true)
try fileManager.createDirectory(at: appBundleURL.appendingPathComponent("Contents/MacOS"), withIntermediateDirectories: true)
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: appBundleURL.appendingPathComponent("Contents/MacOS/AtlasRestoreConflict"))
var appTrashedURL: NSURL?
try fileManager.trashItem(at: appBundleURL, resultingItemURL: &appTrashedURL)
let appTrashedPath = try XCTUnwrap((appTrashedURL as URL?)?.path)
addTeardownBlock {
try? FileManager.default.removeItem(at: directRoot)
try? FileManager.default.removeItem(at: appRoot)
if let directTrashedURL {
try? FileManager.default.removeItem(at: directTrashedURL as URL)
}
if let appTrashedURL {
try? FileManager.default.removeItem(at: appTrashedURL as URL)
}
}
let directFinding = Finding(
id: UUID(),
title: "Direct restore fixture",
detail: directTargetURL.path,
bytes: 11,
risk: .safe,
category: "Developer tools",
targetPaths: [directTargetURL.path]
)
let helperApp = AppFootprint(
id: UUID(),
name: "Atlas Restore Conflict",
bundleIdentifier: "com.atlas.restore-conflict",
bundlePath: appBundleURL.path,
bytes: 17,
leftoverItems: 1
)
let directRecoveryItem = RecoveryItem(
id: UUID(),
title: directFinding.title,
detail: directFinding.detail,
originalPath: directTargetURL.path,
bytes: directFinding.bytes,
deletedAt: Date(),
expiresAt: Date().addingTimeInterval(3600),
payload: .finding(directFinding),
restoreMappings: [RecoveryPathMapping(originalPath: directTargetURL.path, trashedPath: directTrashedPath)]
)
let helperRecoveryItem = RecoveryItem(
id: UUID(),
title: helperApp.name,
detail: helperApp.bundlePath,
originalPath: helperApp.bundlePath,
bytes: helperApp.bytes,
deletedAt: Date(),
expiresAt: Date().addingTimeInterval(3600),
payload: .app(helperApp),
restoreMappings: [RecoveryPathMapping(originalPath: helperApp.bundlePath, trashedPath: appTrashedPath)]
)
let state = AtlasWorkspaceState(
snapshot: AtlasWorkspaceSnapshot(
reclaimableSpaceBytes: 0,
findings: [],
apps: [],
taskRuns: [],
recoveryItems: [directRecoveryItem, helperRecoveryItem],
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: RestoreConflictPrivilegedHelperExecutor(),
allowStateOnlyCleanExecution: false
)
let restore = try await worker.submit(
AtlasRequestEnvelope(command: .restoreItems(taskID: UUID(), itemIDs: [directRecoveryItem.id, helperRecoveryItem.id]))
)
guard case let .rejected(code, reason) = restore.response.response else {
return XCTFail("Expected rejected restore response")
}
XCTAssertEqual(code, .restoreConflict)
XCTAssertTrue(reason.contains(helperApp.bundlePath))
XCTAssertTrue(fileManager.fileExists(atPath: directTargetURL.path))
XCTAssertFalse(fileManager.fileExists(atPath: directTrashedPath))
XCTAssertFalse(fileManager.fileExists(atPath: helperApp.bundlePath))
XCTAssertTrue(fileManager.fileExists(atPath: appTrashedPath))
XCTAssertFalse(restore.snapshot.findings.contains(where: { $0.id == directFinding.id }))
XCTAssertFalse(restore.snapshot.apps.contains(where: { $0.id == helperApp.id }))
XCTAssertEqual(restore.snapshot.recoveryItems.map(\.id), [directRecoveryItem.id, helperRecoveryItem.id])
let persisted = repository.loadState()
XCTAssertFalse(persisted.snapshot.findings.contains(where: { $0.id == directFinding.id }))
XCTAssertFalse(persisted.snapshot.apps.contains(where: { $0.id == helperApp.id }))
XCTAssertEqual(persisted.snapshot.recoveryItems.map(\.id), [directRecoveryItem.id, helperRecoveryItem.id])
}
func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws { func testExecuteAppUninstallRemovesAppAndCreatesRecoveryEntry() async throws {
let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL()) let repository = AtlasWorkspaceRepository(stateFileURL: temporaryStateFileURL())
let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true) let worker = AtlasScaffoldWorkerService(repository: repository, allowStateOnlyCleanExecution: true)
@@ -1152,6 +1269,16 @@ private actor StubPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting {
} }
} }
private actor RestoreConflictPrivilegedHelperExecutor: AtlasPrivilegedActionExecuting {
func perform(_ action: AtlasHelperAction) async throws -> AtlasHelperActionResult {
AtlasHelperActionResult(
action: action,
success: false,
message: "Restore destination already exists: \(action.destinationPath ?? "<missing>")"
)
}
}
private final class TestClock: @unchecked Sendable { private final class TestClock: @unchecked Sendable {
var now: Date var now: Date

View File

@@ -38,8 +38,8 @@ targets:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app.worker PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app.worker
PRODUCT_NAME: AtlasWorkerXPC PRODUCT_NAME: AtlasWorkerXPC
MARKETING_VERSION: "1.0.0" MARKETING_VERSION: "1.0.1"
CURRENT_PROJECT_VERSION: 1 CURRENT_PROJECT_VERSION: 2
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION) INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION)
INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION) INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)
@@ -64,8 +64,8 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app PRODUCT_BUNDLE_IDENTIFIER: com.atlasformac.app
PRODUCT_NAME: Atlas for Mac PRODUCT_NAME: Atlas for Mac
PRODUCT_MODULE_NAME: AtlasApp PRODUCT_MODULE_NAME: AtlasApp
MARKETING_VERSION: "1.0.0" MARKETING_VERSION: "1.0.1"
CURRENT_PROJECT_VERSION: 1 CURRENT_PROJECT_VERSION: 2
GENERATE_INFOPLIST_FILE: YES GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION) INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION)
INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION) INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)