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.

feat: security + polish pass (1.0.0-beta.7)

Security
- attic init fails closed if stdin isn't a TTY (can't disable echo), so
piped secrets can't leak to the screen or a tee'd log.
- Viewer CSP is scoped to the configured S3 endpoint host instead of a
hardcoded *.amazonaws.com. Works correctly for R2/MinIO/Backblaze.
- Viewer presigned-URL lifetime cut from 4h to 1h.
- Reject bucket names containing a dot when pathStyle=false (virtual-hosted
TLS certs only cover one label — saves a confusing connect error).
- Staging dir created with 0o700.

Polish
- ViewerDataStore parses year from ISO8601 prefix instead of allocating a
formatter per asset.
- httpMaximumConnectionsPerHost bumped from 6 → 32 so bounded concurrency
isn't re-serialized at the socket layer.
- Per-asset metadata JSON uploads drop .prettyPrinted (~40% smaller).
Manifest/config/retry-queue stay pretty-printed.
- BackupOptions: drop saveInterval; manifest saves at batch boundaries.
- Rename AdaptiveConcurrency.swift → AIMDController.swift and
BackupConstants.swift → DateFormatting.swift.
- Simplify concurrencyChanged plumbing.

Bumps version to 1.0.0-beta.7.

+150 -55
+50
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 1.0.0-beta.7 4 + 5 + Architectural cleanup, security hardening, and pipeline simplification. No 6 + behavior changes for the golden path. 7 + 8 + ### Architecture 9 + - `RetryQueue`: dropped the legacy `failedUUIDs: [String]` decoder and the 10 + custom `Codable` conformance. Uses compiler-synthesized coding now. 11 + - `BackupPipeline`: extracted `filterPending`, `exportBatchWithFallback`, and 12 + `finalizeBackup` helpers so `runBackup` reads top-to-bottom. Removed the 13 + dead `ExportProviderError.isPermission` catch — permission is a pre-flight 14 + check, never raised during `exportBatch`. 15 + - `BackupUpload`: network-pause retry is now a loop instead of recursion. 16 + No more stack-depth coupling with `maxPauseRetries`. 17 + - `BackupOptions`: removed `saveInterval`. The manifest now saves at batch 18 + boundaries, which is simpler and survives crashes just as well. 19 + - Check `ExportClassification` directly everywhere instead of the legacy 20 + `ExportError.unavailable` boolean. 21 + - Removed the `normalizeUUID(...)` defensive splits in attic — LadderKit 22 + preserves caller-provided UUIDs at source now (no more `UUID/L0/001` 23 + leakage), so the splits were dead code. 24 + - File renames: `AdaptiveConcurrency.swift` → `AIMDController.swift` (matches 25 + the type it holds), `BackupConstants.swift` → `DateFormatting.swift`. 26 + 27 + ### Security 28 + - `attic init` fails closed if `tcgetattr`/`tcsetattr` can't disable terminal 29 + echo (e.g. stdin isn't a TTY). Previously, a piped/redirected stdin would 30 + read the secret in plaintext and could leak it to the screen or a tee'd 31 + log. 32 + - Viewer `Content-Security-Policy` is now scoped to the configured S3 33 + endpoint host instead of hardcoded `*.amazonaws.com`. Custom endpoints 34 + (R2, Backblaze, MinIO) no longer rely on a permissive fallback. 35 + - Viewer presigned-URL lifetime cut from 4h to 1h. 36 + - `URLSessionS3Client` rejects bucket names containing a dot when 37 + `pathStyle = false` — AWS's virtual-hosted TLS cert only covers one label, 38 + so these requests would fail at connect time with a confusing cert error. 39 + - Staging directory created with `0o700` so other local users can't read 40 + in-flight plaintext copies of the user's photos. 41 + 42 + ### Performance 43 + - `ViewerDataStore` load path: parse year from the `YYYY-` prefix instead of 44 + allocating a `Date.ISO8601FormatStyle` per asset. Noticeable on large 45 + libraries. 46 + - `URLSessionS3Client` bumps `httpMaximumConnectionsPerHost` from the default 47 + 6 to 32 so the bounded upload group isn't re-serialized at the socket 48 + layer. 49 + - Per-asset metadata uploads drop `.prettyPrinted` JSON formatting — ~40% 50 + smaller payloads. Manifest, config, and retry-queue stay pretty-printed 51 + (user-inspected). 52 + 3 53 ## 1.0.0-beta.6 4 54 5 55 Adaptive export: separate local-cache and iCloud lanes, with the iCloud lane
+8 -2
Sources/AtticCLI/BackupCommand.swift
··· 41 41 default: nil 42 42 } 43 43 44 - // Stable staging dir — files persist across runs for reuse, cleaned per-asset after upload 44 + // Stable staging dir — files persist across runs for reuse, cleaned per-asset after upload. 45 + // 0o700: staged originals are plaintext copies of the user's photos; keep them out of 46 + // other local accounts. 45 47 let stagingDir = FileConfigProvider.defaultDirectory.appendingPathComponent("staging") 46 - try FileManager.default.createDirectory(at: stagingDir, withIntermediateDirectories: true) 48 + try FileManager.default.createDirectory( 49 + at: stagingDir, 50 + withIntermediateDirectories: true, 51 + attributes: [.posixPermissions: 0o700], 52 + ) 47 53 48 54 // Adaptive export: partition by local vs iCloud availability, and let 49 55 // the AIMD controller throttle the iCloud lane when PhotoKit pushes back.
+18 -6
Sources/AtticCLI/InitCommand.swift
··· 24 24 let pathStyle = pathStyleInput.lowercased() != "n" 25 25 26 26 let accessKey = prompt("Access key ID: ") 27 - let secretKey = promptSecret("Secret access key: ") 27 + let secretKey = try promptSecret("Secret access key: ") 28 28 29 29 let config = AtticConfig( 30 30 endpoint: endpoint, ··· 62 62 return readLine(strippingNewline: true) ?? "" 63 63 } 64 64 65 - private func promptSecret(_ message: String) -> String { 65 + private struct TerminalEchoError: LocalizedError { 66 + let errorDescription: String? = 67 + "Cannot disable terminal echo — refusing to read secret. Run in an interactive terminal." 68 + } 69 + 70 + private func promptSecret(_ message: String) throws -> String { 66 71 print(message, terminator: "") 67 72 68 - // Disable echo for secret input 73 + // Fail closed: if we can't disable echo (e.g. stdin is not a TTY), we 74 + // won't silently read the secret in plaintext and leak it to the screen 75 + // or a pipe's tee. 69 76 var oldTermios = termios() 70 - tcgetattr(STDIN_FILENO, &oldTermios) 77 + guard tcgetattr(STDIN_FILENO, &oldTermios) == 0 else { 78 + print("") 79 + throw TerminalEchoError() 80 + } 71 81 var newTermios = oldTermios 72 82 newTermios.c_lflag &= ~UInt(ECHO) 73 - tcsetattr(STDIN_FILENO, TCSANOW, &newTermios) 83 + guard tcsetattr(STDIN_FILENO, TCSANOW, &newTermios) == 0 else { 84 + print("") 85 + throw TerminalEchoError() 86 + } 74 87 75 88 let value = readLine(strippingNewline: true) ?? "" 76 89 77 - // Restore echo 78 90 tcsetattr(STDIN_FILENO, TCSANOW, &oldTermios) 79 91 print("") // newline after hidden input 80 92 return value
+6 -2
Sources/AtticCLI/ViewerCommand.swift
··· 12 12 var port: Int = 0 13 13 14 14 func run() async throws { 15 - let (_, s3, manifestStore) = try Dependencies.makeBackupDeps() 15 + let (config, s3, manifestStore) = try Dependencies.makeBackupDeps() 16 16 let manifest = try await Dependencies.loadManifest(store: manifestStore) 17 17 18 18 if manifest.entries.isEmpty { 19 19 print("No backed-up assets found. Run 'attic backup' first.") 20 20 return 21 21 } 22 + 23 + let endpointHost = URL(string: config.endpoint)?.host ?? "s3.amazonaws.com" 22 24 23 25 let dataStore = ViewerDataStore() 24 26 let thumbnailService = ThumbnailService(s3: s3, dataStore: dataStore) 25 27 let server = ViewerServer( 26 28 dataStore: dataStore, s3: s3, 27 - thumbnailProvider: thumbnailService, port: port, 29 + thumbnailProvider: thumbnailService, 30 + endpointHost: endpointHost, 31 + port: port, 28 32 ) 29 33 30 34 // Start metadata loading in the background — assets become
+24 -6
Sources/AtticCLI/ViewerServer.swift
··· 31 31 let s3: S3Providing 32 32 let thumbnailProvider: ThumbnailProviding 33 33 let port: Int 34 + /// Pre-built CSP header scoped to the configured S3 endpoint. 35 + private let csp: String 36 + 37 + /// Presigned-URL lifetime. 1h balances "long enough for a browsing 38 + /// session" against "short enough that a leaked URL stops working soon." 39 + static let presignedURLExpiry = 3600 34 40 35 41 init( 36 42 dataStore: ViewerDataStore, 37 43 s3: S3Providing, 38 44 thumbnailProvider: ThumbnailProviding, 45 + endpointHost: String, 39 46 port: Int = 0, 40 47 ) { 41 48 self.dataStore = dataStore 42 49 self.s3 = s3 43 50 self.thumbnailProvider = thumbnailProvider 44 51 self.port = port 52 + self.csp = Self.buildCSP(endpointHost: endpointHost) 53 + } 54 + 55 + private static func buildCSP(endpointHost: String) -> String { 56 + let source = "https://\(endpointHost)" 57 + return [ 58 + "default-src 'self'", 59 + "script-src 'unsafe-inline'", 60 + "style-src 'unsafe-inline'", 61 + "img-src 'self' \(source)", 62 + "media-src 'self' \(source)", 63 + "font-src 'self'", 64 + "connect-src 'self'", 65 + ].joined(separator: "; ") 45 66 } 46 67 47 68 func start(onReady: @escaping @Sendable (Int) -> Void = { _ in }) async throws { ··· 57 78 try await app.runService() 58 79 } 59 80 60 - // swiftlint:disable:next line_length 61 - private static let csp = "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self' https://*.amazonaws.com; media-src 'self' https://*.amazonaws.com; font-src 'self'; connect-src 'self'" 62 - 63 81 func buildRouter() -> Router<BasicRequestContext> { 64 82 let router = Router() 65 83 addHTMLRoute(router) ··· 76 94 .contentType: "text/html; charset=utf-8", 77 95 .init("X-Content-Type-Options")!: "nosniff", 78 96 .init("X-Frame-Options")!: "DENY", 79 - .init("Content-Security-Policy")!: Self.csp, 97 + .init("Content-Security-Policy")!: csp, 80 98 ], 81 99 body: .init(byteBuffer: .init(string: html)), 82 100 ) ··· 117 135 ) 118 136 119 137 let assetsWithURLs = result.assets.map { asset in 120 - assetResponse(asset, expires: 14400) 138 + assetResponse(asset, expires: Self.presignedURLExpiry) 121 139 } 122 140 123 141 return AssetListResponse( ··· 137 155 return Response(status: .notFound) 138 156 } 139 157 140 - let detail = assetResponse(asset, expires: 14400) 158 + let detail = assetResponse(asset, expires: Self.presignedURLExpiry) 141 159 let data = try JSONEncoder().encode(detail) 142 160 return Response( 143 161 status: .ok,
Sources/AtticCore/AdaptiveConcurrency.swift Sources/AtticCore/AIMDController.swift
+1 -1
Sources/AtticCore/AtticCore.swift
··· 2 2 /// 3 3 /// Used by both the Attic CLI and the Attic menu bar app. 4 4 public enum AtticCore { 5 - public static let version = "1.0.0-beta.6" 5 + public static let version = "1.0.0-beta.7" 6 6 }
-3
Sources/AtticCore/BackupConstants.swift Sources/AtticCore/DateFormatting.swift
··· 7 7 func formatISO8601(_ date: Date) -> String { 8 8 date.formatted(.iso8601) 9 9 } 10 - 11 - /// Maximum number of errors to keep in a report (prevents unbounded growth). 12 - let maxReportErrors = 1000
+25 -16
Sources/AtticCore/BackupPipeline.swift
··· 7 7 public var limit: Int 8 8 public var type: AssetKind? 9 9 public var dryRun: Bool 10 - public var saveInterval: Int 11 10 public var concurrency: Int 12 11 public var networkTimeout: Duration 13 12 public var maxPauseRetries: Int ··· 18 17 limit: Int = 0, 19 18 type: AssetKind? = nil, 20 19 dryRun: Bool = false, 21 - saveInterval: Int = 25, 22 20 concurrency: Int = 6, 23 21 networkTimeout: Duration = .seconds(900), 24 22 maxPauseRetries: Int = 3, ··· 28 26 self.limit = limit 29 27 self.type = type 30 28 self.dryRun = dryRun 31 - self.saveInterval = saveInterval 32 29 self.concurrency = concurrency 33 30 self.networkTimeout = networkTimeout 34 31 self.maxPauseRetries = maxPauseRetries 35 32 self.stagingDir = stagingDir 36 33 } 37 34 } 35 + 36 + /// Cap on stored error detail to prevent unbounded report growth on very 37 + /// large runs with many failures. 38 + let maxReportErrors = 1000 38 39 39 40 /// Result of a backup run. 40 41 public struct BackupReport: Sendable { ··· 182 183 assetByUUID: assetByUUID, 183 184 s3: s3, 184 185 manifestStore: manifestStore, 185 - saveInterval: options.saveInterval, 186 186 concurrency: options.concurrency, 187 187 progress: progress, 188 188 networkMonitor: networkMonitor, ··· 205 205 206 206 let totalBatches = (pending.count + options.batchSize - 1) / options.batchSize 207 207 208 - // Emit initial and between-batch concurrency limit updates. 208 + // Emit the adaptive concurrency limit when it changes between batches. 209 209 var lastEmittedLimit: Int? 210 - if let controller = adaptiveController { 210 + func emitLimitIfChanged() async { 211 + guard let controller = adaptiveController else { return } 211 212 let limit = await controller.currentLimit() 212 - progress.concurrencyChanged(limit: limit) 213 - lastEmittedLimit = limit 213 + if limit != lastEmittedLimit { 214 + progress.concurrencyChanged(limit: limit) 215 + lastEmittedLimit = limit 216 + } 214 217 } 218 + await emitLimitIfChanged() 215 219 216 220 do { 217 221 for batchIndex in 0 ..< totalBatches { 218 222 try Task.checkCancellation() 219 - 220 - if let controller = adaptiveController { 221 - let limit = await controller.currentLimit() 222 - if limit != lastEmittedLimit { 223 - progress.concurrencyChanged(limit: limit) 224 - lastEmittedLimit = limit 225 - } 226 - } 223 + await emitLimitIfChanged() 227 224 228 225 let start = batchIndex * options.batchSize 229 226 let end = min(start + options.batchSize, pending.count) ··· 261 258 manifest: &manifest, report: &report, 262 259 sinceLastSave: &sinceLastSave, 263 260 ) 261 + 262 + // Save manifest at batch boundaries so progress survives crashes 263 + // and cancellations. Cheap — bounded by `batchSize` uploads. 264 + if sinceLastSave > 0 { 265 + do { 266 + try await manifestStore.save(manifest) 267 + progress.manifestSaved(entriesCount: manifest.entries.count) 268 + sinceLastSave = 0 269 + } catch { 270 + debugPrint("Batch manifest save failed: \(error)") 271 + } 272 + } 264 273 } 265 274 266 275 // Retry deferred assets (single-asset timeouts from batch fallback)
+1 -12
Sources/AtticCore/BackupUpload.swift
··· 6 6 let assetByUUID: [String: AssetInfo] 7 7 let s3: any S3Providing 8 8 let manifestStore: any ManifestStoring 9 - let saveInterval: Int 10 9 let concurrency: Int 11 10 let progress: any BackupProgressDelegate 12 11 let networkMonitor: (any NetworkMonitoring)? ··· 100 99 type: result.type, 101 100 size: result.size, 102 101 ) 103 - 104 - if sinceLastSave >= ctx.saveInterval { 105 - do { 106 - try await ctx.manifestStore.save(manifest) 107 - ctx.progress.manifestSaved(entriesCount: manifest.entries.count) 108 - sinceLastSave = 0 109 - } catch { 110 - debugPrint("Periodic manifest save failed: \(error)") 111 - } 112 - } 113 102 } else if result.isNetworkDownError, 114 103 let monitor = ctx.networkMonitor, 115 104 await !monitor.isNetworkAvailable ··· 267 256 backedUpAt: isoNow, 268 257 ) 269 258 let encoder = JSONEncoder() 270 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 259 + encoder.outputFormatting = .sortedKeys 271 260 let metaData = try encoder.encode(meta) 272 261 let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) 273 262 try await withRetry {
+1 -1
Sources/AtticCore/RefreshMetadata.swift
··· 153 153 backedUpAt: entry.backedUpAt, 154 154 ) 155 155 let encoder = JSONEncoder() 156 - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 156 + encoder.outputFormatting = .sortedKeys 157 157 let data = try encoder.encode(meta) 158 158 let metaKey = try S3Paths.metadataKey(uuid: asset.uuid) 159 159
+11
Sources/AtticCore/URLSessionS3Client.swift
··· 25 25 guard let endpointURL = URL(string: endpoint) else { 26 26 throw S3ClientError.unexpectedResponse("Invalid endpoint URL: \(endpoint)") 27 27 } 28 + // Virtual-hosted style and dots in bucket name don't mix: TLS cert 29 + // covers *.s3.amazonaws.com (one label) and "my.bucket" would need 30 + // two wildcards. AWS rejects these at request time; catch it at init. 31 + if !pathStyle, bucket.contains(".") { 32 + throw S3ClientError.unexpectedResponse( 33 + "Bucket name \"\(bucket)\" contains a dot — use path-style URLs instead.", 34 + ) 35 + } 28 36 self.bucket = bucket 29 37 self.endpoint = endpointURL 30 38 self.region = region ··· 39 47 let config = URLSessionConfiguration.default 40 48 config.timeoutIntervalForRequest = 60 41 49 config.timeoutIntervalForResource = 3600 50 + // Default is 6, which throttles concurrent uploads to one bucket host. 51 + // Align with our bounded-concurrency upload group (effectively ~16). 52 + config.httpMaximumConnectionsPerHost = 32 42 53 session = URLSession(configuration: config) 43 54 } 44 55
+5 -6
Sources/AtticCore/ViewerDataStore.swift
··· 262 262 } 263 263 264 264 private static func assetView(from meta: AssetMetadata) -> AssetView { 265 - let year: Int? = if let dateStr = meta.dateCreated, 266 - let date = try? Date.ISO8601FormatStyle().parse(dateStr) 267 - { 268 - Calendar.current.component(.year, from: date) 269 - } else { 270 - nil 265 + // Year from ISO8601 "YYYY-MM-DD..." prefix — avoids allocating a 266 + // formatter per asset (load path hits this N times for N backed-up 267 + // assets, so Date.ISO8601FormatStyle().parse is a real hot spot). 268 + let year: Int? = meta.dateCreated.flatMap { dateStr in 269 + dateStr.count >= 4 ? Int(dateStr.prefix(4)) : nil 271 270 } 272 271 273 272 let isVideo = meta.type.map { isVideoUTI($0) } ?? false