RadrootsDurableStagingTests.swift (11104B)
1 import Darwin 2 import Foundation 3 @testable import RadrootsKit 4 import Testing 5 6 @Test func durableStagingPreservesOldBytesAtEveryInterruptedInstallBoundary() throws { 7 for phase in RadrootsAtomicFile.Phase.allCases { 8 let fixture = try DurableStagingFixture() 9 defer { fixture.remove() } 10 let url = fixture.base.appendingPathComponent("owned/value") 11 let old = Data("old valid object".utf8) 12 let new = Data(repeating: 42, count: 100_000) 13 try RadrootsAtomicFile.install(old, at: url) 14 #expect(throws: StagingFault.self) { 15 try RadrootsAtomicFile.installForTesting(new, at: url) { current in 16 if current == phase { 17 throw StagingFault.interrupted 18 } 19 } 20 } 21 #expect(try Data(contentsOf: url) == (phase == .beforeDirectorySync ? new : old)) 22 try RadrootsAtomicFile.install(new, at: url) 23 #expect(try Data(contentsOf: url) == new) 24 #expect(try FileManager.default.contentsOfDirectory(atPath: url.deletingLastPathComponent().path) == ["value"]) 25 } 26 } 27 28 @Test func durableStagingRejectsSymlinkAndReplacedDirectoryAuthority() throws { 29 let fixture = try DurableStagingFixture() 30 defer { fixture.remove() } 31 let outside = fixture.base.appendingPathComponent("outside") 32 let owned = fixture.base.appendingPathComponent("owned") 33 try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) 34 let external = outside.appendingPathComponent("value") 35 try Data("outside".utf8).write(to: external) 36 try FileManager.default.createSymbolicLink(at: owned, withDestinationURL: outside) 37 #expect(throws: RadrootsAppleFileError.self) { 38 try RadrootsAtomicFile.install(Data("bad".utf8), at: owned.appendingPathComponent("value")) 39 } 40 #expect(try Data(contentsOf: external) == Data("outside".utf8)) 41 try FileManager.default.removeItem(at: owned) 42 let url = owned.appendingPathComponent("value") 43 try RadrootsAtomicFile.install(Data("old".utf8), at: url) 44 let retained = fixture.base.appendingPathComponent("retained") 45 #expect(throws: RadrootsAppleFileError.self) { 46 try RadrootsAtomicFile.installForTesting(Data("new".utf8), at: url) { phase in 47 if phase == .beforeInstall { 48 try FileManager.default.moveItem(at: owned, to: retained) 49 try FileManager.default.createSymbolicLink(at: owned, withDestinationURL: outside) 50 } 51 } 52 } 53 #expect(try Data(contentsOf: external) == Data("outside".utf8)) 54 #expect(try Data(contentsOf: retained.appendingPathComponent("value")) == Data("old".utf8)) 55 } 56 57 @Test func durableStagingPreservesOldFileWhenDestinationCannotBeWritten() throws { 58 let fixture = try DurableStagingFixture() 59 defer { fixture.remove() } 60 let directory = fixture.base.appendingPathComponent("locked") 61 let url = directory.appendingPathComponent("value") 62 try RadrootsAtomicFile.install(Data("old".utf8), at: url) 63 #expect(Darwin.chmod(directory.path, 0o500) == 0) 64 defer { _ = Darwin.chmod(directory.path, 0o700) } 65 #expect(throws: RadrootsAppleFileError.self) { 66 try RadrootsAtomicFile.install(Data("replacement".utf8), at: url) 67 } 68 #expect(try Data(contentsOf: url) == Data("old".utf8)) 69 } 70 71 @Test func durableStagingSurvivesCachePurgeAndRejectsOpaqueIdentityConflicts() throws { 72 let fixture = try DurableStagingFixture() 73 defer { fixture.remove() } 74 let access = RadrootsAppleFileAccess(roots: fixture.roots) 75 let bytes = Data("saved referenced draft".utf8) 76 let blob = try access.stageBlob(bytes) 77 try access.reset(scope: .cache) 78 try access.reset(scope: .temporary) 79 #expect(try access.readStagedBlob(blob) == bytes) 80 #expect(try fixture.roots.stagedBlobURL(for: blob).path.hasPrefix(fixture.roots.stagedBlobsRoot.path + "/")) 81 try RadrootsAtomicFile.synchronizeExisting(at: fixture.roots.stagedBlobURL(for: blob)) 82 try access.installStagedBlob(bytes, reference: blob) 83 #expect(throws: RadrootsAppleFileError.self) { 84 try access.installStagedBlob(Data(repeating: 65, count: bytes.count), reference: blob) 85 } 86 #expect(try access.readStagedBlob(blob) == bytes) 87 let partial = fixture.roots.stagedBlobsRoot.appendingPathComponent(".radroots_pending_interrupted") 88 try Data("partial".utf8).write(to: partial) 89 _ = try access.sweepStagedBlobs(olderThan: .distantPast) 90 #expect(FileManager.default.fileExists(atPath: partial.path)) 91 #expect(try access.readStagedBlob(blob) == bytes) 92 } 93 94 @Test func durableStagingRepairsOnlyVerifiedContentIdentityAndPreservesSourceOnCopyFailure() throws { 95 let fixture = try DurableStagingFixture() 96 defer { fixture.remove() } 97 let access = RadrootsAppleFileAccess(roots: fixture.roots) 98 let bytes = Data("verified bytes".utf8) 99 let blob = try RadrootsStagedBlobReference(blobID: RadrootsAppleFileDigest.sha256(bytes), sizeBytes: bytes.count) 100 try access.installStagedBlob(bytes, reference: blob) 101 let url = try fixture.roots.stagedBlobURL(for: blob) 102 try Data("corrupt".utf8).write(to: url, options: .atomic) 103 try access.installStagedBlob(bytes, reference: blob) 104 #expect(try access.readStagedBlob(blob) == bytes) 105 let destination = RadrootsFileReference(scope: .data, relativePath: "imported") 106 try access.write(.inline(bytes), to: destination) 107 let invalid = fixture.base.appendingPathComponent("missing") 108 #expect(throws: RadrootsAppleFileError.self) { 109 _ = try access.copyExternalFile(invalid, to: destination, mediaType: nil, suggestedFilename: nil) 110 } 111 #expect(try access.read(destination, mode: .inline(maxBytes: bytes.count)) == .inline(bytes)) 112 } 113 114 @Test func stagedBlobLeaseKeepsExactBytesAfterSourceReplacementPurgeAndRestart() throws { 115 let fixture = try DurableStagingFixture() 116 defer { fixture.remove() } 117 let access = RadrootsAppleFileAccess(roots: fixture.roots) 118 let bytes = Data("original upload body".utf8) 119 let blob = try access.stageBlob(bytes) 120 let digest = RadrootsAppleFileDigest.sha256(bytes) 121 let lease = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "stable_attempt") 122 let source = try fixture.roots.stagedBlobURL(for: blob) 123 try Data(repeating: 65, count: bytes.count).write(to: source, options: .atomic) 124 try access.releaseStagedBlob(blob) 125 try access.resetStagedBlobs() 126 try access.reset(scope: .cache) 127 try access.reset(scope: .temporary) 128 #expect(try Data(contentsOf: lease.fileURL) == bytes) 129 let recoveredRoots = try RadrootsAppleFileRoots( 130 appIdentifier: "org.radroots.tests", dataRoot: fixture.roots.dataRoot, 131 cacheRoot: fixture.roots.cacheRoot, temporaryRoot: fixture.roots.temporaryRoot, 132 stagedBlobsRoot: fixture.roots.stagedBlobsRoot 133 ) 134 #expect(recoveredRoots.dataRoot.path == fixture.roots.dataRoot.path) 135 let reopened = RadrootsAppleFileAccess(roots: recoveredRoots) 136 #expect(try reopened.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "stable_attempt") == lease) 137 let attributes = try FileManager.default.attributesOfItem(atPath: lease.fileURL.path) 138 #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o400) 139 #expect(!lease.debugDescription.contains(fixture.base.path)) 140 try reopened.releaseStagedBlobLease(lease) 141 try reopened.releaseStagedBlobLease(lease) 142 #expect(!FileManager.default.fileExists(atPath: lease.fileURL.path)) 143 } 144 145 @Test func stagedBlobLeaseRejectsChangedBytesAndUnsafeIdentifiers() throws { 146 let fixture = try DurableStagingFixture() 147 defer { fixture.remove() } 148 let access = RadrootsAppleFileAccess(roots: fixture.roots) 149 let bytes = Data("original body".utf8) 150 let blob = try access.stageBlob(bytes) 151 let digest = RadrootsAppleFileDigest.sha256(bytes) 152 for identifier in ["../escape", "bad/child", " ", String(repeating: "x", count: 129)] { 153 #expect(throws: RadrootsAppleFileError.self) { 154 _ = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: identifier) 155 } 156 } 157 let lease = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "attempt") 158 try Data(repeating: 65, count: bytes.count).write(to: lease.fileURL, options: .atomic) 159 #expect(throws: RadrootsAppleFileError.self) { 160 _ = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "attempt") 161 } 162 #expect(try Data(contentsOf: lease.fileURL) != bytes) 163 } 164 165 @Test func stagedBlobLeaseConcurrentSameIdentityHasOneImmutableFile() async throws { 166 let fixture = try DurableStagingFixture() 167 defer { fixture.remove() } 168 let roots = fixture.roots 169 let bytes = Data(repeating: 7, count: 100_000) 170 let blob = try RadrootsAppleFileAccess(roots: roots).stageBlob(bytes) 171 let digest = RadrootsAppleFileDigest.sha256(bytes) 172 let leases = try await withThrowingTaskGroup(of: RadrootsStagedBlobLease.self) { group in 173 for _ in 0 ..< 8 { 174 group.addTask { 175 try RadrootsAppleFileAccess(roots: roots).leaseStagedBlob( 176 blob, 177 expectedSHA256: digest, 178 identifier: "same" 179 ) 180 } 181 } 182 var results: [RadrootsStagedBlobLease] = [] 183 for try await result in group { 184 results.append(result) 185 } 186 return results 187 } 188 #expect(leases.count == 8) 189 let first = try #require(leases.first) 190 #expect(leases.allSatisfy { $0 == first }) 191 #expect(try Data(contentsOf: first.fileURL) == bytes) 192 #expect(try FileManager.default 193 .contentsOfDirectory(atPath: first.fileURL.deletingLastPathComponent().path) == ["same"]) 194 } 195 196 private enum StagingFault: Error { case interrupted } 197 198 private struct DurableStagingFixture { 199 let base: URL 200 let roots: RadrootsAppleFileRoots 201 202 init() throws { 203 let raw = FileManager.default.temporaryDirectory 204 .appendingPathComponent("radroots-durable-staging-\(UUID().uuidString)") 205 try FileManager.default.createDirectory(at: raw, withIntermediateDirectories: true) 206 let pointer = try #require(raw.path.withCString { Darwin.realpath($0, nil) }) 207 defer { Darwin.free(pointer) } 208 base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 209 let data = base.appendingPathComponent("data", isDirectory: true) 210 roots = try RadrootsAppleFileRoots(appIdentifier: "org.radroots.tests", dataRoot: data, 211 cacheRoot: base.appendingPathComponent("cache", isDirectory: true), 212 temporaryRoot: base.appendingPathComponent("tmp", isDirectory: true), 213 stagedBlobsRoot: data.appendingPathComponent( 214 "staged_blobs", 215 isDirectory: true 216 )) 217 } 218 219 func remove() { 220 try? FileManager.default.removeItem(at: base) 221 } 222 }