229 lines
15 KiB
Swift
229 lines
15 KiB
Swift
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]) }
|
|
}
|