feat: add contextual actions and recoverable application uninstall

This commit is contained in:
2026-10-12 15:01:15 +02:00
parent 8d8e2278ab
commit 2598f6f1dd
10 changed files with 2361 additions and 12 deletions
@@ -0,0 +1,530 @@
@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) }
}
@@ -0,0 +1,228 @@
import ConjureKit
import Darwin
import Foundation
import Testing
@Test func uninstallDiscoveryUsesOnlyExactApplicationAndExtensionIdentifiersWithoutWriting() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let identifier = "test.conjure.editor"
let extensionIdentifier = "test.conjure.editor.share"
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: identifier)
let extensionURL = app.appendingPathComponent("Contents/PlugIns/Share.appex")
_ = try uninstallDiscoveryBundle(at: extensionURL, identifier: extensionIdentifier, packageType: "XPC!")
let relatives = [
"Preferences/\(identifier).plist",
"Caches/\(identifier)",
"Application Support/\(identifier)",
"Logs/\(identifier)",
"Saved Application State/\(identifier).savedState",
"Application Scripts/\(identifier)",
"Containers/\(identifier)",
"Preferences/\(extensionIdentifier).plist",
"Caches/\(extensionIdentifier)",
]
let candidates = try relatives.map { try uninstallDiscoveryFile(root.appendingPathComponent("Library/\($0)"), data: Data("safe".utf8)) }
let lookalikes = try [
"Caches/\(identifier).other", "Caches/prefix.\(identifier)",
"Application Support/Editor", "Preferences/\(identifier).plist.backup",
"Group Containers/group.\(identifier)",
].map { try uninstallDiscoveryFile(root.appendingPathComponent("Library/\($0)"), data: Data("unrelated".utf8)) }
let before = try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted()
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(result.map(\.url) == [app] + candidates.sorted { $0.path < $1.path })
#expect(result.allSatisfy { $0.isSelectable && $0.defaultSelected && $0.reason == nil })
#expect(result.dropFirst().allSatisfy { $0.size == 4 })
#expect(try FileManager.default.subpathsOfDirectory(atPath: root.path).sorted() == before)
for url in candidates { #expect(try Data(contentsOf: url) == Data("safe".utf8)) }
for url in lookalikes { #expect(try Data(contentsOf: url) == Data("unrelated".utf8)) }
}
@Test func uninstallDiscoveryIncludesNestedExtensionsButNeverLinkedExtensionIdentifiers() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
let first = try uninstallDiscoveryBundle(
at: app.appendingPathComponent("Contents/Extensions/First.appex"), identifier: "test.editor.first", packageType: "XPC!"
)
_ = try uninstallDiscoveryBundle(
at: first.appendingPathComponent("Contents/PlugIns/Nested.appex"), identifier: "test.editor.nested", packageType: "XPC!"
)
let external = try uninstallDiscoveryBundle(at: root.appendingPathComponent("External.appex"), identifier: "test.external", packageType: "XPC!")
try FileManager.default.createSymbolicLink(at: app.appendingPathComponent("Contents/Extensions/Linked.appex"), withDestinationURL: external)
let firstData = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor.first"))
let nestedData = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor.nested"))
_ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.external"))
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(Set(result.map(\.url)) == Set([app, firstData, nestedData]))
}
@Test func uninstallDiscoveryPreservesAndLocksLinksWithoutSizingTheirTargets() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
let outside = root.appendingPathComponent("Outside", isDirectory: true)
_ = try uninstallDiscoveryFile(outside.appendingPathComponent("payload"), data: Data(repeating: 1, count: 100_000))
let cache = root.appendingPathComponent("Library/Caches/test.editor", isDirectory: true)
try FileManager.default.createDirectory(at: cache, withIntermediateDirectories: true)
_ = try uninstallDiscoveryFile(cache.appendingPathComponent("local"), data: Data("123".utf8))
let childLink = cache.appendingPathComponent("external")
try FileManager.default.createSymbolicLink(at: childLink, withDestinationURL: outside)
let logs = root.appendingPathComponent("Library/Logs/test.editor")
try FileManager.default.createDirectory(at: logs.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.createSymbolicLink(at: logs, withDestinationURL: outside)
let preferences = root.appendingPathComponent("Library/Preferences")
try FileManager.default.createSymbolicLink(at: preferences, withDestinationURL: outside)
_ = try uninstallDiscoveryFile(outside.appendingPathComponent("test.editor.plist"))
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
let cacheCandidate = try #require(result.first { $0.url.path == cache.path })
let logsCandidate = try #require(result.first { $0.url.path == logs.path })
#expect(result.count == 3)
#expect(cacheCandidate.isSelectable)
#expect(cacheCandidate.size == 3 + Int64(outside.path.utf8.count))
#expect(!logsCandidate.isSelectable && !logsCandidate.defaultSelected)
#expect(logsCandidate.url.path != outside.path)
#expect(logsCandidate.size == Int64(outside.path.utf8.count))
#expect(try Data(contentsOf: outside.appendingPathComponent("payload")).count == 100_000)
}
@Test func uninstallDiscoveryLeavesActuallySignedSharedGroupsUncheckedAndIgnoresUnsignedClaims() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor", nativeExecutable: true)
let extensionURL = try uninstallDiscoveryBundle(
at: app.appendingPathComponent("Contents/PlugIns/Share.appex"), identifier: "test.editor.share", packageType: "XPC!", nativeExecutable: true
)
try uninstallDiscoverySign(extensionURL, groups: ["group.test.extension"], in: root)
try uninstallDiscoverySign(app, groups: ["group.test.shared", "../unsafe"], in: root)
let shared = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.shared"))
let scripts = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Application Scripts/group.test.shared"))
let extensionGroup = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.extension"))
_ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Group Containers/group.test.editor"))
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(Set(result.map(\.url)) == Set([app, shared, scripts, extensionGroup]))
#expect(result.dropFirst().allSatisfy { $0.isSelectable && !$0.defaultSelected })
let unsigned = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Unsigned.app"), identifier: "test.unsigned")
let unsignedInfo = unsigned.appendingPathComponent("Contents/Info.plist")
var info = try #require(PropertyListSerialization.propertyList(from: Data(contentsOf: unsignedInfo), format: nil) as? [String: Any])
info["com.apple.security.application-groups"] = ["group.test.shared"]
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: unsignedInfo)
let unsignedResult = try UninstallDiscovery.scan(application: Application(url: unsigned, name: "Unsigned"), home: root, ownApplicationURL: nil)
#expect(unsignedResult.map(\.url) == [unsigned])
}
@Test func uninstallDiscoveryLocksProtectedAndInvalidApplicationsWithClearReasons() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Conjure.app"), identifier: "test.conjure")
_ = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.conjure"))
let own = try UninstallDiscovery.scan(application: Application(url: app, name: "Conjure"), home: root, ownApplicationURL: app)
#expect(own.count == 1 && own[0].url == app)
#expect(!own[0].isSelectable && !own[0].defaultSelected)
let system = try UninstallDiscovery.scan(
application: Application(url: URL(fileURLWithPath: "/System/Applications/Protected.app"), name: "Protected"),
home: root, ownApplicationURL: nil
)
#expect(system.count == 1 && !system[0].isSelectable)
#expect(system[0].size == nil)
let duplicate = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Duplicate.app"), identifier: "test.conjure")
let duplicateResult = try UninstallDiscovery.scan(
application: Application(url: duplicate, name: "Duplicate"), home: root, ownApplicationURL: app
)
#expect(duplicateResult.count == 1 && !duplicateResult[0].isSelectable)
for identifier in ["../outside", "test/escape", "test..invalid", ""] {
let invalid = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Invalid.app"), identifier: identifier)
let result = try UninstallDiscovery.scan(application: Application(url: invalid, name: "Invalid"), home: root, ownApplicationURL: nil)
#expect(result.count == 1 && !result[0].isSelectable && !result[0].defaultSelected)
}
let missing = try UninstallDiscovery.scan(application: Application(url: root.appendingPathComponent("Missing.app"), name: "Missing"), home: root, ownApplicationURL: nil)
#expect(missing.count == 1 && !missing[0].isSelectable && missing[0].reason != nil)
}
@Test func uninstallDiscoveryExplainsLockedFileAndParentPermissions() throws {
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
let cache = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Caches/test.editor"))
#expect(chflags(cache.path, UInt32(UF_IMMUTABLE)) == 0)
defer { _ = chflags(cache.path, 0) }
let locked = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
let candidate = try #require(locked.first { $0.url == cache })
#expect(!candidate.isSelectable && !candidate.defaultSelected)
#expect(candidate.size == 4)
// Root can bypass POSIX permissions; the immutable-file check above applies to every runner.
if geteuid() != 0 {
let preferences = try uninstallDiscoveryFile(root.appendingPathComponent("Library/Preferences/test.editor.plist"))
let parent = preferences.deletingLastPathComponent()
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: parent.path)
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: parent.path) }
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
let denied = try #require(result.first { $0.url == preferences })
#expect(!denied.isSelectable && !denied.defaultSelected)
}
}
@Test func uninstallDiscoveryRejectsUnownedRootsWhenFixtureOwnershipCanBeChanged() throws {
guard geteuid() == 0 else { return }
let root = try uninstallDiscoveryRoot()
defer { try? FileManager.default.removeItem(at: root) }
let app = try uninstallDiscoveryBundle(at: root.appendingPathComponent("Editor.app"), identifier: "test.editor")
#expect(chown(app.path, 501, gid_t.max) == 0)
let result = try UninstallDiscovery.scan(application: Application(url: app, name: "Editor"), home: root, ownApplicationURL: nil)
#expect(result.count == 1 && !result[0].isSelectable && !result[0].defaultSelected)
}
private func uninstallDiscoveryRoot() throws -> URL {
let root = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".ConjureDiscoveryTests-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
return root
}
@discardableResult
private func uninstallDiscoveryFile(_ url: URL, data: Data = Data("data".utf8)) throws -> URL {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: url)
return url
}
@discardableResult
private func uninstallDiscoveryBundle(at url: URL, identifier: String, packageType: String = "APPL", nativeExecutable: Bool = false) throws -> URL {
let url = URL(fileURLWithPath: url.path, isDirectory: true)
let binary = url.appendingPathComponent("Contents/MacOS/Launcher")
try FileManager.default.createDirectory(at: binary.deletingLastPathComponent(), withIntermediateDirectories: true)
let info: [String: Any] = ["CFBundleIdentifier": identifier, "CFBundlePackageType": packageType, "CFBundleExecutable": "Launcher"]
try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0).write(to: url.appendingPathComponent("Contents/Info.plist"))
if nativeExecutable {
try FileManager.default.copyItem(at: URL(fileURLWithPath: "/bin/echo"), to: binary)
} else {
try Data("#!/bin/sh\nexit 0\n".utf8).write(to: binary)
}
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
return url
}
private func uninstallDiscoverySign(_ url: URL, groups: [String], in root: URL) throws {
let entitlements = root.appendingPathComponent("\(url.lastPathComponent).entitlements")
try PropertyListSerialization.data(
fromPropertyList: ["com.apple.security.application-groups": groups], format: .xml, options: 0
).write(to: entitlements)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
process.arguments = ["--force", "--sign", "-", "--timestamp=none", "--entitlements", entitlements.path, url.path]
let errors = Pipe()
process.standardError = errors
try process.run()
process.waitUntilExit()
let message = String(decoding: errors.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
guard process.terminationStatus == 0 else { throw NSError(domain: "UninstallDiscoveryTests.codesign", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message]) }
}