apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

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, &current, 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 }