commit d6b568213bf8d32fa79d8e94a78076a0b5b0e5d2
parent 1126a77ed87387719a6c6d3b4ce580582af29553
Author: triesap <tyson@radroots.org>
Date: Fri, 2 Oct 2026 14:30:17 +0000
files: preflight owned write capacity
- Check original bytes against descriptor-relative available blocks
- Preserve typed capacity errors and ambiguous write recovery
- Declare the local required reason without exporting capacity
- Verify block bounds old bytes and exact lease replay
Diffstat:
5 files changed, 109 insertions(+), 2 deletions(-)
diff --git a/README b/README
@@ -20,7 +20,10 @@ extbuild-enabled checkout, first run
## Storage capacity
File, capture and transfer adapters report typed capacity failures without raw
-filesystem details. A failed write may already have installed bytes; retain the
+filesystem details. The atomic file writer checks original-byte capacity on its
+held directory descriptor before creating pending bytes. This local check does
+not reserve space, export capacity values, or replace actual write/quota error
+handling. A failed write may already have installed bytes; retain the
original identity and reconcile before retrying. The transfer receipt envelope
has a separate bounded-capacity error. Neither error authorizes deletion of
unresolved receipts, staged media or leases, or automatic network retry.
diff --git a/Sources/RadrootsKit/PrivacyInfo.xcprivacy b/Sources/RadrootsKit/PrivacyInfo.xcprivacy
@@ -27,6 +27,14 @@
<string>CA92.1</string>
</array>
</dict>
+ <dict>
+ <key>NSPrivacyAccessedAPIType</key>
+ <string>NSPrivacyAccessedAPICategoryDiskSpace</string>
+ <key>NSPrivacyAccessedAPITypeReasons</key>
+ <array>
+ <string>E174.1</string>
+ </array>
+ </dict>
</array>
</dict>
</plist>
diff --git a/Sources/RadrootsKit/RadrootsAtomicFile.swift b/Sources/RadrootsKit/RadrootsAtomicFile.swift
@@ -6,7 +6,7 @@ import Foundation
/// callers must inspect/recover the exact destination before acknowledging it.
enum RadrootsAtomicFile {
enum Mode: Sendable { case replace, create }
- enum Phase: CaseIterable { case afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync }
+ enum Phase: CaseIterable { case beforeCapacityCheck, afterCapacityCheck, afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync }
static let maximumBytes = 512 * 1024 * 1024
/// A short, synchronous cross-owner transaction. Contention fails closed
@@ -178,6 +178,11 @@ enum RadrootsAtomicFile {
}
let directory = try Directory.open(Array(parts.dropLast()), create: true)
defer { Darwin.close(directory.descriptor) }
+ try directory.validate()
+ try fault?(.beforeCapacityCheck)
+ try RadrootsFileCapacity.require(data.count, on: directory.descriptor)
+ try fault?(.afterCapacityCheck)
+ try directory.validate()
let temporary = ".radroots_pending_" + UUID().uuidString.lowercased()
let descriptor = temporary.withCString {
Darwin.openat(directory.descriptor, $0, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0o600)
diff --git a/Sources/RadrootsKit/RadrootsFileCapacity.swift b/Sources/RadrootsKit/RadrootsFileCapacity.swift
@@ -0,0 +1,27 @@
+import Darwin
+import Foundation
+
+/// App-owned file write admission only. Capacity stays on device and is never
+/// telemetry. This is a conservative preflight, not a reservation: write/sync
+/// still classify actual ENOSPC and EDQUOT and preserve ambiguous outcomes.
+enum RadrootsFileCapacity {
+ static func require(_ bytes: Int, on descriptor: Int32) throws {
+ guard bytes >= 0 else { throw RadrootsAppleFileError.invalidRequest }
+ var volume = statfs()
+ guard Darwin.fstatfs(descriptor, &volume) == 0 else {
+ throw RadrootsAppleFileError.posix(errno)
+ }
+ guard volume.f_bsize > 0 else { throw RadrootsAppleFileError.permanentFailure }
+ guard admits(UInt64(bytes), availableBlocks: volume.f_bavail, blockSize: UInt64(volume.f_bsize)) else {
+ throw RadrootsAppleFileError.spaceInsufficient
+ }
+ }
+
+ static func admits(_ bytes: UInt64, availableBlocks: UInt64, blockSize: UInt64) -> Bool {
+ guard blockSize > 0 else { return false }
+ // Compare blocks without multiplying filesystem counters or rounding
+ // bytes upward with an addition that could overflow UInt64.
+ let required = bytes / blockSize + (bytes % blockSize == 0 ? 0 : 1)
+ return availableBlocks >= required
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsFileCapacityPreflightTests.swift b/Tests/RadrootsKitTests/RadrootsFileCapacityPreflightTests.swift
@@ -0,0 +1,64 @@
+import Darwin
+import Foundation
+@testable import RadrootsKit
+import Testing
+
+@Test func capacityPreflightUsesOverflowSafeBlockAdmission() {
+ #expect(RadrootsFileCapacity.admits(0, availableBlocks: 0, blockSize: 4096))
+ #expect(RadrootsFileCapacity.admits(4096, availableBlocks: 1, blockSize: 4096))
+ #expect(!RadrootsFileCapacity.admits(4097, availableBlocks: 1, blockSize: 4096))
+ #expect(!RadrootsFileCapacity.admits(1, availableBlocks: 0, blockSize: 4096))
+ #expect(!RadrootsFileCapacity.admits(1, availableBlocks: .max, blockSize: 0))
+ #expect(RadrootsFileCapacity.admits(.max, availableBlocks: .max, blockSize: 4096))
+ #expect(!RadrootsFileCapacity.admits(.max, availableBlocks: 1, blockSize: 4096))
+}
+
+@Test func capacityPreflightUsesTheOwnedDirectoryAndFailsClosedOnInvalidProbe() throws {
+ let fixture = try CapacityFixture()
+ defer { fixture.remove() }
+ let descriptor = fixture.base.path.withCString { Darwin.open($0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) }
+ #expect(descriptor >= 0)
+ defer { Darwin.close(descriptor) }
+ try RadrootsFileCapacity.require(0, on: descriptor)
+ try RadrootsFileCapacity.require(1, on: descriptor)
+ #expect(throws: RadrootsAppleFileError.permanentFailure) {
+ try RadrootsFileCapacity.require(1, on: -1)
+ }
+ #expect(throws: RadrootsAppleFileError.invalidRequest) {
+ try RadrootsFileCapacity.require(-1, on: descriptor)
+ }
+}
+
+@Test func capacityPreflightRefusesBeforeCreatingTemporaryBytesAndRetainsTheOldFile() throws {
+ for phase in [RadrootsAtomicFile.Phase.beforeCapacityCheck, .afterCapacityCheck] {
+ let fixture = try CapacityFixture()
+ defer { fixture.remove() }
+ let url = fixture.base.appendingPathComponent("value")
+ let old = Data("acknowledged".utf8)
+ try RadrootsAtomicFile.install(old, at: url)
+ #expect(throws: RadrootsAppleFileError.spaceInsufficient) {
+ try RadrootsAtomicFile.installForTesting(Data("pending".utf8), at: url) {
+ if $0 == phase { throw RadrootsAppleFileError.spaceInsufficient }
+ }
+ }
+ #expect(try Data(contentsOf: url) == old)
+ #expect(!(try FileManager.default.contentsOfDirectory(atPath: fixture.base.path)).contains { $0.hasPrefix(".radroots_pending_") })
+ }
+}
+
+@Test func capacityPreflightRetainsExactStagedLeaseReplayAndOriginalFailure() throws {
+ let fixture = try CapacityFixture()
+ defer { fixture.remove() }
+ let live = RadrootsAppleFileAccess(roots: fixture.roots)
+ let data = Data("pending upload".utf8)
+ let blob = try live.stageBlob(data)
+ let digest = RadrootsAppleFileDigest.sha256(data)
+ let exact = try live.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "exact")
+ let refused = RadrootsAppleFileAccess(roots: fixture.roots, persistence: capacityPersistence(.beforeCapacityCheck))
+ #expect(try refused.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "exact") == exact)
+ #expect(throws: RadrootsAppleFileError.spaceInsufficient) {
+ _ = try refused.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "new")
+ }
+ #expect(try live.readStagedBlob(blob) == data)
+ #expect(try Data(contentsOf: exact.fileURL) == data)
+}