Files
conjure/Sources/ConjureKit/ApplicationUninstaller.swift

739 lines
38 KiB
Swift

import CryptoKit
import Darwin
import Foundation
public struct UninstallReport: Sendable {
public let messages: [String]
public let needsRecovery: Bool
public init(messages: [String], needsRecovery: Bool) {
self.messages = messages
self.needsRecovery = needsRecovery
}
}
// Internal checkpoints interrupt the real filesystem implementation, rather than replace it.
enum UninstallCheckpoint: Sendable {
case beforeStage(Int), afterStageMove(Int), beforeCommit
case beforeRollback(Int), beforeTrash(Int), afterTrashMove(Int)
}
struct UninstallInterruption: Error {}
public actor ApplicationUninstaller {
private let journalDirectory: URL
private let trashDirectory: @Sendable (URL) throws -> URL
private let checkpoint: @Sendable (UninstallCheckpoint) throws -> Void
public init(journalDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/Conjure/UninstallTransactions", isDirectory: true)) {
self.journalDirectory = journalDirectory
trashDirectory = Self.nativeTrashDirectory
checkpoint = { _ in }
}
init(
journalDirectory: URL,
trashDirectory: @escaping @Sendable (URL) throws -> URL,
checkpoint: @escaping @Sendable (UninstallCheckpoint) throws -> Void = { _ in }
) {
self.journalDirectory = journalDirectory
self.trashDirectory = trashDirectory
self.checkpoint = checkpoint
}
public func execute(_ urls: [URL]) throws -> UninstallReport {
let store = try Store(directory: journalDirectory)
let recovery = recover(in: store)
guard !recovery.needsRecovery else {
throw Failure("Uninstall cannot start until recovery is resolved. " + recovery.messages.joined(separator: " "))
}
let originals = try selectedPaths(urls)
guard !originals.isEmpty else { throw Failure("No items were selected for uninstall.") }
let key = try store.key(create: true)
var journal = Journal(id: UUID().uuidString, roots: [], entries: [], committed: false)
do {
for (index, original) in originals.enumerated() {
let parent = try Directory(path: parentPath(original))
let info = try requiredInfo(parent.fd, leaf(original))
try validateCandidate(original, info: info)
let parentIdentity = try parent.identity()
guard parentIdentity.device == info.identity.device else {
throw Failure("A mounted volume itself cannot be uninstalled: \(original).")
}
let rootIndex: Int
if let existing = journal.roots.firstIndex(where: { $0.identity.device == info.identity.device }) {
rootIndex = existing
} else {
let name = ".conjure-uninstall-\(journal.id)-\(info.identity.device)"
try makeDirectory(parent.fd, name, mode: 0o700)
let root = try Directory(path: originalParentPath(original, name))
try root.requirePrivate()
let identity = try root.identity()
guard identity.device == info.identity.device else { throw Failure("Quarantine is not on the selected item's volume.") }
journal.roots.append(Root(path: root.path, identity: identity, parentIdentity: parentIdentity))
rootIndex = journal.roots.count - 1
}
journal.entries.append(Entry(original: original, identity: info.identity,
parentIdentity: parentIdentity, root: rootIndex,
stagedName: "item-\(index)", state: .planned, trash: nil))
}
try store.save(journal, key: key)
} catch {
// Initial persistence may have succeeded before its durability acknowledgement failed.
if (try? store.contains(journal)) != false {
throw Failure("Uninstall did not start: \(error.localizedDescription)" + retained(journal, store: store))
}
let cleanup = removeEmptyRoots(journal)
throw Failure("Uninstall did not start: \(error.localizedDescription). No selected items were moved."
+ (cleanup.isEmpty ? "" : retained(journal, store: store, details: cleanup)))
}
do {
for index in journal.entries.indices {
journal.entries[index].state = .stageIntent
try store.save(journal, key: key)
try checkpoint(.beforeStage(index))
let entry = journal.entries[index]
let parent = try originalParent(entry)
let root = try quarantine(journal.roots[entry.root])
try requireIdentity(parent.fd, leaf(entry.original), entry.identity)
try move(parent, leaf(entry.original), root, entry.stagedName)
try checkpoint(.afterStageMove(index))
try requireIdentity(root.fd, entry.stagedName, entry.identity)
journal.entries[index].state = .staged
try store.save(journal, key: key)
}
try checkpoint(.beforeCommit)
} catch is UninstallInterruption {
throw Failure("Uninstall was interrupted before commit; recovery will restore staged items." + retained(journal, store: store))
} catch {
let failures = rollback(&journal, store: store, key: key)
let restored = journal.entries.allSatisfy { $0.state == .restored }
let outcome = restored ? " All selected items were restored." : " Rollback could not restore every item."
throw Failure("Uninstall failed: \(error.localizedDescription)." + outcome
+ (failures.isEmpty ? "" : retained(journal, store: store, details: failures)))
}
journal.committed = true
do {
try store.save(journal, key: key)
} catch {
// A failed durability acknowledgement may still have persisted the commit. Never guess and roll back.
return UninstallReport(messages: recovery.messages + ["Commit persistence needs recovery: \(error.localizedDescription)"
+ retained(journal, store: store)], needsRecovery: true)
}
let failures = finalize(&journal, store: store, key: key)
if !failures.isEmpty {
return UninstallReport(messages: recovery.messages + ["Uninstall committed, but Trash finalization needs recovery."
+ retained(journal, store: store, details: failures)], needsRecovery: true)
}
return UninstallReport(messages: recovery.messages + ["Moved \(journal.entries.count) selected item(s) to Trash. Finder's Put Back command is not available; original paths and Trash destinations are retained in \(store.path)/Completed/\(journal.id).json."], needsRecovery: false)
}
public func recover() -> UninstallReport {
do { return recover(in: try Store(directory: journalDirectory)) }
catch { return UninstallReport(messages: ["Uninstall recovery could not open \(journalDirectory.path): \(error.localizedDescription). No files were moved."], needsRecovery: true) }
}
private func recover(in store: Store) -> UninstallReport {
var messages: [String] = []
var needsRecovery = false
do {
let names = try store.journalNames()
guard !names.isEmpty else { return UninstallReport(messages: [], needsRecovery: false) }
let key = try store.key(create: false)
for name in names {
do {
var journal = try store.load(name, key: key)
try validate(journal, name: name)
let committed = journal.committed
let failures = committed ? finalize(&journal, store: store, key: key)
: rollback(&journal, store: store, key: key)
if failures.isEmpty {
messages.append(committed ? "Recovered a committed uninstall and finished moving its items to Trash. Original paths are retained in \(store.path)/Completed/\(journal.id).json; Finder Put Back is unavailable."
: "Recovered an interrupted uninstall and restored its original items.")
} else {
needsRecovery = true
messages.append("Uninstall recovery needs attention." + retained(journal, store: store, details: failures))
}
} catch {
needsRecovery = true
messages.append("Recovery refused journal \(store.path)/\(name): \(error.localizedDescription). No moves were authorized by this journal; retain it and its quarantine for manual inspection.")
}
}
} catch {
needsRecovery = true
messages.append("Recovery refused journals in \(store.path): \(error.localizedDescription). No files were moved.")
}
return UninstallReport(messages: messages, needsRecovery: needsRecovery)
}
private func rollback(_ journal: inout Journal, store: Store, key: SymmetricKey) -> [String] {
var failures: [String] = []
for index in journal.entries.indices.reversed() {
if journal.entries[index].state == .restored { continue }
do {
let entry = journal.entries[index]
let parent = try originalParent(entry)
let root = try quarantine(journal.roots[entry.root])
let original = try fileInfo(parent.fd, leaf(entry.original))
let staged = try fileInfo(root.fd, entry.stagedName)
if let staged {
guard staged.identity == entry.identity else { throw Failure("Quarantined identity changed; nothing at that path will be moved.") }
guard original == nil else { throw Failure("Original path is occupied; it will not be overwritten.") }
journal.entries[index].state = .rollbackIntent
try store.save(journal, key: key)
try checkpoint(.beforeRollback(index))
try requireIdentity(root.fd, entry.stagedName, entry.identity)
try move(root, entry.stagedName, parent, leaf(entry.original))
try requireIdentity(parent.fd, leaf(entry.original), entry.identity)
} else {
guard original?.identity == entry.identity else { throw Failure("Original identity is missing or changed; no unrelated file will be moved.") }
}
journal.entries[index].state = .restored
try store.save(journal, key: key)
} catch {
failures.append("\(journal.entries[index].original): \(error.localizedDescription)")
}
}
if failures.isEmpty { failures += finish(journal, store: store) }
return failures
}
private func finalize(_ journal: inout Journal, store: Store, key: SymmetricKey) -> [String] {
var failures: [String] = []
for index in journal.entries.indices {
if journal.entries[index].state == .trashed { continue }
do {
var entry = journal.entries[index]
let root = try quarantine(journal.roots[entry.root])
if entry.trash == nil {
let url = try trashDirectory(URL(fileURLWithPath: entry.original))
let trash = try Directory(path: checkedPath(url))
try trash.requirePrivate()
let identity = try trash.identity()
guard identity.device == entry.identity.device else { throw Failure("Trash is on another volume; the committed item remains in quarantine.") }
let name = trashName(original: entry.original, id: journal.id, index: index)
entry.trash = Location(path: trash.path, identity: identity, name: name)
entry.state = .trashIntent
journal.entries[index] = entry
try store.save(journal, key: key)
}
guard let destination = entry.trash else { throw Failure("Missing Trash intent.") }
let trash = try Directory(path: destination.path)
try trash.requirePrivate()
guard try trash.identity() == destination.identity else { throw Failure("Trash directory identity changed.") }
let staged = try fileInfo(root.fd, entry.stagedName)
let alreadyTrashed = try fileInfo(trash.fd, destination.name)
if let staged {
guard staged.identity == entry.identity, alreadyTrashed == nil else { throw Failure("Staging identity changed or Trash destination is occupied; neither item will be overwritten.") }
try checkpoint(.beforeTrash(index))
try requireIdentity(root.fd, entry.stagedName, entry.identity)
try move(root, entry.stagedName, trash, destination.name)
try checkpoint(.afterTrashMove(index))
try requireIdentity(trash.fd, destination.name, entry.identity)
} else {
guard alreadyTrashed?.identity == entry.identity else { throw Failure("Cannot identify the item in either quarantine or its recorded Trash destination.") }
}
journal.entries[index].state = .trashed
try store.save(journal, key: key)
} catch is UninstallInterruption {
failures.append("Interrupted finalization of \(journal.entries[index].original).")
break
} catch {
failures.append("\(journal.entries[index].original): \(error.localizedDescription)")
}
}
if failures.isEmpty { failures += finish(journal, store: store) }
return failures
}
private func finish(_ journal: Journal, store: Store) -> [String] {
let failures = removeEmptyRoots(journal)
guard failures.isEmpty else { return failures }
do {
if journal.committed { try store.archive(journal) }
else { try store.remove(journal) }
return []
} catch { return ["Journal cleanup failed: \(error.localizedDescription)"] }
}
private func removeEmptyRoots(_ journal: Journal) -> [String] {
journal.roots.compactMap { root in
do {
let parent = try Directory(path: parentPath(root.path))
guard try parent.identity() == root.parentIdentity else { throw Failure("Quarantine parent identity changed.") }
guard let current = try fileInfo(parent.fd, leaf(root.path)) else { return nil }
guard current.identity == root.identity else { throw Failure("Quarantine identity changed.") }
guard unlinkat(parent.fd, leaf(root.path), AT_REMOVEDIR) == 0 else { throw posixFailure("Removing empty quarantine", root.path) }
try parent.sync()
return nil
} catch { return "Retained quarantine \(root.path): \(error.localizedDescription)" }
}
}
private func selectedPaths(_ urls: [URL]) throws -> [String] {
let paths = try Set(urls.map(checkedPath)).sorted()
guard paths.count <= 512 else { throw Failure("Too many uninstall items were selected.") }
var selected: [String] = []
var identities = Set<Identity>()
for path in paths {
if selected.contains(where: { path.hasPrefix($0 + "/") }) { continue }
let parent = try Directory(path: parentPath(path))
let info = try requiredInfo(parent.fd, leaf(path))
try validateCandidate(path, info: info)
guard identities.insert(info.identity).inserted else { throw Failure("Selected paths refer to the same filesystem identity: \(path).") }
selected.append(path)
}
return selected
}
private func validateCandidate(_ path: String, info: FileInfo) throws {
let own = Bundle.main.bundleURL.resolvingSymlinksInPath().path
let home = FileManager.default.homeDirectoryForCurrentUser.resolvingSymlinksInPath().path
let journal = try checkedPath(journalDirectory)
let protected = ["/System", "/usr", "/bin", "/sbin", "/dev", "/private/etc", "/Library/Apple"]
guard path != "/", path != "/private", path != home,
!protected.contains(where: { path == $0 || path.hasPrefix($0 + "/") }),
path != own, !own.hasPrefix(path + "/"), !path.hasPrefix(own + "/"),
path != journal, !journal.hasPrefix(path + "/"), !path.hasPrefix(journal + "/") else {
throw Failure("Protected path cannot be uninstalled: \(path).")
}
guard info.owner == getuid(), info.isRegular || info.isDirectory else {
throw Failure("Only your own regular files or directories without symlink ancestry can be uninstalled: \(path).")
}
guard info.flags & UInt32(UF_IMMUTABLE | SF_IMMUTABLE | UF_APPEND | SF_APPEND) == 0 else {
throw Failure("Locked item cannot be uninstalled: \(path).")
}
}
private func validate(_ journal: Journal, name: String) throws {
guard UUID(uuidString: journal.id)?.uuidString == journal.id,
name == journal.id + ".json", journal.version == 1,
!journal.entries.isEmpty, journal.entries.count <= 512,
!journal.roots.isEmpty, journal.roots.count <= journal.entries.count else { throw Failure("Invalid journal structure.") }
var paths: [String] = []
var identities = Set<Identity>()
var usedRoots = Set<Int>()
for (index, entry) in journal.entries.enumerated() {
guard try checkedPath(URL(fileURLWithPath: entry.original)) == entry.original,
journal.roots.indices.contains(entry.root),
entry.stagedName == "item-\(index)",
identities.insert(entry.identity).inserted,
!paths.contains(where: { entry.original == $0 || entry.original.hasPrefix($0 + "/") || $0.hasPrefix(entry.original + "/") }) else { throw Failure("Invalid immutable selection in journal.") }
let root = journal.roots[entry.root]
guard root.identity.device == entry.identity.device,
root.identity.kind == UInt32(S_IFDIR),
!entry.original.hasPrefix(root.path + "/") else { throw Failure("Invalid quarantine location.") }
if usedRoots.insert(entry.root).inserted {
guard root.parentIdentity == entry.parentIdentity,
root.path == originalParentPath(entry.original, ".conjure-uninstall-\(journal.id)-\(entry.identity.device)") else {
throw Failure("Invalid quarantine parent or location.")
}
}
if journal.committed {
guard [.staged, .trashIntent, .trashed].contains(entry.state),
(entry.state == .staged) == (entry.trash == nil) else { throw Failure("Invalid committed journal state.") }
} else {
guard entry.trash == nil, ![.trashIntent, .trashed].contains(entry.state) else { throw Failure("Invalid precommit journal state.") }
}
if let trash = entry.trash {
guard try checkedPath(URL(fileURLWithPath: trash.path)) == trash.path,
trash.identity.device == entry.identity.device,
trash.identity.kind == UInt32(S_IFDIR),
trash.name == trashName(original: entry.original, id: journal.id, index: index) else {
throw Failure("Invalid Trash intent.")
}
}
// Signed journals also undergo protected-path validation without trusting live candidate contents.
let synthetic = FileInfo(identity: entry.identity, owner: getuid(), flags: 0)
try validateCandidate(entry.original, info: synthetic)
paths.append(entry.original)
}
guard usedRoots.count == journal.roots.count else { throw Failure("Unused quarantine in journal.") }
}
private func originalParent(_ entry: Entry) throws -> Directory {
let parent = try Directory(path: parentPath(entry.original))
guard try parent.identity() == entry.parentIdentity else { throw Failure("Original parent directory identity changed; recovery will not use a replacement.") }
return parent
}
private func quarantine(_ root: Root) throws -> Directory {
let directory = try Directory(path: root.path)
try directory.requirePrivate()
guard try directory.identity() == root.identity else { throw Failure("Quarantine directory identity changed.") }
return directory
}
private func retained(_ journal: Journal, store: Store, details: [String] = []) -> String {
" Journal: \(store.path)/\(journal.id).json. Quarantine: "
+ journal.roots.map(\.path).joined(separator: ", ") + ". " + details.joined(separator: " ")
}
private static func nativeTrashDirectory(_ original: URL) throws -> URL {
let parent = try Directory(path: parentPath(checkedPath(original)))
let sourceDevice = try parent.identity().device
let homeTrash = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".Trash", isDirectory: true)
let home = try Directory(path: homeTrash.deletingLastPathComponent().path)
if try home.identity().device == sourceDevice {
try makeDirectory(home.fd, ".Trash", mode: 0o700, allowExisting: true)
return homeTrash
}
var filesystem = statfs()
guard fstatfs(parent.fd, &filesystem) == 0 else { throw posixFailure("Finding volume", original.path) }
let mount = withUnsafePointer(to: &filesystem.f_mntonname) {
$0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { String(cString: $0) }
}
return try volumeTrashDirectory(URL(fileURLWithPath: mount, isDirectory: true))
}
// Also used by real-filesystem tests without requiring an external disk to be mounted.
static func volumeTrashDirectory(_ url: URL) throws -> URL {
let volume = try Directory(path: checkedPath(url))
try makeDirectory(volume.fd, ".Trashes", mode: 0o1777, allowExisting: true)
let trashes = try Directory(path: volume.path + "/.Trashes")
var value = stat()
guard fstat(trashes.fd, &value) == 0,
value.st_uid == 0 || value.st_uid == getuid(),
value.st_mode & 0o002 == 0 || value.st_mode & mode_t(S_ISVTX) != 0 else {
throw Failure("Untrusted volume Trash directory: \(trashes.path).")
}
let user = String(getuid())
try makeDirectory(trashes.fd, user, mode: 0o700, allowExisting: true)
let destination = try Directory(path: trashes.path + "/" + user)
try destination.requirePrivate()
return URL(fileURLWithPath: destination.path, isDirectory: true)
}
}
private struct Failure: LocalizedError {
let errorDescription: String?
init(_ description: String) { errorDescription = description }
}
private struct Identity: Codable, Hashable {
let device: UInt64
let inode: UInt64
let birthSeconds: Int64
let birthNanoseconds: Int64
let kind: UInt32
init(_ value: stat) {
device = UInt64(UInt32(bitPattern: value.st_dev))
inode = UInt64(value.st_ino)
birthSeconds = Int64(value.st_birthtimespec.tv_sec)
birthNanoseconds = Int64(value.st_birthtimespec.tv_nsec)
kind = UInt32(value.st_mode) & UInt32(S_IFMT)
}
}
private struct FileInfo {
let identity: Identity
let owner: uid_t
let flags: UInt32
var isRegular: Bool { identity.kind == UInt32(S_IFREG) }
var isDirectory: Bool { identity.kind == UInt32(S_IFDIR) }
}
private struct Root: Codable {
let path: String
let identity: Identity
let parentIdentity: Identity
}
private struct Location: Codable {
let path: String
let identity: Identity
let name: String
}
private enum State: String, Codable { case planned, stageIntent, staged, rollbackIntent, restored, trashIntent, trashed }
private struct Entry: Codable {
let original: String
let identity: Identity
let parentIdentity: Identity
let root: Int
let stagedName: String
var state: State
var trash: Location?
}
private struct Journal: Codable {
var version = 1
let id: String
var roots: [Root]
var entries: [Entry]
var committed: Bool
}
private struct Envelope: Codable {
let payload: Data
let authentication: Data
}
// Descriptor-relative walks never follow ancestor symlinks. Renames are exclusive and never copy.
private final class Directory {
let path: String
let fd: Int32
init(path: String, create: Bool = false) throws {
self.path = try checkedPath(URL(fileURLWithPath: path))
// Directory operations need search access, not permission to list contents (notably macOS Trash).
var current = open("/", O_SEARCH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
guard current >= 0 else { throw posixFailure("Opening root", path) }
do {
for component in self.path.split(separator: "/") {
let part = String(component)
if create { try makeDirectory(current, part, mode: 0o700, allowExisting: true) }
let next = openat(current, part, O_SEARCH | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW)
guard next >= 0 else { throw posixFailure("Opening directory without symlink traversal", self.path) }
close(current)
current = next
}
fd = current
} catch { close(current); throw error }
}
deinit { close(fd) }
func identity() throws -> Identity {
var value = stat()
guard fstat(fd, &value) == 0 else { throw posixFailure("Reading directory identity", path) }
return Identity(value)
}
func requirePrivate() throws {
var value = stat()
guard fstat(fd, &value) == 0, value.st_uid == getuid(), value.st_mode & 0o077 == 0 else {
throw Failure("Directory must be owned by you with private permissions: \(path).")
}
}
func sync() throws {
guard fsync(fd) == 0 else { throw posixFailure("Persisting directory changes", path) }
}
}
private final class Store {
let directory: Directory
let lock: Int32
var path: String { directory.path }
private var savedIdentities: [String: Identity] = [:]
init(directory url: URL) throws {
directory = try Directory(path: checkedPath(url), create: true)
try directory.requirePrivate()
lock = openat(directory.fd, ".lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0o600)
guard lock >= 0 else { throw posixFailure("Opening uninstall lock", directory.path) }
do {
try privateFile(lock, path: directory.path + "/.lock")
guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { throw Failure("Another uninstall or recovery is active. Try again when it finishes.") }
} catch { close(lock); throw error }
}
deinit { close(lock) }
func journalNames() throws -> [String] {
let copy = openat(directory.fd, ".", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
guard copy >= 0, let stream = fdopendir(copy) else {
if copy >= 0 { close(copy) }
throw posixFailure("Listing uninstall journals", path)
}
defer { closedir(stream) }
var names: [String] = []
while let item = readdir(stream) {
let name = withUnsafePointer(to: &item.pointee.d_name) {
$0.withMemoryRebound(to: CChar.self, capacity: Int(item.pointee.d_namlen) + 1) { String(cString: $0) }
}
if name == "." || name == ".." || name == ".lock" || name == "key" || name.hasPrefix(".tmp-") { continue }
if name == "Completed" {
let completed = try Directory(path: path + "/Completed")
try completed.requirePrivate()
continue
}
guard name.hasSuffix(".json") else { throw Failure("Unexpected entry in private journal directory: \(path)/\(name).") }
names.append(name)
}
return names.sorted()
}
func key(create: Bool) throws -> SymmetricKey {
if let _ = try fileInfo(directory.fd, "key") {
let bytes = try readFile("key", maximum: 32)
guard bytes.count == 32 else { throw Failure("Invalid uninstall journal authentication key.") }
return SymmetricKey(data: bytes)
}
guard create else { throw Failure("Journal authentication key is missing; existing journals cannot be trusted.") }
let key = SymmetricKey(size: .bits256)
let bytes = key.withUnsafeBytes { Data($0) }
try writeFile(bytes, name: "key")
try directory.sync()
return key
}
func load(_ name: String, key: SymmetricKey) throws -> Journal {
let bytes = try readFile(name, maximum: 4 * 1024 * 1024)
let envelope = try JSONDecoder().decode(Envelope.self, from: bytes)
guard HMAC<SHA256>.isValidAuthenticationCode(envelope.authentication, authenticating: envelope.payload, using: key) else {
throw Failure("Journal authentication failed (modified or untrusted journal).")
}
return try JSONDecoder().decode(Journal.self, from: envelope.payload)
}
func contains(_ journal: Journal) throws -> Bool {
try fileInfo(directory.fd, journal.id + ".json") != nil
}
func archive(_ journal: Journal) throws {
try makeDirectory(directory.fd, "Completed", mode: 0o700, allowExisting: true)
let completed = try Directory(path: path + "/Completed")
try completed.requirePrivate()
let name = journal.id + ".json"
try requireIdentity(directory.fd, name, savedIdentities[name])
try move(directory, name, completed, name)
savedIdentities.removeValue(forKey: name)
}
func save(_ journal: Journal, key: SymmetricKey) throws {
let payload = try JSONEncoder().encode(journal)
let authentication = Data(HMAC<SHA256>.authenticationCode(for: payload, using: key))
let bytes = try JSONEncoder().encode(Envelope(payload: payload, authentication: authentication))
guard bytes.count <= 4 * 1024 * 1024 else { throw Failure("Transaction journal exceeds its recoverable size limit.") }
let name = journal.id + ".json"
let temporary = ".tmp-" + UUID().uuidString
try writeFile(bytes, name: temporary)
if let current = try fileInfo(directory.fd, name) {
guard savedIdentities[name] == current.identity else { throw Failure("Journal identity changed; refusing to replace it: \(path)/\(name).") }
guard renameat(directory.fd, temporary, directory.fd, name) == 0 else { throw posixFailure("Replacing journal", path + "/" + name) }
} else {
guard savedIdentities[name] == nil else { throw Failure("Journal disappeared; refusing to recreate it.") }
guard renameatx_np(directory.fd, temporary, directory.fd, name, UInt32(RENAME_EXCL)) == 0 else { throw posixFailure("Creating journal", path + "/" + name) }
}
savedIdentities[name] = try requiredInfo(directory.fd, name).identity
try directory.sync()
}
func remove(_ journal: Journal) throws {
let name = journal.id + ".json"
try requireIdentity(directory.fd, name, savedIdentities[name])
guard unlinkat(directory.fd, name, 0) == 0 else { throw posixFailure("Removing completed journal", path + "/" + name) }
savedIdentities.removeValue(forKey: name)
try directory.sync()
}
private func readFile(_ name: String, maximum: Int) throws -> Data {
let fd = openat(directory.fd, name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
guard fd >= 0 else { throw posixFailure("Reading journal file", path + "/" + name) }
defer { close(fd) }
try privateFile(fd, path: path + "/" + name)
var value = stat()
guard fstat(fd, &value) == 0, value.st_size >= 0, value.st_size <= maximum else { throw Failure("Journal file exceeds its size limit.") }
var data = Data(count: Int(value.st_size))
try data.withUnsafeMutableBytes { bytes in
var offset = 0
while offset < bytes.count {
let count = Darwin.read(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset)
if count < 0, errno == EINTR { continue }
guard count > 0 else { throw posixFailure("Reading complete journal", path + "/" + name) }
offset += count
}
}
savedIdentities[name] = Identity(value)
return data
}
private func writeFile(_ bytes: Data, name: String) throws {
let fd = openat(directory.fd, name, O_WRONLY | O_CREAT | O_NOFOLLOW | O_CLOEXEC | O_EXCL, 0o600)
guard fd >= 0 else { throw posixFailure("Writing journal file", path + "/" + name) }
defer { close(fd) }
try privateFile(fd, path: path + "/" + name)
try bytes.withUnsafeBytes { buffer in
var offset = 0
while offset < buffer.count {
let count = Darwin.write(fd, buffer.baseAddress!.advanced(by: offset), buffer.count - offset)
if count < 0, errno == EINTR { continue }
guard count > 0 else { throw posixFailure("Writing complete journal", path + "/" + name) }
offset += count
}
}
guard fsync(fd) == 0 else { throw posixFailure("Persisting journal", path + "/" + name) }
// Full sync asks macOS to flush drive caches too; some filesystems support only fsync.
if fcntl(fd, F_FULLFSYNC) != 0, errno != EINVAL, errno != ENOTSUP { throw posixFailure("Flushing journal", path + "/" + name) }
}
}
private func privateFile(_ fd: Int32, path: String) throws {
var value = stat()
guard fstat(fd, &value) == 0, value.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG),
value.st_uid == getuid(), value.st_mode & 0o077 == 0, value.st_nlink == 1 else {
throw Failure("Untrusted journal file (type, owner, permissions, or hard links): \(path).")
}
}
private func checkedPath(_ url: URL) throws -> String {
let path = url.path
guard url.isFileURL, url.host == nil || url.host == "" || url.host == "localhost",
url.query == nil, url.fragment == nil,
path.hasPrefix("/"), !path.contains("\0"), path.utf8.count < 16_384,
!path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == "." || $0 == ".." }) else {
throw Failure("Uninstall requires an absolute local path without dot components.")
}
return path == "/" ? path : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
}
private func parentPath(_ path: String) -> String { URL(fileURLWithPath: path).deletingLastPathComponent().path }
private func leaf(_ path: String) -> String { URL(fileURLWithPath: path).lastPathComponent }
private func originalParentPath(_ original: String, _ name: String) -> String { URL(fileURLWithPath: parentPath(original)).appendingPathComponent(name).path }
private func trashName(original: String, id: String, index: Int) -> String {
let url = URL(fileURLWithPath: original)
let ext = url.pathExtension
let suffix = !ext.isEmpty && ext.utf8.count <= 32 ? "." + ext : ""
let base = suffix.isEmpty ? url.lastPathComponent : url.deletingPathExtension().lastPathComponent
// Leave room for a collision-free transaction suffix even for maximum-length source filenames.
return String(decoding: base.utf8.prefix(120), as: UTF8.self) + " (Conjure \(id)-\(index))" + suffix
}
private func fileInfo(_ parent: Int32, _ name: String) throws -> FileInfo? {
var value = stat()
guard fstatat(parent, name, &value, AT_SYMLINK_NOFOLLOW) == 0 else {
if errno == ENOENT { return nil }
throw posixFailure("Reading filesystem identity", name)
}
return FileInfo(identity: Identity(value), owner: value.st_uid, flags: value.st_flags)
}
private func requiredInfo(_ parent: Int32, _ name: String) throws -> FileInfo {
guard let info = try fileInfo(parent, name) else { throw Failure("Selected item is missing: \(name).") }
return info
}
private func requireIdentity(_ parent: Int32, _ name: String, _ identity: Identity?) throws {
guard let identity, try requiredInfo(parent, name).identity == identity else {
throw Failure("Filesystem identity changed: \(name). No replacement will be moved.")
}
}
private func makeDirectory(_ parent: Int32, _ name: String, mode: mode_t, allowExisting: Bool = false) throws {
if mkdirat(parent, name, mode) == 0 {
guard fsync(parent) == 0 else { throw posixFailure("Persisting new directory", name) }
} else if !(allowExisting && errno == EEXIST) { throw posixFailure("Creating directory", name) }
}
private func move(_ source: Directory, _ name: String, _ destination: Directory, _ target: String) throws {
guard try source.identity().device == destination.identity().device else { throw Failure("Refusing a cross-volume move.") }
guard renameatx_np(source.fd, name, destination.fd, target, UInt32(RENAME_EXCL)) == 0 else {
throw posixFailure("Moving without overwriting", source.path + "/" + name + " → " + destination.path + "/" + target)
}
try destination.sync()
try source.sync()
}
private func posixFailure(_ action: String, _ path: String) -> Failure {
let code = errno
return Failure("\(action) \(path): \(String(cString: strerror(code))) (\(code)).")
}