import CryptoKit import Foundation import os nonisolated private let logger = Logger(subsystem: "org.gitbay.gitbay", category: "GitbayClient") /// Speaks the two halves of the gitbay API: `GET /api/v1/read` for the /// commands the registry marks read-only, `POST /api/v1/cmd` for the rest. /// /// Both dispatch the same command registry server-side, so this client adds /// no semantics of its own — it carries argv there and an envelope back. /// /// A client is bound to one account on one instance. Signing out means /// dropping the client, not mutating it. nonisolated final class GitbayClient: Sendable { /// How long the client will wait out a 429 by itself. Beyond this the /// wait belongs on screen, not inside a request that looks hung. static let maximumAutomaticWait: TimeInterval = 3 let instance: GitbayInstance private let token: String private let session: URLSession private let etags: ETagStore private let redirectGuard: RedirectGuard /// Cache keys are scoped by account without holding the token. private let accountKey: String /// Fired on any 401 — the token is expired or revoked. The session /// layer attaches this after sign-in succeeds, so the sign-in probe's /// own 401 (a mistyped token) never signs anyone out. private let unauthorizedHandler = OSAllocatedUnfairLock<(@Sendable () -> Void)?>(initialState: nil) func onUnauthorized(_ handler: @escaping @Sendable () -> Void) { unauthorizedHandler.withLock { $0 = handler } } /// The default transport: ephemeral, so nothing an authenticated /// request returns is written to the on-disk URL cache, and no cookie /// or credential outlives the process. `URLSession.shared` would /// store private repo content in an unprotected Cache.db. nonisolated static func makeEphemeralSession() -> URLSession { let configuration = URLSessionConfiguration.ephemeral configuration.httpCookieAcceptPolicy = .never configuration.httpShouldSetCookies = false configuration.urlCache = nil return URLSession(configuration: configuration) } init( instance: GitbayInstance, token: String, session: URLSession = GitbayClient.makeEphemeralSession(), etags: ETagStore = ETagStore() ) { self.instance = instance self.token = token self.session = session self.etags = etags self.redirectGuard = RedirectGuard(instance: instance) let digest = SHA256.hash(data: Data(token.utf8)) self.accountKey = digest.prefix(8).map { String(format: "%02x", $0) }.joined() } // MARK: - Reads /// Run a read-only command, decoding `data` as `Payload`. /// /// Revalidates with `If-None-Match` when a previous body is cached: a /// 304 costs a round trip and no body, which is the point on a phone. func read( _ argv: [String], as _: Payload.Type ) async throws -> Payload { let envelope: Envelope = try await readEnvelope(argv) guard let data = envelope.data else { throw GitbayError.decoding(MissingData(argv: argv)) } return data } /// Run a read-only command that returns a list. /// /// An empty result arrives as no `data` key at all — Go omits a nil /// slice — so absent means empty here rather than a decode failure. func readList( _ argv: [String], of _: Element.Type ) async throws -> [Element] { let envelope: Envelope<[Element]> = try await readEnvelope(argv) return envelope.data ?? [] } /// One page of a paginated list command. With `--limit`/`--cursor` /// present the server moves the array under `items` and returns the /// opaque `next` cursor alongside; `next` is absent on the last page. nonisolated struct Page: Decodable, Sendable { let items: [Element] let next: String? } /// Run a paginated list command (`repo list`, `issue list`, `mr /// list`, `feed`). Cursors are opaque and kind-checked server-side — /// pass back exactly what `next` carried, never synthesize one. func readPage( _ argv: [String], of _: Element.Type, limit: Int, cursor: String? = nil ) async throws -> Page { var argv = argv + ["--limit", String(limit)] if let cursor { argv.append(contentsOf: ["--cursor", cursor]) } let envelope: Envelope> = try await readEnvelope(argv) guard let page = envelope.data else { throw GitbayError.decoding(MissingData(argv: argv)) } return page } /// Run a read-only command that emits raw text rather than JSON /// (`mr diff`, `build log`). The server wraps those as `output`. func readText(_ argv: [String]) async throws -> String { let envelope: Envelope = try await readEnvelope(argv) return envelope.output ?? "" } private func readEnvelope( _ argv: [String], revalidate: Bool = true ) async throws -> Envelope { let url = instance.readURL(argv: argv) let cacheKey = ETagStore.key(account: accountKey, argv: argv) let cached = revalidate ? etags.entry(for: cacheKey) : nil var request = URLRequest(url: url) // URLSession's own cache would answer some of these transparently // and the revalidation would never be visible here. The ETag layer // in this file is the only one. request.cachePolicy = .reloadIgnoringLocalCacheData if let cached { request.setValue(cached.etag, forHTTPHeaderField: "If-None-Match") } let (body, response) = try await perform(request, argv: argv) if response.statusCode == 304 { guard let cached else { // Only reachable if the entry was evicted between the read // above and the response. Ask again without a validator, // which cannot come back 304. return try await readEnvelope(argv, revalidate: false) } return try decode(cached.payload, status: 200, argv: argv, surface: .read) } if let etag = response.value(forHTTPHeaderField: "ETag"), response.statusCode == 200 { etags.store(ETagEntry(etag: etag, payload: body), for: cacheKey) } return try decode(body, status: response.statusCode, argv: argv, surface: .read) } // MARK: - Writes /// Run a command that changes state. Long text goes in `stdin`, never /// in argv — the same discipline the CLI uses for `--file -`. @discardableResult func run( _ argv: [String], stdin: String? = nil, as _: Payload.Type ) async throws -> Payload? { var request = URLRequest(url: instance.cmdURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") var payload: [String: Any] = ["argv": argv] if let stdin { payload["stdin"] = stdin } request.httpBody = try JSONSerialization.data(withJSONObject: payload) let (body, response) = try await perform(request, argv: argv) let envelope: Envelope = try decode( body, status: response.statusCode, argv: argv, surface: .command ) return envelope.data } /// Run a command whose result carries nothing the caller needs. func run(_ argv: [String], stdin: String? = nil) async throws { _ = try await run(argv, stdin: stdin, as: NoPayload.self) } // MARK: - Transport private func perform( _ request: URLRequest, argv: [String] ) async throws -> (Data, HTTPURLResponse) { guard let url = request.url, instance.isOwn(url) else { throw GitbayError.unexpectedHost(request.url ?? instance.baseURL) } var request = request request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") // One retry, no more. A phone on a flaky network is exactly the // caller that turns a retry loop into a rate-limit spiral. for attempt in 0...1 { let body: Data let response: HTTPURLResponse do { let (data, urlResponse) = try await session.data(for: request, delegate: redirectGuard) guard let http = urlResponse as? HTTPURLResponse else { throw GitbayError.transport(URLError(.badServerResponse)) } (body, response) = (data, http) } catch let error as GitbayError { throw error } catch { throw GitbayError.transport(error) } let isLastAttempt = attempt == 1 if response.statusCode == 429 { let wait = retryAfter(response) ?? Self.maximumAutomaticWait guard !isLastAttempt, wait <= Self.maximumAutomaticWait else { throw GitbayError.rateLimited(retryAfter: wait) } try await Task.sleep(for: .seconds(wait)) continue } if (500...599).contains(response.statusCode), !isLastAttempt { try await Task.sleep(for: .milliseconds(500)) continue } return (body, response) } // The loop returns or throws on every path; this satisfies the // compiler rather than describing a reachable state. throw GitbayError.failure("") } /// gitbay sends Retry-After as whole seconds. private func retryAfter(_ response: HTTPURLResponse) -> TimeInterval? { guard let value = response.value(forHTTPHeaderField: "Retry-After"), let seconds = TimeInterval(value.trimmingCharacters(in: .whitespaces)), seconds >= 0 else { return nil } return seconds } // MARK: - Envelope private enum Surface { case read, command } /// Dates on the wire are RFC 3339, some with fractional seconds /// (SQLite's `%Y-%m-%dT%H:%M:%fZ` default), some without (`repo log`). private static func decoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let text = try decoder.singleValueContainer().decode(String.self) if let date = try? Date(text, strategy: .iso8601) { return date } if let date = try? Date(text, strategy: .iso8601.year().month().day() .dateTimeSeparator(.standard).time(includingFractionalSeconds: true)) { return date } throw DecodingError.dataCorrupted(.init( codingPath: decoder.codingPath, debugDescription: "unrecognized date: \(text)" )) } return decoder } private func decode( _ body: Data, status: Int, argv: [String], surface: Surface ) throws -> Envelope { // Probe the envelope before touching the payload, so a failure or // a foreign protocol version is recognised whatever shape `data` // has — an error envelope never matches the expected payload. let probe: Envelope do { probe = try Self.decoder().decode(Envelope.self, from: body) } catch { throw GitbayError.decoding(error) } guard probe.protocolVersion == 1 else { throw GitbayError.protocolMismatch(probe.protocolVersion) } if let error = failure(probe, status: status, argv: argv, surface: surface) { throw error } do { return try Self.decoder().decode(Envelope.self, from: body) } catch { throw GitbayError.decoding(error) } } private func failure( _ envelope: Envelope, status: Int, argv: [String], surface: Surface ) -> GitbayError? { let message = envelope.message ?? "" // The gate rejections — bad token, unknown command, a write sent to // the read surface — never reach a command, so they carry no exit // code. Those are read off the status. guard let code = envelope.exitCode.flatMap(ExitCode.init(rawValue:)) else { switch status { case 200: return nil case 401: unauthorizedHandler.withLock { $0 }?() return .unauthorized(message) case 403: return .denied(message) case 404: return .notFound(message) case 429: return .rateLimited(retryAfter: 0) case 400 where surface == .read: logger.error("read refused: \(message, privacy: .public)") return .notReadable(message) case 400: logger.error("usage error: \(argv, privacy: .private) — \(message, privacy: .private)") return .usage(message) default: return .failure(message) } } switch code { case .ok: return nil case .usage: // The app built argv wrong. Log it; never put argv on screen. logger.error("usage error: \(argv, privacy: .private) — \(message, privacy: .public)") return .usage(message) case .notFound: return .notFound(message) case .denied: return .denied(message) case .failure, .protocolError: return .failure(message) } } struct MissingData: LocalizedError, Sendable { let argv: [String] var errorDescription: String? { "The response carried no data." } } } /// Refuses to follow a redirect off the instance. Without this a redirect /// would carry the bearer token to whatever host answered. private nonisolated final class RedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable { private let instance: GitbayInstance init(instance: GitbayInstance) { self.instance = instance } func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { guard let url = request.url, instance.isOwn(url) else { logger.error("refused redirect off \(self.instance.baseURL.absoluteString, privacy: .public)") completionHandler(nil) return } completionHandler(request) } }