RadrootsAppleFileRoots.swift (7303B)
1 import Darwin 2 import Foundation 3 4 public struct RadrootsAppleFileRoots: Sendable, Equatable { 5 public let appIdentifier: String 6 public let dataRoot: URL 7 public let cacheRoot: URL 8 public let temporaryRoot: URL 9 public let logsRoot: URL 10 public let stagedBlobsRoot: URL 11 12 public init( 13 appIdentifier: String, 14 dataRoot: URL, 15 cacheRoot: URL, 16 temporaryRoot: URL, 17 logsRoot: URL? = nil, 18 stagedBlobsRoot: URL? = nil 19 ) throws { 20 let normalizedAppIdentifier = try Self.normalizedAppIdentifier(appIdentifier) 21 let normalizedDataRoot = try Self.normalizedRootURL(dataRoot, field: "dataRoot") 22 let normalizedCacheRoot = try Self.normalizedRootURL(cacheRoot, field: "cacheRoot") 23 let normalizedTemporaryRoot = try Self.normalizedRootURL(temporaryRoot, field: "temporaryRoot") 24 self.appIdentifier = normalizedAppIdentifier 25 self.dataRoot = normalizedDataRoot 26 self.cacheRoot = normalizedCacheRoot 27 self.temporaryRoot = normalizedTemporaryRoot 28 self.logsRoot = try Self.normalizedRootURL( 29 logsRoot ?? normalizedCacheRoot.appendingPathComponent("Logs", isDirectory: true), 30 field: "logsRoot" 31 ) 32 self.stagedBlobsRoot = try Self.normalizedRootURL( 33 stagedBlobsRoot 34 ?? normalizedTemporaryRoot.appendingPathComponent("staged_blobs", isDirectory: true), 35 field: "stagedBlobsRoot" 36 ) 37 } 38 39 public static func appContainer( 40 appIdentifier: String, 41 fileManager: FileManager = .default 42 ) throws -> Self { 43 do { 44 let normalizedAppIdentifier = try normalizedAppIdentifier(appIdentifier) 45 let dataBaseURL = try canonicalExistingDirectory( 46 fileManager.url( 47 for: .applicationSupportDirectory, 48 in: .userDomainMask, 49 appropriateFor: nil, 50 create: true 51 ) 52 ) 53 let cacheBaseURL = try canonicalExistingDirectory( 54 fileManager.url( 55 for: .cachesDirectory, 56 in: .userDomainMask, 57 appropriateFor: nil, 58 create: true 59 ) 60 ) 61 let dataRoot = dataBaseURL.appendingPathComponent(normalizedAppIdentifier, isDirectory: true) 62 let cacheRoot = cacheBaseURL.appendingPathComponent( 63 normalizedAppIdentifier, isDirectory: true 64 ) 65 let temporaryRoot = try canonicalExistingDirectory(fileManager.temporaryDirectory) 66 .appendingPathComponent(normalizedAppIdentifier, isDirectory: true) 67 return try Self( 68 appIdentifier: normalizedAppIdentifier, 69 dataRoot: dataRoot, 70 cacheRoot: cacheRoot, 71 temporaryRoot: temporaryRoot 72 ) 73 } catch let error as RadrootsAppleFileError { 74 throw error 75 } catch { 76 throw RadrootsAppleFileError.permanentFailure 77 } 78 } 79 80 public func root(for scope: RadrootsFileScope) -> URL { 81 switch scope { 82 case .data: 83 dataRoot 84 case .cache: 85 cacheRoot 86 case .temporary: 87 temporaryRoot 88 case .logs: 89 logsRoot 90 } 91 } 92 93 public func resolvedURL( 94 for file: RadrootsFileReference, 95 allowRootDirectory: Bool = false 96 ) throws -> URL { 97 let rootURL = root(for: file.scope) 98 let trimmedPath = file.relativePath.trimmingCharacters(in: .whitespacesAndNewlines) 99 if trimmedPath.isEmpty { 100 if allowRootDirectory { 101 return rootURL 102 } 103 throw RadrootsAppleFileError.invalidRequest 104 } 105 if NSString(string: trimmedPath).isAbsolutePath { 106 throw RadrootsAppleFileError.invalidRequest 107 } 108 109 let components = try Self.normalizedRelativeComponents(trimmedPath) 110 let candidateURL = components.isEmpty 111 ? rootURL : rootURL.appendingPathComponent(components.joined(separator: "/")) 112 if candidateURL.path == rootURL.path { 113 if allowRootDirectory { 114 return candidateURL 115 } 116 throw RadrootsAppleFileError.invalidRequest 117 } 118 guard candidateURL.path.hasPrefix(rootURL.path + "/") else { 119 throw RadrootsAppleFileError.invalidRequest 120 } 121 return candidateURL 122 } 123 124 private static func normalizedRelativeComponents(_ trimmedPath: String) throws -> [String] { 125 var components: [String] = [] 126 for component in trimmedPath.split(separator: "/", omittingEmptySubsequences: true) { 127 if component == "." { 128 continue 129 } 130 if component == ".." { 131 guard !components.isEmpty else { throw RadrootsAppleFileError.invalidRequest } 132 components.removeLast() 133 } else { 134 guard !component.utf8.contains(0) else { throw RadrootsAppleFileError.invalidRequest } 135 components.append(String(component)) 136 } 137 } 138 return components 139 } 140 141 public func stagedBlobURL(for blob: RadrootsStagedBlobReference) throws -> URL { 142 let normalizedBlobID = try RadrootsStagedBlobReference.normalizedBlobID(blob.blobID) 143 // Foundation standardization can rewrite an existing /private/var path 144 // back to the /var symlink alias. Keep the already-admitted root bytes. 145 return stagedBlobsRoot.appendingPathComponent(normalizedBlobID, isDirectory: false) 146 } 147 148 public static func normalizedAppIdentifier(_ appIdentifier: String) throws -> String { 149 let trimmed = appIdentifier.trimmingCharacters(in: .whitespacesAndNewlines) 150 guard !trimmed.isEmpty else { 151 throw RadrootsAppleFileError.invalidRequest 152 } 153 return trimmed 154 } 155 156 public static func normalizedRootURL(_ rootURL: URL, field _: String) throws -> URL { 157 guard rootURL.isFileURL else { 158 throw RadrootsAppleFileError.invalidRequest 159 } 160 guard rootURL.path.hasPrefix("/"), !rootURL.path.utf8.contains(0) else { 161 throw RadrootsAppleFileError.invalidRequest 162 } 163 var components: [String] = [] 164 for component in rootURL.path.split(separator: "/", omittingEmptySubsequences: true) { 165 if component == "." { 166 continue 167 } 168 if component == ".." { 169 if !components.isEmpty { 170 components.removeLast() 171 } 172 } else { 173 components.append(String(component)) 174 } 175 } 176 return URL(fileURLWithPath: "/" + components.joined(separator: "/"), isDirectory: true) 177 } 178 179 private static func canonicalExistingDirectory(_ directory: URL) throws -> URL { 180 guard directory.isFileURL, 181 let pointer = directory.path.withCString({ Darwin.realpath($0, nil) }) 182 else { 183 throw RadrootsAppleFileError.permanentFailure 184 } 185 defer { Darwin.free(pointer) } 186 return URL(fileURLWithPath: String(cString: pointer), isDirectory: true) 187 } 188 }