531 lines
26 KiB
Swift
531 lines
26 KiB
Swift
@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) }
|
|
}
|