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

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:
MREADME | 5++++-
MSources/RadrootsKit/PrivacyInfo.xcprivacy | 8++++++++
MSources/RadrootsKit/RadrootsAtomicFile.swift | 7++++++-
ASources/RadrootsKit/RadrootsFileCapacity.swift | 27+++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsFileCapacityPreflightTests.swift | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) +}