commit 1dc3ca9d212c22e5a1ba7196f9fce858c18fbe36
parent c22bcf2219e71a4c1948cb00de855a198f168e8d
Author: triesap <tyson@radroots.org>
Date: Tue, 15 Sep 2026 00:36:11 +0000
files: preserve durable referenced media staging
- Install bounded owned bytes atomically with durable flush barriers
- Preserve immutable lease copies across source replacement and purge
- Retain canonical roots and reject unsafe paths and conflicting identities
- Verify exact API, strict Swift quality and complete native test suites
Diffstat:
9 files changed, 1248 insertions(+), 643 deletions(-)
diff --git a/Sources/RadrootsKit/RadrootsAppleFileAccess.swift b/Sources/RadrootsKit/RadrootsAppleFileAccess.swift
@@ -0,0 +1,315 @@
+import Darwin
+import Foundation
+
+public final class RadrootsAppleFileAccess: RadrootsFileAccess {
+ static let maximumGovernedFileBytes = 512 * 1024 * 1024
+
+ public let roots: RadrootsAppleFileRoots
+ let fileManager: FileManager
+
+ public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) {
+ self.roots = roots
+ self.fileManager = fileManager
+ }
+
+ public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws {
+ let url = try roots.resolvedURL(for: file)
+ try createParentDirectory(for: url)
+ switch payload {
+ case let .inline(inlineData):
+ try classifiedFileSystemOperation {
+ try inlineData.write(to: url, options: [.atomic])
+ }
+ case let .stagedBlob(stagedBlob):
+ let data = try readStagedBlob(stagedBlob)
+ try classifiedFileSystemOperation {
+ try data.write(to: url, options: [.atomic])
+ }
+ }
+ }
+
+ public func read(
+ _ file: RadrootsFileReference, mode: RadrootsFileReadMode
+ ) throws -> RadrootsFileReadResult {
+ switch mode {
+ case let .inline(maxBytes):
+ return try .inline(readGovernedFile(file, maximumBytes: maxBytes))
+ case let .preferInline(maxBytes):
+ do {
+ return try .inline(
+ readGovernedFile(file, maximumBytes: maxBytes, preserveTooLarge: true)
+ )
+ } catch RadrootsGovernedFileReadError.tooLarge {
+ let url = try roots.resolvedURL(for: file)
+ let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
+ return .stagedBlob(staged)
+ }
+ case .stagedBlob:
+ let url = try roots.resolvedURL(for: file)
+ let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
+ return .stagedBlob(staged)
+ }
+ }
+
+ public func delete(_ file: RadrootsFileReference) throws {
+ let url = try roots.resolvedURL(for: file)
+ guard fileManager.fileExists(atPath: url.path) else {
+ return
+ }
+ try classifiedFileSystemOperation {
+ try fileManager.removeItem(at: url)
+ }
+ }
+
+ public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] {
+ let rootURL = roots.root(for: directory.scope).standardizedFileURL
+ let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true)
+ var isDirectory = ObjCBool(false)
+ guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else {
+ return []
+ }
+ guard isDirectory.boolValue else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ return try classifiedFileSystemOperation {
+ let urls = try fileManager.contentsOfDirectory(
+ at: directoryURL,
+ includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
+ options: []
+ )
+ return try urls.map { url in
+ let values = try url.resourceValues(
+ forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
+ )
+ let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL)
+ return RadrootsFileEntry(
+ file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath),
+ name: url.lastPathComponent,
+ isDirectory: values.isDirectory ?? false,
+ sizeBytes: values.fileSize,
+ modifiedAt: values.contentModificationDate
+ )
+ }
+ .sorted { left, right in
+ left.file.relativePath < right.file.relativePath
+ }
+ }
+ }
+
+ public func reset(scope: RadrootsFileScope) throws {
+ let url = roots.root(for: scope)
+ try classifiedFileSystemOperation {
+ if fileManager.fileExists(atPath: url.path) {
+ try fileManager.removeItem(at: url)
+ }
+ try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
+ }
+ }
+}
+
+public extension RadrootsAppleFileAccess {
+ @discardableResult
+ func stageBlob(
+ _ data: Data,
+ mediaType: String? = nil,
+ filenameHint: String? = nil
+ ) throws -> RadrootsStagedBlobReference {
+ guard data.count <= Self.maximumGovernedFileBytes else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let blobID = UUID().uuidString.lowercased()
+ let blob = try RadrootsStagedBlobReference(
+ blobID: blobID,
+ sizeBytes: data.count,
+ mediaType: mediaType,
+ filenameHint: filenameHint
+ )
+ try installStagedBlob(data, reference: blob)
+ return blob
+ }
+
+ @discardableResult
+ func stageFile(
+ _ file: RadrootsFileReference,
+ mediaType: String? = nil,
+ filenameHint: String? = nil
+ ) throws -> RadrootsStagedBlobReference {
+ let sourceURL = try roots.resolvedURL(for: file)
+ return try stageBlob(
+ readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes),
+ mediaType: mediaType,
+ filenameHint: filenameHint ?? sourceURL.lastPathComponent
+ )
+ }
+
+ @discardableResult
+ func stageExternalFile(
+ _ sourceURL: URL,
+ mediaType: String? = nil,
+ filenameHint: String? = nil
+ ) throws -> RadrootsStagedBlobReference {
+ try withSecurityScopedFile(sourceURL) { scopedURL in
+ try stageFileURL(
+ scopedURL,
+ mediaType: mediaType,
+ filenameHint: filenameHint ?? scopedURL.lastPathComponent
+ )
+ }
+ }
+
+ @discardableResult
+ func copyExternalFile(
+ _ sourceURL: URL,
+ to file: RadrootsFileReference,
+ mediaType: String? = nil,
+ suggestedFilename: String? = nil
+ ) throws -> RadrootsImportedDocument {
+ try withSecurityScopedFile(sourceURL) { scopedURL in
+ let destinationURL = try roots.resolvedURL(for: file)
+ try createParentDirectory(for: destinationURL)
+ try copyReplacingItem(from: scopedURL, to: destinationURL)
+ let sizeBytes = try fileSizeUInt64(at: destinationURL)
+ return try RadrootsImportedDocument(
+ file: file,
+ originalURL: scopedURL,
+ suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent,
+ mediaType: mediaType,
+ sizeBytes: sizeBytes
+ )
+ }
+ }
+
+ @discardableResult
+ func prepareExport(
+ _ request: RadrootsExportDocumentRequest
+ ) throws -> RadrootsPreparedExportDocument {
+ let preparedID = UUID().uuidString.lowercased()
+ let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true)
+ let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL
+ try classifiedFileSystemOperation {
+ try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
+ }
+ let preparedData: Data = switch request.source {
+ case let .inlineData(data):
+ data
+ case let .file(file):
+ try readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes)
+ case let .stagedBlob(stagedBlob):
+ try readStagedBlob(stagedBlob)
+ }
+ try classifiedFileSystemOperation {
+ try preparedData.write(to: fileURL, options: [.atomic])
+ }
+ let sizeBytes: UInt64 =
+ if let requestSizeBytes = request.sizeBytes {
+ requestSizeBytes
+ } else {
+ try fileSizeUInt64(at: fileURL)
+ }
+ return try RadrootsPreparedExportDocument(
+ preparedID: preparedID,
+ fileURL: fileURL,
+ suggestedFilename: request.suggestedFilename,
+ mediaType: request.mediaType,
+ sizeBytes: sizeBytes
+ )
+ }
+
+ func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data {
+ guard (0 ... Self.maximumGovernedFileBytes).contains(blob.sizeBytes) else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let data: Data
+ do {
+ data = try RadrootsGovernedFileReader.read(
+ root: roots.stagedBlobsRoot,
+ relativePath: blob.blobID,
+ maximumBytes: blob.sizeBytes
+ )
+ } catch let error as RadrootsGovernedFileReadError {
+ throw mappedGovernedReadError(error)
+ }
+ guard data.count == blob.sizeBytes else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ return data
+ }
+
+ func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws {
+ let url = try stagedBlobURL(for: blob)
+ if fileManager.fileExists(atPath: url.path) {
+ try classifiedFileSystemOperation {
+ try fileManager.removeItem(at: url)
+ }
+ }
+ }
+
+ func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool {
+ let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
+ return fileManager.fileExists(atPath: directoryURL.path)
+ && fileManager.fileExists(atPath: preparedExport.fileURL.path)
+ }
+
+ func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws {
+ let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
+ if fileManager.fileExists(atPath: directoryURL.path) {
+ try classifiedFileSystemOperation {
+ try fileManager.removeItem(at: directoryURL)
+ }
+ }
+ }
+
+ @discardableResult
+ func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] {
+ guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else {
+ return []
+ }
+ return try classifiedFileSystemOperation {
+ let urls = try fileManager.contentsOfDirectory(
+ at: roots.stagedBlobsRoot,
+ includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
+ options: []
+ )
+ var released: [RadrootsStagedBlobReference] = []
+ for url in urls {
+ // Interrupted atomic outputs are identifiable orphans, not valid
+ // blob references. Their owner reconciles them separately.
+ guard !url.lastPathComponent.hasPrefix(".radroots_pending_") else { continue }
+ let values = try url.resourceValues(
+ forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
+ )
+ guard values.isDirectory != true else {
+ continue
+ }
+ guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else {
+ continue
+ }
+ let blob = try RadrootsStagedBlobReference(
+ blobID: url.lastPathComponent,
+ sizeBytes: values.fileSize ?? 0
+ )
+ try fileManager.removeItem(at: url)
+ released.append(blob)
+ }
+ return released.sorted { left, right in
+ left.blobID < right.blobID
+ }
+ }
+ }
+
+ func resetStagedBlobs() throws {
+ try classifiedFileSystemOperation {
+ if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) {
+ try fileManager.removeItem(at: roots.stagedBlobsRoot)
+ }
+ try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
+ }
+ }
+
+ func resetFileRoots() throws {
+ for scope in RadrootsFileScope.allCases {
+ try reset(scope: scope)
+ }
+ try resetStagedBlobs()
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleFileOperations.swift b/Sources/RadrootsKit/RadrootsAppleFileOperations.swift
@@ -0,0 +1,176 @@
+import Darwin
+import Foundation
+
+/// Module-internal mechanics shared by the file owner; no public API.
+extension RadrootsAppleFileAccess {
+ func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
+ try roots.stagedBlobURL(for: blob)
+ }
+
+ var preparedExportsRoot: URL {
+ roots.temporaryRoot.appendingPathComponent("prepared_exports", isDirectory: true)
+ .standardizedFileURL
+ }
+
+ func preparedExportDirectoryURL(for preparedExport: RadrootsPreparedExportDocument) throws -> URL {
+ let normalizedPreparedID = try RadrootsPreparedExportDocument.normalizedPreparedID(
+ preparedExport.preparedID
+ )
+ let directoryURL = preparedExportsRoot.appendingPathComponent(
+ normalizedPreparedID, isDirectory: true
+ ).standardizedFileURL
+ guard preparedExport.fileURL.standardizedFileURL.path.hasPrefix(directoryURL.path + "/") else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ return directoryURL
+ }
+
+ func stageFileURL(
+ _ sourceURL: URL,
+ mediaType: String?,
+ filenameHint: String?
+ ) throws -> RadrootsStagedBlobReference {
+ let sizeBytes = try fileSizeInt(at: sourceURL)
+ guard sizeBytes <= Self.maximumGovernedFileBytes else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ let blobID = UUID().uuidString.lowercased()
+ let blob = try RadrootsStagedBlobReference(
+ blobID: blobID,
+ sizeBytes: sizeBytes,
+ mediaType: mediaType,
+ filenameHint: filenameHint
+ )
+ try installStagedBlob(readExternalBytes(sourceURL), reference: blob)
+ return blob
+ }
+
+ func withSecurityScopedFile<T>(_ sourceURL: URL, _ body: (URL) throws -> T) throws -> T {
+ guard sourceURL.isFileURL else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let scopedURL = sourceURL.standardizedFileURL
+ var isDirectory = ObjCBool(false)
+ guard fileManager.fileExists(atPath: scopedURL.path, isDirectory: &isDirectory) else {
+ throw RadrootsAppleFileError.notFound
+ }
+ guard !isDirectory.boolValue else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let didStartScope = scopedURL.startAccessingSecurityScopedResource()
+ defer {
+ if didStartScope {
+ scopedURL.stopAccessingSecurityScopedResource()
+ }
+ }
+ return try body(scopedURL)
+ }
+
+ func copyReplacingItem(from sourceURL: URL, to destinationURL: URL) throws {
+ guard sourceURL.isFileURL, destinationURL.isFileURL else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ try RadrootsAtomicFile.install(readExternalBytes(sourceURL), at: destinationURL)
+ }
+
+ func readExternalBytes(_ sourceURL: URL) throws -> Data {
+ // The caller holds the user's security-scoped URL grant. Canonicalize
+ // that explicit external parent, then retain the validated file bytes
+ // through installation; do not copy a changing pathname or a symlink.
+ guard let pointer = sourceURL.deletingLastPathComponent().path.withCString({ Darwin.realpath($0, nil) }) else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ defer { Darwin.free(pointer) }
+ let parent = URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
+ do {
+ return try RadrootsGovernedFileReader.read(
+ root: parent, relativePath: sourceURL.lastPathComponent, maximumBytes: Self.maximumGovernedFileBytes
+ )
+ } catch { throw RadrootsAppleFileError.permanentFailure }
+ }
+
+ func createParentDirectory(for url: URL) throws {
+ try classifiedFileSystemOperation {
+ try fileManager.createDirectory(
+ at: url.deletingLastPathComponent(), withIntermediateDirectories: true
+ )
+ }
+ }
+
+ func fileSize(at url: URL) throws -> Int {
+ try fileSizeInt(at: url)
+ }
+
+ func fileSizeInt(at url: URL) throws -> Int {
+ let values = try classifiedFileSystemOperation {
+ try url.resourceValues(forKeys: [.fileSizeKey])
+ }
+ guard let size = values.fileSize else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ return size
+ }
+
+ func fileSizeUInt64(at url: URL) throws -> UInt64 {
+ try UInt64(fileSizeInt(at: url))
+ }
+
+ func readGovernedFile(
+ _ file: RadrootsFileReference,
+ maximumBytes: Int,
+ preserveTooLarge: Bool = false
+ ) throws -> Data {
+ guard (0 ... Self.maximumGovernedFileBytes).contains(maximumBytes) else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let root = roots.root(for: file.scope)
+ let resolved = try roots.resolvedURL(for: file)
+ let relative = try relativePath(for: resolved, under: root)
+ do {
+ return try RadrootsGovernedFileReader.read(
+ root: root,
+ relativePath: relative,
+ maximumBytes: maximumBytes
+ )
+ } catch let error as RadrootsGovernedFileReadError {
+ if preserveTooLarge, error == .tooLarge {
+ throw error
+ }
+ throw mappedGovernedReadError(error)
+ }
+ }
+
+ func mappedGovernedReadError(_ error: RadrootsGovernedFileReadError) -> RadrootsAppleFileError {
+ switch error {
+ case .unavailable:
+ .notFound
+ case .invalidRequest:
+ .invalidRequest
+ case .tooLarge:
+ .permanentFailure
+ case .invalidObject, .changedDuringRead, .ioFailure:
+ .permanentFailure
+ }
+ }
+
+ func relativePath(for url: URL, under rootURL: URL) throws -> String {
+ let rootPath = rootURL.path
+ let filePath = url.path
+ guard filePath.hasPrefix(rootPath + "/") else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ return String(filePath.dropFirst(rootPath.count + 1))
+ }
+
+ func classifiedFileSystemOperation<T>(_ operation: () throws -> T) throws -> T {
+ do {
+ return try operation()
+ } catch let error as RadrootsAppleFileError {
+ throw error
+ } catch let error as RadrootsDocumentInterchangeError {
+ throw error
+ } catch {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleFileRoots.swift b/Sources/RadrootsKit/RadrootsAppleFileRoots.swift
@@ -0,0 +1,188 @@
+import Darwin
+import Foundation
+
+public struct RadrootsAppleFileRoots: Sendable, Equatable {
+ public let appIdentifier: String
+ public let dataRoot: URL
+ public let cacheRoot: URL
+ public let temporaryRoot: URL
+ public let logsRoot: URL
+ public let stagedBlobsRoot: URL
+
+ public init(
+ appIdentifier: String,
+ dataRoot: URL,
+ cacheRoot: URL,
+ temporaryRoot: URL,
+ logsRoot: URL? = nil,
+ stagedBlobsRoot: URL? = nil
+ ) throws {
+ let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier)
+ let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot")
+ let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot")
+ let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot")
+ self.appIdentifier = normalizedAppIdentifier
+ self.dataRoot = normalizedDataRoot
+ self.cacheRoot = normalizedCacheRoot
+ self.temporaryRoot = normalizedTemporaryRoot
+ self.logsRoot = try Self.normalizedRootURL(
+ logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true),
+ field: "logsRoot"
+ )
+ self.stagedBlobsRoot = try Self.normalizedRootURL(
+ stagedBlobsRoot
+ ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true),
+ field: "stagedBlobsRoot"
+ )
+ }
+
+ public static func appContainer(
+ appIdentifier: String,
+ fileManager: FileManager = .default
+ ) throws -> Self {
+ do {
+ let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier)
+ let dataBaseURL = try canonicalExistingDirectory(
+ fileManager.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ )
+ )
+ let cacheBaseURL = try canonicalExistingDirectory(
+ fileManager.url(
+ for: .cachesDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ )
+ )
+ let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
+ let cacheRoot = cacheBaseURL.appendingPathComponent(
+ normalizedAppIdentifier, isDirectory: true
+ )
+ let temporaryRoot = try canonicalExistingDirectory(fileManager.temporaryDirectory)
+ .appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
+ return try Self(
+ appIdentifier: normalizedAppIdentifier,
+ dataRoot: dataRoot,
+ cacheRoot: cacheRoot,
+ temporaryRoot: temporaryRoot
+ )
+ } catch let error as RadrootsAppleFileError {
+ throw error
+ } catch {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ }
+
+ public func root(for scope: RadrootsFileScope) -> URL {
+ switch scope {
+ case .data:
+ dataRoot
+ case .cache:
+ cacheRoot
+ case .temporary:
+ temporaryRoot
+ case .logs:
+ logsRoot
+ }
+ }
+
+ public func resolvedURL(
+ for file: RadrootsFileReference,
+ allowRootDirectory: Bool = false
+ ) throws -> URL {
+ let rootURL = root(for: file.scope)
+ let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmedPath.isEmpty {
+ if allowRootDirectory {
+ return rootURL
+ }
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ if NSString(string: trimmedPath).isAbsolutePath {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+
+ let components = try Self.normalizedRelativeComponents(trimmedPath)
+ let candidateURL = components.isEmpty
+ ? rootURL : rootURL.appendingPathComponent(components.joined(separator: "/"))
+ if candidateURL.path == rootURL.path {
+ if allowRootDirectory {
+ return candidateURL
+ }
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ guard candidateURL.path.hasPrefix(rootURL.path + "/") else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ return candidateURL
+ }
+
+ private static func normalizedRelativeComponents(_ trimmedPath: String) throws -> [String] {
+ var components: [String] = []
+ for component in trimmedPath.split(separator: "/", omittingEmptySubsequences: true) {
+ if component == "." {
+ continue
+ }
+ if component == ".." {
+ guard !components.isEmpty else { throw RadrootsAppleFileError.invalidRequest }
+ components.removeLast()
+ } else {
+ guard !component.utf8.contains(0) else { throw RadrootsAppleFileError.invalidRequest }
+ components.append(String(component))
+ }
+ }
+ return components
+ }
+
+ public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
+ let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID)
+ // Foundation standardization can rewrite an existing /private/var path
+ // back to the /var symlink alias. Keep the already-admitted root bytes.
+ return stagedBlobsRoot.appendingPathComponent(normalizedBlobID, isDirectory: false)
+ }
+
+ public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String {
+ let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ return trimmed
+ }
+
+ public static func normalizedRootURL(_ rootURL: URL, field _: String) throws -> URL {
+ guard rootURL.isFileURL else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ guard rootURL.path.hasPrefix("/"), !rootURL.path.utf8.contains(0) else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ var components: [String] = []
+ for component in rootURL.path.split(separator: "/", omittingEmptySubsequences: true) {
+ if component == "." {
+ continue
+ }
+ if component == ".." {
+ if !components.isEmpty {
+ components.removeLast()
+ }
+ } else {
+ components.append(String(component))
+ }
+ }
+ return URL(fileURLWithPath: "/" + components.joined(separator: "/"), isDirectory: true)
+ }
+
+ private static func canonicalExistingDirectory(_ directory: URL) throws -> URL {
+ guard directory.isFileURL,
+ let pointer = directory.path.withCString({ Darwin.realpath($0, nil) })
+ else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ defer { Darwin.free(pointer) }
+ return URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift b/Sources/RadrootsKit/RadrootsAppleMediaPreparation.swift
@@ -136,18 +136,16 @@ public actor RadrootsAppleMediaPreparer {
try Task.checkCancellation()
try requireProtectedData()
try Task.checkCancellation()
- let stagedURL = try roots.stagedBlobURL(for: staged)
- try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
- if fileManager.fileExists(atPath: stagedURL.path) {
- let existingSize = try Self.fileSize(at: stagedURL)
- let existingDigest = try RadrootsAppleFileDigest.sha256(at: stagedURL)
- if existingSize != outputSize || existingDigest != digest {
- try fileManager.removeItem(at: stagedURL)
- try fileManager.moveItem(at: temporaryURL, to: stagedURL)
- }
- } else {
- try fileManager.moveItem(at: temporaryURL, to: stagedURL)
+ let relative = "media_preparation/" + temporaryURL.lastPathComponent
+ let bytes = try RadrootsGovernedFileReader.read(
+ root: roots.temporaryRoot, relativePath: relative, maximumBytes: request.maximumOutputBytes
+ )
+ guard bytes.count == outputSize, RadrootsAppleFileDigest.sha256(bytes) == digest else {
+ throw RadrootsAppleMediaPreparationError.preparationFailure
}
+ try Task.checkCancellation()
+ try RadrootsAppleFileAccess(roots: roots, fileManager: fileManager).installStagedBlob(bytes, reference: staged)
+ let stagedURL = try roots.stagedBlobURL(for: staged)
#if os(iOS)
try fileManager.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
diff --git a/Sources/RadrootsKit/RadrootsAtomicFile.swift b/Sources/RadrootsKit/RadrootsAtomicFile.swift
@@ -0,0 +1,185 @@
+import Darwin
+import Foundation
+
+/// Synchronous, descriptor-relative installation. An error before publication
+/// preserves the prior destination; an error after publication is ambiguous and
+/// callers must inspect/recover the exact destination before acknowledging it.
+enum RadrootsAtomicFile {
+ enum Mode { case replace, create }
+ enum Phase: CaseIterable { case afterWriteChunk, afterWrite, beforeFileSync, beforeInstall, beforeDirectorySync }
+ static let maximumBytes = 512 * 1024 * 1024
+
+ static func install(_ data: Data, at url: URL, mode: Mode = .replace, readOnly: Bool = false) throws {
+ try install(data, at: url, mode: mode, readOnly: readOnly, fault: nil)
+ }
+
+ static func installForTesting(
+ _ data: Data, at url: URL, mode: Mode = .replace, fault: @escaping (Phase) throws -> Void
+ ) throws {
+ try install(data, at: url, mode: mode, readOnly: false, fault: fault)
+ }
+
+ static func remove(at url: URL) throws {
+ let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
+ guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last,
+ parts.allSatisfy({ $0 != "." && $0 != ".." })
+ else { throw RadrootsAppleFileError.invalidRequest }
+ let directory = try Directory.open(Array(parts.dropLast()), create: false)
+ defer { Darwin.close(directory.descriptor) }
+ var value = stat()
+ guard leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &value, AT_SYMLINK_NOFOLLOW) }) == 0,
+ value.st_mode & S_IFMT == S_IFREG
+ else { throw RadrootsAppleFileError.permanentFailure }
+ try directory.validate()
+ guard leaf.withCString({ Darwin.unlinkat(directory.descriptor, $0, 0) }) == 0,
+ Darwin.fsync(directory.descriptor) == 0
+ else { throw RadrootsAppleFileError.permanentFailure }
+ try directory.validate()
+ }
+
+ static func synchronizeExisting(at url: URL) throws {
+ let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
+ guard url.isFileURL, !url.path.utf8.contains(0), let leaf = parts.last,
+ parts.allSatisfy({ $0 != "." && $0 != ".." })
+ else { throw RadrootsAppleFileError.invalidRequest }
+ let directory = try Directory.open(Array(parts.dropLast()), create: false)
+ defer { Darwin.close(directory.descriptor) }
+ let descriptor = leaf.withCString {
+ Darwin.openat(directory.descriptor, $0, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC)
+ }
+ guard descriptor >= 0 else { throw RadrootsAppleFileError.permanentFailure }
+ defer { Darwin.close(descriptor) }
+ var before = stat()
+ var after = stat()
+ guard Darwin.fstat(descriptor, &before) == 0, before.st_mode & S_IFMT == S_IFREG,
+ Darwin.fsync(descriptor) == 0, Darwin.fsync(directory.descriptor) == 0,
+ leaf.withCString({ Darwin.fstatat(directory.descriptor, $0, &after, AT_SYMLINK_NOFOLLOW) }) == 0,
+ before.st_dev == after.st_dev, before.st_ino == after.st_ino,
+ before.st_size == after.st_size
+ else { throw RadrootsAppleFileError.permanentFailure }
+ try directory.validate()
+ }
+
+ private static func install(
+ _ data: Data, at url: URL, mode: Mode, readOnly: Bool, fault: ((Phase) throws -> Void)?
+ ) throws {
+ guard data.count <= maximumBytes, url.isFileURL, url.path.hasPrefix("/"),
+ !url.path.utf8.contains(0)
+ else { throw RadrootsAppleFileError.invalidRequest }
+ let parts = url.path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
+ guard let leaf = parts.last, parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }) else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let directory = try Directory.open(Array(parts.dropLast()), create: true)
+ defer { Darwin.close(directory.descriptor) }
+ 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)
+ }
+ guard descriptor >= 0 else { throw RadrootsAppleFileError.permanentFailure }
+ defer { Darwin.close(descriptor) }
+ // Keep interrupted files identifiable. Normal failure cleanup is safe;
+ // abrupt process loss leaves the same reserved temporary prefix.
+ defer { _ = temporary.withCString { Darwin.unlinkat(directory.descriptor, $0, 0) } }
+ try writeAll(data, to: descriptor, fault: fault)
+ try fault?(.afterWrite)
+ if readOnly, Darwin.fchmod(descriptor, 0o400) != 0 {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ try fault?(.beforeFileSync)
+ guard Darwin.fsync(descriptor) == 0 else { throw RadrootsAppleFileError.permanentFailure }
+ try directory.validate()
+ try fault?(.beforeInstall)
+ try directory.validate()
+ let installed = temporary.withCString { source in
+ leaf.withCString { destination in
+ switch mode {
+ case .replace:
+ Darwin.renameat(directory.descriptor, source, directory.descriptor, destination)
+ case .create:
+ Darwin.renameatx_np(
+ directory.descriptor,
+ source,
+ directory.descriptor,
+ destination,
+ UInt32(RENAME_EXCL)
+ )
+ }
+ }
+ }
+ guard installed == 0 else { throw RadrootsAppleFileError.permanentFailure }
+ try fault?(.beforeDirectorySync)
+ guard Darwin.fsync(directory.descriptor) == 0 else { throw RadrootsAppleFileError.permanentFailure }
+ try directory.validate()
+ }
+
+ private static func writeAll(_ data: Data, to descriptor: Int32, fault: ((Phase) throws -> Void)?) throws {
+ try data.withUnsafeBytes { bytes in
+ var offset = 0
+ while offset < bytes.count {
+ guard let base = bytes.baseAddress else { throw RadrootsAppleFileError.invalidRequest }
+ let count = Darwin.write(descriptor, base.advanced(by: offset), min(64 * 1024, bytes.count - offset))
+ if count < 0, errno == EINTR {
+ continue
+ }
+ guard count > 0 else { throw RadrootsAppleFileError.permanentFailure }
+ offset += count
+ try fault?(.afterWriteChunk)
+ }
+ }
+ }
+
+ private struct Identity: Equatable {
+ let device: dev_t
+ let inode: ino_t
+
+ init(_ descriptor: Int32) throws {
+ var value = stat()
+ guard Darwin.fstat(descriptor, &value) == 0, value.st_mode & S_IFMT == S_IFDIR else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ device = value.st_dev
+ inode = value.st_ino
+ }
+ }
+
+ private struct Directory {
+ let descriptor: Int32
+ let parts: [String]
+ let identities: [Identity]
+
+ static func open(_ parts: [String], create: Bool) throws -> Self {
+ var descriptor = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC)
+ guard descriptor >= 0 else { throw RadrootsAppleFileError.permanentFailure }
+ do {
+ var identities = try [Identity(descriptor)]
+ for part in parts {
+ if create {
+ let result = part.withCString { Darwin.mkdirat(descriptor, $0, 0o700) }
+ guard result == 0 || errno == EEXIST else { throw RadrootsAppleFileError.permanentFailure }
+ if result == 0, Darwin.fsync(descriptor) != 0 {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ }
+ let next = part.withCString {
+ Darwin.openat(descriptor, $0, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK)
+ }
+ guard next >= 0 else { throw RadrootsAppleFileError.permanentFailure }
+ Darwin.close(descriptor)
+ descriptor = next
+ try identities.append(Identity(descriptor))
+ }
+ return Self(descriptor: descriptor, parts: parts, identities: identities)
+ } catch {
+ Darwin.close(descriptor)
+ throw error
+ }
+ }
+
+ func validate() throws {
+ let current = try Self.open(parts, create: false)
+ defer { Darwin.close(current.descriptor) }
+ guard current.identities == identities else { throw RadrootsAppleFileError.permanentFailure }
+ }
+ }
+}
diff --git a/Sources/RadrootsKit/RadrootsFileAccess.swift b/Sources/RadrootsKit/RadrootsFileAccess.swift
@@ -68,7 +68,8 @@ public struct RadrootsStagedBlobReference: Sendable, Equatable, Hashable, Codabl
throw RadrootsAppleFileError.invalidRequest
}
let allowed = CharacterSet(
- charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")
+ charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
+ )
guard trimmed.rangeOfCharacter(from: allowed.inverted) == nil else {
throw RadrootsAppleFileError.invalidRequest
}
@@ -151,633 +152,3 @@ public protocol RadrootsFileAccess {
-> [RadrootsStagedBlobReference]
func resetStagedBlobs() throws
}
-
-public final class RadrootsAppleFileAccess: RadrootsFileAccess {
- private static let maximumGovernedFileBytes = 512 * 1024 * 1024
-
- public let roots: RadrootsAppleFileRoots
- private let fileManager: FileManager
-
- public init(roots: RadrootsAppleFileRoots, fileManager: FileManager = .default) {
- self.roots = roots
- self.fileManager = fileManager
- }
-
- public func write(_ payload: RadrootsFilePayload, to file: RadrootsFileReference) throws {
- let url = try roots.resolvedURL(for: file)
- try createParentDirectory(for: url)
- switch payload {
- case .inline(let inlineData):
- try classifiedFileSystemOperation {
- try inlineData.write(to: url, options: [.atomic])
- }
- case .stagedBlob(let stagedBlob):
- let data = try readStagedBlob(stagedBlob)
- try classifiedFileSystemOperation {
- try data.write(to: url, options: [.atomic])
- }
- }
- }
-
- public func read(_ file: RadrootsFileReference, mode: RadrootsFileReadMode) throws
- -> RadrootsFileReadResult
- {
- switch mode {
- case .inline(let maxBytes):
- return try .inline(readGovernedFile(file, maximumBytes: maxBytes))
- case .preferInline(let maxBytes):
- do {
- return try .inline(
- readGovernedFile(file, maximumBytes: maxBytes, preserveTooLarge: true)
- )
- } catch RadrootsGovernedFileReadError.tooLarge {
- let url = try roots.resolvedURL(for: file)
- let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
- return .stagedBlob(staged)
- }
- case .stagedBlob:
- let url = try roots.resolvedURL(for: file)
- let staged = try stageFile(file, mediaType: nil, filenameHint: url.lastPathComponent)
- return .stagedBlob(staged)
- }
- }
-
- public func delete(_ file: RadrootsFileReference) throws {
- let url = try roots.resolvedURL(for: file)
- guard fileManager.fileExists(atPath: url.path) else {
- return
- }
- try classifiedFileSystemOperation {
- try fileManager.removeItem(at: url)
- }
- }
-
- public func list(_ directory: RadrootsFileReference) throws -> [RadrootsFileEntry] {
- let rootURL = roots.root(for: directory.scope).standardizedFileURL
- let directoryURL = try roots.resolvedURL(for: directory, allowRootDirectory: true)
- var isDirectory = ObjCBool(false)
- guard fileManager.fileExists(atPath: directoryURL.path, isDirectory: &isDirectory) else {
- return []
- }
- guard isDirectory.boolValue else {
- throw RadrootsAppleFileError.invalidRequest
- }
- return try classifiedFileSystemOperation {
- let urls = try fileManager.contentsOfDirectory(
- at: directoryURL,
- includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
- options: []
- )
- return try urls.map { url in
- let values = try url.resourceValues(
- forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
- )
- let relativePath = try relativePath(for: url.standardizedFileURL, under: rootURL)
- return RadrootsFileEntry(
- file: RadrootsFileReference(scope: directory.scope, relativePath: relativePath),
- name: url.lastPathComponent,
- isDirectory: values.isDirectory ?? false,
- sizeBytes: values.fileSize,
- modifiedAt: values.contentModificationDate
- )
- }
- .sorted { left, right in
- left.file.relativePath < right.file.relativePath
- }
- }
- }
-
- public func reset(scope: RadrootsFileScope) throws {
- let url = roots.root(for: scope)
- try classifiedFileSystemOperation {
- if fileManager.fileExists(atPath: url.path) {
- try fileManager.removeItem(at: url)
- }
- try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
- }
- }
-
- @discardableResult
- public func stageBlob(
- _ data: Data,
- mediaType: String? = nil,
- filenameHint: String? = nil
- ) throws -> RadrootsStagedBlobReference {
- guard data.count <= Self.maximumGovernedFileBytes else {
- throw RadrootsAppleFileError.invalidRequest
- }
- let blobID = UUID().uuidString.lowercased()
- let blob = try RadrootsStagedBlobReference(
- blobID: blobID,
- sizeBytes: data.count,
- mediaType: mediaType,
- filenameHint: filenameHint
- )
- let url = try stagedBlobURL(for: blob)
- try classifiedFileSystemOperation {
- try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
- try data.write(to: url, options: [.atomic])
- }
- return blob
- }
-
- @discardableResult
- public func stageFile(
- _ file: RadrootsFileReference,
- mediaType: String? = nil,
- filenameHint: String? = nil
- ) throws -> RadrootsStagedBlobReference {
- let sourceURL = try roots.resolvedURL(for: file)
- return try stageBlob(
- readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes),
- mediaType: mediaType,
- filenameHint: filenameHint ?? sourceURL.lastPathComponent
- )
- }
-
- @discardableResult
- public func stageExternalFile(
- _ sourceURL: URL,
- mediaType: String? = nil,
- filenameHint: String? = nil
- ) throws -> RadrootsStagedBlobReference {
- try withSecurityScopedFile(sourceURL) { scopedURL in
- try stageFileURL(
- scopedURL,
- mediaType: mediaType,
- filenameHint: filenameHint ?? scopedURL.lastPathComponent
- )
- }
- }
-
- @discardableResult
- public func copyExternalFile(
- _ sourceURL: URL,
- to file: RadrootsFileReference,
- mediaType: String? = nil,
- suggestedFilename: String? = nil
- ) throws -> RadrootsImportedDocument {
- try withSecurityScopedFile(sourceURL) { scopedURL in
- let destinationURL = try roots.resolvedURL(for: file)
- try createParentDirectory(for: destinationURL)
- try copyReplacingItem(from: scopedURL, to: destinationURL)
- let sizeBytes = try fileSizeUInt64(at: destinationURL)
- return try RadrootsImportedDocument(
- file: file,
- originalURL: scopedURL,
- suggestedFilename: suggestedFilename ?? scopedURL.lastPathComponent,
- mediaType: mediaType,
- sizeBytes: sizeBytes
- )
- }
- }
-
- @discardableResult
- public func prepareExport(_ request: RadrootsExportDocumentRequest) throws
- -> RadrootsPreparedExportDocument
- {
- let preparedID = UUID().uuidString.lowercased()
- let directoryURL = preparedExportsRoot.appendingPathComponent(preparedID, isDirectory: true)
- let fileURL = directoryURL.appendingPathComponent(request.suggestedFilename).standardizedFileURL
- try classifiedFileSystemOperation {
- try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
- }
- let preparedData: Data
- switch request.source {
- case .inlineData(let data):
- preparedData = data
- case .file(let file):
- preparedData = try readGovernedFile(file, maximumBytes: Self.maximumGovernedFileBytes)
- case .stagedBlob(let stagedBlob):
- preparedData = try readStagedBlob(stagedBlob)
- }
- try classifiedFileSystemOperation {
- try preparedData.write(to: fileURL, options: [.atomic])
- }
- let sizeBytes: UInt64 =
- if let requestSizeBytes = request.sizeBytes {
- requestSizeBytes
- } else {
- try fileSizeUInt64(at: fileURL)
- }
- return try RadrootsPreparedExportDocument(
- preparedID: preparedID,
- fileURL: fileURL,
- suggestedFilename: request.suggestedFilename,
- mediaType: request.mediaType,
- sizeBytes: sizeBytes
- )
- }
-
- public func readStagedBlob(_ blob: RadrootsStagedBlobReference) throws -> Data {
- guard (0 ... Self.maximumGovernedFileBytes).contains(blob.sizeBytes) else {
- throw RadrootsAppleFileError.invalidRequest
- }
- let data: Data
- do {
- data = try RadrootsGovernedFileReader.read(
- root: roots.stagedBlobsRoot,
- relativePath: blob.blobID,
- maximumBytes: blob.sizeBytes
- )
- } catch let error as RadrootsGovernedFileReadError {
- throw mappedGovernedReadError(error)
- }
- guard data.count == blob.sizeBytes else {
- throw RadrootsAppleFileError.permanentFailure
- }
- return data
- }
-
- public func releaseStagedBlob(_ blob: RadrootsStagedBlobReference) throws {
- let url = try stagedBlobURL(for: blob)
- if fileManager.fileExists(atPath: url.path) {
- try classifiedFileSystemOperation {
- try fileManager.removeItem(at: url)
- }
- }
- }
-
- public func preparedExportExists(_ preparedExport: RadrootsPreparedExportDocument) throws -> Bool {
- let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
- return fileManager.fileExists(atPath: directoryURL.path)
- && fileManager.fileExists(atPath: preparedExport.fileURL.path)
- }
-
- public func releasePreparedExport(_ preparedExport: RadrootsPreparedExportDocument) throws {
- let directoryURL = try preparedExportDirectoryURL(for: preparedExport)
- if fileManager.fileExists(atPath: directoryURL.path) {
- try classifiedFileSystemOperation {
- try fileManager.removeItem(at: directoryURL)
- }
- }
- }
-
- @discardableResult
- public func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference] {
- guard fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) else {
- return []
- }
- return try classifiedFileSystemOperation {
- let urls = try fileManager.contentsOfDirectory(
- at: roots.stagedBlobsRoot,
- includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey],
- options: []
- )
- var released: [RadrootsStagedBlobReference] = []
- for url in urls {
- let values = try url.resourceValues(
- forKeys: [.isDirectoryKey, .fileSizeKey, .contentModificationDateKey]
- )
- guard values.isDirectory != true else {
- continue
- }
- guard let modifiedAt = values.contentModificationDate, modifiedAt < cutoff else {
- continue
- }
- let blob = try RadrootsStagedBlobReference(
- blobID: url.lastPathComponent,
- sizeBytes: values.fileSize ?? 0
- )
- try fileManager.removeItem(at: url)
- released.append(blob)
- }
- return released.sorted { left, right in
- left.blobID < right.blobID
- }
- }
- }
-
- public func resetStagedBlobs() throws {
- try classifiedFileSystemOperation {
- if fileManager.fileExists(atPath: roots.stagedBlobsRoot.path) {
- try fileManager.removeItem(at: roots.stagedBlobsRoot)
- }
- try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
- }
- }
-
- public func resetFileRoots() throws {
- for scope in RadrootsFileScope.allCases {
- try reset(scope: scope)
- }
- try resetStagedBlobs()
- }
-
- private func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
- try roots.stagedBlobURL(for: blob)
- }
-
- private var preparedExportsRoot: URL {
- roots.temporaryRoot.appendingPathComponent("prepared_exports", isDirectory: true)
- .standardizedFileURL
- }
-
- private func preparedExportDirectoryURL(for preparedExport: RadrootsPreparedExportDocument) throws
- -> URL
- {
- let normalizedPreparedID = try RadrootsPreparedExportDocument.normalizedPreparedID(
- preparedExport.preparedID)
- let directoryURL = preparedExportsRoot.appendingPathComponent(
- normalizedPreparedID, isDirectory: true
- ).standardizedFileURL
- guard preparedExport.fileURL.standardizedFileURL.path.hasPrefix(directoryURL.path + "/") else {
- throw RadrootsAppleFileError.invalidRequest
- }
- return directoryURL
- }
-
- private func stageFileURL(
- _ sourceURL: URL,
- mediaType: String?,
- filenameHint: String?
- ) throws -> RadrootsStagedBlobReference {
- let sizeBytes = try fileSizeInt(at: sourceURL)
- guard sizeBytes <= Self.maximumGovernedFileBytes else {
- throw RadrootsAppleFileError.permanentFailure
- }
- let blobID = UUID().uuidString.lowercased()
- let blob = try RadrootsStagedBlobReference(
- blobID: blobID,
- sizeBytes: sizeBytes,
- mediaType: mediaType,
- filenameHint: filenameHint
- )
- let destinationURL = try stagedBlobURL(for: blob)
- try classifiedFileSystemOperation {
- try fileManager.createDirectory(at: roots.stagedBlobsRoot, withIntermediateDirectories: true)
- }
- try copyReplacingItem(from: sourceURL, to: destinationURL)
- return blob
- }
-
- private func withSecurityScopedFile<T>(_ sourceURL: URL, _ body: (URL) throws -> T) throws -> T {
- guard sourceURL.isFileURL else {
- throw RadrootsAppleFileError.invalidRequest
- }
- let scopedURL = sourceURL.standardizedFileURL
- var isDirectory = ObjCBool(false)
- guard fileManager.fileExists(atPath: scopedURL.path, isDirectory: &isDirectory) else {
- throw RadrootsAppleFileError.notFound
- }
- guard !isDirectory.boolValue else {
- throw RadrootsAppleFileError.invalidRequest
- }
- let didStartScope = scopedURL.startAccessingSecurityScopedResource()
- defer {
- if didStartScope {
- scopedURL.stopAccessingSecurityScopedResource()
- }
- }
- return try body(scopedURL)
- }
-
- private func copyReplacingItem(from sourceURL: URL, to destinationURL: URL) throws {
- guard sourceURL.isFileURL, destinationURL.isFileURL else {
- throw RadrootsAppleFileError.invalidRequest
- }
- try createParentDirectory(for: destinationURL)
- try classifiedFileSystemOperation {
- if fileManager.fileExists(atPath: destinationURL.path) {
- try fileManager.removeItem(at: destinationURL)
- }
- try fileManager.copyItem(at: sourceURL, to: destinationURL)
- }
- }
-
- private func createParentDirectory(for url: URL) throws {
- try classifiedFileSystemOperation {
- try fileManager.createDirectory(
- at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
- }
- }
-
- private func fileSize(at url: URL) throws -> Int {
- try fileSizeInt(at: url)
- }
-
- private func fileSizeInt(at url: URL) throws -> Int {
- let values = try classifiedFileSystemOperation {
- try url.resourceValues(forKeys: [.fileSizeKey])
- }
- guard let size = values.fileSize else {
- throw RadrootsAppleFileError.permanentFailure
- }
- return size
- }
-
- private func fileSizeUInt64(at url: URL) throws -> UInt64 {
- try UInt64(fileSizeInt(at: url))
- }
-
- private func readGovernedFile(
- _ file: RadrootsFileReference,
- maximumBytes: Int,
- preserveTooLarge: Bool = false
- ) throws -> Data {
- guard (0 ... Self.maximumGovernedFileBytes).contains(maximumBytes) else {
- throw RadrootsAppleFileError.invalidRequest
- }
- let root = roots.root(for: file.scope)
- let resolved = try roots.resolvedURL(for: file)
- let relative = try relativePath(for: resolved, under: root)
- do {
- return try RadrootsGovernedFileReader.read(
- root: root,
- relativePath: relative,
- maximumBytes: maximumBytes
- )
- } catch let error as RadrootsGovernedFileReadError {
- if preserveTooLarge, error == .tooLarge {
- throw error
- }
- throw mappedGovernedReadError(error)
- }
- }
-
- private func mappedGovernedReadError(_ error: RadrootsGovernedFileReadError)
- -> RadrootsAppleFileError
- {
- switch error {
- case .unavailable:
- .notFound
- case .invalidRequest:
- .invalidRequest
- case .tooLarge:
- .permanentFailure
- case .invalidObject, .changedDuringRead, .ioFailure:
- .permanentFailure
- }
- }
-
- private func relativePath(for url: URL, under rootURL: URL) throws -> String {
- let rootPath = rootURL.standardizedFileURL.path
- let filePath = url.standardizedFileURL.path
- guard filePath.hasPrefix(rootPath + "/") else {
- throw RadrootsAppleFileError.invalidRequest
- }
- return String(filePath.dropFirst(rootPath.count + 1))
- }
-
- private func classifiedFileSystemOperation<T>(_ operation: () throws -> T) throws -> T {
- do {
- return try operation()
- } catch let error as RadrootsAppleFileError {
- throw error
- } catch let error as RadrootsDocumentInterchangeError {
- throw error
- } catch {
- throw RadrootsAppleFileError.permanentFailure
- }
- }
-}
-
-public struct RadrootsAppleFileRoots: Sendable, Equatable {
- public let appIdentifier: String
- public let dataRoot: URL
- public let cacheRoot: URL
- public let temporaryRoot: URL
- public let logsRoot: URL
- public let stagedBlobsRoot: URL
-
- public init(
- appIdentifier: String,
- dataRoot: URL,
- cacheRoot: URL,
- temporaryRoot: URL,
- logsRoot: URL? = nil,
- stagedBlobsRoot: URL? = nil
- ) throws {
- let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier)
- let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot")
- let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot")
- let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot")
- self.appIdentifier = normalizedAppIdentifier
- self.dataRoot = normalizedDataRoot
- self.cacheRoot = normalizedCacheRoot
- self.temporaryRoot = normalizedTemporaryRoot
- self.logsRoot = try Self.normalizedRootURL(
- logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true),
- field: "logsRoot"
- )
- self.stagedBlobsRoot = try Self.normalizedRootURL(
- stagedBlobsRoot
- ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true),
- field: "stagedBlobsRoot"
- )
- }
-
- public static func appContainer(
- appIdentifier: String,
- fileManager: FileManager = .default
- ) throws -> Self {
- do {
- let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier)
- let dataBaseURL = try canonicalExistingDirectory(
- fileManager.url(
- for: .applicationSupportDirectory,
- in: .userDomainMask,
- appropriateFor: nil,
- create: true
- ))
- let cacheBaseURL = try canonicalExistingDirectory(
- fileManager.url(
- for: .cachesDirectory,
- in: .userDomainMask,
- appropriateFor: nil,
- create: true
- ))
- let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
- let cacheRoot = cacheBaseURL.appendingPathComponent(
- normalizedAppIdentifier, isDirectory: true)
- let temporaryRoot = try canonicalExistingDirectory(fileManager.temporaryDirectory)
- .appendingPathComponent(normalizedAppIdentifier, isDirectory: true)
- return try Self(
- appIdentifier: normalizedAppIdentifier,
- dataRoot: dataRoot,
- cacheRoot: cacheRoot,
- temporaryRoot: temporaryRoot
- )
- } catch let error as RadrootsAppleFileError {
- throw error
- } catch {
- throw RadrootsAppleFileError.permanentFailure
- }
- }
-
- public func root(for scope: RadrootsFileScope) -> URL {
- switch scope {
- case .data:
- dataRoot
- case .cache:
- cacheRoot
- case .temporary:
- temporaryRoot
- case .logs:
- logsRoot
- }
- }
-
- public func resolvedURL(
- for file: RadrootsFileReference,
- allowRootDirectory: Bool = false
- ) throws -> URL {
- let rootURL = root(for: file.scope).standardizedFileURL
- let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmedPath.isEmpty {
- if allowRootDirectory {
- return rootURL
- }
- throw RadrootsAppleFileError.invalidRequest
- }
- if NSString(string: trimmedPath).isAbsolutePath {
- throw RadrootsAppleFileError.invalidRequest
- }
-
- let candidateURL = rootURL.appendingPathComponent(trimmedPath).standardizedFileURL
- if candidateURL.path == rootURL.path {
- if allowRootDirectory {
- return candidateURL
- }
- throw RadrootsAppleFileError.invalidRequest
- }
- guard candidateURL.path.hasPrefix(rootURL.path + "/") else {
- throw RadrootsAppleFileError.invalidRequest
- }
- return candidateURL
- }
-
- public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL {
- let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID)
- return stagedBlobsRoot.appendingPathComponent(normalizedBlobID).standardizedFileURL
- }
-
- public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String {
- let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !trimmed.isEmpty else {
- throw RadrootsAppleFileError.invalidRequest
- }
- return trimmed
- }
-
- public static func normalizedRootURL(_ rootURL: URL, field: String) throws -> URL {
- guard rootURL.isFileURL else {
- throw RadrootsAppleFileError.invalidRequest
- }
- let standardized = rootURL.standardizedFileURL
- guard standardized.path.hasPrefix("/") else {
- throw RadrootsAppleFileError.invalidRequest
- }
- return standardized
- }
-
- private static func canonicalExistingDirectory(_ directory: URL) throws -> URL {
- guard directory.isFileURL,
- let pointer = directory.path.withCString({ Darwin.realpath($0, nil) })
- else {
- throw RadrootsAppleFileError.permanentFailure
- }
- defer { Darwin.free(pointer) }
- return URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
- }
-}
diff --git a/Sources/RadrootsKit/RadrootsStagedBlobLease.swift b/Sources/RadrootsKit/RadrootsStagedBlobLease.swift
@@ -0,0 +1,129 @@
+import Foundation
+
+/// An owner-managed immutable copy. Keep it until the native consumer is
+/// definitively finished; releasing the original staged blob cannot remove it.
+/// A full explicit data-root reset still removes all owned application state.
+public struct RadrootsStagedBlobLease: Sendable, Equatable, CustomDebugStringConvertible {
+ public let identifier: String
+ public let blob: RadrootsStagedBlobReference
+ public let sha256: String
+ public let fileURL: URL
+
+ fileprivate init(identifier: String, blob: RadrootsStagedBlobReference, sha256: String, fileURL: URL) {
+ self.identifier = identifier
+ self.blob = blob
+ self.sha256 = sha256
+ self.fileURL = fileURL
+ }
+
+ public var debugDescription: String {
+ "RadrootsStagedBlobLease(identifier: \(identifier), sha256: \(sha256), sizeBytes: \(blob.sizeBytes))"
+ }
+}
+
+extension RadrootsAppleFileAccess {
+ /// Installs an exact reference without removing a prior file first. Matching
+ /// installs are idempotent. An opaque ID cannot replace different bytes;
+ /// a SHA256 ID can repair corrupted bytes only with its verified preimage.
+ public func installStagedBlob(_ data: Data, reference: RadrootsStagedBlobReference) throws {
+ try Self.validateStagedReference(reference)
+ guard data.count == reference.sizeBytes else { throw RadrootsAppleFileError.invalidRequest }
+ let url = try roots.stagedBlobURL(for: reference)
+ do {
+ try RadrootsAtomicFile.install(data, at: url, mode: .create)
+ } catch {
+ let originalError = error
+ do {
+ let existing = try readStagedBlob(reference)
+ if existing == data {
+ try RadrootsAtomicFile.synchronizeExisting(at: url)
+ return
+ }
+ } catch RadrootsAppleFileError.notFound {
+ throw originalError
+ } catch {
+ // Corrupt size/bytes may be repaired only by the exact content
+ // identity below. Symlink traversal still fails in the writer.
+ }
+ guard RadrootsAppleFileDigest.sha256(data) == reference.blobID else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ try RadrootsAtomicFile.install(data, at: url)
+ }
+ }
+
+ /// Reuses the same immutable lease on retry, even after the source blob is
+ /// gone. The host persists the identifier with its native operation before
+ /// admission; this generic owner does not invent transfer/domain identity.
+ public func leaseStagedBlob(
+ _ blob: RadrootsStagedBlobReference, expectedSHA256: String,
+ identifier: String = UUID().uuidString.lowercased()
+ ) throws -> RadrootsStagedBlobLease {
+ try Self.validateStagedReference(blob)
+ guard expectedSHA256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else {
+ throw RadrootsAppleFileError.invalidRequest
+ }
+ let url = try leaseURL(identifier)
+ let lease = RadrootsStagedBlobLease(identifier: identifier, blob: blob, sha256: expectedSHA256, fileURL: url)
+ do {
+ try validateLease(lease)
+ try RadrootsAtomicFile.synchronizeExisting(at: url)
+ return lease
+ } catch RadrootsAppleFileError.notFound {
+ // Only definitive absence admits creation; protected/corrupt or
+ // substituted existing leases must never be overwritten.
+ }
+ let bytes = try readStagedBlob(blob)
+ guard RadrootsAppleFileDigest.sha256(bytes) == expectedSHA256 else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ do {
+ try RadrootsAtomicFile.install(bytes, at: url, mode: .create, readOnly: true)
+ } catch {
+ // A competing identical admission or an ambiguous directory sync
+ // can be recovered only by checking and flushing the exact lease.
+ try validateLease(lease)
+ try RadrootsAtomicFile.synchronizeExisting(at: url)
+ }
+ try validateLease(lease)
+ return lease
+ }
+
+ public func releaseStagedBlobLease(_ lease: RadrootsStagedBlobLease) throws {
+ guard try leaseURL(lease.identifier) == lease.fileURL else { throw RadrootsAppleFileError.invalidRequest }
+ do {
+ try validateLease(lease)
+ } catch RadrootsAppleFileError.notFound { return }
+ try RadrootsAtomicFile.remove(at: lease.fileURL)
+ }
+
+ private func leaseURL(_ identifier: String) throws -> URL {
+ guard identifier.utf8.count <= 128,
+ try RadrootsStagedBlobReference.normalizedBlobID(identifier) == identifier
+ else { throw RadrootsAppleFileError.invalidRequest }
+ return roots.dataRoot.appendingPathComponent("staged_blob_leases", isDirectory: true)
+ .appendingPathComponent(identifier)
+ }
+
+ private func validateLease(_ lease: RadrootsStagedBlobLease) throws {
+ let bytes: Data
+ do {
+ bytes = try RadrootsGovernedFileReader.read(
+ root: lease.fileURL.deletingLastPathComponent(), relativePath: lease.identifier,
+ maximumBytes: lease.blob.sizeBytes
+ )
+ } catch RadrootsGovernedFileReadError.unavailable {
+ throw RadrootsAppleFileError.notFound
+ } catch { throw RadrootsAppleFileError.permanentFailure }
+ guard bytes.count == lease.blob.sizeBytes, RadrootsAppleFileDigest.sha256(bytes) == lease.sha256 else {
+ throw RadrootsAppleFileError.permanentFailure
+ }
+ }
+
+ private static func validateStagedReference(_ blob: RadrootsStagedBlobReference) throws {
+ guard (0 ... RadrootsAtomicFile.maximumBytes).contains(blob.sizeBytes),
+ try RadrootsStagedBlobReference(blobID: blob.blobID, sizeBytes: blob.sizeBytes,
+ mediaType: blob.mediaType, filenameHint: blob.filenameHint) == blob
+ else { throw RadrootsAppleFileError.invalidRequest }
+ }
+}
diff --git a/Tests/RadrootsKitTests/RadrootsDurableStagingTests.swift b/Tests/RadrootsKitTests/RadrootsDurableStagingTests.swift
@@ -0,0 +1,222 @@
+import Darwin
+import Foundation
+@testable import RadrootsKit
+import Testing
+
+@Test func durableStagingPreservesOldBytesAtEveryInterruptedInstallBoundary() throws {
+ for phase in RadrootsAtomicFile.Phase.allCases {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let url = fixture.base.appendingPathComponent("owned/value")
+ let old = Data("old valid object".utf8)
+ let new = Data(repeating: 42, count: 100_000)
+ try RadrootsAtomicFile.install(old, at: url)
+ #expect(throws: StagingFault.self) {
+ try RadrootsAtomicFile.installForTesting(new, at: url) { current in
+ if current == phase {
+ throw StagingFault.interrupted
+ }
+ }
+ }
+ #expect(try Data(contentsOf: url) == (phase == .beforeDirectorySync ? new : old))
+ try RadrootsAtomicFile.install(new, at: url)
+ #expect(try Data(contentsOf: url) == new)
+ #expect(try FileManager.default.contentsOfDirectory(atPath: url.deletingLastPathComponent().path) == ["value"])
+ }
+}
+
+@Test func durableStagingRejectsSymlinkAndReplacedDirectoryAuthority() throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let outside = fixture.base.appendingPathComponent("outside")
+ let owned = fixture.base.appendingPathComponent("owned")
+ try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true)
+ let external = outside.appendingPathComponent("value")
+ try Data("outside".utf8).write(to: external)
+ try FileManager.default.createSymbolicLink(at: owned, withDestinationURL: outside)
+ #expect(throws: RadrootsAppleFileError.self) {
+ try RadrootsAtomicFile.install(Data("bad".utf8), at: owned.appendingPathComponent("value"))
+ }
+ #expect(try Data(contentsOf: external) == Data("outside".utf8))
+ try FileManager.default.removeItem(at: owned)
+ let url = owned.appendingPathComponent("value")
+ try RadrootsAtomicFile.install(Data("old".utf8), at: url)
+ let retained = fixture.base.appendingPathComponent("retained")
+ #expect(throws: RadrootsAppleFileError.self) {
+ try RadrootsAtomicFile.installForTesting(Data("new".utf8), at: url) { phase in
+ if phase == .beforeInstall {
+ try FileManager.default.moveItem(at: owned, to: retained)
+ try FileManager.default.createSymbolicLink(at: owned, withDestinationURL: outside)
+ }
+ }
+ }
+ #expect(try Data(contentsOf: external) == Data("outside".utf8))
+ #expect(try Data(contentsOf: retained.appendingPathComponent("value")) == Data("old".utf8))
+}
+
+@Test func durableStagingPreservesOldFileWhenDestinationCannotBeWritten() throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let directory = fixture.base.appendingPathComponent("locked")
+ let url = directory.appendingPathComponent("value")
+ try RadrootsAtomicFile.install(Data("old".utf8), at: url)
+ #expect(Darwin.chmod(directory.path, 0o500) == 0)
+ defer { _ = Darwin.chmod(directory.path, 0o700) }
+ #expect(throws: RadrootsAppleFileError.self) {
+ try RadrootsAtomicFile.install(Data("replacement".utf8), at: url)
+ }
+ #expect(try Data(contentsOf: url) == Data("old".utf8))
+}
+
+@Test func durableStagingSurvivesCachePurgeAndRejectsOpaqueIdentityConflicts() throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let access = RadrootsAppleFileAccess(roots: fixture.roots)
+ let bytes = Data("saved referenced draft".utf8)
+ let blob = try access.stageBlob(bytes)
+ try access.reset(scope: .cache)
+ try access.reset(scope: .temporary)
+ #expect(try access.readStagedBlob(blob) == bytes)
+ #expect(try fixture.roots.stagedBlobURL(for: blob).path.hasPrefix(fixture.roots.stagedBlobsRoot.path + "/"))
+ try RadrootsAtomicFile.synchronizeExisting(at: fixture.roots.stagedBlobURL(for: blob))
+ try access.installStagedBlob(bytes, reference: blob)
+ #expect(throws: RadrootsAppleFileError.self) {
+ try access.installStagedBlob(Data(repeating: 65, count: bytes.count), reference: blob)
+ }
+ #expect(try access.readStagedBlob(blob) == bytes)
+ let partial = fixture.roots.stagedBlobsRoot.appendingPathComponent(".radroots_pending_interrupted")
+ try Data("partial".utf8).write(to: partial)
+ _ = try access.sweepStagedBlobs(olderThan: .distantPast)
+ #expect(FileManager.default.fileExists(atPath: partial.path))
+ #expect(try access.readStagedBlob(blob) == bytes)
+}
+
+@Test func durableStagingRepairsOnlyVerifiedContentIdentityAndPreservesSourceOnCopyFailure() throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let access = RadrootsAppleFileAccess(roots: fixture.roots)
+ let bytes = Data("verified bytes".utf8)
+ let blob = try RadrootsStagedBlobReference(blobID: RadrootsAppleFileDigest.sha256(bytes), sizeBytes: bytes.count)
+ try access.installStagedBlob(bytes, reference: blob)
+ let url = try fixture.roots.stagedBlobURL(for: blob)
+ try Data("corrupt".utf8).write(to: url, options: .atomic)
+ try access.installStagedBlob(bytes, reference: blob)
+ #expect(try access.readStagedBlob(blob) == bytes)
+ let destination = RadrootsFileReference(scope: .data, relativePath: "imported")
+ try access.write(.inline(bytes), to: destination)
+ let invalid = fixture.base.appendingPathComponent("missing")
+ #expect(throws: RadrootsAppleFileError.self) {
+ _ = try access.copyExternalFile(invalid, to: destination, mediaType: nil, suggestedFilename: nil)
+ }
+ #expect(try access.read(destination, mode: .inline(maxBytes: bytes.count)) == .inline(bytes))
+}
+
+@Test func stagedBlobLeaseKeepsExactBytesAfterSourceReplacementPurgeAndRestart() throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let access = RadrootsAppleFileAccess(roots: fixture.roots)
+ let bytes = Data("original upload body".utf8)
+ let blob = try access.stageBlob(bytes)
+ let digest = RadrootsAppleFileDigest.sha256(bytes)
+ let lease = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "stable_attempt")
+ let source = try fixture.roots.stagedBlobURL(for: blob)
+ try Data(repeating: 65, count: bytes.count).write(to: source, options: .atomic)
+ try access.releaseStagedBlob(blob)
+ try access.resetStagedBlobs()
+ try access.reset(scope: .cache)
+ try access.reset(scope: .temporary)
+ #expect(try Data(contentsOf: lease.fileURL) == bytes)
+ let recoveredRoots = try RadrootsAppleFileRoots(
+ appIdentifier: "org.radroots.tests", dataRoot: fixture.roots.dataRoot,
+ cacheRoot: fixture.roots.cacheRoot, temporaryRoot: fixture.roots.temporaryRoot,
+ stagedBlobsRoot: fixture.roots.stagedBlobsRoot
+ )
+ #expect(recoveredRoots.dataRoot.path == fixture.roots.dataRoot.path)
+ let reopened = RadrootsAppleFileAccess(roots: recoveredRoots)
+ #expect(try reopened.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "stable_attempt") == lease)
+ let attributes = try FileManager.default.attributesOfItem(atPath: lease.fileURL.path)
+ #expect((attributes[.posixPermissions] as? NSNumber)?.intValue == 0o400)
+ #expect(!lease.debugDescription.contains(fixture.base.path))
+ try reopened.releaseStagedBlobLease(lease)
+ try reopened.releaseStagedBlobLease(lease)
+ #expect(!FileManager.default.fileExists(atPath: lease.fileURL.path))
+}
+
+@Test func stagedBlobLeaseRejectsChangedBytesAndUnsafeIdentifiers() throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let access = RadrootsAppleFileAccess(roots: fixture.roots)
+ let bytes = Data("original body".utf8)
+ let blob = try access.stageBlob(bytes)
+ let digest = RadrootsAppleFileDigest.sha256(bytes)
+ for identifier in ["../escape", "bad/child", " ", String(repeating: "x", count: 129)] {
+ #expect(throws: RadrootsAppleFileError.self) {
+ _ = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: identifier)
+ }
+ }
+ let lease = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "attempt")
+ try Data(repeating: 65, count: bytes.count).write(to: lease.fileURL, options: .atomic)
+ #expect(throws: RadrootsAppleFileError.self) {
+ _ = try access.leaseStagedBlob(blob, expectedSHA256: digest, identifier: "attempt")
+ }
+ #expect(try Data(contentsOf: lease.fileURL) != bytes)
+}
+
+@Test func stagedBlobLeaseConcurrentSameIdentityHasOneImmutableFile() async throws {
+ let fixture = try DurableStagingFixture()
+ defer { fixture.remove() }
+ let roots = fixture.roots
+ let bytes = Data(repeating: 7, count: 100_000)
+ let blob = try RadrootsAppleFileAccess(roots: roots).stageBlob(bytes)
+ let digest = RadrootsAppleFileDigest.sha256(bytes)
+ let leases = try await withThrowingTaskGroup(of: RadrootsStagedBlobLease.self) { group in
+ for _ in 0 ..< 8 {
+ group.addTask {
+ try RadrootsAppleFileAccess(roots: roots).leaseStagedBlob(
+ blob,
+ expectedSHA256: digest,
+ identifier: "same"
+ )
+ }
+ }
+ var results: [RadrootsStagedBlobLease] = []
+ for try await result in group {
+ results.append(result)
+ }
+ return results
+ }
+ #expect(leases.count == 8)
+ let first = try #require(leases.first)
+ #expect(leases.allSatisfy { $0 == first })
+ #expect(try Data(contentsOf: first.fileURL) == bytes)
+ #expect(try FileManager.default
+ .contentsOfDirectory(atPath: first.fileURL.deletingLastPathComponent().path) == ["same"])
+}
+
+private enum StagingFault: Error { case interrupted }
+
+private struct DurableStagingFixture {
+ let base: URL
+ let roots: RadrootsAppleFileRoots
+
+ init() throws {
+ let raw = FileManager.default.temporaryDirectory
+ .appendingPathComponent("radroots-durable-staging-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: raw, withIntermediateDirectories: true)
+ let pointer = try #require(raw.path.withCString { Darwin.realpath($0, nil) })
+ defer { Darwin.free(pointer) }
+ base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true)
+ let data = base.appendingPathComponent("data", isDirectory: true)
+ roots = try RadrootsAppleFileRoots(appIdentifier: "org.radroots.tests", dataRoot: data,
+ cacheRoot: base.appendingPathComponent("cache", isDirectory: true),
+ temporaryRoot: base.appendingPathComponent("tmp", isDirectory: true),
+ stagedBlobsRoot: data.appendingPathComponent(
+ "staged_blobs",
+ isDirectory: true
+ ))
+ }
+
+ func remove() {
+ try? FileManager.default.removeItem(at: base)
+ }
+}
diff --git a/contracts/api_baselines/apple_kit.txt b/contracts/api_baselines/apple_kit.txt
@@ -139,6 +139,10 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15ScannedDocumentV 22:s:s16SendableMetatypeP 22:Swift.SendableMetatype
12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15ScannedDocumentV 4:s:SH 14:Swift.Hashable
12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15ScannedDocumentV 4:s:SQ 15:Swift.Equatable
+12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15StagedBlobLeaseV 13:s:s8SendableP 14:Swift.Sendable
+12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15StagedBlobLeaseV 22:s:s16SendableMetatypeP 22:Swift.SendableMetatype
+12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15StagedBlobLeaseV 34:s:s28CustomDebugStringConvertibleP 34:Swift.CustomDebugStringConvertible
+12:relationship 11:RadrootsKit 10:conformsTo 35:s:11RadrootsKit0A15StagedBlobLeaseV 4:s:SQ 15:Swift.Equatable
12:relationship 11:RadrootsKit 10:conformsTo 36:s:11RadrootsKit0A16AppleMediaPickerC 13:s:s8SendableP 14:Swift.Sendable
12:relationship 11:RadrootsKit 10:conformsTo 36:s:11RadrootsKit0A16AppleMediaPickerC 22:s:s16SendableMetatypeP 22:Swift.SendableMetatype
12:relationship 11:RadrootsKit 10:conformsTo 36:s:11RadrootsKit0A16AppleMediaPickerC 31:s:11RadrootsKit0A11MediaPickerP 0:
@@ -781,6 +785,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 103:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV010permissionE03forAA0adE0OSo015UNAuthorizationE0V_tFZ 49:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A23CaptureIntakeValidationO28normalizedScannerOutputKindsySayAA0a8DocumentgH4KindOGAGKFZ 43:s:11RadrootsKit0A23CaptureIntakeValidationO 0:
12:relationship 11:RadrootsKit 8:memberOf 104:s:11RadrootsKit0A26BackgroundTransferProgressV16bytesTransferred18totalBytesExpectedACs5Int64V_AGSgtKcfc 46:s:11RadrootsKit0A26BackgroundTransferProgressV 0:
+12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A15AppleFileAccessC17installStagedBlob_9referencey10Foundation4DataV_AA0agH9ReferenceVtKF 35:s:11RadrootsKit0A15AppleFileAccessC 0:
12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A26AppleIdentityMetadataStoreC9namespace12userDefaults9keyPrefixACSS_So06NSUserI0CSStKcfc 46:s:11RadrootsKit0A26AppleIdentityMetadataStoreC 0:
12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A29UnavailableBackgroundTransferV8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 49:s:11RadrootsKit0A29UnavailableBackgroundTransferV 0:
12:relationship 11:RadrootsKit 8:memberOf 105:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV06activeE11IdentifiersShyAA0adE10IdentifierVGyYaYbKcvp 51:s:11RadrootsKit0A31AppleBackgroundTransferAdaptersV 0:
@@ -806,6 +811,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 114:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV4read_12maximumBytes10Foundation4DataVAA0ade5LocalF0O_SitKF 55:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV 0:
12:relationship 11:RadrootsKit 8:memberOf 115:s:11RadrootsKit0A21ExportDocumentRequestV19normalizedSizeBytes6source09requestedgH0s6UInt64VSgAA0acD6SourceO_AItKFZ 41:s:11RadrootsKit0A21ExportDocumentRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 117:s:11RadrootsKit0A15IdentityCustodyC014exportPortableC010passphraseAA0aC19PortabilityEnvelopeVAA0aC10PassphraseV_tYaKF 35:s:11RadrootsKit0A15IdentityCustodyC 0:
+12:relationship 11:RadrootsKit 8:memberOf 118:s:11RadrootsKit0A15AppleFileAccessC15leaseStagedBlob_14expectedSHA25610identifierAA0agH5LeaseVAA0agH9ReferenceV_S2StKF 35:s:11RadrootsKit0A15AppleFileAccessC 0:
12:relationship 11:RadrootsKit 8:memberOf 118:s:11RadrootsKit0A15IdentityCustodyC06importC0_5label15replaceExistingAA0aC8SnapshotVAA0aC14SecretMaterialV_SSSgSbtYaKF 35:s:11RadrootsKit0A15IdentityCustodyC 0:
12:relationship 11:RadrootsKit 8:memberOf 118:s:11RadrootsKit0A23AppleBackgroundTransferC015handleEventsForD10URLSession10identifier17completionHandlerySS_yyYbctYaF 43:s:11RadrootsKit0A23AppleBackgroundTransferC 0:
12:relationship 11:RadrootsKit 8:memberOf 118:s:11RadrootsKit0A27DocumentPresentationAdapterO11contentType3for07UniformG11Identifiers6UTTypeVAA0aC11ContentKindO_tFZ 47:s:11RadrootsKit0A27DocumentPresentationAdapterO 0:
@@ -904,6 +910,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 46:s:11RadrootsKit0A14TelemetryLevelO5erroryA2CmF 34:s:11RadrootsKit0A14TelemetryLevelO 0:
12:relationship 11:RadrootsKit 8:memberOf 46:s:11RadrootsKit0A14TelemetryLevelO5traceyA2CmF 34:s:11RadrootsKit0A14TelemetryLevelO 0:
12:relationship 11:RadrootsKit 8:memberOf 46:s:11RadrootsKit0A15IdentityCustodyC04lockC0yyF 35:s:11RadrootsKit0A15IdentityCustodyC 0:
+12:relationship 11:RadrootsKit 8:memberOf 46:s:11RadrootsKit0A15StagedBlobLeaseV6sha256SSvp 35:s:11RadrootsKit0A15StagedBlobLeaseV 0:
12:relationship 11:RadrootsKit 8:memberOf 46:s:11RadrootsKit0A17ShareTransferItemV7PayloadO 37:s:11RadrootsKit0A17ShareTransferItemV 0:
12:relationship 11:RadrootsKit 8:memberOf 47:s:11RadrootsKit0A13IdentityStateO7corruptyA2CmF 33:s:11RadrootsKit0A13IdentityStateO 0:
12:relationship 11:RadrootsKit 8:memberOf 47:s:11RadrootsKit0A14PermissionKindO6camerayA2CmF 34:s:11RadrootsKit0A14PermissionKindO 0:
@@ -943,6 +950,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A13FileReferenceV12relativePathSSvp 33:s:11RadrootsKit0A13FileReferenceV 0:
12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A14SecureStoreKeyV10normalizedACyKF 34:s:11RadrootsKit0A14SecureStoreKeyV 0:
12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A14TelemetryFieldV4boolyACSS_SbtKFZ 34:s:11RadrootsKit0A14TelemetryFieldV 0:
+12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A15StagedBlobLeaseV10identifierSSvp 35:s:11RadrootsKit0A15StagedBlobLeaseV 0:
12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A17ShareTransferItemV7subjectSSSgvp 37:s:11RadrootsKit0A17ShareTransferItemV 0:
12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A17UserPresenceErrorO7timeoutyA2CmF 37:s:11RadrootsKit0A17UserPresenceErrorO 0:
12:relationship 11:RadrootsKit 8:memberOf 51:s:11RadrootsKit0A18LocationCoordinateV8latitudeSdvp 38:s:11RadrootsKit0A18LocationCoordinateV 0:
@@ -1040,6 +1048,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A15AppleFileAccessC16resetStagedBlobsyyKF 35:s:11RadrootsKit0A15AppleFileAccessC 0:
12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A15LocationReadingV14altitudeMetersSdSgvp 35:s:11RadrootsKit0A15LocationReadingV 0:
12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A15OpaqueSignatureV16debugDescriptionSSvp 35:s:11RadrootsKit0A15OpaqueSignatureV 0:
+12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A15StagedBlobLeaseV16debugDescriptionSSvp 35:s:11RadrootsKit0A15StagedBlobLeaseV 0:
12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A16ImportedDocumentV9sizeBytess6UInt64Vvp 36:s:11RadrootsKit0A16ImportedDocumentV 0:
12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A16PermissionStatusO13notDeterminedyA2CmF 36:s:11RadrootsKit0A16PermissionStatusO 0:
12:relationship 11:RadrootsKit 8:memberOf 57:s:11RadrootsKit0A17OpaqueSignPurposeO8rawValueACSgSS_tcfc 37:s:11RadrootsKit0A17OpaqueSignPurposeO 0:
@@ -1080,6 +1089,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 58:s:11RadrootsKit0A28AppleExternalActionsAdaptersV4liveACvpZ 48:s:11RadrootsKit0A28AppleExternalActionsAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 58:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV4liveACvpZ 48:s:11RadrootsKit0A28AppleLoggerTelemetryAdaptersV 0:
12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A14TelemetryEventV6fieldsSayAA0aC5FieldVGvp 34:s:11RadrootsKit0A14TelemetryEventV 0:
+12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A15StagedBlobLeaseV4blobAA0acD9ReferenceVvp 35:s:11RadrootsKit0A15StagedBlobLeaseV 0:
12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A16ImportedDocumentV17suggestedFilenameSSvp 36:s:11RadrootsKit0A16ImportedDocumentV 0:
12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A17OpaqueSignRequestV16debugDescriptionSSvp 37:s:11RadrootsKit0A17OpaqueSignRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 59:s:11RadrootsKit0A17UserPresenceErrorO14invalidRequestyA2CmF 37:s:11RadrootsKit0A17UserPresenceErrorO 0:
@@ -1123,6 +1133,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 60:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerVACycfc 54:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV 0:
12:relationship 11:RadrootsKit 8:memberOf 61:s:11RadrootsKit0A15IdentityCustodyC7recoverAA0aC8SnapshotVyKF 35:s:11RadrootsKit0A15IdentityCustodyC 0:
12:relationship 11:RadrootsKit 8:memberOf 61:s:11RadrootsKit0A15IdentityCustodyC8snapshotAA0aC8SnapshotVyF 35:s:11RadrootsKit0A15IdentityCustodyC 0:
+12:relationship 11:RadrootsKit 8:memberOf 61:s:11RadrootsKit0A15StagedBlobLeaseV7fileURL10Foundation0G0Vvp 35:s:11RadrootsKit0A15StagedBlobLeaseV 0:
12:relationship 11:RadrootsKit 8:memberOf 61:s:11RadrootsKit0A17MediaImportResultV5itemsSayAA0aC5AssetVGvp 37:s:11RadrootsKit0A17MediaImportResultV 0:
12:relationship 11:RadrootsKit 8:memberOf 61:s:11RadrootsKit0A17ShareTransferItemV7PayloadO4textyAESScAEmF 46:s:11RadrootsKit0A17ShareTransferItemV7PayloadO 0:
12:relationship 11:RadrootsKit 8:memberOf 61:s:11RadrootsKit0A17UserPresenceErrorO16errorDescriptionSSSgvp 37:s:11RadrootsKit0A17UserPresenceErrorO 0:
@@ -1398,6 +1409,7 @@ radroots.apple-kit.public-api.v1
12:relationship 11:RadrootsKit 8:memberOf 71:s:11RadrootsKit0A28AppleImagePreparationRequestV17maximumPixelCountSivp 48:s:11RadrootsKit0A28AppleImagePreparationRequestV 0:
12:relationship 11:RadrootsKit 8:memberOf 71:s:11RadrootsKit0A28LocationServicesAvailabilityV08locationD7EnabledSbvp 48:s:11RadrootsKit0A28LocationServicesAvailabilityV 0:
12:relationship 11:RadrootsKit 8:memberOf 71:s:11RadrootsKit0A31BackgroundTransferNetworkPolicyO8rawValueACSgSS_tcfc 51:s:11RadrootsKit0A31BackgroundTransferNetworkPolicyO 0:
+12:relationship 11:RadrootsKit 8:memberOf 72:s:11RadrootsKit0A15AppleFileAccessC22releaseStagedBlobLeaseyyAA0aghI0VKF 35:s:11RadrootsKit0A15AppleFileAccessC 0:
12:relationship 11:RadrootsKit 8:memberOf 72:s:11RadrootsKit0A15ScannedDocumentV10outputKindAA0ad13ScannerOutputF0Ovp 35:s:11RadrootsKit0A15ScannedDocumentV 0:
12:relationship 11:RadrootsKit 8:memberOf 72:s:11RadrootsKit0A20AppleExternalActionsC4openyyAA0aD13ActionRequestVYaKF 40:s:11RadrootsKit0A20AppleExternalActionsC 0:
12:relationship 11:RadrootsKit 8:memberOf 72:s:11RadrootsKit0A20AppleExternalActionsC8adaptersAcA0acdE8AdaptersV_tcfc 40:s:11RadrootsKit0A20AppleExternalActionsC 0:
@@ -2042,6 +2054,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 102:s:11RadrootsKit0A34DocumentExportPresentationModifierV4body7contentQr7SwiftUI05_ViewF8_ContentVyACG_tF 14:body(content:) 95:@MainActor func body(content: RadrootsDocumentExportPresentationModifier.Content) -> some View\n
6:symbol 11:RadrootsKit 12:swift.method 102:s:11RadrootsKit0A34DocumentImportPresentationModifierV4body7contentQr7SwiftUI05_ViewF8_ContentVyACG_tF 14:body(content:) 95:@MainActor func body(content: RadrootsDocumentImportPresentationModifier.Content) -> some View\n
6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A10FileAccessP16sweepStagedBlobs9olderThanSayAA0aF13BlobReferenceVG10Foundation4DateV_tKF 28:sweepStagedBlobs(olderThan:) 104:@discardableResult func sweepStagedBlobs(olderThan cutoff: Date) throws -> [RadrootsStagedBlobReference]
+6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A15AppleFileAccessC17installStagedBlob_9referencey10Foundation4DataV_AA0agH9ReferenceVtKF 31:installStagedBlob(_:reference:) 83:func installStagedBlob(_ data: Data, reference: RadrootsStagedBlobReference) throws
6:symbol 11:RadrootsKit 12:swift.method 105:s:11RadrootsKit0A29UnavailableBackgroundTransferV8snapshot3forAA0adE8SnapshotVSgAA0adE10IdentifierV_tYaKF 14:snapshot(for:) 110:func snapshot(for _: RadrootsBackgroundTransferIdentifier) async throws -> RadrootsBackgroundTransferSnapshot?
6:symbol 11:RadrootsKit 12:swift.method 106:s:11RadrootsKit0A11SecureStoreP3put_3for6policyy10Foundation4DataV_AA0acD3KeyVAA0A18SecretAccessPolicyVtKF 18:put(_:for:policy:) 99:func put(_ value: Data, for key: RadrootsSecureStoreKey, policy: RadrootsSecretAccessPolicy) throws
6:symbol 11:RadrootsKit 12:swift.method 107:s:11RadrootsKit0A18BackgroundTransferP6settle_12verificationyAA0acD10IdentifierV_AA0acD12VerificationOtYaKF 23:settle(_:verification:) 130:func settle(_ identifier: RadrootsBackgroundTransferIdentifier, verification: RadrootsBackgroundTransferVerification) async throws
@@ -2056,6 +2069,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 114:s:11RadrootsKit0A35AppleBackgroundTransferFileResolverV4read_12maximumBytes10Foundation4DataVAA0ade5LocalF0O_SitKF 21:read(_:maximumBytes:) 88:func read(_ file: RadrootsBackgroundTransferLocalFile, maximumBytes: Int) throws -> Data
6:symbol 11:RadrootsKit 12:swift.method 116:s:11RadrootsKit0A10FileAccessP9stageBlob_9mediaType12filenameHintAA0a6StagedF9ReferenceV10Foundation4DataV_SSSgALtKF 36:stageBlob(_:mediaType:filenameHint:) 128:@discardableResult func stageBlob(_ data: Data, mediaType: String?, filenameHint: String?) throws -> RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 12:swift.method 117:s:11RadrootsKit0A15IdentityCustodyC014exportPortableC010passphraseAA0aC19PortabilityEnvelopeVAA0aC10PassphraseV_tYaKF 35:exportPortableIdentity(passphrase:) 119:func exportPortableIdentity(passphrase: RadrootsIdentityPassphrase) async throws -> RadrootsIdentityPortabilityEnvelope
+6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A15AppleFileAccessC15leaseStagedBlob_14expectedSHA25610identifierAA0agH5LeaseVAA0agH9ReferenceV_S2StKF 45:leaseStagedBlob(_:expectedSHA256:identifier:) 168:func leaseStagedBlob(_ blob: RadrootsStagedBlobReference, expectedSHA256: String, identifier: String = UUID().uuidString.lowercased()) throws -> RadrootsStagedBlobLease
6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A15IdentityCustodyC06importC0_5label15replaceExistingAA0aC8SnapshotVAA0aC14SecretMaterialV_SSSgSbtYaKF 40:importIdentity(_:label:replaceExisting:) 176:@discardableResult func importIdentity(_ material: RadrootsIdentitySecretMaterial, label: String? = nil, replaceExisting: Bool = false) async throws -> RadrootsIdentitySnapshot
6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A23AppleBackgroundTransferC015handleEventsForD10URLSession10identifier17completionHandlerySS_yyYbctYaF 66:handleEventsForBackgroundURLSession(identifier:completionHandler:) 117:func handleEventsForBackgroundURLSession(identifier: String, completionHandler: @escaping @Sendable () -> Void) async
6:symbol 11:RadrootsKit 12:swift.method 118:s:11RadrootsKit0A29UnavailableBackgroundTransferV6settle_12verificationyAA0adE10IdentifierV_AA0adE12VerificationOtYaKF 23:settle(_:verification:) 121:func settle(_: RadrootsBackgroundTransferIdentifier, verification _: RadrootsBackgroundTransferVerification) async throws
@@ -2124,6 +2138,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.method 70:s:11RadrootsKit0A34UnavailableBackgroundTaskSchedulerV9cancelAllyyYaKF 11:cancelAll() 29:func cancelAll() async throws
6:symbol 11:RadrootsKit 12:swift.method 71:s:11RadrootsKit0A24TelemetryRedactionPolicyV19containsUnsafeValueySbSSF 23:containsUnsafeValue(_:) 49:func containsUnsafeValue(_ value: String) -> Bool
6:symbol 11:RadrootsKit 12:swift.method 71:s:11RadrootsKit0A25BackgroundTransferRequestV6encode2toys7Encoder_p_tKF 11:encode(to:) 43:func encode(to encoder: any Encoder) throws
+6:symbol 11:RadrootsKit 12:swift.method 72:s:11RadrootsKit0A15AppleFileAccessC22releaseStagedBlobLeaseyyAA0aghI0VKF 26:releaseStagedBlobLease(_:) 68:func releaseStagedBlobLease(_ lease: RadrootsStagedBlobLease) throws
6:symbol 11:RadrootsKit 12:swift.method 72:s:11RadrootsKit0A20AppleExternalActionsC4openyyAA0aD13ActionRequestVYaKF 8:open(_:) 64:func open(_ request: RadrootsExternalActionRequest) async throws
6:symbol 11:RadrootsKit 12:swift.method 72:s:11RadrootsKit0A23BackgroundTransferStoreP12saveSnapshotyyAA0acdG0VYaKF 16:saveSnapshot(_:) 78:func saveSnapshot(_ snapshot: RadrootsBackgroundTransferSnapshot) async throws
6:symbol 11:RadrootsKit 12:swift.method 72:s:11RadrootsKit0A26BackgroundTransferProgressV6encode2toys7Encoder_p_tKF 11:encode(to:) 43:func encode(to encoder: any Encoder) throws
@@ -2225,6 +2240,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 12:swift.struct 35:s:11RadrootsKit0A15LocationReadingV 23:RadrootsLocationReading 30:struct RadrootsLocationReading
6:symbol 11:RadrootsKit 12:swift.struct 35:s:11RadrootsKit0A15OpaqueSignatureV 23:RadrootsOpaqueSignature 30:struct RadrootsOpaqueSignature
6:symbol 11:RadrootsKit 12:swift.struct 35:s:11RadrootsKit0A15ScannedDocumentV 23:RadrootsScannedDocument 30:struct RadrootsScannedDocument
+6:symbol 11:RadrootsKit 12:swift.struct 35:s:11RadrootsKit0A15StagedBlobLeaseV 23:RadrootsStagedBlobLease 30:struct RadrootsStagedBlobLease
6:symbol 11:RadrootsKit 12:swift.struct 36:s:11RadrootsKit0A16IdentitySnapshotV 24:RadrootsIdentitySnapshot 31:struct RadrootsIdentitySnapshot
6:symbol 11:RadrootsKit 12:swift.struct 36:s:11RadrootsKit0A16ImportedDocumentV 24:RadrootsImportedDocument 31:struct RadrootsImportedDocument
6:symbol 11:RadrootsKit 12:swift.struct 37:s:11RadrootsKit0A17MediaImportResultV 25:RadrootsMediaImportResult 32:struct RadrootsMediaImportResult
@@ -2312,6 +2328,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 45:s:11RadrootsKit0A11ShareResultV9completedSbvp 9:completed 19:let completed: Bool
6:symbol 11:RadrootsKit 14:swift.property 45:s:11RadrootsKit0A9FileEntryV11isDirectorySbvp 11:isDirectory 21:let isDirectory: Bool
6:symbol 11:RadrootsKit 14:swift.property 46:s:11RadrootsKit0A12ShareRequestV7subjectSSSgvp 7:subject 20:let subject: String?
+6:symbol 11:RadrootsKit 14:swift.property 46:s:11RadrootsKit0A15StagedBlobLeaseV6sha256SSvp 6:sha256 18:let sha256: String
6:symbol 11:RadrootsKit 14:swift.property 47:s:11RadrootsKit0A14TelemetryEventV8categorySSvp 8:category 20:let category: String
6:symbol 11:RadrootsKit 14:swift.property 47:s:11RadrootsKit0A14TelemetryLevelO8severitySivp 8:severity 25:var severity: Int { get }
6:symbol 11:RadrootsKit 14:swift.property 48:s:11RadrootsKit0A10MediaAssetV4kindAA0aC4KindOvp 4:kind 27:let kind: RadrootsMediaKind
@@ -2326,6 +2343,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 50:s:11RadrootsKit0A21SharePresentationLinkV4bodyQrvp 4:body 38:@MainActor var body: some View { get }
6:symbol 11:RadrootsKit 14:swift.property 51:s:11RadrootsKit0A10MediaAssetV9sizeBytess6UInt64Vvp 9:sizeBytes 21:let sizeBytes: UInt64
6:symbol 11:RadrootsKit 14:swift.property 51:s:11RadrootsKit0A13FileReferenceV12relativePathSSvp 12:relativePath 24:let relativePath: String
+6:symbol 11:RadrootsKit 14:swift.property 51:s:11RadrootsKit0A15StagedBlobLeaseV10identifierSSvp 10:identifier 22:let identifier: String
6:symbol 11:RadrootsKit 14:swift.property 51:s:11RadrootsKit0A17ShareTransferItemV7subjectSSSgvp 7:subject 20:let subject: String?
6:symbol 11:RadrootsKit 14:swift.property 51:s:11RadrootsKit0A18LocationCoordinateV8latitudeSdvp 8:latitude 20:let latitude: Double
6:symbol 11:RadrootsKit 14:swift.property 51:s:11RadrootsKit0A18UserPresenceResultV8verifiedSbvp 8:verified 18:let verified: Bool
@@ -2367,6 +2385,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A10MediaAssetV4fileAA0A13FileReferenceVvp 4:file 31:let file: RadrootsFileReference
6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A15LocationReadingV14altitudeMetersSdSgvp 14:altitudeMeters 27:let altitudeMeters: Double?
6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A15OpaqueSignatureV16debugDescriptionSSvp 16:debugDescription 36:var debugDescription: String { get }
+6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A15StagedBlobLeaseV16debugDescriptionSSvp 16:debugDescription 36:var debugDescription: String { get }
6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A16ImportedDocumentV9sizeBytess6UInt64Vvp 9:sizeBytes 21:let sizeBytes: UInt64
6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A18MediaCaptureResultV4itemAA0aC5AssetVvp 4:item 28:let item: RadrootsMediaAsset
6:symbol 11:RadrootsKit 14:swift.property 57:s:11RadrootsKit0A21ExportDocumentRequestV9mediaTypeSSSgvp 9:mediaType 22:let mediaType: String?
@@ -2381,6 +2400,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 58:s:11RadrootsKit0A22PreparedExportDocumentV10preparedIDSSvp 10:preparedID 22:let preparedID: String
6:symbol 11:RadrootsKit 14:swift.property 58:s:11RadrootsKit0A22PreparedExportDocumentV9mediaTypeSSSgvp 9:mediaType 22:let mediaType: String?
6:symbol 11:RadrootsKit 14:swift.property 59:s:11RadrootsKit0A14TelemetryEventV6fieldsSayAA0aC5FieldVGvp 6:fields 36:let fields: [RadrootsTelemetryField]
+6:symbol 11:RadrootsKit 14:swift.property 59:s:11RadrootsKit0A15StagedBlobLeaseV4blobAA0acD9ReferenceVvp 4:blob 37:let blob: RadrootsStagedBlobReference
6:symbol 11:RadrootsKit 14:swift.property 59:s:11RadrootsKit0A16ImportedDocumentV17suggestedFilenameSSvp 17:suggestedFilename 29:let suggestedFilename: String
6:symbol 11:RadrootsKit 14:swift.property 59:s:11RadrootsKit0A17OpaqueSignRequestV16debugDescriptionSSvp 16:debugDescription 36:var debugDescription: String { get }
6:symbol 11:RadrootsKit 14:swift.property 59:s:11RadrootsKit0A18MediaPickerSupportV15importAvailableSbvp 15:importAvailable 25:let importAvailable: Bool
@@ -2394,6 +2414,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 14:swift.property 60:s:11RadrootsKit0A20IdentityPublicRecordV14identityHandleSSvp 14:identityHandle 26:let identityHandle: String
6:symbol 11:RadrootsKit 14:swift.property 60:s:11RadrootsKit0A21BackgroundTaskRequestV4kindAA0acD4KindOvp 4:kind 36:let kind: RadrootsBackgroundTaskKind
6:symbol 11:RadrootsKit 14:swift.property 60:s:11RadrootsKit0A26VerifiedArtifactDescriptorV9mediaTypeSSvp 9:mediaType 21:let mediaType: String
+6:symbol 11:RadrootsKit 14:swift.property 61:s:11RadrootsKit0A15StagedBlobLeaseV7fileURL10Foundation0G0Vvp 7:fileURL 16:let fileURL: URL
6:symbol 11:RadrootsKit 14:swift.property 61:s:11RadrootsKit0A17MediaImportResultV5itemsSayAA0aC5AssetVGvp 5:items 31:let items: [RadrootsMediaAsset]
6:symbol 11:RadrootsKit 14:swift.property 61:s:11RadrootsKit0A17UserPresenceErrorO16errorDescriptionSSSgvp 16:errorDescription 37:var errorDescription: String? { get }
6:symbol 11:RadrootsKit 14:swift.property 61:s:11RadrootsKit0A18UserPresenceResultV6policyAA0acD6PolicyOvp 6:policy 38:let policy: RadrootsUserPresencePolicy
@@ -2889,7 +2910,7 @@ radroots.apple-kit.public-api.v1
6:symbol 11:RadrootsKit 17:swift.type.method 83:s:11RadrootsKit0A16ImportedDocumentV21normalizedOriginalURLy10Foundation0G0VSgAHKFZ 25:normalizedOriginalURL(_:) 69:static func normalizedOriginalURL(_ originalURL: URL?) throws -> URL?
6:symbol 11:RadrootsKit 17:swift.type.method 83:s:11RadrootsKit0A22PreparedExportDocumentV17normalizedFileURLy10Foundation0H0VAGKFZ 21:normalizedFileURL(_:) 59:static func normalizedFileURL(_ fileURL: URL) throws -> URL
6:symbol 11:RadrootsKit 17:swift.type.method 84:s:11RadrootsKit0A24ExternalActionValidationO16normalizedWebURLy10Foundation0H0VSSKFZ 20:normalizedWebURL(_:) 59:static func normalizedWebURL(_ value: String) throws -> URL
-6:symbol 11:RadrootsKit 17:swift.type.method 85:s:11RadrootsKit0A14AppleFileRootsV17normalizedRootURL_5field10Foundation0H0VAH_SStKFZ 27:normalizedRootURL(_:field:) 74:static func normalizedRootURL(_ rootURL: URL, field: String) throws -> URL
+6:symbol 11:RadrootsKit 17:swift.type.method 85:s:11RadrootsKit0A14AppleFileRootsV17normalizedRootURL_5field10Foundation0H0VAH_SStKFZ 27:normalizedRootURL(_:field:) 76:static func normalizedRootURL(_ rootURL: URL, field _: String) throws -> URL
6:symbol 11:RadrootsKit 17:swift.type.method 86:s:11RadrootsKit0A29ApplePermissionStatusAdaptersV019currentNotificationE0AA0adE0OyYaFZ 27:currentNotificationStatus() 73:static func currentNotificationStatus() async -> RadrootsPermissionStatus
6:symbol 11:RadrootsKit 17:swift.type.method 87:s:11RadrootsKit0A21DocumentImportRequestV22normalizedContentKindsySayAA0acG4KindOGAGKFZ 26:normalizedContentKinds(_:) 128:static func normalizedContentKinds(_ allowedContentKinds: [RadrootsDocumentContentKind]) throws -> [RadrootsDocumentContentKind]
6:symbol 11:RadrootsKit 17:swift.type.method 87:s:11RadrootsKit0A24ExternalActionValidationO18normalizedNostrURIy10Foundation3URLVSSKFZ 22:normalizedNostrURI(_:) 61:static func normalizedNostrURI(_ value: String) throws -> URL