apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README | LICENSE

commit 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:
ASources/RadrootsKit/RadrootsAppleFileAccess.swift | 315+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleFileOperations.swift | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASources/RadrootsKit/RadrootsAppleFileRoots.swift | 188+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsAppleMediaPreparation.swift | 20+++++++++-----------
ASources/RadrootsKit/RadrootsAtomicFile.swift | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MSources/RadrootsKit/RadrootsFileAccess.swift | 633+------------------------------------------------------------------------------
ASources/RadrootsKit/RadrootsStagedBlobLease.swift | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ATests/RadrootsKitTests/RadrootsDurableStagingTests.swift | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/api_baselines/apple_kit.txt | 23++++++++++++++++++++++-
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