RadrootsFileMaintenanceTests.swift (13504B)
1 import Darwin 2 import Foundation 3 @testable import RadrootsKit 4 import Testing 5 6 @Test func fileMaintenanceSharedUseExcludesMaintenanceUntilLastRelease() throws { 7 let f = try FileMaintenanceFixture() 8 defer { f.remove() } 9 var first = try #require(try f.coordinator.reserveUse()) 10 var second = try f.coordinator.reserveUse() 11 #expect(second != nil) 12 try first.validate() 13 #expect(try f.coordinator.reserveMaintenance() == nil) 14 second = nil 15 #expect(second == nil) 16 #expect(try f.coordinator.reserveMaintenance() == nil) 17 // Retain first explicitly through the final contention observation. 18 withExtendedLifetime(first) {} 19 // A distinct root remains independent. 20 first = try #require(try RadrootsAppleFileMaintenance(root: f.base.appendingPathComponent("other")).reserveUse()) 21 let maintenance = try #require(try f.coordinator.reserveMaintenance()) 22 #expect(try f.coordinator.reserveUse() == nil) 23 #expect(try f.coordinator.reserveMaintenance() == nil) 24 try maintenance.validate() 25 withExtendedLifetime((first, maintenance)) {} 26 } 27 28 @Test func fileMaintenanceScanRetainsExclusiveOwnerAndRejectsCrossScanEntry() throws { 29 let f = try FileMaintenanceFixture() 30 defer { f.remove() } 31 try f.write("blob") 32 var reservation = try f.coordinator.reserveMaintenance() 33 var scan: RadrootsFileMaintenanceScan? = try #require(reservation).openDirectory() 34 reservation = nil 35 #expect(try f.coordinator.reserveUse() == nil) 36 let entry = try #require(scan).next().entries.first 37 let captured = try #require(entry) 38 scan = nil 39 let next = try #require(try f.coordinator.reserveMaintenance()) 40 let newScan = try next.openDirectory() 41 #expect(throws: RadrootsAppleFileError.invalidRequest) { _ = try newScan.remove(captured) } 42 #expect(FileManager.default.fileExists(atPath: f.path("blob").path)) 43 } 44 45 @Test func fileMaintenanceBoundedTraversalAndInterruptedCollectionResume() throws { 46 let f = try FileMaintenanceFixture() 47 defer { f.remove() } 48 for index in 0 ..< 150 { 49 try f.write("blob-\(index)") 50 } 51 do { 52 let reservation = try #require(try f.coordinator.reserveMaintenance()) 53 let scan = try reservation.openDirectory() 54 #expect(throws: RadrootsAppleFileError.invalidRequest) { _ = try scan.next(limit: 0) } 55 #expect(throws: RadrootsAppleFileError.invalidRequest) { _ = try scan.next(limit: 65) } 56 let page = try scan.next(limit: 7) 57 #expect(page.scannedEntries == 7 && !page.reachedEnd) 58 #expect(page.entries.count <= 7) 59 for entry in page.entries { 60 #expect(try scan.remove(entry)) 61 } 62 } 63 let reservation = try #require(try f.coordinator.reserveMaintenance()) 64 let scan = try reservation.openDirectory() 65 var names: Set<String> = [] 66 var ended = false 67 for _ in 0 ..< 30 { 68 let page = try scan.next(limit: 7) 69 #expect(page.scannedEntries <= 7 && page.entries.count <= 7) 70 for entry in page.entries { 71 #expect(names.insert(entry.name).inserted) 72 #expect(entry.kind == .regularFile && entry.sizeBytes == 3) 73 } 74 if page.reachedEnd { 75 ended = true; break 76 } 77 } 78 #expect(ended && names.count >= 143 && names.count < 150) 79 #expect(try scan.next().reachedEnd) 80 #expect(!names.contains(RadrootsFileMaintenanceGate.name)) 81 } 82 83 @Test func fileMaintenanceRetainsReplacedChangedHardlinkedAndNonregularFiles() throws { 84 let f = try FileMaintenanceFixture() 85 defer { f.remove() } 86 for name in ["changed", "replaced", "hardlink", "removed", "eligible"] { 87 try f.write(name) 88 } 89 try FileManager.default.linkItem(at: f.path("hardlink"), to: f.path("alias")) 90 try FileManager.default.createDirectory(at: f.path("directory"), withIntermediateDirectories: false) 91 try FileManager.default.createSymbolicLink(at: f.path("symlink"), withDestinationURL: f.path("eligible")) 92 let reservation = try #require(try f.coordinator.reserveMaintenance()) 93 let scan = try reservation.openDirectory() 94 let entries = try Dictionary(uniqueKeysWithValues: scan.next().entries.map { ($0.name, $0) }) 95 try Data("different bytes".utf8).write(to: f.path("changed")) 96 try Data("new".utf8).write(to: f.path("replaced"), options: .atomic) 97 try FileManager.default.removeItem(at: f.path("removed")) 98 for name in ["changed", "replaced", "hardlink", "alias", "directory", "symlink", "removed"] { 99 #expect(try !scan.remove(#require(entries[name]))) 100 } 101 #expect(try scan.remove(#require(entries["eligible"]))) 102 #expect(try !scan.remove(#require(entries["eligible"]))) 103 #expect(try Data(contentsOf: f.path("replaced")) == Data("new".utf8)) 104 #expect(try Data(contentsOf: f.path("changed")) == Data("different bytes".utf8)) 105 } 106 107 @Test func fileMaintenanceRejectsChangedParentsCoordinationAndUnsafePaths() throws { 108 let f = try FileMaintenanceFixture() 109 defer { f.remove() } 110 try f.write("blob") 111 let reservation = try #require(try f.coordinator.reserveMaintenance()) 112 for path in ["..", "../root", "/root", "root//child", "root/", "\0"] { 113 #expect(throws: RadrootsAppleFileError.invalidRequest) { _ = try reservation.openDirectory(relativePath: path) } 114 } 115 try FileManager.default.createSymbolicLink(at: f.path("link"), withDestinationURL: f.base) 116 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try reservation.openDirectory(relativePath: "link") } 117 let scan = try reservation.openDirectory() 118 let blob = try #require(try scan.next().entries.first { $0.name == "blob" }) 119 try Data().write(to: f.path(RadrootsFileMaintenanceGate.name), options: .atomic) 120 #expect(throws: RadrootsAppleFileError.permanentFailure) { try reservation.validate() } 121 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try scan.remove(blob) } 122 #expect(FileManager.default.fileExists(atPath: f.path("blob").path)) 123 try FileManager.default.moveItem(at: f.root, to: f.base.appendingPathComponent("moved")) 124 try FileManager.default.createDirectory(at: f.root, withIntermediateDirectories: false) 125 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try scan.next() } 126 } 127 128 @Test func fileMaintenanceSupportsBoundedSubdirectoryInspection() throws { 129 let f = try FileMaintenanceFixture() 130 defer { f.remove() } 131 try FileManager.default.createDirectory(at: f.path("staging"), withIntermediateDirectories: false) 132 try f.write("staging/blob") 133 let reservation = try #require(try f.coordinator.reserveMaintenance()) 134 let scan = try reservation.openDirectory(relativePath: "staging") 135 let blob = try #require(try scan.next().entries.first) 136 #expect(blob.name == "blob") 137 #expect(try scan.remove(blob)) 138 #expect(FileManager.default.fileExists(atPath: f.path(RadrootsFileMaintenanceGate.name).path)) 139 } 140 141 @Test func fileMaintenanceCancellationDoesNotReleaseUndrainedUse() async throws { 142 let f = try FileMaintenanceFixture() 143 defer { f.remove() } 144 let latch = FileMaintenanceLatch() 145 let task = try holdMaintenanceUse(#require(try f.coordinator.reserveUse()), latch: latch) 146 task.cancel() 147 #expect(try f.coordinator.reserveMaintenance() == nil) 148 await latch.open() 149 try await task.value 150 #expect(try f.coordinator.reserveMaintenance() != nil) 151 } 152 153 private func holdMaintenanceUse(_ lease: RadrootsFileUseReservation, latch: FileMaintenanceLatch) throws -> Task<Void, Error> { 154 try lease.validate() 155 return Task { 156 defer { withExtendedLifetime(lease) {} } 157 await latch.wait() 158 try lease.validate() 159 } 160 } 161 162 @Test func fileMaintenanceConcurrentReadersAndCollectorsNeverOverlap() async throws { 163 let f = try FileMaintenanceFixture() 164 defer { f.remove() } 165 let probe = FileMaintenanceProbe() 166 try await withThrowingTaskGroup(of: Void.self) { group in 167 for owner in 0 ..< 8 { 168 group.addTask { 169 for _ in 0 ..< 30 { 170 if owner < 6 { 171 if let lease = try f.coordinator.reserveUse() { 172 await probe.enter(exclusive: false) 173 await Task.yield() 174 await probe.leave(exclusive: false) 175 withExtendedLifetime(lease) {} 176 } 177 } else if let maintenance = try f.coordinator.reserveMaintenance() { 178 await probe.enter(exclusive: true) 179 await Task.yield() 180 await probe.leave(exclusive: true) 181 withExtendedLifetime(maintenance) {} 182 } 183 await Task.yield() 184 } 185 } 186 } 187 try await group.waitForAll() 188 } 189 #expect(await probe.violations == 0) 190 #expect(await probe.completed > 0) 191 #expect(try f.coordinator.reserveMaintenance() != nil) 192 } 193 194 @Test func fileMaintenanceRejectsInvalidCoordinationAndReplacedParent() throws { 195 for variant in ["nonempty", "symlink", "hardlink", "parent"] { 196 let f = try FileMaintenanceFixture() 197 defer { f.remove() } 198 try f.write("blob") 199 let gate = f.path(RadrootsFileMaintenanceGate.name) 200 switch variant { 201 case "nonempty": try Data("unknown".utf8).write(to: gate) 202 case "symlink": try FileManager.default.createSymbolicLink(at: gate, withDestinationURL: f.path("blob")) 203 case "hardlink": 204 try Data().write(to: gate) 205 try FileManager.default.linkItem(at: gate, to: f.path("alias")) 206 default: 207 let reservation = try #require(try f.coordinator.reserveMaintenance()) 208 let scan = try reservation.openDirectory() 209 let entry = try #require(try scan.next().entries.first) 210 try FileManager.default.moveItem(at: f.root, to: f.base.appendingPathComponent("moved")) 211 try FileManager.default.createDirectory(at: f.root, withIntermediateDirectories: false) 212 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try scan.remove(entry) } 213 #expect(FileManager.default.fileExists(atPath: f.base.appendingPathComponent("moved/blob").path)) 214 continue 215 } 216 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try f.coordinator.reserveUse() } 217 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try f.coordinator.reserveMaintenance() } 218 } 219 } 220 221 private actor FileMaintenanceProbe { 222 private var readers = 0 223 private var writers = 0 224 private(set) var violations = 0 225 private(set) var completed = 0 226 func enter(exclusive: Bool) { 227 if writers != 0 || exclusive && readers != 0 { 228 violations += 1 229 } 230 if exclusive { 231 writers += 1 232 } else { 233 readers += 1 234 } 235 } 236 237 func leave(exclusive: Bool) { 238 if exclusive { 239 writers -= 1 240 } else { 241 readers -= 1 242 } 243 completed += 1 244 } 245 } 246 247 @Test func fileMaintenanceFailedScanCannotResumeAfterParentRestoration() throws { 248 let f = try FileMaintenanceFixture() 249 defer { f.remove() } 250 try f.write("blob") 251 let reservation = try #require(try f.coordinator.reserveMaintenance()) 252 let scan = try reservation.openDirectory() 253 _ = try scan.next(limit: 1) 254 let moved = f.base.appendingPathComponent("moved") 255 try FileManager.default.moveItem(at: f.root, to: moved) 256 try FileManager.default.createDirectory(at: f.root, withIntermediateDirectories: false) 257 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try scan.next() } 258 try FileManager.default.removeItem(at: f.root) 259 try FileManager.default.moveItem(at: moved, to: f.root) 260 try reservation.validate() 261 #expect(throws: RadrootsAppleFileError.permanentFailure) { _ = try scan.next() } 262 let fresh = try reservation.openDirectory() 263 #expect(try fresh.next().entries.map(\.name) == ["blob"]) 264 } 265 266 private actor FileMaintenanceLatch { 267 private var continuation: CheckedContinuation<Void, Never>? 268 private var opened = false 269 func wait() async { 270 if opened { 271 return 272 } 273 await withCheckedContinuation { continuation = $0 } 274 } 275 276 func open() { 277 opened = true 278 continuation?.resume() 279 continuation = nil 280 } 281 } 282 283 private struct FileMaintenanceFixture: Sendable { 284 let base: URL 285 let root: URL 286 var coordinator: RadrootsAppleFileMaintenance { 287 RadrootsAppleFileMaintenance(root: root) 288 } 289 290 init() throws { 291 let raw = FileManager.default.temporaryDirectory 292 .appendingPathComponent("file-maintenance-\(UUID().uuidString)", isDirectory: true) 293 try FileManager.default.createDirectory(at: raw, withIntermediateDirectories: true) 294 let pointer = try #require(raw.path.withCString { Darwin.realpath($0, nil) }) 295 defer { Darwin.free(pointer) } 296 base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 297 root = base.appendingPathComponent("root", isDirectory: true) 298 try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) 299 } 300 301 func path(_ name: String) -> URL { 302 root.appendingPathComponent(name) 303 } 304 305 func write(_ name: String) throws { 306 try Data("old".utf8).write(to: path(name)) 307 } 308 309 func remove() { 310 try? FileManager.default.removeItem(at: base) 311 } 312 }