fix: enforce recovery retention and fail-closed restore semantics
- prune expired recovery items on load/save and reject expired restores at worker boundary - add restoreExpired and restoreConflict protocol/application error mapping - disable expired restore actions in History and reload persisted state after restore failures - add recovery expiry/conflict coverage plus sync protocol, architecture, state-machine, and recovery contract docs - wire AtlasAppTests into the shared Xcode scheme and add app-layer regression coverage for expired restore reload behavior Refs: ATL-221 ATL-222 ATL-223 ATL-224 ATL-225, vibe-kanban SID-9
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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;若仍失败,请重新安装当前版本。";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<UUID> {
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user