@testable import ConjureKit import Darwin import Foundation import Testing @Test func uninstallStagesRealFilesAndRetainsOriginalPathManifestInTrash() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor.app", text: "selected data") let report = try await fixture.engine().execute([original]) #expect(!report.needsRecovery) #expect(!fixture.exists(original)) let trashed = try #require(fixture.trashItems().first) #expect(try fixture.contents(trashed) == "selected data") #expect(trashed.pathExtension == "app") #expect(try fixture.activeJournals().isEmpty) #expect(try fixture.quarantines().isEmpty) let completed = try #require(fixture.completedJournals().first) let entries = try fixture.journalEntries(completed) #expect(entries.count == 1) #expect(entries[0]["original"] as? String == original.path) let destination = try #require(entries[0]["trash"] as? [String: Any]) #expect(destination["path"] as? String == fixture.trash.path) #expect(destination["name"] as? String == trashed.lastPathComponent) #expect(report.messages.joined().contains(completed.path)) #expect(!(await fixture.engine().recover()).needsRecovery) } @Test func uninstallMidStageFailureRollsBackInReverseOrder() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let alpha = try fixture.file("Alpha", text: "alpha") let beta = try fixture.file("Beta", text: "beta") let gamma = try fixture.file("Gamma", text: "gamma") let engine = fixture.engine { event in switch event { case .beforeStage(2): throw UninstallTestFailure() case .beforeRollback(1): #expect(!fixture.exists(alpha)) #expect(!fixture.exists(beta)) case .beforeRollback(0): #expect(fixture.exists(beta)) #expect(!fixture.exists(alpha)) default: break } } _ = await uninstallFailure(engine, [gamma, beta, alpha]) #expect(try fixture.contents(alpha) == "alpha") #expect(try fixture.contents(beta) == "beta") #expect(try fixture.contents(gamma) == "gamma") #expect(try fixture.quarantines().isEmpty) #expect(try fixture.activeJournals().isEmpty) #expect(try fixture.trashItems().isEmpty) } @Test func uninstallRollbackConflictPreservesRecreatedOriginalAndQuarantinedData() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let alpha = try fixture.file("Alpha", text: "original alpha") let beta = try fixture.file("Beta", text: "original beta") let engine = fixture.engine { event in if case .beforeStage(1) = event { try Data("recreated alpha".utf8).write(to: alpha) throw UninstallTestFailure() } } let message = await uninstallFailure(engine, [alpha, beta]) #expect(message.contains(fixture.journals.path)) let quarantine = try #require(fixture.quarantines().first) #expect(message.contains(quarantine.path)) #expect(try fixture.contents(alpha) == "recreated alpha") #expect(try fixture.contents(quarantine.appendingPathComponent("item-0")) == "original alpha") #expect(try fixture.contents(beta) == "original beta") #expect(try fixture.activeJournals().count == 1) let next = try fixture.file("Next", text: "must stay") _ = await uninstallFailure(fixture.engine(), [next]) #expect(try fixture.contents(next) == "must stay") #expect(try fixture.contents(alpha) == "recreated alpha") try FileManager.default.moveItem(at: alpha, to: fixture.base.appendingPathComponent("RecreatedAlpha")) let recovered = await fixture.engine().recover() #expect(!recovered.needsRecovery) #expect(try fixture.contents(alpha) == "original alpha") #expect(try fixture.activeJournals().isEmpty) } @Test func uninstallRecoversMoveThatInterruptedBeforeStageJournalUpdate() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "recover me") let interrupted = fixture.engine { event in if case .afterStageMove(0) = event { throw UninstallInterruption() } } _ = await uninstallFailure(interrupted, [original]) #expect(!fixture.exists(original)) let journal = try #require(fixture.activeJournals().first) #expect(try fixture.journalEntries(journal)[0]["state"] as? String == "stageIntent") let report = await fixture.engine().recover() #expect(!report.needsRecovery) #expect(try fixture.contents(original) == "recover me") #expect(try fixture.activeJournals().isEmpty) #expect(try fixture.quarantines().isEmpty) #expect(try fixture.trashItems().isEmpty) } @Test func uninstallRecoversPartialCommittedTrashFinalizationWithoutRollingBack() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let alpha = try fixture.file("Alpha", text: "alpha") let beta = try fixture.file("Beta", text: "beta") let failing = fixture.engine { event in if case .beforeTrash(1) = event { throw UninstallTestFailure() } } let report = try await failing.execute([alpha, beta]) #expect(report.needsRecovery) #expect(try fixture.trashItems().count == 1) #expect(!fixture.exists(alpha)) #expect(!fixture.exists(beta)) let firstTrash = try #require(fixture.trashItems().first) try Data("new alpha".utf8).write(to: alpha) let recovery = await fixture.engine().recover() #expect(!recovery.needsRecovery) #expect(try fixture.contents(alpha) == "new alpha") #expect(!fixture.exists(beta)) #expect(try fixture.contents(firstTrash) == "alpha") let contents = try fixture.trashItems().map { try fixture.contents($0) }.sorted() #expect(contents == ["alpha", "beta"]) #expect(try fixture.activeJournals().isEmpty) } @Test func uninstallRecoversTrashMoveThatInterruptedBeforeJournalUpdate() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let alpha = try fixture.file("Alpha", text: "alpha") let beta = try fixture.file("Beta", text: "beta") let interrupted = fixture.engine { event in if case .afterTrashMove(0) = event { throw UninstallInterruption() } } let report = try await interrupted.execute([alpha, beta]) #expect(report.needsRecovery) #expect(try fixture.trashItems().count == 1) let journal = try #require(fixture.activeJournals().first) #expect(try fixture.journalEntries(journal)[0]["state"] as? String == "trashIntent") #expect(!(await fixture.engine().recover()).needsRecovery) #expect(try fixture.trashItems().count == 2) #expect(!fixture.exists(alpha)) #expect(!fixture.exists(beta)) } @Test func uninstallCollapsesNestedAndRepeatedSelectionsIntoOneMove() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let bundle = fixture.selected.appendingPathComponent("Editor.app", isDirectory: true) try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: false) let child = bundle.appendingPathComponent("Settings") try Data("nested data".utf8).write(to: child) let report = try await fixture.engine().execute([child, bundle, bundle]) #expect(!report.needsRecovery) #expect(try fixture.trashItems().count == 1) let trashed = try #require(fixture.trashItems().first) #expect(try fixture.contents(trashed.appendingPathComponent("Settings")) == "nested data") let completed = try #require(fixture.completedJournals().first) #expect(try fixture.journalEntries(completed).count == 1) } @Test func uninstallRejectsChangedIdentityBetweenIntentAndMove() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "reviewed identity") let displaced = fixture.base.appendingPathComponent("Displaced") let engine = fixture.engine { event in if case .beforeStage(0) = event { try FileManager.default.moveItem(at: original, to: displaced) try Data("replacement identity".utf8).write(to: original) } } _ = await uninstallFailure(engine, [original]) #expect(try fixture.contents(original) == "replacement identity") #expect(try fixture.contents(displaced) == "reviewed identity") #expect(try fixture.activeJournals().count == 1) #expect((await fixture.engine().recover()).needsRecovery) #expect(try fixture.contents(original) == "replacement identity") #expect(try fixture.trashItems().isEmpty) } @Test func uninstallRecoveryRejectsChangedQuarantineIdentity() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected identity") _ = await uninstallFailure(fixture.engine { event in if case .afterStageMove(0) = event { throw UninstallInterruption() } }, [original]) let root = try #require(fixture.quarantines().first) let staged = root.appendingPathComponent("item-0") let displaced = fixture.base.appendingPathComponent("Displaced") try FileManager.default.moveItem(at: staged, to: displaced) try Data("untrusted replacement".utf8).write(to: staged) let recovery = await fixture.engine().recover() #expect(recovery.needsRecovery) #expect(!fixture.exists(original)) #expect(try fixture.contents(staged) == "untrusted replacement") #expect(try fixture.contents(displaced) == "selected identity") #expect(try fixture.activeJournals().count == 1) } @Test func uninstallTamperedJournalNeverAuthorizesAnArbitraryMove() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") let victim = try fixture.file("Victim", text: "unrelated data") _ = await uninstallFailure(fixture.engine { event in if case .afterStageMove(0) = event { throw UninstallInterruption() } }, [original]) let journal = try #require(fixture.activeJournals().first) var envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: journal)) as? [String: Any]) let encoded = try #require(envelope["payload"] as? String) let data = try #require(Data(base64Encoded: encoded)) var payload = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) var entries = try #require(payload["entries"] as? [[String: Any]]) entries[0]["original"] = victim.path payload["entries"] = entries envelope["payload"] = try JSONSerialization.data(withJSONObject: payload).base64EncodedString() try JSONSerialization.data(withJSONObject: envelope).write(to: journal) let recovery = await fixture.engine().recover() #expect(recovery.needsRecovery) #expect(try fixture.contents(victim) == "unrelated data") #expect(!fixture.exists(original)) let root = try #require(fixture.quarantines().first) #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") #expect(fixture.exists(journal)) } @Test func uninstallRefusesJournalSymlinksAndMissingAuthenticationKeys() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") _ = await uninstallFailure(fixture.engine { event in if case .afterStageMove(0) = event { throw UninstallInterruption() } }, [original]) let journal = try #require(fixture.activeJournals().first) let saved = fixture.base.appendingPathComponent("SavedJournal") try FileManager.default.moveItem(at: journal, to: saved) let victim = try fixture.file("Victim", text: "untrusted journal target") try FileManager.default.createSymbolicLink(at: journal, withDestinationURL: victim) #expect((await fixture.engine().recover()).needsRecovery) #expect(try fixture.contents(victim) == "untrusted journal target") #expect(!fixture.exists(original)) try FileManager.default.removeItem(at: journal) try FileManager.default.moveItem(at: saved, to: journal) try FileManager.default.removeItem(at: fixture.journals.appendingPathComponent("key")) let report = await fixture.engine().recover() #expect(report.needsRecovery) #expect(fixture.exists(journal)) #expect(!fixture.exists(original)) } @Test func uninstallRejectsCandidateAndAncestorSymlinksWithoutFollowingThem() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "must stay") let alias = fixture.selected.appendingPathComponent("Alias") try FileManager.default.createSymbolicLink(at: alias, withDestinationURL: original) _ = await uninstallFailure(fixture.engine(), [alias]) let linked = fixture.base.appendingPathComponent("Linked") try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: fixture.selected) _ = await uninstallFailure(fixture.engine(), [linked.appendingPathComponent("Editor")]) #expect(try fixture.contents(original) == "must stay") #expect(try fixture.activeJournals().isEmpty) #expect(try fixture.quarantines().isEmpty) } @Test func uninstallAncestorSymlinkRaceCannotMoveVictimData() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected") let displaced = fixture.base.appendingPathComponent("Displaced", isDirectory: true) let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true) try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false) let victim = victimDirectory.appendingPathComponent("Editor") try Data("victim".utf8).write(to: victim) let engine = fixture.engine { event in if case .beforeStage(0) = event { try FileManager.default.moveItem(at: fixture.selected, to: displaced) try FileManager.default.createSymbolicLink(at: fixture.selected, withDestinationURL: victimDirectory) } } _ = await uninstallFailure(engine, [original]) #expect(try fixture.contents(victim) == "victim") #expect(try fixture.contents(displaced.appendingPathComponent("Editor")) == "selected") #expect((await fixture.engine().recover()).needsRecovery) #expect(try fixture.trashItems().isEmpty) } @Test func uninstallCreatesMissingPerVolumeTrashAndLeavesOriginalMapping() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor.app", text: "external volume data") let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true) try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false) let engine = ApplicationUninstaller(journalDirectory: fixture.journals, trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) }) #expect(!fixture.exists(volume.appendingPathComponent(".Trashes"))) let report = try await engine.execute([original]) #expect(!report.needsRecovery) let userTrash = volume.appendingPathComponent(".Trashes/\(getuid())", isDirectory: true) let items = try FileManager.default.contentsOfDirectory(at: userTrash, includingPropertiesForKeys: nil) #expect(items.count == 1) #expect(try fixture.contents(try #require(items.first)) == "external volume data") #expect(try fixture.completedJournals().count == 1) } @Test func uninstallInaccessibleTrashRetainsCommitForLaterRecovery() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "retained data") try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: fixture.trash.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) } let report = try await fixture.engine().execute([original]) #expect(report.needsRecovery) #expect(!fixture.exists(original)) let root = try #require(fixture.quarantines().first) #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "retained data") #expect(try fixture.activeJournals().count == 1) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) #expect(!(await fixture.engine().recover()).needsRecovery) #expect(try fixture.trashItems().count == 1) } @Test func uninstallSystemAndCurrentApplicationSelectionsFailClosed() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } for url in [URL(fileURLWithPath: "/System", isDirectory: true), Bundle.main.bundleURL.resolvingSymlinksInPath()] { _ = await uninstallFailure(fixture.engine(), [url]) } #expect(try fixture.activeJournals().isEmpty) } @Test func uninstallPreservesOccupiedTrashDestinationAndRetainsQuarantine() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") let engine = fixture.engine { event in if case .beforeTrash(0) = event { let journal = try #require(fixture.activeJournals().first) let entry = try fixture.journalEntries(journal)[0] let destination = try #require(entry["trash"] as? [String: Any]) let name = try #require(destination["name"] as? String) try Data("unrelated Trash data".utf8).write(to: fixture.trash.appendingPathComponent(name)) } } let report = try await engine.execute([original]) #expect(report.needsRecovery) #expect(!fixture.exists(original)) let unrelated = try #require(fixture.trashItems().first) #expect(try fixture.contents(unrelated) == "unrelated Trash data") let root = try #require(fixture.quarantines().first) #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") #expect((await fixture.engine().recover()).needsRecovery) #expect(try fixture.contents(unrelated) == "unrelated Trash data") } @Test func uninstallDoesNotDeleteUnknownFilesAddedToQuarantine() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") let engine = fixture.engine { event in if case .beforeTrash(0) = event { let root = try #require(fixture.quarantines().first) try Data("untrusted data".utf8).write(to: root.appendingPathComponent("Unknown")) } } let report = try await engine.execute([original]) #expect(report.needsRecovery) let root = try #require(fixture.quarantines().first) #expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data") #expect(try fixture.activeJournals().count == 1) #expect(try fixture.trashItems().count == 1) #expect((await fixture.engine().recover()).needsRecovery) #expect(try fixture.contents(root.appendingPathComponent("Unknown")) == "untrusted data") } @Test func uninstallMalformedJournalRetainsAllStagedData() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") _ = await uninstallFailure(fixture.engine { event in if case .afterStageMove(0) = event { throw UninstallInterruption() } }, [original]) let journal = try #require(fixture.activeJournals().first) try Data("not a journal".utf8).write(to: journal) #expect((await fixture.engine().recover()).needsRecovery) #expect(!fixture.exists(original)) let root = try #require(fixture.quarantines().first) #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") #expect(try fixture.contents(journal) == "not a journal") } @Test func uninstallHandlesMaximumLengthFilenamesWithoutLosingOriginalAttribution() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let name = String(repeating: "A", count: 250) + ".app" let original = try fixture.file(name, text: "long filename") let report = try await fixture.engine().execute([original]) #expect(!report.needsRecovery) let trashed = try #require(fixture.trashItems().first) #expect(trashed.lastPathComponent.utf8.count <= 255) #expect(trashed.pathExtension == "app") #expect(try fixture.contents(trashed) == "long filename") let completed = try #require(fixture.completedJournals().first) #expect(try fixture.journalEntries(completed)[0]["original"] as? String == original.path) } @Test func uninstallRejectsJournalDirectoryAndVolumeTrashSymlinks() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") let victimDirectory = fixture.base.appendingPathComponent("Victim", isDirectory: true) try FileManager.default.createDirectory(at: victimDirectory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) let victim = victimDirectory.appendingPathComponent("Keep") try Data("unrelated data".utf8).write(to: victim) try FileManager.default.createSymbolicLink(at: fixture.journals, withDestinationURL: victimDirectory) _ = await uninstallFailure(fixture.engine(), [original]) #expect(try fixture.contents(original) == "selected data") #expect(try fixture.contents(victim) == "unrelated data") #expect(!fixture.exists(victimDirectory.appendingPathComponent(".lock"))) try FileManager.default.removeItem(at: fixture.journals) let volume = fixture.base.appendingPathComponent("Volume", isDirectory: true) try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: false) try FileManager.default.createSymbolicLink(at: volume.appendingPathComponent(".Trashes"), withDestinationURL: victimDirectory) let engine = ApplicationUninstaller(journalDirectory: fixture.journals, trashDirectory: { _ in try ApplicationUninstaller.volumeTrashDirectory(volume) }) let report = try await engine.execute([original]) #expect(report.needsRecovery) #expect(try fixture.contents(victim) == "unrelated data") #expect(!fixture.exists(original)) let root = try #require(fixture.quarantines().first) #expect(try fixture.contents(root.appendingPathComponent("item-0")) == "selected data") } @Test func uninstallNeedsSearchAndWritePermissionButNotDirectoryListingPermission() async throws { let fixture = try UninstallFixture() defer { fixture.remove() } let original = try fixture.file("Editor", text: "selected data") for directory in [fixture.selected, fixture.trash] { try FileManager.default.setAttributes([.posixPermissions: 0o300], ofItemAtPath: directory.path) } defer { for directory in [fixture.selected, fixture.trash] { try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path) } } let report = try await fixture.engine().execute([original]) #expect(!report.needsRecovery) #expect(!fixture.exists(original)) try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: fixture.trash.path) let trashed = try #require(fixture.trashItems().first) #expect(try fixture.contents(trashed) == "selected data") } private struct UninstallTestFailure: Error {} private func uninstallFailure(_ engine: ApplicationUninstaller, _ urls: [URL]) async -> String { do { _ = try await engine.execute(urls) Issue.record("Expected uninstall execution to throw.") return "" } catch { return error.localizedDescription } } private struct UninstallFixture: Sendable { let base: URL let selected: URL let trash: URL let journals: URL init() throws { guard let temporary = realpath(FileManager.default.temporaryDirectory.path, nil) else { throw CocoaError(.fileReadUnknown) } defer { free(temporary) } base = URL(fileURLWithPath: String(cString: temporary), isDirectory: true) .appendingPathComponent("ConjureUninstallTests-" + UUID().uuidString, isDirectory: true) selected = base.appendingPathComponent("Selected", isDirectory: true) trash = base.appendingPathComponent("Trash", isDirectory: true) journals = base.appendingPathComponent("Journals", isDirectory: true) for url in [base, selected, trash] { try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) } } func engine(checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in }) -> ApplicationUninstaller { let destination = trash return ApplicationUninstaller(journalDirectory: journals, trashDirectory: { _ in destination }, checkpoint: checkpoint) } func file(_ name: String, text: String) throws -> URL { let url = selected.appendingPathComponent(name) try Data(text.utf8).write(to: url) return url } func exists(_ url: URL) -> Bool { FileManager.default.fileExists(atPath: url.path) } func contents(_ url: URL) throws -> String { String(decoding: try Data(contentsOf: url), as: UTF8.self) } func trashItems() throws -> [URL] { try children(trash) } func quarantines() throws -> [URL] { try children(selected).filter { $0.lastPathComponent.hasPrefix(".conjure-uninstall-") } } func activeJournals() throws -> [URL] { try children(journals).filter { $0.pathExtension == "json" } } func completedJournals() throws -> [URL] { try children(journals.appendingPathComponent("Completed", isDirectory: true)) } func journalEntries(_ url: URL) throws -> [[String: Any]] { let envelope = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: url)) as? [String: Any]) let encoded = try #require(envelope["payload"] as? String) let bytes = try #require(Data(base64Encoded: encoded)) let payload = try #require(JSONSerialization.jsonObject(with: bytes) as? [String: Any]) return try #require(payload["entries"] as? [[String: Any]]) } private func children(_ url: URL) throws -> [URL] { if !exists(url) { return [] } return try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil).sorted { $0.path < $1.path } } func remove() { try? FileManager.default.removeItem(at: base) } }