Keep using Photos.app like you always do. Attic quietly backs up your originals and edits to an S3 bucket you control. One-way, append-only.
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge refactor/lightweight-s3-client: replace aws-sdk-swift with lightweight URLSession S3 client

+547 -128
+2 -2
Package.swift
··· 9 9 .library(name: "AtticCore", targets: ["AtticCore"]), 10 10 ], 11 11 dependencies: [ 12 - .package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.6.0"), 12 + .package(url: "https://github.com/adam-fowler/aws-signer-v4.git", from: "3.0.0"), 13 13 .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"), 14 14 .package(url: "https://github.com/tijs/ladder.git", from: "0.3.4"), 15 15 ], ··· 17 17 .target( 18 18 name: "AtticCore", 19 19 dependencies: [ 20 - .product(name: "AWSS3", package: "aws-sdk-swift"), 20 + .product(name: "AWSSigner", package: "aws-signer-v4"), 21 21 .product(name: "LadderKit", package: "ladder"), 22 22 ], 23 23 path: "Sources/AtticCore"
+3 -3
Sources/AtticCLI/Dependencies.swift
··· 23 23 } 24 24 25 25 /// Create an S3 client from config + credentials. 26 - static func makeS3Client(config: AtticConfig, credentials: S3Credentials) throws -> AWSS3Client { 27 - try AWSS3Client( 26 + static func makeS3Client(config: AtticConfig, credentials: S3Credentials) throws -> URLSessionS3Client { 27 + try URLSessionS3Client( 28 28 credentials: credentials, 29 29 bucket: config.bucket, 30 30 endpoint: config.endpoint, ··· 36 36 /// Create the full set of backup dependencies. 37 37 static func makeBackupDeps() throws -> ( 38 38 config: AtticConfig, 39 - s3: AWSS3Client, 39 + s3: URLSessionS3Client, 40 40 manifestStore: S3ManifestStore 41 41 ) { 42 42 let config = try loadConfig()
-123
Sources/AtticCore/AWSS3Client.swift
··· 1 - import Foundation 2 - import AWSS3 3 - import SmithyIdentity 4 - import Smithy 5 - 6 - /// S3 provider using the official AWS SDK for Swift. 7 - public struct AWSS3Client: S3Providing { 8 - private let client: S3Client 9 - private let bucket: String 10 - 11 - public init( 12 - credentials: S3Credentials, 13 - bucket: String, 14 - endpoint: String, 15 - region: String, 16 - pathStyle: Bool 17 - ) throws { 18 - let awsCredentials = AWSCredentialIdentity( 19 - accessKey: credentials.accessKeyId, 20 - secret: credentials.secretAccessKey 21 - ) 22 - let config = try S3Client.S3ClientConfig( 23 - awsCredentialIdentityResolver: StaticAWSCredentialIdentityResolver(awsCredentials), 24 - region: region, 25 - forcePathStyle: pathStyle, 26 - endpoint: endpoint 27 - ) 28 - self.client = S3Client(config: config) 29 - self.bucket = bucket 30 - } 31 - 32 - public func putObject(key: String, body: Data, contentType: String?) async throws { 33 - let input = PutObjectInput( 34 - body: .data(body), 35 - bucket: bucket, 36 - contentType: contentType, 37 - key: key 38 - ) 39 - _ = try await client.putObject(input: input) 40 - } 41 - 42 - public func putObject(key: String, fileURL: URL, contentType: String?) async throws { 43 - // Use memory-mapped I/O to avoid loading the entire file into heap. 44 - // The kernel pages in only what the network layer reads. 45 - let data = try Data(contentsOf: fileURL, options: .mappedIfSafe) 46 - let input = PutObjectInput( 47 - body: .data(data), 48 - bucket: bucket, 49 - contentType: contentType, 50 - key: key 51 - ) 52 - _ = try await client.putObject(input: input) 53 - } 54 - 55 - public func getObject(key: String) async throws -> Data { 56 - let input = GetObjectInput(bucket: bucket, key: key) 57 - let output = try await client.getObject(input: input) 58 - guard let body = output.body else { 59 - throw S3ClientError.emptyResponse(key) 60 - } 61 - return try await body.readData() ?? Data() 62 - } 63 - 64 - public func headObject(key: String) async throws -> S3ObjectMeta? { 65 - do { 66 - let input = HeadObjectInput(bucket: bucket, key: key) 67 - let output = try await client.headObject(input: input) 68 - return S3ObjectMeta( 69 - contentLength: Int(output.contentLength ?? 0), 70 - contentType: output.contentType 71 - ) 72 - } catch is AWSS3.NotFound { 73 - return nil 74 - } catch { 75 - // Some S3-compatible providers return different error types for 404 76 - let description = String(describing: error) 77 - if description.contains("NotFound") || description.contains("NoSuchKey") 78 - || description.contains("404") { 79 - return nil 80 - } 81 - throw error 82 - } 83 - } 84 - 85 - public func listObjects(prefix: String) async throws -> [S3ListObject] { 86 - var results: [S3ListObject] = [] 87 - var continuationToken: String? 88 - 89 - repeat { 90 - let input = ListObjectsV2Input( 91 - bucket: bucket, 92 - continuationToken: continuationToken, 93 - prefix: prefix 94 - ) 95 - let output = try await client.listObjectsV2(input: input) 96 - 97 - for object in output.contents ?? [] { 98 - if let key = object.key { 99 - results.append(S3ListObject( 100 - key: key, 101 - size: Int(object.size ?? 0) 102 - )) 103 - } 104 - } 105 - 106 - continuationToken = output.nextContinuationToken 107 - } while continuationToken != nil 108 - 109 - return results 110 - } 111 - } 112 - 113 - /// Errors from the S3 client wrapper. 114 - public enum S3ClientError: Error, CustomStringConvertible { 115 - case emptyResponse(String) 116 - 117 - public var description: String { 118 - switch self { 119 - case .emptyResponse(let key): 120 - "Empty response body for S3 key: \(key)" 121 - } 122 - } 123 - }
+10
Sources/AtticCore/S3ManifestStore.swift
··· 76 76 } 77 77 78 78 private func isNotFoundError(_ error: Error) -> Bool { 79 + if let s3Error = error as? S3ClientError { 80 + switch s3Error { 81 + case .httpError(404, _): 82 + return true 83 + case .s3Error(let code, _): 84 + return code == "NoSuchKey" || code == "NotFound" 85 + default: 86 + break 87 + } 88 + } 79 89 let description = String(describing: error) 80 90 return description.contains("NotFound") || description.contains("NoSuchKey") 81 91 || description.contains("notFound")
+116
Sources/AtticCore/S3XMLParsing.swift
··· 1 + import Foundation 2 + 3 + /// Parsed result from an S3 ListObjectsV2 response. 4 + struct ListObjectsV2Result { 5 + var objects: [S3ListObject] = [] 6 + var isTruncated: Bool = false 7 + var nextContinuationToken: String? 8 + } 9 + 10 + /// Parse an S3 ListObjectsV2 XML response. 11 + func parseListObjectsV2(data: Data) -> ListObjectsV2Result { 12 + let parser = ListObjectsV2Parser() 13 + let xmlParser = XMLParser(data: data) 14 + xmlParser.delegate = parser 15 + xmlParser.parse() 16 + return parser.result 17 + } 18 + 19 + /// Parse an S3 error XML response, returning (code, message). 20 + func parseS3Error(data: Data) -> (code: String, message: String)? { 21 + let parser = S3ErrorParser() 22 + let xmlParser = XMLParser(data: data) 23 + xmlParser.delegate = parser 24 + xmlParser.parse() 25 + guard !parser.code.isEmpty else { return nil } 26 + return (parser.code, parser.message) 27 + } 28 + 29 + // MARK: - ListObjectsV2 Parser 30 + 31 + private class ListObjectsV2Parser: NSObject, XMLParserDelegate { 32 + var result = ListObjectsV2Result() 33 + private var currentElement = "" 34 + private var currentText = "" 35 + private var inContents = false 36 + private var currentKey = "" 37 + private var currentSize = 0 38 + 39 + func parser( 40 + _ parser: XMLParser, didStartElement elementName: String, 41 + namespaceURI: String?, qualifiedName: String?, 42 + attributes: [String: String] = [:] 43 + ) { 44 + currentElement = elementName 45 + currentText = "" 46 + if elementName == "Contents" { 47 + inContents = true 48 + currentKey = "" 49 + currentSize = 0 50 + } 51 + } 52 + 53 + func parser(_ parser: XMLParser, foundCharacters string: String) { 54 + currentText += string 55 + } 56 + 57 + func parser( 58 + _ parser: XMLParser, didEndElement elementName: String, 59 + namespaceURI: String?, qualifiedName: String? 60 + ) { 61 + let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) 62 + 63 + if inContents { 64 + switch elementName { 65 + case "Key": currentKey = text 66 + case "Size": currentSize = Int(text) ?? 0 67 + case "Contents": 68 + if !currentKey.isEmpty { 69 + result.objects.append(S3ListObject(key: currentKey, size: currentSize)) 70 + } 71 + inContents = false 72 + default: break 73 + } 74 + } else { 75 + switch elementName { 76 + case "IsTruncated": result.isTruncated = (text == "true") 77 + case "NextContinuationToken": result.nextContinuationToken = text 78 + default: break 79 + } 80 + } 81 + } 82 + } 83 + 84 + // MARK: - S3 Error Parser 85 + 86 + private class S3ErrorParser: NSObject, XMLParserDelegate { 87 + var code = "" 88 + var message = "" 89 + private var currentElement = "" 90 + private var currentText = "" 91 + 92 + func parser( 93 + _ parser: XMLParser, didStartElement elementName: String, 94 + namespaceURI: String?, qualifiedName: String?, 95 + attributes: [String: String] = [:] 96 + ) { 97 + currentElement = elementName 98 + currentText = "" 99 + } 100 + 101 + func parser(_ parser: XMLParser, foundCharacters string: String) { 102 + currentText += string 103 + } 104 + 105 + func parser( 106 + _ parser: XMLParser, didEndElement elementName: String, 107 + namespaceURI: String?, qualifiedName: String? 108 + ) { 109 + let text = currentText.trimmingCharacters(in: .whitespacesAndNewlines) 110 + switch elementName { 111 + case "Code": code = text 112 + case "Message": message = text 113 + default: break 114 + } 115 + } 116 + }
+240
Sources/AtticCore/URLSessionS3Client.swift
··· 1 + import Foundation 2 + import AWSSigner 3 + import NIOHTTP1 4 + 5 + /// S3 client using URLSession and aws-signer-v4. 6 + /// 7 + /// Replaces the full AWS SDK with a lightweight implementation that only 8 + /// needs URLSession (built-in) and SigV4 signing. Supports S3-compatible 9 + /// providers via custom endpoints and path-style URLs. 10 + public struct URLSessionS3Client: S3Providing, @unchecked Sendable { 11 + private let bucket: String 12 + private let endpoint: URL 13 + private let region: String 14 + private let pathStyle: Bool 15 + private let signer: AWSSigner 16 + private let session: URLSession 17 + 18 + public init( 19 + credentials: S3Credentials, 20 + bucket: String, 21 + endpoint: String, 22 + region: String, 23 + pathStyle: Bool 24 + ) throws { 25 + guard let endpointURL = URL(string: endpoint) else { 26 + throw S3ClientError.unexpectedResponse("Invalid endpoint URL: \(endpoint)") 27 + } 28 + self.bucket = bucket 29 + self.endpoint = endpointURL 30 + self.region = region 31 + self.pathStyle = pathStyle 32 + 33 + let creds = StaticCredential( 34 + accessKeyId: credentials.accessKeyId, 35 + secretAccessKey: credentials.secretAccessKey 36 + ) 37 + self.signer = AWSSigner(credentials: creds, name: "s3", region: region) 38 + 39 + let config = URLSessionConfiguration.default 40 + config.timeoutIntervalForRequest = 30 41 + config.timeoutIntervalForResource = 600 42 + self.session = URLSession(configuration: config) 43 + } 44 + 45 + // MARK: - S3Providing 46 + 47 + public func putObject(key: String, body: Data, contentType: String?) async throws { 48 + var request = try makeRequest(key: key, method: "PUT") 49 + if let contentType { 50 + request.setValue(contentType, forHTTPHeaderField: "Content-Type") 51 + } 52 + request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length") 53 + signRequest(&request, hasBody: true) 54 + 55 + let (data, response) = try await session.upload(for: request, from: body) 56 + try checkResponse(response, data: data, key: key) 57 + } 58 + 59 + public func putObject(key: String, fileURL: URL, contentType: String?) async throws { 60 + let fileSize = try fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0 61 + 62 + var request = try makeRequest(key: key, method: "PUT") 63 + if let contentType { 64 + request.setValue(contentType, forHTTPHeaderField: "Content-Type") 65 + } 66 + request.setValue("\(fileSize)", forHTTPHeaderField: "Content-Length") 67 + signRequest(&request, hasBody: true) 68 + 69 + let (data, response) = try await session.upload(for: request, fromFile: fileURL) 70 + try checkResponse(response, data: data, key: key) 71 + } 72 + 73 + public func getObject(key: String) async throws -> Data { 74 + var request = try makeRequest(key: key, method: "GET") 75 + signRequest(&request) 76 + 77 + let (data, response) = try await session.data(for: request) 78 + try checkResponse(response, data: data, key: key) 79 + return data 80 + } 81 + 82 + public func headObject(key: String) async throws -> S3ObjectMeta? { 83 + var request = try makeRequest(key: key, method: "HEAD") 84 + signRequest(&request) 85 + 86 + let (_, response) = try await session.data(for: request) 87 + guard let http = response as? HTTPURLResponse else { 88 + throw S3ClientError.unexpectedResponse("Not an HTTP response") 89 + } 90 + 91 + if http.statusCode == 404 || http.statusCode == 403 { 92 + // Some S3-compatible providers return 403 for missing objects 93 + return nil 94 + } 95 + 96 + if http.statusCode >= 400 { 97 + throw S3ClientError.httpError(http.statusCode, "HEAD \(key)") 98 + } 99 + 100 + let contentLength = Int(http.value(forHTTPHeaderField: "Content-Length") ?? "0") ?? 0 101 + let contentType = http.value(forHTTPHeaderField: "Content-Type") 102 + return S3ObjectMeta(contentLength: contentLength, contentType: contentType) 103 + } 104 + 105 + public func listObjects(prefix: String) async throws -> [S3ListObject] { 106 + var results: [S3ListObject] = [] 107 + var continuationToken: String? 108 + 109 + repeat { 110 + var components = URLComponents() 111 + components.queryItems = [ 112 + URLQueryItem(name: "list-type", value: "2"), 113 + URLQueryItem(name: "prefix", value: prefix), 114 + ] 115 + if let token = continuationToken { 116 + components.queryItems?.append(URLQueryItem(name: "continuation-token", value: token)) 117 + } 118 + 119 + var request = try makeRequest(key: "", method: "GET") 120 + // Append query string to the bucket-level URL 121 + guard let baseURL = request.url, 122 + var fullComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) 123 + else { 124 + throw S3ClientError.unexpectedResponse("Failed to construct list URL") 125 + } 126 + fullComponents.queryItems = components.queryItems 127 + request.url = fullComponents.url 128 + signRequest(&request) 129 + 130 + let (data, response) = try await session.data(for: request) 131 + try checkResponse(response, data: data, key: "list:\(prefix)") 132 + 133 + let parsed = parseListObjectsV2(data: data) 134 + results.append(contentsOf: parsed.objects) 135 + continuationToken = parsed.isTruncated ? parsed.nextContinuationToken : nil 136 + } while continuationToken != nil 137 + 138 + return results 139 + } 140 + 141 + // MARK: - Helpers 142 + 143 + private func makeRequest(key: String, method: String) throws -> URLRequest { 144 + let url: URL 145 + if pathStyle { 146 + // Path-style: endpoint/bucket/key 147 + if key.isEmpty { 148 + url = endpoint.appendingPathComponent(bucket) 149 + } else { 150 + url = endpoint.appendingPathComponent(bucket).appendingPathComponent(key) 151 + } 152 + } else { 153 + // Virtual-hosted: bucket.host/key 154 + let host = endpoint.host ?? "" 155 + let scheme = endpoint.scheme ?? "https" 156 + let port = endpoint.port.map { ":\($0)" } ?? "" 157 + let bucketHost = "\(scheme)://\(bucket).\(host)\(port)" 158 + guard let baseURL = URL(string: bucketHost) else { 159 + throw S3ClientError.unexpectedResponse( 160 + "Invalid virtual-hosted URL: \(bucketHost)") 161 + } 162 + if key.isEmpty { 163 + url = baseURL 164 + } else { 165 + url = baseURL.appendingPathComponent(key) 166 + } 167 + } 168 + 169 + var request = URLRequest(url: url) 170 + request.httpMethod = method 171 + request.setValue(url.host, forHTTPHeaderField: "Host") 172 + return request 173 + } 174 + 175 + private func signRequest(_ request: inout URLRequest, hasBody: Bool = false) { 176 + guard let url = request.url else { return } 177 + 178 + let method = HTTPMethod(rawValue: request.httpMethod ?? "GET") 179 + 180 + // Collect existing headers 181 + var nioHeaders = HTTPHeaders() 182 + if let allHeaders = request.allHTTPHeaderFields { 183 + for (name, value) in allHeaders { 184 + nioHeaders.add(name: name, value: value) 185 + } 186 + } 187 + 188 + // For uploads, use UNSIGNED-PAYLOAD to avoid hashing large files. 189 + // For bodiless requests (GET/HEAD), use an empty body so the signer 190 + // computes the correct empty-payload hash — some S3-compatible 191 + // providers reject UNSIGNED-PAYLOAD on non-PUT requests. 192 + let body: AWSSigner.BodyData = hasBody 193 + ? .string("UNSIGNED-PAYLOAD") 194 + : .string("") 195 + 196 + let signedHeaders = signer.signHeaders( 197 + url: url, 198 + method: method, 199 + headers: nioHeaders, 200 + body: body, 201 + date: Date() 202 + ) 203 + 204 + // Apply signed headers back to the URLRequest 205 + for (name, value) in signedHeaders { 206 + request.setValue(value, forHTTPHeaderField: name) 207 + } 208 + } 209 + 210 + private func checkResponse(_ response: URLResponse, data: Data, key: String) throws { 211 + guard let http = response as? HTTPURLResponse else { 212 + throw S3ClientError.unexpectedResponse("Not an HTTP response") 213 + } 214 + 215 + guard http.statusCode >= 200 && http.statusCode < 300 else { 216 + if let s3Error = parseS3Error(data: data) { 217 + throw S3ClientError.s3Error(code: s3Error.code, message: s3Error.message) 218 + } 219 + throw S3ClientError.httpError(http.statusCode, key) 220 + } 221 + } 222 + } 223 + 224 + /// Errors from the S3 client. 225 + public enum S3ClientError: Error, CustomStringConvertible { 226 + case httpError(Int, String) 227 + case unexpectedResponse(String) 228 + case s3Error(code: String, message: String) 229 + 230 + public var description: String { 231 + switch self { 232 + case .httpError(let status, let key): 233 + "S3 HTTP \(status) for key: \(key)" 234 + case .unexpectedResponse(let msg): 235 + "Unexpected response: \(msg)" 236 + case .s3Error(let code, let message): 237 + "S3 error \(code): \(message)" 238 + } 239 + } 240 + }
+38
Tests/AtticCoreTests/S3ManifestStoreTests.swift
··· 62 62 } 63 63 } 64 64 65 + /// S3 provider that throws a configurable error on getObject. 66 + private actor ThrowingS3Provider: S3Providing { 67 + let error: Error 68 + 69 + init(error: Error) { 70 + self.error = error 71 + } 72 + 73 + func putObject(key: String, body: Data, contentType: String?) async throws {} 74 + func putObject(key: String, fileURL: URL, contentType: String?) async throws {} 75 + func getObject(key: String) async throws -> Data { throw error } 76 + func headObject(key: String) async throws -> S3ObjectMeta? { nil } 77 + func listObjects(prefix: String) async throws -> [S3ListObject] { [] } 78 + } 79 + 65 80 @Suite("S3ManifestStore") 66 81 struct S3ManifestStoreTests { 67 82 @Test func loadReturnsEmptyWhenKeyMissing() async throws { ··· 69 84 let store = S3ManifestStore(s3: s3) 70 85 let manifest = try await store.load() 71 86 #expect(manifest.entries.isEmpty) 87 + } 88 + 89 + @Test func loadReturnsEmptyOnHTTP404() async throws { 90 + let s3 = ThrowingS3Provider(error: S3ClientError.httpError(404, "manifest.json")) 91 + let store = S3ManifestStore(s3: s3) 92 + let manifest = try await store.load() 93 + #expect(manifest.entries.isEmpty) 94 + } 95 + 96 + @Test func loadReturnsEmptyOnS3NoSuchKey() async throws { 97 + let s3 = ThrowingS3Provider( 98 + error: S3ClientError.s3Error(code: "NoSuchKey", message: "Not found")) 99 + let store = S3ManifestStore(s3: s3) 100 + let manifest = try await store.load() 101 + #expect(manifest.entries.isEmpty) 102 + } 103 + 104 + @Test func loadThrowsOnNon404HTTPError() async throws { 105 + let s3 = ThrowingS3Provider(error: S3ClientError.httpError(403, "manifest.json")) 106 + let store = S3ManifestStore(s3: s3) 107 + await #expect(throws: S3ClientError.self) { 108 + _ = try await store.load() 109 + } 72 110 } 73 111 74 112 @Test func saveAndLoadRoundTrip() async throws {
+138
Tests/AtticCoreTests/S3XMLParsingTests.swift
··· 1 + import Testing 2 + import Foundation 3 + @testable import AtticCore 4 + 5 + @Suite("S3XMLParsing") 6 + struct S3XMLParsingTests { 7 + // MARK: - ListObjectsV2 8 + 9 + @Test func parsesListObjectsV2Response() { 10 + let xml = """ 11 + <?xml version="1.0" encoding="UTF-8"?> 12 + <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> 13 + <Name>my-bucket</Name> 14 + <Prefix>originals/</Prefix> 15 + <IsTruncated>false</IsTruncated> 16 + <Contents> 17 + <Key>originals/2024/01/abc.heic</Key> 18 + <Size>1234567</Size> 19 + </Contents> 20 + <Contents> 21 + <Key>originals/2024/02/def.jpg</Key> 22 + <Size>89012</Size> 23 + </Contents> 24 + </ListBucketResult> 25 + """ 26 + let result = parseListObjectsV2(data: Data(xml.utf8)) 27 + 28 + #expect(result.objects.count == 2) 29 + #expect(result.objects[0].key == "originals/2024/01/abc.heic") 30 + #expect(result.objects[0].size == 1234567) 31 + #expect(result.objects[1].key == "originals/2024/02/def.jpg") 32 + #expect(result.objects[1].size == 89012) 33 + #expect(result.isTruncated == false) 34 + #expect(result.nextContinuationToken == nil) 35 + } 36 + 37 + @Test func parsesTruncatedResponseWithContinuationToken() { 38 + let xml = """ 39 + <?xml version="1.0" encoding="UTF-8"?> 40 + <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> 41 + <IsTruncated>true</IsTruncated> 42 + <NextContinuationToken>abc123token</NextContinuationToken> 43 + <Contents> 44 + <Key>file1.txt</Key> 45 + <Size>100</Size> 46 + </Contents> 47 + </ListBucketResult> 48 + """ 49 + let result = parseListObjectsV2(data: Data(xml.utf8)) 50 + 51 + #expect(result.objects.count == 1) 52 + #expect(result.isTruncated == true) 53 + #expect(result.nextContinuationToken == "abc123token") 54 + } 55 + 56 + @Test func parsesEmptyListResponse() { 57 + let xml = """ 58 + <?xml version="1.0" encoding="UTF-8"?> 59 + <ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> 60 + <IsTruncated>false</IsTruncated> 61 + </ListBucketResult> 62 + """ 63 + let result = parseListObjectsV2(data: Data(xml.utf8)) 64 + 65 + #expect(result.objects.isEmpty) 66 + #expect(result.isTruncated == false) 67 + } 68 + 69 + @Test func skipsContentsWithEmptyKey() { 70 + let xml = """ 71 + <?xml version="1.0" encoding="UTF-8"?> 72 + <ListBucketResult> 73 + <Contents> 74 + <Key></Key> 75 + <Size>100</Size> 76 + </Contents> 77 + <Contents> 78 + <Key>valid.txt</Key> 79 + <Size>200</Size> 80 + </Contents> 81 + </ListBucketResult> 82 + """ 83 + let result = parseListObjectsV2(data: Data(xml.utf8)) 84 + 85 + #expect(result.objects.count == 1) 86 + #expect(result.objects[0].key == "valid.txt") 87 + } 88 + 89 + // MARK: - S3 Error Parsing 90 + 91 + @Test func parsesS3ErrorResponse() { 92 + let xml = """ 93 + <?xml version="1.0" encoding="UTF-8"?> 94 + <Error> 95 + <Code>NoSuchKey</Code> 96 + <Message>The specified key does not exist.</Message> 97 + <Key>missing-file.txt</Key> 98 + <RequestId>abc123</RequestId> 99 + </Error> 100 + """ 101 + let error = parseS3Error(data: Data(xml.utf8)) 102 + 103 + #expect(error != nil) 104 + #expect(error?.code == "NoSuchKey") 105 + #expect(error?.message == "The specified key does not exist.") 106 + } 107 + 108 + @Test func parsesAccessDeniedError() { 109 + let xml = """ 110 + <?xml version="1.0" encoding="UTF-8"?> 111 + <Error> 112 + <Code>AccessDenied</Code> 113 + <Message>Access Denied</Message> 114 + </Error> 115 + """ 116 + let error = parseS3Error(data: Data(xml.utf8)) 117 + 118 + #expect(error?.code == "AccessDenied") 119 + #expect(error?.message == "Access Denied") 120 + } 121 + 122 + @Test func returnsNilForNonErrorXML() { 123 + let xml = """ 124 + <?xml version="1.0" encoding="UTF-8"?> 125 + <SomethingElse> 126 + <Value>not an error</Value> 127 + </SomethingElse> 128 + """ 129 + let error = parseS3Error(data: Data(xml.utf8)) 130 + 131 + #expect(error == nil) 132 + } 133 + 134 + @Test func returnsNilForEmptyData() { 135 + let error = parseS3Error(data: Data()) 136 + #expect(error == nil) 137 + } 138 + }