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() 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() var usedRoots = Set() 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.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.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)).") }