RadrootsAtomicFile.swift (15205B)
1 import Darwin 2 import Foundation 3 4 /// Synchronous, descriptor-relative installation. An error before publication 5 /// preserves the prior destination; an error after publication is ambiguous and 6 /// callers must inspect/recover the exact destination before acknowledging it. 7 enum RadrootsAtomicFile { 8 enum Mode: Sendable { case replace, create } 9 enum Phase: CaseIterable { case beforeCapacityCheck, afterCapacityCheck, afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync } 10 static let maximumBytes = 512 * 1024 * 1024 11 12 /// A short, synchronous cross-owner transaction. Contention fails closed 13 /// instead of blocking a cooperative executor or awaiting under a lock. 14 static func withExclusiveLock<T>(at url: URL, _ body: () throws -> T) throws -> T { 15 guard let descriptor = try acquireExclusiveLock(at: url) else { 16 throw RadrootsAppleFileError.permanentFailure 17 } 18 defer { Darwin.close(descriptor) } 19 return try body() 20 } 21 22 /// The caller owns and closes the returned descriptor. Nil denotes only 23 /// active contention; invalid paths and I/O failures remain errors. 24 static func acquireExclusiveLock( 25 at url: URL, create: Bool = true, injectedOpenError: ((Int) throws -> Int32?)? = nil 26 ) throws -> Int32? { 27 let parts = url.path.split(separator: "/").map(String.init) 28 guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last, 29 parts.allSatisfy({ $0 != "." && $0 != ".." }) 30 else { throw RadrootsAppleFileError.invalidRequest } 31 let directory = try Directory.open(Array(parts.dropLast()), create: create) 32 defer { Darwin.close(directory.descriptor) } 33 return try acquireExclusiveLock(in: directory, name: leaf, create: create, injectedOpenError: injectedOpenError) 34 } 35 36 static func acquireExclusiveLock( 37 in directory: Directory, name: String, create: Bool, injectedOpenError: ((Int) throws -> Int32?)? = nil 38 ) throws -> Int32? { 39 guard !name.isEmpty, !name.contains("/"), !name.utf8.contains(0), name != ".", name != ".." else { 40 throw RadrootsAppleFileError.invalidRequest 41 } 42 var descriptor: Int32 = -1 43 // Concurrent first creation can return ENOENT even with O_CREAT. Retry 44 // only that absence, under the exact validated parent and a fixed bound. 45 // The injection is synchronous and used only by filesystem regressions. 46 for attempt in 0 ..< 4 { 47 try directory.validate() 48 let opened: (Int32, Int32) 49 if let error = try injectedOpenError?(attempt) { 50 opened = (-1, error) 51 } else { 52 opened = name.withCString { 53 let value = Darwin.openat(directory.descriptor, $0, 54 O_RDWR | (create ? O_CREAT : 0) | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK | O_EXLOCK, 0o600) 55 return (value, errno) 56 } 57 } 58 if opened.0 >= 0 { descriptor = opened.0; break } 59 if opened.1 == EWOULDBLOCK || !create && opened.1 == ENOENT { return nil } 60 guard create, opened.1 == ENOENT else { throw RadrootsAppleFileError.posix(opened.1) } 61 if attempt == 3 { throw RadrootsAppleFileError.transientFailure } 62 } 63 var value = stat() 64 guard Darwin.fstat(descriptor, &value) == 0 else { 65 let failure = RadrootsAppleFileError.posix(errno) 66 Darwin.close(descriptor) 67 throw failure 68 } 69 guard value.st_mode & S_IFMT == S_IFREG else { 70 Darwin.close(descriptor) 71 throw RadrootsAppleFileError.permanentFailure 72 } 73 do { 74 try directory.validate() 75 } catch { 76 Darwin.close(descriptor); throw error 77 } 78 return descriptor 79 } 80 81 /// The caller holds this reservation and its acquisition/retirement gate. 82 /// Never unlink an inode substituted after the descriptor was acquired. 83 static func removeEmptyLockedFile(at url: URL, descriptor: Int32) throws -> Bool { 84 let parts = url.path.split(separator: "/").map(String.init) 85 guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last, 86 parts.allSatisfy({ $0 != "." && $0 != ".." }) 87 else { throw RadrootsAppleFileError.invalidRequest } 88 let directory = try Directory.open(Array(parts.dropLast()), create: false) 89 defer { Darwin.close(directory.descriptor) } 90 return try removeEmptyLockedFile(in: directory, name: leaf, descriptor: descriptor) 91 } 92 93 static func removeEmptyLockedFile(in directory: Directory, name: String, descriptor: Int32) throws -> Bool { 94 guard !name.isEmpty, !name.contains("/"), !name.utf8.contains(0), name != ".", name != ".." else { 95 throw RadrootsAppleFileError.invalidRequest 96 } 97 var owned = stat() 98 guard Darwin.fstat(descriptor, &owned) == 0 else { throw RadrootsAppleFileError.posix(errno) } 99 guard owned.st_mode & S_IFMT == S_IFREG, owned.st_size == 0 else { return false } 100 try directory.validate() 101 var current = stat() 102 let found = name.withCString { Darwin.fstatat(directory.descriptor, $0, ¤t, AT_SYMLINK_NOFOLLOW) } 103 if found != 0, errno == ENOENT { return false } 104 guard found == 0 else { throw RadrootsAppleFileError.posix(errno) } 105 guard current.st_mode & S_IFMT == S_IFREG, current.st_size == 0, 106 current.st_dev == owned.st_dev, current.st_ino == owned.st_ino else { return false } 107 guard name.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0 108 else { throw RadrootsAppleFileError.posix(errno) } 109 guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } 110 try directory.validate() 111 return true 112 } 113 114 static func install(_ data: Data, at url: URL, mode: Mode = .replace, readOnly: Bool = false) throws { 115 try install(data, at: url, mode: mode, readOnly: readOnly, fault: nil) 116 } 117 118 static func installForTesting( 119 _ data: Data, at url: URL, mode: Mode = .replace, readOnly: Bool = false, 120 fault: @escaping (Phase) throws -> Void 121 ) throws { 122 try install(data, at: url, mode: mode, readOnly: readOnly, fault: fault) 123 } 124 125 static func remove(at url: URL) throws { 126 let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) 127 guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last, 128 parts.allSatisfy({ $0 != "." && $0 != ".." }) 129 else { throw RadrootsAppleFileError.invalidRequest } 130 let directory = try Directory.open(Array(parts.dropLast()), create: false) 131 defer { Darwin.close(directory.descriptor) } 132 var value = stat() 133 guard leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &value, AT_SYMLINK_NOFOLLOW) }) == 0 134 else { throw RadrootsAppleFileError.posix(errno) } 135 guard value.st_mode & S_IFMT == S_IFREG else { throw RadrootsAppleFileError.permanentFailure } 136 try directory.validate() 137 guard leaf.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0 138 else { throw RadrootsAppleFileError.posix(errno) } 139 guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } 140 try directory.validate() 141 } 142 143 static func synchronizeExisting(at url: URL) throws { 144 let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) 145 guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last, 146 parts.allSatisfy({ $0 != "." && $0 != ".." }) 147 else { throw RadrootsAppleFileError.invalidRequest } 148 let directory = try Directory.open(Array(parts.dropLast()), create: false) 149 defer { Darwin.close(directory.descriptor) } 150 let descriptor = leaf.withCString { 151 Darwin.openat(directory.descriptor, $0, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) 152 } 153 guard descriptor >= 0 else { throw RadrootsAppleFileError.posix(errno) } 154 defer { Darwin.close(descriptor) } 155 var before = stat() 156 var after = stat() 157 guard Darwin.fstat(descriptor, &before) == 0 else { throw RadrootsAppleFileError.posix(errno) } 158 guard before.st_mode & S_IFMT == S_IFREG else { throw RadrootsAppleFileError.permanentFailure } 159 guard Darwin.fsync(descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } 160 guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } 161 guard leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &after, AT_SYMLINK_NOFOLLOW) }) == 0 162 else { throw RadrootsAppleFileError.posix(errno) } 163 guard before.st_dev == after.st_dev, before.st_ino == after.st_ino, 164 before.st_size == after.st_size 165 else { throw RadrootsAppleFileError.permanentFailure } 166 try directory.validate() 167 } 168 169 private static func install( 170 _ data: Data, at url: URL, mode: Mode, readOnly: Bool, fault: ((Phase) throws -> Void)? 171 ) throws { 172 guard data.count <= maximumBytes, url.isFileURL, url.path.hasPrefix("/"), 173 !url.path.utf8.contains(0) 174 else { throw RadrootsAppleFileError.invalidRequest } 175 let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) 176 guard let leaf = parts.last, parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else { 177 throw RadrootsAppleFileError.invalidRequest 178 } 179 let directory = try Directory.open(Array(parts.dropLast()), create: true) 180 defer { Darwin.close(directory.descriptor) } 181 try directory.validate() 182 try fault?(.beforeCapacityCheck) 183 try RadrootsFileCapacity.require(data.count, on: directory.descriptor) 184 try fault?(.afterCapacityCheck) 185 try directory.validate() 186 let temporary = ".radroots_pending_" + UUID().uuidString.lowercased() 187 let descriptor = temporary.withCString { 188 Darwin.openat(directory.descriptor, $0, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600) 189 } 190 guard descriptor >= 0 else { throw RadrootsAppleFileError.posix(errno) } 191 defer { Darwin.close(descriptor) } 192 // Keep interrupted files identifiable. Normal failure cleanup is safe; 193 // abrupt process loss leaves the same reserved temporary prefix. 194 defer { _ = temporary.withCString { Darwin.unlinkat(directory.descriptor, $0, 0) } } 195 try writeAll(data, to: descriptor, fault: fault) 196 try fault?(.afterWrite) 197 if readOnly, Darwin.fchmod(descriptor, 0o400) != 0 { 198 throw RadrootsAppleFileError.posix(errno) 199 } 200 try fault?(.beforeFileSync) 201 guard Darwin.fsync(descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } 202 try directory.validate() 203 try fault?(.beforeInstall) 204 try directory.validate() 205 let installed = temporary.withCString { source in 206 leaf.withCString { destination in 207 switch mode { 208 case .replace: 209 Darwin.renameat(directory.descriptor, source, directory.descriptor, destination) 210 case .create: 211 Darwin.renameatx_np( 212 directory.descriptor, 213 source, 214 directory.descriptor, 215 destination, 216 UInt32(RENAME_EXCL) 217 ) 218 } 219 } 220 } 221 guard installed == 0 else { throw RadrootsAppleFileError.posix(errno) } 222 try fault?(.beforeDirectorySync) 223 guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.posix(errno) } 224 try directory.validate() 225 } 226 227 private static func writeAll(_ data: Data, to descriptor: Int32, fault: ((Phase) throws -> Void)?) throws { 228 try data.withUnsafeBytes { bytes in 229 var offset = 0 230 while offset < bytes.count { 231 guard let base = bytes.baseAddress else { throw RadrootsAppleFileError.invalidRequest } 232 let count = Darwin.write(descriptor, base.advanced(by: offset), min(64 * 1024, bytes.count - offset)) 233 if count < 0 { 234 let code = errno 235 if code == EINTR { continue } 236 throw RadrootsAppleFileError.posix(code) 237 } 238 guard count > 0 else { throw RadrootsAppleFileError.permanentFailure } 239 offset += count 240 try fault?(.afterWriteChunk) 241 } 242 } 243 } 244 245 private struct Identity: Equatable { 246 let device: dev_t 247 let inode: ino_t 248 249 init(_ descriptor: Int32) throws { 250 var value = stat() 251 guard Darwin.fstat(descriptor, &value) == 0 else { throw RadrootsAppleFileError.posix(errno) } 252 guard value.st_mode & S_IFMT == S_IFDIR else { 253 throw RadrootsAppleFileError.permanentFailure 254 } 255 device = value.st_dev 256 inode = value.st_ino 257 } 258 } 259 260 struct Directory { 261 let descriptor: Int32 262 let parts: [String] 263 private let identities: [Identity] 264 265 static func open(_ parts: [String], create: Bool) throws -> Self { 266 var descriptor = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) 267 guard descriptor >= 0 else { throw RadrootsAppleFileError.posix(errno) } 268 do { 269 var identities = try [Identity(descriptor)] 270 for part in parts { 271 if create { 272 let result = part.withCString { Darwin.mkdirat(descriptor, $0, 0o700) } 273 guard result == 0 || errno == EEXIST else { throw RadrootsAppleFileError.posix(errno) } 274 if result == 0, Darwin.fsync(descriptor) != 0 { 275 throw RadrootsAppleFileError.posix(errno) 276 } 277 } 278 let next = part.withCString { 279 Darwin.openat(descriptor, $0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK) 280 } 281 guard next >= 0 else { throw RadrootsAppleFileError.posix(errno) } 282 Darwin.close(descriptor) 283 descriptor = next 284 try identities.append(Identity(descriptor)) 285 } 286 return Self(descriptor: descriptor, parts: parts, identities: identities) 287 } catch { 288 Darwin.close(descriptor) 289 throw error 290 } 291 } 292 293 func validate() throws { 294 let current = try Self.open(parts, create: false) 295 defer { Darwin.close(current.descriptor) } 296 guard current.identities == identities else { throw RadrootsAppleFileError.permanentFailure } 297 } 298 } 299 }