From 850531fd07302dc4ba7f649d98a6f88e08092926 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:45:26 +0200 Subject: [PATCH] feat(ios): access-token support + git-panel endpoints across the package layer APIClient (77 -> 125 tests, coverage 92.22%): POST /auth probe with the four distinct outcomes from the frozen contract, Cookie/Accept landed at the same single header choke point that already enforces Origin-iff-G, plus the whole project-ops surface the server has had since late July and iOS consumed none of: /projects/log, /projects/pr, /projects/worktree/state, git stage/commit/push/ fetch, worktree create/remove/prune, GET /sessions, follow-up queue. HostRegistry (30 -> 73 tests, 88.12% -> 92.49%): per-host token in the Keychain under the existing SecItemShim conventions (device-only, never synchronizable), charset/length validated at the boundary, old token-less records still decode. SessionCore (93 -> 108 tests, 96.74%): the WS upgrade carries the cookie from the same point that writes Origin, and a 401 handshake is a terminal .unauthorized -- never entering the backoff loop, since retrying one wrong shared token is a brute-force generator against the server's 10/min limiter. --- .../Sources/APIClient/APIClient.swift | 105 +++- .../Sources/APIClient/AccessToken.swift | 132 +++++ .../Sources/APIClient/Endpoints.swift | 95 +++- .../Sources/APIClient/FollowupQueue.swift | 83 ++++ .../APIClient/Sources/APIClient/GitLog.swift | 110 +++++ .../Sources/APIClient/GitWrite.swift | 377 ++++++++++++++ .../APIClient/Sources/APIClient/History.swift | 90 ++++ .../APIClient/Sources/APIClient/Models.swift | 47 ++ .../Sources/APIClient/PrStatus.swift | 155 ++++++ .../Sources/APIClient/Projects.swift | 93 ++-- .../Sources/APIClient/Tolerance.swift | 64 +++ .../Sources/APIClient/WorktreeState.swift | 120 +++++ .../APIClientTests/AccessTokenTests.swift | 314 ++++++++++++ .../APIClientTests/GitPanelReadTests.swift | 435 +++++++++++++++++ .../Tests/APIClientTests/GitWriteTests.swift | 459 ++++++++++++++++++ .../Tests/APIClientTests/ProjectsTests.swift | 61 +++ .../Sources/HostRegistry/AccessToken.swift | 89 ++++ .../Sources/HostRegistry/Host.swift | 78 ++- .../Sources/HostRegistry/HostStore.swift | 46 +- .../HostRegistry/InMemoryHostStore.swift | 14 + .../HostRegistry/KeychainHostStore.swift | 21 + .../HostRegistryTests/AccessTokenTests.swift | 210 ++++++++ .../HostAccessTokenStoreTests.swift | 337 +++++++++++++ .../HostTokenMigrationTests.swift | 192 ++++++++ .../HostRegistryTests/TestFixtures.swift | 24 + .../Sources/SessionCore/AuthCookie.swift | 42 ++ .../Sources/SessionCore/SessionEngine.swift | 20 +- .../Sources/SessionCore/SessionEvent.swift | 8 + .../SessionCore/URLSessionTermTransport.swift | 129 ++++- .../SessionCoreTests/AuthCookieTests.swift | 60 +++ .../SessionCoreTests/ScriptedWSServer.swift | 53 +- .../SessionCoreTests/SessionEngineTests.swift | 64 +++ .../URLSessionTermTransportTests.swift | 170 +++++++ 33 files changed, 4191 insertions(+), 106 deletions(-) create mode 100644 ios/Packages/APIClient/Sources/APIClient/AccessToken.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/GitLog.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/GitWrite.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/History.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/PrStatus.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/Tolerance.swift create mode 100644 ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift create mode 100644 ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift create mode 100644 ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift create mode 100644 ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift create mode 100644 ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift create mode 100644 ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift create mode 100644 ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift create mode 100644 ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift diff --git a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift index 0005cf2..974f508 100644 --- a/ios/Packages/APIClient/Sources/APIClient/APIClient.swift +++ b/ios/Packages/APIClient/Sources/APIClient/APIClient.swift @@ -3,23 +3,46 @@ import WireProtocol /// Typed client for the server's HTTP surface (frozen contract, plan §3.4). /// -/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: only the two G (state-changing) -/// endpoints stamp `Origin: endpoint.originHeader`; the four RO GETs never do. -/// Stamping lives in ONE place — `APIRoute.urlRequest(for:)` — and the value is -/// single-point derived by `HostEndpoint` (never hand-assembled). +/// **Origin 铁律(安全语义,plan §3.4/§5.1)**: `Origin: endpoint.originHeader` is +/// stamped **iff** the route is G (state-changing); RO GETs never carry it. +/// Stamping lives in ONE place — `APIRoute.urlRequest(for:accessToken:)` — and +/// the value is single-point derived by `HostEndpoint` (never hand-assembled). +/// The optional access-token `Cookie` is stamped at that same single point and is +/// **orthogonal**: it never replaces Origin (ios-completion §1.1). /// /// The server is an UNTRUSTED input source at this boundary (plan §4): bodies /// are decoded tolerantly (malformed entries dropped), statuses are mapped to /// explicit `APIClientError`s, and nothing here ever crashes on bad input. -public struct APIClient: Sendable { +public struct APIClient: Sendable, CustomStringConvertible, CustomDebugStringConvertible { public let endpoint: HostEndpoint private let http: any HTTPTransport + /// The host's optional shared access token (`WEBTERM_TOKEN`, ios-completion + /// §1.1). SECRET: private, never printed (see `description`), never put in a + /// URL, only ever leaving as a `Cookie` header stamped in `APIRoute`. + /// nil = the host has no token configured (LAN zero-config). + private let accessToken: String? - public init(endpoint: HostEndpoint, http: any HTTPTransport) { + public init(endpoint: HostEndpoint, http: any HTTPTransport, accessToken: String? = nil) { self.endpoint = endpoint self.http = http + self.accessToken = accessToken } + /// Whether this client carries an access token — PRESENCE only. There is + /// deliberately no getter for the value: the token leaves this type solely + /// as a `Cookie` header (plan §5: never log, never a URL, never a report). + public var hasAccessToken: Bool { accessToken != nil } + + /// Redacted on purpose: the default reflection-based description of a + /// struct holding a secret would print it into any log line that + /// interpolates the client. + public var description: String { + "APIClient(origin: \(endpoint.originHeader), accessToken: " + + (accessToken == nil ? "none)" : ")") + } + + public var debugDescription: String { description } + // MARK: - RO (read-only — NO Origin header) /// `GET /live-sessions` (src/server.ts:257-259) — the discovery list every @@ -107,11 +130,35 @@ public struct APIClient: Sendable { // MARK: - Internals (shared with the P1 feature files, T-iOS-38) + /// The ONE request choke point. Two structural guarantees live here: + /// - a configured token is shape-validated before it can reach a header + /// (`.malformedToken`, fail-fast — never silently send an unauthenticated + /// request and let the user read the 401 as "server is down"); + /// - a 401 becomes the typed `.unauthorized` (ios-completion §1.1) for every + /// route except the two families that define their own 401 + /// (`UnauthorizedPolicy.routeDefined`). func perform(_ route: APIRoute) async throws -> (Data, HTTPURLResponse) { - guard let request = route.urlRequest(for: endpoint) else { + let token = try validatedAccessToken() + guard let request = route.urlRequest(for: endpoint, accessToken: token) else { throw APIClientError.invalidRequest } - return try await http.send(request) + let (data, response) = try await http.send(request) + if response.statusCode == HTTPStatus.unauthorized, + route.unauthorizedPolicy == .accessTokenGate { + throw APIClientError.unauthorized + } + return (data, response) + } + + /// nil when no token is configured; throws `.malformedToken` when one is + /// configured but violates the frozen charset/length rule (which is also + /// what makes CRLF header injection impossible). + private func validatedAccessToken() throws -> String? { + guard let accessToken else { return nil } + guard AccessTokenRule.isWellFormed(accessToken) else { + throw APIClientError.malformedToken + } + return accessToken } /// 200 → ok; 404 → `.sessionNotFound`; anything else → `.unexpectedStatus`. @@ -125,6 +172,44 @@ public struct APIClient: Sendable { throw APIClientError.unexpectedStatus(response.statusCode) } } + + /// The `/projects/*` three-prong contract, shared by `detail`/`log`/`pr` + /// (src/server.ts:1033-1042 and friends): `path` missing/empty → 400, + /// non-git dir → 404, read failure → 500. `notFound` is a parameter because + /// `/projects/worktree/state`'s 404 means "not a worktree", not "no project". + static func requireGitReadOK( + _ response: HTTPURLResponse, notFound: APIClientError = .projectNotFound + ) throws { + switch response.statusCode { + case HTTPStatus.ok: + return + case HTTPStatus.badRequest: + throw APIClientError.projectPathInvalid + case HTTPStatus.notFound: + throw notFound + case HTTPStatus.internalServerError: + throw APIClientError.gitDataUnavailable + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } + + /// Mirror of the server's own `path` guard, applied BEFORE any network I/O + /// (validate at the boundary, plan §4) — every `/projects/*` route rejects + /// an empty path with 400, so there is nothing to learn from the round trip. + static func requireNonEmptyPath(_ path: String) throws { + guard !path.isEmpty else { + throw APIClientError.projectPathInvalid + } + } + + /// Decode a single JSON object body, or `.invalidResponseBody`. + static func decodeObject(_ type: T.Type, from data: Data) throws -> T { + guard let value = try? JSONDecoder().decode(T.self, from: data) else { + throw APIClientError.invalidResponseBody + } + return value + } } /// Named HTTP status codes used by the client (no magic numbers, plan §4). @@ -132,8 +217,12 @@ enum HTTPStatus { static let ok = 200 static let noContent = 204 static let badRequest = 400 + static let unauthorized = 401 static let forbidden = 403 static let notFound = 404 + static let conflict = 409 + static let payloadTooLarge = 413 static let tooManyRequests = 429 static let internalServerError = 500 + static let serviceUnavailable = 503 } diff --git a/ios/Packages/APIClient/Sources/APIClient/AccessToken.swift b/ios/Packages/APIClient/Sources/APIClient/AccessToken.swift new file mode 100644 index 0000000..4fd1806 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/AccessToken.swift @@ -0,0 +1,132 @@ +import Foundation +import WireProtocol + +// B1 · optional shared access token (`WEBTERM_TOKEN`) — ios-completion §1.1 +// FROZEN contract, cross-checked against `src/http/auth.ts` + `src/server.ts`. +// +// Server facts: +// | cookie name | `webterm_auth` | auth.ts:30 | +// | login endpoint | `POST /auth` | server.ts:393 | +// | request body | `{"token":""}` + JSON C-T | server.ts:396 | +// | `Accept` | MUST NOT contain `text/html` | server.ts:366 | +// | valid | 204 **with** `Set-Cookie` | server.ts:412 | +// | wrong token | 401 `{"error":"invalid token"}` | server.ts:418 | +// | rate limited | 429 (10/min/IP) | server.ts:399 | +// | auth DISABLED | 204 **without** `Set-Cookie` | server.ts:404 | +// +// IMPLEMENTATION DECISION (frozen): a native client KNOWS its token, so it +// hand-writes `Cookie: webterm_auth=` and NEVER parses `Set-Cookie` nor +// relies on a cookie jar (URLSession/OkHttp jars behave inconsistently on a WS +// upgrade and are hard to test). Hand-written header == the same pattern as the +// hand-written `Origin`, pinned by pure-function unit tests. +// +// HONEST BOUNDARY (src/http/auth.ts header): the token is a bar-raiser, NOT a +// TLS substitute. On bare `ws://`/`http://` it travels in cleartext and is +// replayable by a LAN sniffer; it only meaningfully hardens the TLS-terminated +// relay/tunnel path. + +/// The auth cookie, single-point (name + value assembly). +enum AuthCookie { + /// `AUTH_COOKIE_NAME` (src/http/auth.ts:30). + static let name = "webterm_auth" + /// Response header the probe reads. The client checks its PRESENCE only and + /// never parses its value — the token it would echo is already known. + static let setCookieHeader = "Set-Cookie" + + /// `webterm_auth=`. Callers MUST pass a token that already satisfies + /// `AccessTokenRule.isWellFormed` — the charset check is what guarantees no + /// CR/LF (header injection) and no `;` (cookie splitting) can appear here. + static func headerValue(for token: String) -> String { + "\(name)=\(token)" + } +} + +/// The frozen token shape: 16–512 characters from `[A-Za-z0-9._~+/=-]` +/// (CLAUDE.md / `src/config.ts` — the server REFUSES TO START with anything +/// else, so a shape-violating token can never be the right one). +enum AccessTokenRule { + static let minLength = 16 + static let maxLength = 512 + /// URL/cookie-safe set, byte-for-byte the server's `[A-Za-z0-9._~+/=-]`. + private static let allowed = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-" + ) + + static func isWellFormed(_ token: String) -> Bool { + (minLength...maxLength).contains(token.count) && token.allSatisfy(allowed.contains) + } +} + +/// The four outcomes of the `POST /auth` pairing-time probe (ios-completion +/// §1.1). They are RESULTS, not errors: three of the four are perfectly normal +/// states the pairing UI must tell apart. +public enum AccessTokenProbeResult: Sendable, Equatable { + /// 204 **with** `Set-Cookie` — the token is correct; persist it (Keychain). + case valid + /// 204 **without** `Set-Cookie` — this host has auth DISABLED. It is NOT + /// "authenticated": nothing was verified and nothing should be persisted. + case authDisabled + /// 401 — wrong token. + case invalidToken + /// 429 — 10 attempts/min/IP exceeded; ask the user to wait. + case rateLimited +} + +extension Endpoints { + static let authPath = "/auth" + + /// `POST /auth` with body `{"token":…}`. + /// + /// - `.guarded`: this is a state-changing POST (it mints a session cookie), + /// so it stamps `Origin` like every other write. The server does not + /// Origin-check `/auth` today — keeping the rule uniform costs one header + /// and avoids a special case that would rot the moment it does. + /// - `.routeDefined`: this route's own 401 means "wrong token", not "the + /// gate rejected you" — the probe maps it to `.invalidToken`. + static func auth(token: String) throws -> APIRoute { + APIRoute( + method: .post, path: authPath, originPolicy: .guarded, + body: try JSONEncoder().encode(AuthTokenBody(token: token)), + unauthorizedPolicy: .routeDefined + ) + } + + private struct AuthTokenBody: Encodable { + let token: String + } +} + +extension APIClient { + /// One-shot pairing-time probe of a candidate access token + /// (`POST /auth`, ios-completion §1.1). + /// + /// The token travels ONLY in the JSON body — never in a URL query (the + /// server's `?token=` bootstrap exists for browsers, which strip it from + /// history afterwards; a native client has no reason to put a secret in a + /// URL that lands in logs). A shape-violating candidate is rejected here, + /// before any network I/O (`.malformedToken`). + /// + /// Returns one of the four frozen outcomes; any other status throws + /// `.unexpectedStatus` rather than guessing. + public func probeAccessToken(_ token: String) async throws -> AccessTokenProbeResult { + guard AccessTokenRule.isWellFormed(token) else { + throw APIClientError.malformedToken + } + let (_, response) = try await perform(try Endpoints.auth(token: token)) + switch response.statusCode { + case HTTPStatus.noContent: + // THE distinction the whole feature hinges on: a 204 without a + // Set-Cookie means the host never enabled auth. Reporting that as + // "authenticated" would persist a token that gates nothing and + // teach the user the host is protected when it is not. + return response.value(forHTTPHeaderField: AuthCookie.setCookieHeader) == nil + ? .authDisabled : .valid + case HTTPStatus.unauthorized: + return .invalidToken + case HTTPStatus.tooManyRequests: + return .rateLimited + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift index 0a14f9a..2172bf2 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Endpoints.swift @@ -21,10 +21,28 @@ enum OriginPolicy: Sendable, Equatable { case guarded } +/// How a 401 on this route must be READ (ios-completion §1.1). The access-token +/// gate answers 401 for any unauthed request (src/server.ts:459 `authGate` step +/// 5), so on almost every route 401 means "token missing/wrong" → typed +/// `.unauthorized`. Two route families define their OWN 401 and must not be +/// swallowed by that rule — declared here, at the route, so the exception is +/// visible instead of hidden in a call site. +enum UnauthorizedPolicy: Sendable, Equatable { + /// Default — a 401 can only be the access-token gate. + case accessTokenGate + /// The route owns its 401: `POST /auth`'s wrong-token answer + /// (src/server.ts:418) and the git-write family, where the server + /// CLASSIFIES a host-side git credential failure as 401 + /// (src/http/git-ops.ts:108 "Push authentication required on the host."). + case routeDefined +} + /// Header/content-type names used by the builder (no magic strings inline). enum HeaderName { static let origin = "Origin" static let contentType = "Content-Type" + static let accept = "Accept" + static let cookie = "Cookie" } enum ContentTypeValue { @@ -41,28 +59,43 @@ struct APIRoute: Sendable, Equatable { /// nil for no query. Percent-encoding happens ONCE, in the route builder /// (T-iOS-38: `/projects/detail?path=`) — never at call sites. let percentEncodedQuery: String? + /// See `UnauthorizedPolicy`. Defaults to the gate reading. + let unauthorizedPolicy: UnauthorizedPolicy init( method: HTTPMethod, path: String, originPolicy: OriginPolicy, body: Data?, - percentEncodedQuery: String? = nil + percentEncodedQuery: String? = nil, + unauthorizedPolicy: UnauthorizedPolicy = .accessTokenGate ) { self.method = method self.path = path self.originPolicy = originPolicy self.body = body self.percentEncodedQuery = percentEncodedQuery + self.unauthorizedPolicy = unauthorizedPolicy } /// Build the `URLRequest` against `endpoint.baseURL`'s scheme/host/port: /// the path is REPLACED, the query is REPLACED by `percentEncodedQuery` /// (dropped when nil), fragment/credentials are dropped — the same - /// derivation philosophy as `HostEndpoint.wsURL`. Origin stamping - /// happens HERE and only here (single point; hand-stamping elsewhere is a - /// review CRITICAL, plan §5.1). - func urlRequest(for endpoint: HostEndpoint) -> URLRequest? { + /// derivation philosophy as `HostEndpoint.wsURL`. + /// + /// **Every header this client sends is stamped HERE and only here** (single + /// point; hand-stamping elsewhere is a review CRITICAL, plan §5.1): + /// - `Origin` **iff** `.guarded` — the security split; + /// - `Cookie: webterm_auth=` iff a token is configured — ORTHOGONAL to + /// the Origin rule (ios-completion §1.1: the token never REPLACES Origin, + /// both travel together, on RO and G alike); + /// - `Accept: application/json` always — an `Accept` containing `text/html` + /// makes the server treat the request as a browser navigation and answer + /// 302→/login instead of 401/204 (src/server.ts:366-369,459). + /// + /// `accessToken` MUST already be shape-validated (`AccessTokenRule`); the + /// charset check is what makes a header-injection value impossible here. + func urlRequest(for endpoint: HostEndpoint, accessToken: String? = nil) -> URLRequest? { guard var components = URLComponents( url: endpoint.baseURL, resolvingAgainstBaseURL: true ) else { return nil } @@ -78,9 +111,15 @@ struct APIRoute: Sendable, Equatable { var request = URLRequest(url: url) request.httpMethod = method.rawValue + request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.accept) if originPolicy == .guarded { request.setValue(endpoint.originHeader, forHTTPHeaderField: HeaderName.origin) } + if let accessToken { + request.setValue( + AuthCookie.headerValue(for: accessToken), forHTTPHeaderField: HeaderName.cookie + ) + } if let body { request.httpBody = body request.setValue(ContentTypeValue.json, forHTTPHeaderField: HeaderName.contentType) @@ -89,17 +128,24 @@ struct APIRoute: Sendable, Equatable { } } -/// Builders for the frozen endpoints (plan §3.4 + T-iOS-38 P1 增量). Route -/// table (verified against src/server.ts): -/// - RO — `GET /live-sessions` (:257) · `GET /live-sessions/:id/preview` (:314) -/// · `GET /live-sessions/:id/events` (:528) · `GET /config/ui` (:609) -/// · `GET /projects` (:262) · `GET /projects/detail?path=` (:293) -/// · `GET /prefs` (:273) -/// - G — `DELETE /live-sessions/:id` (:354) · `POST /hook/decision` (:503) -/// · `PUT /prefs` (:278) · `POST|DELETE /push/apns-token` (frozen T-iOS-20 -/// shape, mirrors `/push/subscribe` :461-498) -/// P1 builders live beside their feature models: `ApnsToken.swift`, -/// `Projects.swift`, `Prefs.swift` (T-iOS-38 single owner). +/// Builders for the frozen endpoints (plan §3.4 · T-iOS-38 P1 · B1 增量). Route +/// table (verified against src/server.ts at the line numbers shown): +/// - RO — `GET /live-sessions` (:485) · `GET /live-sessions/:id/preview` (:585) +/// · `GET /live-sessions/:id/events` (:984) · `GET /config/ui` (:1320) +/// · `GET /projects` (:507) · `GET /projects/detail?path=` (:564) +/// · `GET /prefs` (:518) · `GET /projects/log?path=[&n=]` (:1030) +/// · `GET /projects/pr?path=` (:1058) +/// · `GET /projects/worktree/state?path=` (:542) · `GET /sessions` (:479) +/// - G — `DELETE /live-sessions/:id` (:689) · `POST /hook/decision` (:959) +/// · `PUT /prefs` (:523) · `POST|DELETE /push/apns-token` (:871/:892) +/// · `POST /auth` (:393) · `POST /live-sessions/:id/queue` (:605) +/// · `POST /projects/git/stage|commit|push|fetch` (:1184/:1219/:1256/:1290) +/// · `POST /projects/worktree` (:1095) · `DELETE /projects/worktree` (:1124) +/// · `POST /projects/worktree/prune` (:1154) +/// Builders live beside their feature models: `ApnsToken.swift`, +/// `Projects.swift`, `Prefs.swift`, `AccessToken.swift`, `GitLog.swift`, +/// `PrStatus.swift`, `WorktreeState.swift`, `GitWrite.swift`, `History.swift`, +/// `FollowupQueue.swift`. enum Endpoints { /// Strict RFC 3986 unreserved set — everything else gets percent-encoded. /// Deliberately stricter than `.urlQueryAllowed`: a bare `+` in a query is @@ -108,6 +154,21 @@ enum Endpoints { static let unreservedCharacters = CharacterSet( charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" ) + + /// THE single percent-encoding choke point for every query value in this + /// package (`?path=`, and anything added later). nil = not encodable → + /// callers surface `.invalidRequest` instead of building a broken URL. + static func percentEncode(_ value: String) -> String? { + value.addingPercentEncoding(withAllowedCharacters: unreservedCharacters) + } + + /// `path=` — the query shared by every `/projects/*` read + /// route (`detail`, `log`, `pr`, `worktree/state`). One builder, so a new + /// route cannot re-introduce the `+`-decodes-to-space class of bug. + static func pathQuery(_ path: String) -> String? { + percentEncode(path).map { "path=\($0)" } + } + static func liveSessions() -> APIRoute { APIRoute(method: .get, path: "/live-sessions", originPolicy: .readOnly, body: nil) } @@ -155,7 +216,7 @@ enum Endpoints { /// Server session ids are lowercase `crypto.randomUUID()` strings and /// `:id` route params are matched as EXACT strings — always serialize /// lowercase (same rule as `MessageCodec`'s attach encoding). - private static func pathId(_ id: UUID) -> String { + static func pathId(_ id: UUID) -> String { id.uuidString.lowercased() } diff --git a/ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift b/ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift new file mode 100644 index 0000000..5f247d7 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift @@ -0,0 +1,83 @@ +import Foundation +import WireProtocol + +// B1 · `POST /live-sessions/:id/queue` (src/server.ts:605-643) — **G**. +// State-changing (it causes shell input on the next idle) → Origin guard + per-IP +// rate limit. Body: `{text, appendEnter?}`. +// +// BYTE-SHUTTLE (the project's central invariant): the bytes are stored and later +// injected VERBATIM. The server never parses them as a shell command, and +// neither does this client — `text` is passed through untouched, exactly like a +// keystroke. The FE owns the Enter decision (`appendEnter` → a trailing `\r`, +// 0x0D — never `\n`), so the stored entry is byte-identical to what typing it +// would have produced. + +/// `POST /live-sessions/:id/queue` 200 body — the queue's new depth. +struct QueueDepth: Decodable { + let length: Int +} + +extension Endpoints { + /// `POST /live-sessions/:id/queue` — G. `appendEnter` is always serialized + /// (the server reads `=== true`, so an explicit `false` is honest and + /// symmetric rather than relying on an absent-key default). + static func enqueueFollowup( + sessionId: UUID, text: String, appendEnter: Bool + ) throws -> APIRoute { + APIRoute( + method: .post, path: "/live-sessions/\(pathId(sessionId))/queue", + originPolicy: .guarded, + body: try JSONEncoder().encode( + FollowupBody(text: text, appendEnter: appendEnter) + ) + ) + } + + private struct FollowupBody: Encodable { + let text: String + let appendEnter: Bool + } +} + +extension APIClient { + /// Enqueue a follow-up prompt, fired into the PTY on the session's next idle + /// (w2). Returns the queue's NEW depth. + /// + /// G → `Origin` byte-equal. Server-enforced limits (theirs, documented for + /// callers): body ≤ 16 KB, `text` + optional `\r` ≤ `queueItemMaxBytes` + /// (→ 413), depth ≤ `queueMaxItems` (→ 409, never a silent drop), per-IP + /// rate limit (→ 429), `QUEUE_ENABLED=0` (→ 503). + /// An empty `text` is rejected before any network I/O (mirrors the 400 rule). + @discardableResult + public func enqueueFollowup( + sessionId: UUID, text: String, appendEnter: Bool + ) async throws -> Int { + guard !text.isEmpty else { + throw APIClientError.queueTextInvalid + } + let route = try Endpoints.enqueueFollowup( + sessionId: sessionId, text: text, appendEnter: appendEnter + ) + let (data, response) = try await perform(route) + switch response.statusCode { + case HTTPStatus.ok: + return try Self.decodeObject(QueueDepth.self, from: data).length + case HTTPStatus.badRequest: + throw APIClientError.queueTextInvalid + case HTTPStatus.forbidden: + throw APIClientError.forbidden + case HTTPStatus.notFound: + throw APIClientError.sessionNotFound + case HTTPStatus.conflict: + throw APIClientError.queueFull + case HTTPStatus.payloadTooLarge: + throw APIClientError.queueTextTooLarge + case HTTPStatus.tooManyRequests: + throw APIClientError.rateLimited + case HTTPStatus.serviceUnavailable: + throw APIClientError.queueDisabled + default: + throw APIClientError.unexpectedStatus(response.statusCode) + } + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/GitLog.swift b/ios/Packages/APIClient/Sources/APIClient/GitLog.swift new file mode 100644 index 0000000..3bd1c80 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/GitLog.swift @@ -0,0 +1,110 @@ +import Foundation +import WireProtocol + +// B1 · `GET /projects/log?path=[&n=]` (src/server.ts:1030-1056) — RO, NO Origin. +// Response = `src/types.ts:757-772` `GitLogResult`. Always 200 on a valid git +// dir (a git failure degrades to an empty commit list server-side); `path` +// missing → 400, non-git dir → 404, read failure → 500. + +/// One commit from `git log` (src/types.ts:757-763). `hash` and `at` are +/// REQUIRED — a commit without them cannot be rendered or opened, so the entry +/// is dropped while its siblings survive. `at` = `%ct * 1000` (epoch ms, an +/// integer — unlike the `stat()`-derived timestamps elsewhere). +/// +/// Every field is INERT display text: render as plain text, never autolink, +/// never pass to a shell. +public struct CommitLogEntry: Sendable, Equatable { + public let hash: String + public let at: Int + public let subject: String + /// w6/G4: reachable from HEAD but not from `@{u}`. nil = the server did not + /// say (no upstream to compare against) — which is NOT the same as `false`. + public let unpushed: Bool? + + public init(hash: String, at: Int, subject: String = "", unpushed: Bool? = nil) { + self.hash = hash + self.at = at + self.subject = subject + self.unpushed = unpushed + } +} + +extension CommitLogEntry: Decodable { + private enum CodingKeys: String, CodingKey { + case hash, at, subject, unpushed + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hash = try container.decode(String.self, forKey: .hash) + at = try container.decode(Int.self, forKey: .at) + subject = (try? container.decode(String.self, forKey: .subject)) ?? "" + unpushed = try? container.decode(Bool.self, forKey: .unpushed) + } +} + +/// `GET /projects/log` result (src/types.ts:765-772). +public struct GitLogResult: Sendable, Equatable { + public let commits: [CommitLogEntry] + /// More commits exist beyond the server's cap. + public let truncated: Bool + /// w6/G4: upstream short name, used to label the pushed/unpushed boundary. + /// nil ⇒ nothing to compare against, so NO boundary may be drawn. + public let upstream: String? + + public init(commits: [CommitLogEntry], truncated: Bool = false, upstream: String? = nil) { + self.commits = commits + self.truncated = truncated + self.upstream = upstream + } +} + +extension GitLogResult: Decodable { + private enum CodingKeys: String, CodingKey { + case commits, truncated, upstream + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + commits = LossyList.decode(CommitLogEntry.self, in: container, forKey: .commits) + truncated = (try? container.decode(Bool.self, forKey: .truncated)) ?? false + upstream = try? container.decode(String.self, forKey: .upstream) + } +} + +extension Endpoints { + /// Mirror of `src/http/git-log.ts:32` `GIT_LOG_MAX` — the server's `?n=` + /// clamp ceiling. Clamping client-side too keeps the URL honest about what + /// will come back (the server re-clamps regardless). + static let gitLogMaxCount = 50 + static let gitLogMinCount = 1 + + /// `GET /projects/log?path=[&n=]` — RO, no Origin. nil = `path` could not be + /// percent-encoded. A nil `n` omits the parameter (server default applies). + static func gitLog(path: String, n: Int?) -> APIRoute? { + guard var query = pathQuery(path) else { return nil } + if let n { + query += "&n=\(min(max(n, gitLogMinCount), gitLogMaxCount))" + } + return APIRoute( + method: .get, path: "/projects/log", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } +} + +extension APIClient { + /// `GET /projects/log?path=[&n=]` — the repo's recent commits. RO → no + /// Origin. `n` is clamped to `1...50`; nil leaves it to the server. + /// 400/404/500 → `.projectPathInvalid` / `.projectNotFound` / + /// `.gitDataUnavailable`; an empty path is rejected before any network I/O. + public func gitLog(path: String, n: Int? = nil) async throws -> GitLogResult { + try Self.requireNonEmptyPath(path) + guard let route = Endpoints.gitLog(path: path, n: n) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + try Self.requireGitReadOK(response) + return try Self.decodeObject(GitLogResult.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/GitWrite.swift b/ios/Packages/APIClient/Sources/APIClient/GitWrite.swift new file mode 100644 index 0000000..9d1a0ee --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/GitWrite.swift @@ -0,0 +1,377 @@ +import Foundation +import WireProtocol + +// B1 · the seven **G** (state-changing) git/worktree routes — the highest-risk +// channel in the app. Every one of them: `Origin` (CSRF) → `gitOpsEnabled` / +// `worktreeEnabled` kill-switch (403) → per-IP rate limit (429) → +// `isValidGitDir` three-prong (404). Sources: +// POST /projects/git/stage src/server.ts:1184-1218 {path,files,stage} +// POST /projects/git/commit src/server.ts:1219-1255 {path,message} +// POST /projects/git/push src/server.ts:1256-1289 {path} +// POST /projects/git/fetch src/server.ts:1290-1319 {path} +// POST /projects/worktree src/server.ts:1095-1123 {path,branch[,base]} +// DELETE /projects/worktree src/server.ts:1124-1153 {path,worktreePath,force} +// POST /projects/worktree/prune src/server.ts:1154-1183 {path} +// +// The remote/branch/refspec of push and fetch are ALWAYS derived server-side — +// this client cannot point them at an arbitrary URL, and never force-pushes. +// Failure bodies carry the server's already-classified, already-sanitized `error` +// string only (never raw git stderr, SEC-M10): the client shows it verbatim. + +/// Outcome of one guarded git write. Three shapes, because the server gives +/// exactly three: +/// - `.ok` — 200 with the route's payload; +/// - `.rejected` — a 4xx/5xx carrying the server's SAFE `error` message, to be +/// displayed INERTLY. **403 is overloaded** (the Origin guard AND the feature +/// kill-switch both answer 403) and the client cannot tell them apart by +/// status, so it surfaces the message instead of inventing a typed variant; +/// - `.rateLimited` — 429. Do NOT auto-retry (that is what the limiter is for). +public enum GitWriteOutcome: Sendable, Equatable { + case ok(Payload) + case rejected(status: Int, message: String?) + case rateLimited +} + +/// A guarded write's 200 payload. `degraded` is what a 200 with a missing or +/// garbled body decodes to: the write ALREADY HAPPENED, so a bad body must not +/// be reported as a failure — and the fallback must not be a crash path either. +protocol GitWritePayload: Decodable, Sendable, Equatable { + static var degraded: Self { get } +} + +// MARK: - Per-route 200 payloads + +/// `POST /projects/git/stage` → `{ok,staged,count}`. +public struct StageResult: Sendable, Equatable, Decodable { + /// true = files were staged (`git add`); false = unstaged (`git restore --staged`). + public let staged: Bool + public let count: Int + + public init(staged: Bool = false, count: Int = 0) { + self.staged = staged + self.count = count + } + + private enum CodingKeys: String, CodingKey { case staged, count } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + staged = (try? container.decode(Bool.self, forKey: .staged)) ?? false + count = LossyNumber.int(in: container, forKey: .count) ?? 0 + } +} + +/// `POST /projects/git/commit` → `{ok,commit}` (short sha; `""` is possible). +public struct CommitResult: Sendable, Equatable, Decodable { + public let commit: String + + public init(commit: String = "") { + self.commit = commit + } + + private enum CodingKeys: String, CodingKey { case commit } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + commit = (try? container.decode(String.self, forKey: .commit)) ?? "" + } +} + +/// `POST /projects/git/push` → `{ok,branch,remote}` (both server-derived). +public struct PushResult: Sendable, Equatable, Decodable { + public let branch: String? + public let remote: String? + + public init(branch: String? = nil, remote: String? = nil) { + self.branch = branch + self.remote = remote + } + + private enum CodingKeys: String, CodingKey { case branch, remote } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + branch = try? container.decode(String.self, forKey: .branch) + remote = try? container.decode(String.self, forKey: .remote) + } +} + +/// `POST /projects/git/fetch` → `{ok,remote,lastFetchMs}`. `lastFetchMs` is the +/// post-fetch `FETCH_HEAD` mtime — fractional (Double), same as `SyncState`. +/// nil means the mtime could not be read: the UI must NOT then claim the +/// `behind` count was freshly verified. +public struct FetchResult: Sendable, Equatable, Decodable { + public let remote: String? + public let lastFetchMs: Double? + + public init(remote: String? = nil, lastFetchMs: Double? = nil) { + self.remote = remote + self.lastFetchMs = lastFetchMs + } + + private enum CodingKeys: String, CodingKey { case remote, lastFetchMs } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + remote = try? container.decode(String.self, forKey: .remote) + lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs) + } +} + +/// `POST /projects/worktree` → `{ok,path,branch}` (git's canonical values). +public struct CreateWorktreeResult: Sendable, Equatable, Decodable { + public let path: String? + public let branch: String? + + public init(path: String? = nil, branch: String? = nil) { + self.path = path + self.branch = branch + } + + private enum CodingKeys: String, CodingKey { case path, branch } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try? container.decode(String.self, forKey: .path) + branch = try? container.decode(String.self, forKey: .branch) + } +} + +/// `DELETE /projects/worktree` → `{ok,path}` (the canonical path removed). +public struct RemoveWorktreeResult: Sendable, Equatable, Decodable { + public let path: String? + + public init(path: String? = nil) { + self.path = path + } + + private enum CodingKeys: String, CodingKey { case path } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try? container.decode(String.self, forKey: .path) + } +} + +/// `POST /projects/worktree/prune` → `{ok,pruned}`. An empty list means +/// "nothing to prune" — the route is idempotent. +public struct PruneWorktreesResult: Sendable, Equatable, Decodable { + public let pruned: [String] + + public init(pruned: [String] = []) { + self.pruned = pruned + } + + private enum CodingKeys: String, CodingKey { case pruned } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pruned = (try? container.decode([String].self, forKey: .pruned)) ?? [] + } +} + +// All-defaults fallbacks, declared next to nothing else so they stay one line +// each and cannot drift from the memberwise defaults above. +extension StageResult: GitWritePayload { static var degraded: Self { .init() } } +extension CommitResult: GitWritePayload { static var degraded: Self { .init() } } +extension PushResult: GitWritePayload { static var degraded: Self { .init() } } +extension FetchResult: GitWritePayload { static var degraded: Self { .init() } } +extension CreateWorktreeResult: GitWritePayload { static var degraded: Self { .init() } } +extension RemoveWorktreeResult: GitWritePayload { static var degraded: Self { .init() } } +extension PruneWorktreesResult: GitWritePayload { static var degraded: Self { .init() } } + +/// Shape of a failure body: the worktree routes emit `{error}`, git-ops +/// `{ok:false,error}`. Both carry `error` as a SAFE string. +private struct GitErrorBody: Decodable { + let error: String? +} + +// MARK: - Request bodies (frozen field-for-field against src/server.ts) + +private struct StageBody: Encodable { + let path: String + let files: [String] + let stage: Bool +} + +private struct CommitBody: Encodable { + let path: String + let message: String +} + +/// `{path}` — push · fetch · worktree prune all take exactly this. +private struct RepoPathBody: Encodable { + let path: String +} + +private struct CreateWorktreeBody: Encodable { + let path: String + let branch: String + /// Omitted from the JSON when nil — the server reads a missing `base` as + /// "branch from HEAD", and an explicit `null`/`""` is NOT the same thing. + let base: String? +} + +private struct RemoveWorktreeBody: Encodable { + let path: String + let worktreePath: String + let force: Bool +} + +// MARK: - Routes + +extension Endpoints { + /// Every guarded git write shares this shape: JSON body + `Origin`, and + /// `.routeDefined` 401 — `src/http/git-ops.ts:108` classifies a HOST-side + /// git credential failure as 401 ("Push authentication required on the + /// host."), which must not be mistaken for the access-token gate telling us + /// to enter a token. + private static func gitWriteRoute( + _ method: HTTPMethod, _ path: String, _ body: Body + ) throws -> APIRoute { + APIRoute( + method: method, path: path, originPolicy: .guarded, + body: try JSONEncoder().encode(body), + unauthorizedPolicy: .routeDefined + ) + } + + static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute { + try gitWriteRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage)) + } + + static func gitCommit(path: String, message: String) throws -> APIRoute { + try gitWriteRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message)) + } + + static func gitPush(path: String) throws -> APIRoute { + try gitWriteRoute(.post, "/projects/git/push", RepoPathBody(path: path)) + } + + static func gitFetch(path: String) throws -> APIRoute { + try gitWriteRoute(.post, "/projects/git/fetch", RepoPathBody(path: path)) + } + + static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute { + try gitWriteRoute( + .post, "/projects/worktree", + CreateWorktreeBody(path: path, branch: branch, base: base) + ) + } + + /// DELETE **with** a JSON body — the server reads `express.json` here + /// (src/server.ts:1124), so this is the wire shape, unusual as it looks. + static func removeWorktree(path: String, worktreePath: String, force: Bool) throws -> APIRoute { + try gitWriteRoute( + .delete, "/projects/worktree", + RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force) + ) + } + + static func pruneWorktrees(path: String) throws -> APIRoute { + try gitWriteRoute(.post, "/projects/worktree/prune", RepoPathBody(path: path)) + } +} + +// MARK: - Client calls + +extension APIClient { + /// Stage (`stage: true` → `git add`) or unstage (`false` → + /// `git restore --staged`) specific files. The file list is capped and + /// realpath-contained server-side; body limit 64 KB. + public func gitStage( + path: String, files: [String], stage: Bool + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, StageResult.self) { + try Endpoints.gitStage(path: path, files: files, stage: stage) + } + } + + /// Commit the STAGED changes. `message` is length-capped server-side and + /// passed as a single `-m ` argv — never a shell string, never a + /// pathspec. An empty message comes back as `.rejected(400, …)`. + public func gitCommit( + path: String, message: String + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, CommitResult.self) { + try Endpoints.gitCommit(path: path, message: message) + } + } + + /// Push the current branch to its existing upstream, or `-u + /// ` when it has none. Never a force-push; the remote is derived + /// server-side. Tighter rate limit than stage/commit (network-bound). + public func gitPush(path: String) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, PushResult.self) { + try Endpoints.gitPush(path: path) + } + } + + /// Refresh remote-tracking refs (`refs/remotes` only — never a pull) so the + /// panel's `behind` stops being a stale guess. + public func gitFetch(path: String) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, FetchResult.self) { + try Endpoints.gitFetch(path: path) + } + } + + /// Create a git worktree (`base` nil ⇒ branch from HEAD). The only + /// write-to-disk feature; gated by `WORKTREE_ENABLED` (403 when off). + public func createWorktree( + path: String, branch: String, base: String? + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, CreateWorktreeResult.self) { + try Endpoints.createWorktree(path: path, branch: branch, base: base) + } + } + + /// Remove a worktree. Destructive: a dirty worktree needs `force: true` + /// (otherwise the server answers 409 with a safe message), and the main + /// worktree can never be removed (400). + public func removeWorktree( + path: String, worktreePath: String, force: Bool + ) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, RemoveWorktreeResult.self) { + try Endpoints.removeWorktree(path: path, worktreePath: worktreePath, force: force) + } + } + + /// Prune stale worktree registrations (idempotent — an empty `pruned` list + /// means there was nothing to reclaim). + public func pruneWorktrees(path: String) async throws -> GitWriteOutcome { + try await performGitWrite(path: path, PruneWorktreesResult.self) { + try Endpoints.pruneWorktrees(path: path) + } + } + + /// The ONE place a guarded git write is executed and its status mapped, so + /// all seven routes cannot drift apart: empty path rejected before any + /// network I/O → 200 decoded (a garbled payload degrades to defaults rather + /// than throwing — the write already happened) → 429 `.rateLimited` → + /// everything else `.rejected` with the server's safe message. + private func performGitWrite( + path: String, + _ payload: Payload.Type, + route build: () throws -> APIRoute + ) async throws -> GitWriteOutcome { + try Self.requireNonEmptyPath(path) + let (data, response) = try await perform(try build()) + switch response.statusCode { + case HTTPStatus.ok: + let decoded = try? JSONDecoder().decode(Payload.self, from: data) + return .ok(decoded ?? Payload.degraded) + case HTTPStatus.tooManyRequests: + return .rateLimited + default: + return .rejected( + status: response.statusCode, message: Self.decodeGitError(from: data) + ) + } + } + + /// The server's SAFE `error` string, or nil when the body is empty/ + /// unparseable — the client never invents a reason. + private static func decodeGitError(from data: Data) -> String? { + (try? JSONDecoder().decode(GitErrorBody.self, from: data))?.error + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/History.swift b/ios/Packages/APIClient/Sources/APIClient/History.swift new file mode 100644 index 0000000..08508fd --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/History.swift @@ -0,0 +1,90 @@ +import Foundation +import WireProtocol + +// B1 · `GET /sessions` (src/server.ts:479-481) — RO, NO Origin. Response = +// `src/http/history.ts:13-19` `HistorySession[]`: the host's most recently +// modified Claude Code session files, i.e. the `claude --resume` picker's data +// (T-iOS-32). +// +// SECURITY (Sec H3, accepted upstream risk — documented, not introduced here): +// this route is UNAUTHENTICATED on a token-less host and returns session cwds +// plus the first ~120 chars of each first prompt. That matches the app's threat +// model (the whole app hands a shell to anyone who can reach the port; deploy +// behind Tailscale) — but it means the CLIENT must treat every field as INERT +// display text: never autolink, never interpolate into a shell command. + +/// One past Claude Code session (src/http/history.ts:13-19). +/// +/// `id` stays a **String**, not a UUID: it is the `.jsonl` filename stem the +/// host will pass to `claude --resume ` verbatim. Parsing it as a UUID would +/// buy nothing and would DROP any session whose file was renamed — while the id +/// still resumes fine. Required + non-empty, though: an entry without one cannot +/// be resumed, so it is dropped rather than rendered as a dead row. +public struct HistorySession: Sendable, Equatable { + public let id: String + /// The session's working directory (`""` when the jsonl had none). + public let cwd: String + /// Last cwd segment, for display (`"unknown"` server-side when cwd is empty). + public let project: String + /// The jsonl's mtime in ms. `fs.stat().mtimeMs` is FRACTIONAL on a real host + /// (e.g. `1785390645813.5327`), so this is a Double — decoding it as Int + /// would fail and silently drop every entry (src/http/history.ts:105). + public let mtimeMs: Double + /// First user prompt, whitespace-collapsed and truncated to 120 chars + /// server-side. INERT text. + public let preview: String + + public init(id: String, cwd: String = "", project: String = "", mtimeMs: Double, preview: String = "") { + self.id = id + self.cwd = cwd + self.project = project + self.mtimeMs = mtimeMs + self.preview = preview + } +} + +extension HistorySession: Decodable { + private enum CodingKeys: String, CodingKey { + case id, cwd, project, mtimeMs, preview + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawId = try container.decode(String.self, forKey: .id) + guard !rawId.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .id, in: container, + debugDescription: "empty session id is not resumable" + ) + } + id = rawId + mtimeMs = try container.decode(Double.self, forKey: .mtimeMs) + cwd = (try? container.decode(String.self, forKey: .cwd)) ?? "" + project = (try? container.decode(String.self, forKey: .project)) ?? "" + preview = (try? container.decode(String.self, forKey: .preview)) ?? "" + } +} + +extension Endpoints { + /// `GET /sessions` — RO, no Origin (src/server.ts:479). + static func claudeSessions() -> APIRoute { + APIRoute(method: .get, path: "/sessions", originPolicy: .readOnly, body: nil) + } +} + +extension APIClient { + /// `GET /sessions` — the host's recent Claude Code sessions, newest first + /// (the server sorts by mtime and caps at 50). RO → no Origin. + /// + /// Malformed entries are dropped one by one; a non-array body throws + /// `.invalidResponseBody`. The server answers `[]` (never an error) when + /// `~/.claude/projects` is missing, so an empty list means "no history", + /// not "failed". + public func claudeSessions() async throws -> [HistorySession] { + let (data, response) = try await perform(Endpoints.claudeSessions()) + guard response.statusCode == HTTPStatus.ok else { + throw APIClientError.unexpectedStatus(response.statusCode) + } + return try LossyList.decodeBody(HistorySession.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Models.swift b/ios/Packages/APIClient/Sources/APIClient/Models.swift index d21b3f9..0ca48ef 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Models.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Models.swift @@ -187,6 +187,37 @@ public enum APIClientError: Error, Equatable, Sendable { /// 500 from `GET /projects/detail` — the server failed reading the repo /// (src/server.ts:306-309, body `{error}`). case projectDetailUnavailable + /// **401 from the access-token gate** (src/server.ts:459) — the host has + /// `WEBTERM_TOKEN` set and this request carried no (or a wrong) `webterm_auth` + /// cookie. Distinct from a transport failure ON PURPOSE (ios-completion + /// §1.1): the UI must offer "enter the access token", not "retry". + case unauthorized + /// A configured access token violates the frozen shape (16–512 chars of + /// `[A-Za-z0-9._~+/=-]`). Raised BEFORE any network I/O — a shape-invalid + /// token can never match a server token (the server refuses to start with + /// one), and refusing it here is also what makes header injection + /// impossible. + case malformedToken + /// 500 from a read-only git side-channel (`/projects/log`, `/projects/pr`, + /// `/projects/worktree/state`) — the host failed to read git state. + case gitDataUnavailable + /// 404 from `GET /projects/worktree/state` (src/server.ts:549-552) — that + /// path is not a worktree of the repo (removed / never existed). + case worktreeNotFound + /// 503 from `POST /live-sessions/:id/queue` — `QUEUE_ENABLED=0` on the host + /// (src/server.ts:608-611). A configuration state, not a failure to retry. + case queueDisabled + /// 409 from `POST /live-sessions/:id/queue` — the queue is at + /// `queueMaxItems` (src/server.ts:637-641). Never silently dropped. + case queueFull + /// 400 from `POST /live-sessions/:id/queue` — empty text or a malformed + /// session id (src/server.ts:620-627). Also raised client-side for empty + /// text before any network I/O. + case queueTextInvalid + /// 413 from `POST /live-sessions/:id/queue` — text (plus the optional + /// trailing `\r`) exceeds the host's `queueItemMaxBytes` + /// (src/server.ts:632-635). + case queueTextTooLarge /// Any other non-success status code. case unexpectedStatus(Int) @@ -213,6 +244,22 @@ public enum APIClientError: Error, Equatable, Sendable { "项目不存在(路径可能已移动或删除)。" case .projectDetailUnavailable: "读取项目详情失败,请稍后再试。" + case .unauthorized: + "该主机启用了访问令牌,请填写正确的令牌后重试。" + case .malformedToken: + "访问令牌格式不合法:需 16–512 个字符,且只能包含 A-Z a-z 0-9 . _ ~ + / = -。" + case .gitDataUnavailable: + "读取 git 状态失败,请稍后再试。" + case .worktreeNotFound: + "该 worktree 已不存在(可能已被删除或清理)。" + case .queueDisabled: + "该主机关闭了排队注入功能(QUEUE_ENABLED=0)。" + case .queueFull: + "排队已满,请先等前面的任务发出去。" + case .queueTextInvalid: + "要排队的内容为空或会话标识不合法。" + case .queueTextTooLarge: + "内容过长,超出了主机允许的单条上限。" case .unexpectedStatus(let status): "服务器返回了意外状态码 \(status)。" } diff --git a/ios/Packages/APIClient/Sources/APIClient/PrStatus.swift b/ios/Packages/APIClient/Sources/APIClient/PrStatus.swift new file mode 100644 index 0000000..fa17880 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/PrStatus.swift @@ -0,0 +1,155 @@ +import Foundation +import WireProtocol + +// B1 · `GET /projects/pr?path=` (src/server.ts:1058-1076) — RO, NO Origin. +// Response = `src/types.ts:617-648` `PrStatus`. +// +// Out-of-band side-channel: the host spawns its own `gh` CLI, which makes a +// NETWORK call to GitHub with the host's credential. This route NEVER accepts or +// forwards a token, and `GH_ENABLED=0` disables it entirely. A valid git dir +// ALWAYS answers 200: every degrade (gh missing / unauthed / no PR / disabled) +// lives in `availability`, not in the HTTP status — so the client renders one +// chip instead of branching on errors. + +/// Why a `PrStatus` has (or lacks) PR data (src/types.ts:619-625). +/// An unknown/future wire value degrades to `.error`: a new server availability +/// must never make the chip crash or hide the row. +public enum PrAvailability: String, Sendable, Equatable, CaseIterable { + /// A PR exists for the current branch; the sibling fields are populated. + case ok + /// gh works but the branch has no PR (or no remote / default repo). + case noPr = "no-pr" + /// The `gh` binary is not on the host's PATH (ENOENT). + case notInstalled = "not-installed" + /// gh is present but not logged in (needs `gh auth login`). + case unauthenticated + /// `GH_ENABLED=0` — the feature is off and gh is never spawned. + case disabled + /// gh spawned but failed for another reason (timeout, …). Also the + /// unknown/missing fallback. + case error +} + +/// Rolled-up CI check counts from gh's `statusCheckRollup` +/// (src/types.ts:628-633). +public struct PrCheckSummary: Sendable, Equatable { + public let total: Int + public let passing: Int + public let failing: Int + public let pending: Int + + public init(total: Int = 0, passing: Int = 0, failing: Int = 0, pending: Int = 0) { + self.total = total + self.passing = passing + self.failing = failing + self.pending = pending + } +} + +extension PrCheckSummary: Decodable { + private enum CodingKeys: String, CodingKey { + case total, passing, failing, pending + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + total = LossyNumber.int(in: container, forKey: .total) ?? 0 + passing = LossyNumber.int(in: container, forKey: .passing) ?? 0 + failing = LossyNumber.int(in: container, forKey: .failing) ?? 0 + pending = LossyNumber.int(in: container, forKey: .pending) ?? 0 + } +} + +/// `GET /projects/pr` result (src/types.ts:635-648). Everything except +/// `availability` is present only when `availability == .ok`. +/// +/// `state` / `mergeable` stay RAW inert strings (the server already lower-cases +/// gh's `OPEN`/`MERGEABLE`): they are display text, and inventing an enum here +/// would just add a second place for a future gh value to break. +public struct PrStatus: Sendable, Equatable { + public let availability: PrAvailability + public let number: Int? + public let title: String? + public let url: String? + public let state: String? + public let isDraft: Bool? + public let mergeable: String? + public let headRefName: String? + public let baseRefName: String? + public let checks: PrCheckSummary? + + public init( + availability: PrAvailability = .error, + number: Int? = nil, + title: String? = nil, + url: String? = nil, + state: String? = nil, + isDraft: Bool? = nil, + mergeable: String? = nil, + headRefName: String? = nil, + baseRefName: String? = nil, + checks: PrCheckSummary? = nil + ) { + self.availability = availability + self.number = number + self.title = title + self.url = url + self.state = state + self.isDraft = isDraft + self.mergeable = mergeable + self.headRefName = headRefName + self.baseRefName = baseRefName + self.checks = checks + } +} + +extension PrStatus: Decodable { + private enum CodingKeys: String, CodingKey { + case availability, number, title, url, state, isDraft, mergeable + case headRefName, baseRefName, checks + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let rawAvailability = try? container.decode(String.self, forKey: .availability) + availability = rawAvailability.flatMap(PrAvailability.init(rawValue:)) ?? .error + number = LossyNumber.int(in: container, forKey: .number) + title = try? container.decode(String.self, forKey: .title) + url = try? container.decode(String.self, forKey: .url) + state = try? container.decode(String.self, forKey: .state) + isDraft = try? container.decode(Bool.self, forKey: .isDraft) + mergeable = try? container.decode(String.self, forKey: .mergeable) + headRefName = try? container.decode(String.self, forKey: .headRefName) + baseRefName = try? container.decode(String.self, forKey: .baseRefName) + checks = try? container.decode(PrCheckSummary.self, forKey: .checks) + } +} + +extension Endpoints { + /// `GET /projects/pr?path=` — RO, no Origin. nil = `path` not encodable. + static func projectPr(path: String) -> APIRoute? { + pathQuery(path).map { query in + APIRoute( + method: .get, path: "/projects/pr", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } + } +} + +extension APIClient { + /// `GET /projects/pr?path=` — the current branch's PR + CI rollup. RO → no + /// Origin. 400/404/500 → `.projectPathInvalid` / `.projectNotFound` / + /// `.gitDataUnavailable`; an empty path is rejected before any network I/O. + /// Note that a REACHABLE-but-degraded gh is a 200 with a non-`ok` + /// `availability`, NOT an error. + public func prStatus(path: String) async throws -> PrStatus { + try Self.requireNonEmptyPath(path) + guard let route = Endpoints.projectPr(path: path) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + try Self.requireGitReadOK(response) + return try Self.decodeObject(PrStatus.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/Projects.swift b/ios/Packages/APIClient/Sources/APIClient/Projects.swift index dd4bb40..6814ea5 100644 --- a/ios/Packages/APIClient/Sources/APIClient/Projects.swift +++ b/ios/Packages/APIClient/Sources/APIClient/Projects.swift @@ -23,10 +23,13 @@ public struct ProjectSessionRef: Sendable, Equatable { public let clientCount: Int public let createdAt: Int public let exited: Bool + /// w6/G7: where the session runs — lets the UI attribute it to a worktree + /// (src/types.ts:353). Optional additive field; nil on pre-w6 servers. + public let cwd: String? public init( id: UUID, title: String?, status: ClaudeStatus, - clientCount: Int, createdAt: Int, exited: Bool + clientCount: Int, createdAt: Int, exited: Bool, cwd: String? = nil ) { self.id = id self.title = title @@ -34,12 +37,13 @@ public struct ProjectSessionRef: Sendable, Equatable { self.clientCount = clientCount self.createdAt = createdAt self.exited = exited + self.cwd = cwd } } extension ProjectSessionRef: Decodable { private enum CodingKeys: String, CodingKey { - case id, title, status, clientCount, createdAt, exited + case id, title, status, clientCount, createdAt, exited, cwd } public init(from decoder: any Decoder) throws { @@ -53,6 +57,7 @@ extension ProjectSessionRef: Decodable { let rawStatus = try? container.decode(String.self, forKey: .status) status = rawStatus.flatMap(ClaudeStatus.init(rawValue:)) ?? .unknown title = try? container.decode(String.self, forKey: .title) + cwd = try? container.decode(String.self, forKey: .cwd) } } @@ -69,12 +74,22 @@ public struct ProjectInfo: Sendable, Equatable { /// Uncommitted changes; only present when the server runs the dirty check. public let dirty: Bool? /// Newest `~/.claude/projects` mtime for this cwd (ms) — the sort key. + /// Derived from `fs.stat().mtimeMs`, so it arrives FRACTIONAL on a real host + /// and is decoded via `LossyNumber.int` (see there for why a plain + /// `decode(Int.self)` silently nils this field). public let lastActiveMs: Int? + /// W3 sync chip — commits on HEAD not on `@{u}` (src/types.ts:366). + public let ahead: Int? + /// W3 sync chip — commits on `@{u}` not on HEAD (src/types.ts:367). + public let behind: Int? + /// HEAD commit time in ms (`git log -1 --format=%ct * 1000`, an integer). + public let lastCommitMs: Int? public let sessions: [ProjectSessionRef] public init( name: String, path: String, isGit: Bool, branch: String?, - dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef] + dirty: Bool?, lastActiveMs: Int?, sessions: [ProjectSessionRef], + ahead: Int? = nil, behind: Int? = nil, lastCommitMs: Int? = nil ) { self.name = name self.path = path @@ -82,6 +97,9 @@ public struct ProjectInfo: Sendable, Equatable { self.branch = branch self.dirty = dirty self.lastActiveMs = lastActiveMs + self.ahead = ahead + self.behind = behind + self.lastCommitMs = lastCommitMs self.sessions = sessions } } @@ -89,6 +107,7 @@ public struct ProjectInfo: Sendable, Equatable { extension ProjectInfo: Decodable { private enum CodingKeys: String, CodingKey { case name, path, isGit, branch, dirty, lastActiveMs, sessions + case ahead, behind, lastCommitMs } public init(from decoder: any Decoder) throws { @@ -98,7 +117,10 @@ extension ProjectInfo: Decodable { isGit = try container.decode(Bool.self, forKey: .isGit) branch = try? container.decode(String.self, forKey: .branch) dirty = try? container.decode(Bool.self, forKey: .dirty) - lastActiveMs = try? container.decode(Int.self, forKey: .lastActiveMs) + lastActiveMs = LossyNumber.int(in: container, forKey: .lastActiveMs) + ahead = LossyNumber.int(in: container, forKey: .ahead) + behind = LossyNumber.int(in: container, forKey: .behind) + lastCommitMs = LossyNumber.int(in: container, forKey: .lastCommitMs) sessions = LossyList.decode(ProjectSessionRef.self, in: container, forKey: .sessions) } @@ -106,10 +128,7 @@ extension ProjectInfo: Decodable { /// `invalidResponseBody`; malformed elements dropped one by one (same /// pattern as `LiveSessionInfo.decodeList`). static func decodeList(from data: Data) throws -> [ProjectInfo] { - guard let entries = try? JSONDecoder().decode([LossyBox].self, from: data) else { - throw APIClientError.invalidResponseBody - } - return entries.compactMap(\.value) + try LossyList.decodeBody(ProjectInfo.self, from: data) } } @@ -164,6 +183,11 @@ public struct ProjectDetail: Sendable, Equatable { public let isGit: Bool public let branch: String? public let dirty: Bool? + /// w6/G1: `git status --porcelain` line count, same gate as `dirty` + /// (src/types.ts:388). nil ⇒ the host has the dirty check off, NOT "clean". + public let dirtyCount: Int? + /// w6/G1: upstream sync state; nil for a non-git dir (src/types.ts:389). + public let sync: SyncState? public let worktrees: [WorktreeInfo] public let sessions: [ProjectSessionRef] public let hasClaudeMd: Bool @@ -173,13 +197,16 @@ public struct ProjectDetail: Sendable, Equatable { public init( name: String, path: String, isGit: Bool, branch: String?, dirty: Bool?, worktrees: [WorktreeInfo], sessions: [ProjectSessionRef], - hasClaudeMd: Bool, claudeMd: String? + hasClaudeMd: Bool, claudeMd: String?, + dirtyCount: Int? = nil, sync: SyncState? = nil ) { self.name = name self.path = path self.isGit = isGit self.branch = branch self.dirty = dirty + self.dirtyCount = dirtyCount + self.sync = sync self.worktrees = worktrees self.sessions = sessions self.hasClaudeMd = hasClaudeMd @@ -190,6 +217,7 @@ public struct ProjectDetail: Sendable, Equatable { extension ProjectDetail: Decodable { private enum CodingKeys: String, CodingKey { case name, path, isGit, branch, dirty, worktrees, sessions, hasClaudeMd, claudeMd + case dirtyCount, sync } public init(from decoder: any Decoder) throws { @@ -199,6 +227,8 @@ extension ProjectDetail: Decodable { isGit = try container.decode(Bool.self, forKey: .isGit) branch = try? container.decode(String.self, forKey: .branch) dirty = try? container.decode(Bool.self, forKey: .dirty) + dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount) + sync = try? container.decode(SyncState.self, forKey: .sync) hasClaudeMd = (try? container.decode(Bool.self, forKey: .hasClaudeMd)) ?? false claudeMd = try? container.decode(String.self, forKey: .claudeMd) worktrees = LossyList.decode(WorktreeInfo.self, in: container, forKey: .worktrees) @@ -206,31 +236,6 @@ extension ProjectDetail: Decodable { } } -// MARK: - Lossy decoding helpers (shared per-element tolerance) - -/// Per-element tolerance shim: a malformed element becomes nil instead of -/// failing the whole array (same pattern as WireProtocol's TimelineEvent). -struct LossyBox: Decodable { - let value: Wrapped? - - init(from decoder: any Decoder) { - value = try? Wrapped(from: decoder) - } -} - -enum LossyList { - /// Decode `[Element]` at `key`, dropping malformed elements; a missing or - /// wrong-typed array degrades to `[]`. - static func decode( - _ type: Element.Type, - in container: KeyedDecodingContainer, - forKey key: Key - ) -> [Element] { - let boxes = (try? container.decode([LossyBox].self, forKey: key)) ?? [] - return boxes.compactMap(\.value) - } -} - // MARK: - Routes + client calls extension Endpoints { @@ -240,17 +245,17 @@ extension Endpoints { } /// `GET /projects/detail?path=` — RO, no Origin (src/server.ts:293-310). - /// The ONE place `path` gets percent-encoded (strict unreserved-only set — - /// see `unreservedCharacters` for why `.urlQueryAllowed` is not enough). - /// nil = the path could not be encoded (surfaced as `.invalidRequest`). + /// `path` is encoded by the shared `pathQuery` choke point (strict + /// unreserved-only set — see `unreservedCharacters` for why + /// `.urlQueryAllowed` is not enough). nil = the path could not be encoded + /// (surfaced as `.invalidRequest`). static func projectDetail(path: String) -> APIRoute? { - guard let encoded = path.addingPercentEncoding( - withAllowedCharacters: unreservedCharacters - ) else { return nil } - return APIRoute( - method: .get, path: "/projects/detail", originPolicy: .readOnly, - body: nil, percentEncodedQuery: "path=\(encoded)" - ) + pathQuery(path).map { query in + APIRoute( + method: .get, path: "/projects/detail", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } } } diff --git a/ios/Packages/APIClient/Sources/APIClient/Tolerance.swift b/ios/Packages/APIClient/Sources/APIClient/Tolerance.swift new file mode 100644 index 0000000..2dc3cb6 --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/Tolerance.swift @@ -0,0 +1,64 @@ +import Foundation + +// Shared tolerant-decoding helpers for the UNTRUSTED server boundary (plan §4). +// ONE home for all of them: unknown fields ignored, malformed elements dropped +// one by one, wrong-typed optionals degraded to nil — never a crash, never a +// whole-list failure because of a single bad entry. + +/// Per-element tolerance shim: a malformed element becomes nil instead of +/// failing the whole array (same pattern as WireProtocol's TimelineEvent). +struct LossyBox: Decodable { + let value: Wrapped? + + init(from decoder: any Decoder) { + value = try? Wrapped(from: decoder) + } +} + +enum LossyList { + /// Decode `[Element]` at `key`, dropping malformed elements; a missing or + /// wrong-typed array degrades to `[]`. + static func decode( + _ type: Element.Type, + in container: KeyedDecodingContainer, + forKey key: Key + ) -> [Element] { + let boxes = (try? container.decode([LossyBox].self, forKey: key)) ?? [] + return boxes.compactMap(\.value) + } + + /// Decode a whole top-level `[Element]` body, dropping malformed elements. + /// A non-array top level throws `.invalidResponseBody` — that is the + /// "this port speaks HTTP but is not web-terminal" signal, not a degrade. + static func decodeBody( + _ type: Element.Type, from data: Data + ) throws -> [Element] { + guard let boxes = try? JSONDecoder().decode([LossyBox].self, from: data) else { + throw APIClientError.invalidResponseBody + } + return boxes.compactMap(\.value) + } +} + +enum LossyNumber { + /// Decode an integer-valued field that the server may serialize as a + /// FRACTIONAL JSON number. + /// + /// This is not paranoia: every `*Ms` field derived from `fs.stat().mtimeMs` + /// (`ProjectInfo.lastActiveMs` — src/http/projects.ts:171, + /// `HistorySession.mtimeMs` — src/http/history.ts:105, + /// `SyncState.lastFetchMs` — src/http/projects.ts:170) carries sub-millisecond + /// precision on APFS: a real body reads `1785390645813.5327`. A plain + /// `decode(Int.self)` FAILS on that, so a bare `try?` would silently null the + /// field (or drop the whole entry) against every real server. + static func int( + in container: KeyedDecodingContainer, forKey key: Key + ) -> Int? { + if let exact = try? container.decode(Int.self, forKey: key) { return exact } + guard let fractional = try? container.decode(Double.self, forKey: key), + fractional.isFinite, + fractional >= Double(Int.min), fractional <= Double(Int.max) + else { return nil } + return Int(fractional) + } +} diff --git a/ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift b/ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift new file mode 100644 index 0000000..2433cdb --- /dev/null +++ b/ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift @@ -0,0 +1,120 @@ +import Foundation +import WireProtocol + +// B1 · `GET /projects/worktree/state?path=` (src/server.ts:542-562) — RO, NO +// Origin. Response = `src/types.ts:392-408` (`SyncState` / `WorktreeState`), +// fetched lazily per worktree row (w6/G7) because listing N worktrees eagerly +// would spend a git spawn per row on data nothing renders. + +/// Upstream sync state for one repo or worktree (src/types.ts:392-398). +/// +/// EVERY field degrades independently, and each absence is a NORMAL state: no +/// upstream, detached HEAD, empty repo and never-fetched all leave their field +/// nil. Two rules the UI must honour (they are the reason this type is all +/// optionals instead of zeros): +/// - `ahead` is always trustworthy (local refs only); +/// - `behind` is only as fresh as `lastFetchMs`, because `@{u}` is a locally +/// cached remote ref that only a fetch moves — a stale `behind: 0` must NEVER +/// render as "in sync"; +/// - `upstream == nil` means "nothing to compare against", which is NOT +/// "nothing to push". +public struct SyncState: Sendable, Equatable { + /// e.g. `origin/develop`; nil ⇒ the branch tracks nothing. + public let upstream: String? + /// Commits on HEAD not on `@{u}`. + public let ahead: Int? + /// Commits on `@{u}` not on HEAD — trust only with a fresh `lastFetchMs`. + public let behind: Int? + /// `FETCH_HEAD` mtime (ms); nil ⇒ never fetched. Comes from + /// `fs.stat().mtimeMs`, so it is FRACTIONAL on a real host — hence Double, + /// not Int (src/http/projects.ts readLastFetchMs). + public let lastFetchMs: Double? + /// HEAD is not on a branch ⇒ no branch, no ahead/behind. + public let detached: Bool? + + public init( + upstream: String? = nil, ahead: Int? = nil, behind: Int? = nil, + lastFetchMs: Double? = nil, detached: Bool? = nil + ) { + self.upstream = upstream + self.ahead = ahead + self.behind = behind + self.lastFetchMs = lastFetchMs + self.detached = detached + } +} + +extension SyncState: Decodable { + private enum CodingKeys: String, CodingKey { + case upstream, ahead, behind, lastFetchMs, detached + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + upstream = try? container.decode(String.self, forKey: .upstream) + ahead = LossyNumber.int(in: container, forKey: .ahead) + behind = LossyNumber.int(in: container, forKey: .behind) + lastFetchMs = try? container.decode(Double.self, forKey: .lastFetchMs) + detached = try? container.decode(Bool.self, forKey: .detached) + } +} + +/// The git state of ONE worktree (src/types.ts:402-408). +public struct WorktreeState: Sendable, Equatable { + public let path: String + public let branch: String? + public let sync: SyncState? + /// `git status --porcelain` line count; nil ⇒ the host has the dirty check + /// disabled (`PROJECT_DIRTY_CHECK=0`), which is NOT "clean". + public let dirtyCount: Int? + + public init(path: String, branch: String? = nil, sync: SyncState? = nil, dirtyCount: Int? = nil) { + self.path = path + self.branch = branch + self.sync = sync + self.dirtyCount = dirtyCount + } +} + +extension WorktreeState: Decodable { + private enum CodingKeys: String, CodingKey { + case path, branch, sync, dirtyCount + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + path = try container.decode(String.self, forKey: .path) + branch = try? container.decode(String.self, forKey: .branch) + sync = try? container.decode(SyncState.self, forKey: .sync) + dirtyCount = LossyNumber.int(in: container, forKey: .dirtyCount) + } +} + +extension Endpoints { + /// `GET /projects/worktree/state?path=` — RO, no Origin. + static func worktreeState(path: String) -> APIRoute? { + pathQuery(path).map { query in + APIRoute( + method: .get, path: "/projects/worktree/state", originPolicy: .readOnly, + body: nil, percentEncodedQuery: query + ) + } + } +} + +extension APIClient { + /// `GET /projects/worktree/state?path=` — branch + sync + dirty count for + /// ONE worktree row. RO → no Origin. 400 → `.projectPathInvalid`, + /// **404 → `.worktreeNotFound`** (this route's 404 means "not a worktree of + /// this repo", not "no such project"), 500 → `.gitDataUnavailable`. + /// An empty path is rejected before any network I/O. + public func worktreeState(path: String) async throws -> WorktreeState { + try Self.requireNonEmptyPath(path) + guard let route = Endpoints.worktreeState(path: path) else { + throw APIClientError.invalidRequest + } + let (data, response) = try await perform(route) + try Self.requireGitReadOK(response, notFound: .worktreeNotFound) + return try Self.decodeObject(WorktreeState.self, from: data) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift new file mode 100644 index 0000000..975fa17 --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/AccessTokenTests.swift @@ -0,0 +1,314 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// B1 · 访问令牌(`WEBTERM_TOKEN`)—— ios-completion §1.1 冻结契约。 +/// +/// 服务端事实(`src/http/auth.ts` + `src/server.ts:393-460`): +/// - `POST /auth`,body `{"token":""}`,`Content-Type: application/json`; +/// - **`Accept` 必须不含 `text/html`** —— 含则被当成表单走 302,而不是 204/401; +/// - 204 **有** `Set-Cookie: webterm_auth=…` ⇒ 令牌正确; +/// - 204 **无** `Set-Cookie` ⇒ 该服务器**没开鉴权**(**不得**据此认为"已认证"); +/// - 401 ⇒ 令牌错;429 ⇒ 限流(10 次/分/IP)。 +/// +/// 原生客户端**自己知道令牌**,因此手写 `Cookie: webterm_auth=`,不解析 +/// `Set-Cookie`、不依赖系统 cookie jar。`Cookie` 与 `Origin` **正交**:令牌 +/// **不替代** Origin 检查,两者都要带(`server.ts` 先 Origin 再 cookie)。 +/// +/// 安全:令牌**绝不进 URL query**(`?token=` bootstrap 只给浏览器用)、绝不进日志。 +struct AccessTokenTests { + private static let base = "http://192.168.1.5:3000" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + /// 合法形状:16–512 个 `[A-Za-z0-9._~+/=-]` 字符(CLAUDE.md / config 校验)。 + private static let token = "s3cret-token_value.~+/=" + private static let setCookieValue = + "webterm_auth=\(token); Path=/; Max-Age=2592000; HttpOnly; SameSite=Strict" + + private func makeEndpoint(_ base: String = AccessTokenTests.base) throws -> HostEndpoint { + let url = try #require(URL(string: base)) + return try #require(HostEndpoint(baseURL: url)) + } + + private func routeURL(_ path: String) throws -> URL { + try #require(URL(string: Self.base + path)) + } + + private func makeClient( + token: String?, http: FakeHTTPTransport + ) throws -> APIClient { + APIClient(endpoint: try makeEndpoint(), http: http, accessToken: token) + } + + // MARK: - POST /auth 探针:请求形状 + + @Test("POST /auth:body 恰为 {\"token\":…}、Content-Type=JSON、Accept 不含 text/html、令牌绝不进 URL") + func authProbeRequestShapeIsFrozen() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + method: "POST", url: try routeURL("/auth"), status: 204, + headers: ["Set-Cookie": Self.setCookieValue] + ) + + // Act + _ = try await client.probeAccessToken(Self.token) + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.httpMethod == "POST") + #expect(request.url == (try routeURL("/auth"))) + #expect(request.url?.query == nil) // 令牌绝不进 URL query + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + // /auth 是变更型 POST(签发 cookie)⇒ 照 Origin-iff-G 规则带 Origin(与 Android 一致) + #expect(request.value(forHTTPHeaderField: "Origin") == (try makeEndpoint()).originHeader) + let accept = try #require(request.value(forHTTPHeaderField: "Accept")) + #expect(!accept.contains("text/html")) // 含 text/html 会被当成表单走 302 + let body = try #require(request.httpBody) + let object = try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + #expect(Set(object.keys) == Set(["token"])) + #expect(object["token"] as? String == Self.token) + } + + @Test("每个请求都带 Accept: application/json —— 未认证时服务器才回 401 JSON 而不是 302 登录页") + func everyRequestAcceptsJSONSoTheGateAnswers401NotARedirect() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: Self.token, http: http) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8)) + await http.queueSuccess( + method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204 + ) + + // Act + _ = try await client.liveSessions() + try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString))) + + // Assert + let requests = await http.recordedRequests + #expect(requests.count == 2) + for request in requests { + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + } + } + + // MARK: - POST /auth 探针:四种结局互不混淆 + + @Test("204 + Set-Cookie ⇒ .valid(令牌正确,可保存)") + func probe204WithSetCookieMeansTokenValid() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + method: "POST", url: try routeURL("/auth"), status: 204, + headers: ["Set-Cookie": Self.setCookieValue] + ) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .valid) + } + + @Test("204 但无 Set-Cookie ⇒ .authDisabled(服务器没开鉴权),绝不报成 .valid") + func probe204WithoutSetCookieMeansAuthDisabledNotAuthenticated() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 204) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .authDisabled) + #expect(result != .valid) // 冻结契约:不得据此认为"已认证" + } + + @Test("401 ⇒ .invalidToken(不是 .unauthorized —— /auth 自己的 401 是路由语义)") + func probe401MeansInvalidTokenNotGateUnauthorized() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + method: "POST", url: try routeURL("/auth"), status: 401, + body: Data(#"{"error":"invalid token"}"#.utf8) + ) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .invalidToken) + } + + @Test("429 ⇒ .rateLimited(10 次/分/IP,src/server.ts:399-402)") + func probe429MeansRateLimited() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 429) + + // Act + let result = try await client.probeAccessToken(Self.token) + + // Assert + #expect(result == .rateLimited) + } + + @Test("其他状态码(500) ⇒ unexpectedStatus,不猜测") + func probeUnexpectedStatusThrows() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(method: "POST", url: try routeURL("/auth"), status: 500) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(500)) { + _ = try await client.probeAccessToken(Self.token) + } + } + + @Test("形状非法的令牌(过短/越界字符/过长)联网前就拒:malformedToken,零请求") + func malformedTokenIsRejectedBeforeAnyNetworkIO() async throws { + // Arrange — 服务器启动时就校验 16–512 与字符集,故形状非法者不可能是正确令牌 + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + let malformed = [ + "short", // < 16 + String(repeating: "a", count: 513), // > 512 + "has space in it x", // 越界字符:空格 + "has\r\nCRLF-injection-x", // 越界字符:CRLF(头注入) + "中文令牌中文令牌中文令牌中文令牌", // 越界字符:非 ASCII + ] + + // Act + Assert + for candidate in malformed { + await #expect(throws: APIClientError.malformedToken) { + _ = try await client.probeAccessToken(candidate) + } + } + #expect(await http.recordedRequests.isEmpty) + } + + // MARK: - Cookie 与 Origin 正交 + + @Test("Cookie iff 配置了令牌:RO 带 Cookie 不带 Origin;G 带 Cookie **并且**带 Origin") + func cookieIsOrthogonalToTheOriginRule() async throws { + // Arrange + let http = FakeHTTPTransport() + let endpoint = try makeEndpoint() + let client = APIClient(endpoint: endpoint, http: http, accessToken: Self.token) + let id = try #require(UUID(uuidString: Self.sessionIdString)) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8)) + await http.queueSuccess( + method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 204 + ) + + // Act + _ = try await client.liveSessions() + try await client.killSession(id: id) + + // Assert + let requests = await http.recordedRequests + let readOnly = try #require(requests.first) + let guarded = try #require(requests.last) + #expect(readOnly.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)") + #expect(readOnly.value(forHTTPHeaderField: "Origin") == nil) // RO 仍绝不带 Origin + #expect(guarded.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(Self.token)") + #expect(guarded.value(forHTTPHeaderField: "Origin") == endpoint.originHeader) // 令牌不替代 Origin + } + + @Test("未配置令牌 ⇒ 完全不带 Cookie 头(LAN 零配置行为不变)") + func noTokenConfiguredMeansNoCookieHeaderAtAll() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess(url: try routeURL("/live-sessions"), body: Data("[]".utf8)) + + // Act + _ = try await client.liveSessions() + + // Assert + let request = try #require(await http.recordedRequests.first) + #expect(request.value(forHTTPHeaderField: "Cookie") == nil) + } + + @Test("配置了形状非法的令牌 ⇒ 任何调用联网前抛 malformedToken(绝不静默发无认证请求)") + func malformedConfiguredTokenFailsFastOnEveryCall() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: "bad token", http: http) + + // Act + Assert + await #expect(throws: APIClientError.malformedToken) { + _ = try await client.liveSessions() + } + #expect(await http.recordedRequests.isEmpty) + } + + // MARK: - 401 语义:类型化 .unauthorized,可与网络错区分 + + @Test("普通 RO 端点收 401 ⇒ 类型化 .unauthorized(不是 unexpectedStatus/网络错)") + func gate401OnReadOnlyEndpointSurfacesAsUnauthorized() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: nil, http: http) + await http.queueSuccess( + url: try routeURL("/live-sessions"), status: 401, + body: Data(#"{"error":"authentication required"}"#.utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.unauthorized) { + _ = try await client.liveSessions() + } + } + + @Test("普通 G 端点收 401 ⇒ 同样是 .unauthorized,且话术非空(UI 引导补令牌)") + func gate401OnGuardedEndpointSurfacesAsUnauthorized() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: Self.token, http: http) + await http.queueSuccess( + method: "DELETE", url: try routeURL("/live-sessions/\(Self.sessionIdString)"), status: 401 + ) + + // Act + Assert + await #expect(throws: APIClientError.unauthorized) { + try await client.killSession(id: try #require(UUID(uuidString: Self.sessionIdString))) + } + #expect(!APIClientError.unauthorized.message.isEmpty) + } + + // MARK: - 令牌零泄漏 + + @Test("APIClient 的 description/debugDescription 不含令牌明文(零日志泄漏)") + func clientDescriptionNeverLeaksTheToken() async throws { + // Arrange + let http = FakeHTTPTransport() + let client = try makeClient(token: Self.token, http: http) + + // Act + let rendered = "\(client)" + String(reflecting: client) + + // Assert + #expect(!rendered.contains(Self.token)) + #expect(rendered.contains("redacted")) + } + + @Test("hasAccessToken 只暴露有无,不暴露值") + func hasAccessTokenExposesPresenceOnly() async throws { + // Arrange + Act + let http = FakeHTTPTransport() + let withToken = try makeClient(token: Self.token, http: http) + let without = try makeClient(token: nil, http: http) + + // Assert + #expect(withToken.hasAccessToken) + #expect(!without.hasAccessToken) + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift new file mode 100644 index 0000000..30890ec --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/GitPanelReadTests.swift @@ -0,0 +1,435 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// B1 · git 面板**只读**端点(ios-completion §1.2;真源 = `src/`): +/// - `GET /projects/log?path=[&n=]`(`src/server.ts:1030-1056`)→ `GitLogResult` +/// (`src/types.ts:757-772`,含 w6/G4 的 `unpushed`/`upstream`); +/// - `GET /projects/pr?path=`(`src/server.ts:1058-1076`)→ `PrStatus` +/// (`src/types.ts:617-648`);有效 git 目录一律 200,降级写在 body 的 availability 里; +/// - `GET /projects/worktree/state?path=`(`src/server.ts:542-562`)→ `WorktreeState` +/// (`src/types.ts:392-408`,`sync` 各字段独立降级); +/// - `GET /sessions`(`src/server.ts:479-481`)→ `[HistorySession]` +/// (`src/http/history.ts:13-19`,`claude --resume` 历史)。 +/// +/// 三条 `/projects/*` 都做同样的三叉校验:`path` 缺失 → 400、非 git 目录 → 404、 +/// 读失败 → 500。全部 RO ⇒ **绝不带 Origin**。 +struct GitPanelReadTests { + private static let base = "http://192.168.1.5:3000" + private static let repoPath = "/Users/dev/web-terminal" + private static let encodedRepoPath = "%2FUsers%2Fdev%2Fweb-terminal" + + private struct Fixture { + let http: FakeHTTPTransport + let client: APIClient + } + + private func makeFixture() throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture(http: http, client: APIClient(endpoint: endpoint, http: http)) + } + + private func routeURL(_ pathAndQuery: String) throws -> URL { + try #require(URL(string: Self.base + pathAndQuery)) + } + + // MARK: - Origin iff-G(RO 侧:四个端点一律不带 Origin) + + @Test("Origin iff-G(RO 侧):log/pr/worktree-state/sessions 四个端点均为 GET 且不带 Origin") + func readOnlyGitEndpointsNeverCarryOrigin() async throws { + // Arrange + let fixture = try makeFixture() + let query = "?path=\(Self.encodedRepoPath)" + await fixture.http.queueSuccess( + url: try routeURL("/projects/log\(query)"), body: Data(#"{"commits":[]}"#.utf8) + ) + await fixture.http.queueSuccess( + url: try routeURL("/projects/pr\(query)"), body: Data(#"{"availability":"no-pr"}"#.utf8) + ) + await fixture.http.queueSuccess( + url: try routeURL("/projects/worktree/state\(query)"), + body: Data(#"{"path":"\#(Self.repoPath)"}"#.utf8) + ) + await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data("[]".utf8)) + + // Act + _ = try await fixture.client.gitLog(path: Self.repoPath) + _ = try await fixture.client.prStatus(path: Self.repoPath) + _ = try await fixture.client.worktreeState(path: Self.repoPath) + _ = try await fixture.client.claudeSessions() + + // Assert + let requests = await fixture.http.recordedRequests + #expect(requests.count == 4) + for request in requests { + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Origin") == nil) + } + } + + // MARK: - /projects/log + + @Test("log 的 path 严格 percent-encode(单点);n 缺省则不带 n 参数") + func logPathIsStrictlyEncodedAndNilCountOmitsTheParam() async throws { + // Arrange + let fixture = try makeFixture() + let raw = "/Users/dev/my proj+α" + let encoded = "%2FUsers%2Fdev%2Fmy%20proj%2B%CE%B1" + let expected = try routeURL("/projects/log?path=\(encoded)") + await fixture.http.queueSuccess(url: expected, body: Data(#"{"commits":[]}"#.utf8)) + + // Act + _ = try await fixture.client.gitLog(path: raw, n: nil) + + // Assert + #expect(await fixture.http.recordedRequests.first?.url == expected) + } + + @Test("log 的 n 客户端夹到 [1,50](镜像 src/http/git-log.ts GIT_LOG_MAX,服务端仍会再夹一次)") + func logCountIsClampedClientSide() async throws { + // Arrange + let fixture = try makeFixture() + let body = Data(#"{"commits":[]}"#.utf8) + let cases: [(Int, Int)] = [(0, 1), (-7, 1), (10, 10), (999, 50)] + for (_, clamped) in cases { + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)&n=\(clamped)"), + body: body + ) + } + + // Act + for (requested, _) in cases { + _ = try await fixture.client.gitLog(path: Self.repoPath, n: requested) + } + + // Assert — FakeHTTPTransport 按整 URL 精确匹配,走到这里即夹取正确 + let queries = await fixture.http.recordedRequests.compactMap(\.url?.query) + #expect(queries == cases.map { "path=\(Self.encodedRepoPath)&n=\($0.1)" }) + } + + @Test("GitLogResult 全字段解码(hash/at/subject/unpushed + truncated + upstream,w6/G4)") + func gitLogDecodesFullSample() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"commits":[{"hash":"abc1234","at":1720000000000,"subject":"feat: x","unpushed":true},\ + {"hash":"def5678","at":1719990000000,"subject":"fix: y"}],\ + "truncated":true,"upstream":"origin/develop"} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) + ) + + // Act + let result = try await fixture.client.gitLog(path: Self.repoPath) + + // Assert + #expect(result.commits.count == 2) + #expect(result.truncated) + #expect(result.upstream == "origin/develop") + let first = try #require(result.commits.first) + #expect(first.hash == "abc1234") + #expect(first.at == 1_720_000_000_000) + #expect(first.subject == "feat: x") + #expect(first.unpushed == true) + #expect(result.commits.last?.unpushed == nil) // 缺省 ⇒ nil(不是 false) + } + + @Test("log 容忍:畸形 commit 逐条丢弃、subject 缺省为空、truncated 缺省 false、未知字段忽略") + func gitLogToleratesDegradedShapes() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"commits":[{"hash":"ok1","at":1,"subject":"s"},42,{"at":2},{"hash":"ok2","at":3},\ + {"hash":"bad","at":"nope"}],"futureField":{"x":1}} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) + ) + + // Act + let result = try await fixture.client.gitLog(path: Self.repoPath) + + // Assert + #expect(result.commits.map(\.hash) == ["ok1", "ok2"]) + #expect(result.commits.last?.subject == "") + #expect(!result.truncated) + #expect(result.upstream == nil) + } + + @Test("log 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒") + func gitLogMapsErrorStatuses() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/log?path=\(Self.encodedRepoPath)") + for status in [400, 404, 500] { + await fixture.http.queueSuccess( + url: url, status: status, body: Data(#"{"error":"nope"}"#.utf8) + ) + } + + // Act + Assert + let expected: [APIClientError] = [ + .projectPathInvalid, .projectNotFound, .gitDataUnavailable, + ] + for error in expected { + await #expect(throws: error) { + _ = try await fixture.client.gitLog(path: Self.repoPath) + } + #expect(!error.message.isEmpty) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitLog(path: "") + } + #expect(await fixture.http.recordedRequests.count == 3) // 空 path 未联网 + } + + @Test("log 200 但 body 非对象 → invalidResponseBody") + func gitLogRejectsNonObjectBody() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + url: try routeURL("/projects/log?path=\(Self.encodedRepoPath)"), + body: Data("[1,2]".utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.gitLog(path: Self.repoPath) + } + } + + // MARK: - /projects/pr + + @Test("PrStatus 全字段解码(availability=ok + checks 嵌套计数)") + func prStatusDecodesFullSample() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"availability":"ok","number":42,"title":"feat: x","url":"https://github.com/a/b/pull/42",\ + "state":"open","isDraft":false,"mergeable":"mergeable","headRefName":"feat/x",\ + "baseRefName":"develop","checks":{"total":5,"passing":4,"failing":0,"pending":1}} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/pr?path=\(Self.encodedRepoPath)"), body: Data(body.utf8) + ) + + // Act + let pr = try await fixture.client.prStatus(path: Self.repoPath) + + // Assert + #expect(pr.availability == .ok) + #expect(pr.number == 42) + #expect(pr.state == "open") + #expect(pr.isDraft == false) + #expect(pr.mergeable == "mergeable") + #expect(pr.headRefName == "feat/x") + #expect(pr.baseRefName == "develop") + #expect(pr.checks == PrCheckSummary(total: 5, passing: 4, failing: 0, pending: 1)) + } + + @Test("PrStatus 降级:未知/缺失 availability → .error;各降级值可解;字段缺省 → nil") + func prStatusDegradesUnknownAvailability() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)") + let bodies = [ + #"{"availability":"quantum-ci"}"#, // 未来值 → .error + #"{}"#, // 缺失 → .error + #"{"availability":"not-installed"}"#, + #"{"availability":"unauthenticated"}"#, + #"{"availability":"disabled"}"#, + #"{"availability":"no-pr"}"#, + ] + for body in bodies { + await fixture.http.queueSuccess(url: url, body: Data(body.utf8)) + } + + // Act + var seen: [PrAvailability] = [] + for _ in bodies { + seen.append(try await fixture.client.prStatus(path: Self.repoPath).availability) + } + + // Assert + #expect(seen == [.error, .error, .notInstalled, .unauthenticated, .disabled, .noPr]) + // 非 ok 时兄弟字段一律缺省 → nil(绝不编造 PR 号) + await fixture.http.queueSuccess(url: url, body: Data(#"{"availability":"no-pr"}"#.utf8)) + let degraded = try await fixture.client.prStatus(path: Self.repoPath) + #expect(degraded.number == nil) + #expect(degraded.checks == nil) + } + + @Test("pr 400/404/500 → projectPathInvalid/projectNotFound/gitDataUnavailable;空 path 联网前拒") + func prStatusMapsErrorStatuses() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/pr?path=\(Self.encodedRepoPath)") + for status in [400, 404, 500] { + await fixture.http.queueSuccess(url: url, status: status) + } + + // Act + Assert + for error in [APIClientError.projectPathInvalid, .projectNotFound, .gitDataUnavailable] { + await #expect(throws: error) { + _ = try await fixture.client.prStatus(path: Self.repoPath) + } + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.prStatus(path: "") + } + } + + // MARK: - /projects/worktree/state + + @Test("WorktreeState 全字段解码;sync.lastFetchMs 是 fs.stat().mtimeMs —— **带小数**也必须解出来") + func worktreeStateDecodesFullSampleIncludingFractionalMtime() async throws { + // Arrange — 1785390645813.5327 是真实 stat().mtimeMs 的形状(APFS 纳秒精度) + let fixture = try makeFixture() + let body = """ + {"path":"\(Self.repoPath)","branch":"develop","dirtyCount":3,\ + "sync":{"upstream":"origin/develop","ahead":2,"behind":1,\ + "lastFetchMs":1785390645813.5327,"detached":false}} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"), + body: Data(body.utf8) + ) + + // Act + let state = try await fixture.client.worktreeState(path: Self.repoPath) + + // Assert + #expect(state.path == Self.repoPath) + #expect(state.branch == "develop") + #expect(state.dirtyCount == 3) + let sync = try #require(state.sync) + #expect(sync.upstream == "origin/develop") + #expect(sync.ahead == 2) + #expect(sync.behind == 1) + #expect(sync.detached == false) + let lastFetchMs = try #require(sync.lastFetchMs) + #expect(abs(lastFetchMs - 1_785_390_645_813.5327) < 0.001) + } + + @Test("WorktreeState 各字段独立降级:无 upstream/detached HEAD/从未 fetch 都是正常态") + func worktreeStateDegradesEachFieldIndependently() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"path":"\(Self.repoPath)","sync":{"detached":true},"unknownField":1} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)"), + body: Data(body.utf8) + ) + + // Act + let state = try await fixture.client.worktreeState(path: Self.repoPath) + + // Assert + #expect(state.branch == nil) + #expect(state.dirtyCount == nil) + let sync = try #require(state.sync) + #expect(sync.detached == true) + #expect(sync.upstream == nil) + #expect(sync.ahead == nil) + #expect(sync.behind == nil) + #expect(sync.lastFetchMs == nil) // 从未 fetch —— 绝不编造时间戳 + } + + @Test("worktree/state 400/404/500 → projectPathInvalid/worktreeNotFound/gitDataUnavailable;非对象 body → invalidResponseBody") + func worktreeStateMapsErrorStatuses() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/projects/worktree/state?path=\(Self.encodedRepoPath)") + for status in [400, 404, 500] { + await fixture.http.queueSuccess(url: url, status: status) + } + await fixture.http.queueSuccess(url: url, body: Data("7".utf8)) + + // Act + Assert + for error in [APIClientError.projectPathInvalid, .worktreeNotFound, .gitDataUnavailable] { + await #expect(throws: error) { + _ = try await fixture.client.worktreeState(path: Self.repoPath) + } + #expect(!error.message.isEmpty) + } + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.worktreeState(path: Self.repoPath) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.worktreeState(path: "") + } + } + + // MARK: - GET /sessions(claude --resume 历史) + + @Test("HistorySession 解码:mtimeMs 来自 fs.stat() —— **带小数**必须解出来(否则整条被丢)") + func claudeSessionsDecodeFractionalMtime() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + [{"id":"0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b","cwd":"/Users/dev/web-terminal",\ + "project":"web-terminal","mtimeMs":1785390645813.5327,"preview":"修一下 CJK locale"},\ + {"id":"11111111-2222-4333-8444-555555555555","cwd":"","project":"unknown",\ + "mtimeMs":1700000000000,"preview":""}] + """ + await fixture.http.queueSuccess(url: try routeURL("/sessions"), body: Data(body.utf8)) + + // Act + let sessions = try await fixture.client.claudeSessions() + + // Assert + #expect(sessions.count == 2) + let first = try #require(sessions.first) + #expect(first.id == "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b") + #expect(first.cwd == "/Users/dev/web-terminal") + #expect(first.project == "web-terminal") + #expect(abs(first.mtimeMs - 1_785_390_645_813.5327) < 0.001) + #expect(first.preview == "修一下 CJK locale") + #expect(sessions.last?.mtimeMs == 1_700_000_000_000) // 整数也要能解 + } + + @Test("/sessions 容忍:畸形条目逐条丢弃、可选字段缺省、非数组 body → invalidResponseBody") + func claudeSessionsToleratesDegradedShapes() async throws { + // Arrange + let fixture = try makeFixture() + let url = try routeURL("/sessions") + let degraded = """ + [{"id":"keep-me","mtimeMs":1},"nope",{"cwd":"/x"},{"id":"","mtimeMs":2},\ + {"id":"also-keep","mtimeMs":3,"extra":true}] + """ + await fixture.http.queueSuccess(url: url, body: Data(degraded.utf8)) + await fixture.http.queueSuccess(url: url, body: Data(#"{"error":"x"}"#.utf8)) + + // Act + let sessions = try await fixture.client.claudeSessions() + + // Assert — id 是 `claude --resume ` 的实参,缺失/空 ⇒ 该条无用,丢弃 + #expect(sessions.map(\.id) == ["keep-me", "also-keep"]) + #expect(sessions.first?.cwd == "") + #expect(sessions.first?.project == "") + #expect(sessions.first?.preview == "") + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.claudeSessions() + } + } + + @Test("/sessions 非 200 → unexpectedStatus") + func claudeSessionsRejectsBadStatus() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess(url: try routeURL("/sessions"), status: 500) + + // Act + Assert + await #expect(throws: APIClientError.unexpectedStatus(500)) { + _ = try await fixture.client.claudeSessions() + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift new file mode 100644 index 0000000..050f8aa --- /dev/null +++ b/ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift @@ -0,0 +1,459 @@ +import Foundation +import Testing +import TestSupport +import WireProtocol +import APIClient + +/// B1 · **G(变更)** 端点:git 写通道 + worktree 管理 + w2 注入队列 +/// (ios-completion §1.2;真源 = `src/`)。 +/// +/// - `POST /projects/git/stage`(`src/server.ts:1184-1218`)body `{path,files,stage}` +/// - `POST /projects/git/commit`(`:1219-1255`)body `{path,message}` +/// - `POST /projects/git/push`(`:1256-1289`)body `{path}` +/// - `POST /projects/git/fetch`(`:1290-1319`)body `{path}` +/// - `POST /projects/worktree`(`:1095-1123`)body `{path,branch[,base]}` +/// - `DELETE /projects/worktree`(`:1124-1153`)body `{path,worktreePath,force}` +/// - `POST /projects/worktree/prune`(`:1154-1183`)body `{path}` +/// - `POST /live-sessions/:id/queue`(`:605-643`)body `{text[,appendEnter]}` +/// +/// 全部 **必带 `Origin`**。失败 body 只带服务端**已分类、已脱敏**的 `error` +/// 字符串(`src/http/git-ops.ts` / `worktrees.ts`,SEC-M10)—— 客户端逐字展示, +/// 绝不自己拼 git stderr。403 是**重载**状态(Origin 守卫失败 **和** 功能开关关闭 +/// 都是 403),客户端无法凭状态区分,因此原样展示 message。 +struct GitWriteTests { + private static let base = "http://192.168.1.5:3000" + private static let repoPath = "/Users/dev/web-terminal" + private static let sessionIdString = "0f5a1b2c-3d4e-4f60-8a9b-0c1d2e3f4a5b" + + private struct Fixture { + let http: FakeHTTPTransport + let endpoint: HostEndpoint + let client: APIClient + } + + private func makeFixture(accessToken: String? = nil) throws -> Fixture { + let baseURL = try #require(URL(string: Self.base)) + let endpoint = try #require(HostEndpoint(baseURL: baseURL)) + let http = FakeHTTPTransport() + return Fixture( + http: http, endpoint: endpoint, + client: APIClient(endpoint: endpoint, http: http, accessToken: accessToken) + ) + } + + private func routeURL(_ path: String) throws -> URL { + try #require(URL(string: Self.base + path)) + } + + private func bodyObject(_ request: URLRequest) throws -> [String: Any] { + let body = try #require(request.httpBody) + return try #require(try JSONSerialization.jsonObject(with: body) as? [String: Any]) + } + + // MARK: - Origin iff-G(G 侧:七条写路由全带 Origin,逐字符相等) + + @Test("Origin iff-G(G 侧):七条 git/worktree 写路由全部带逐字符相等的 Origin + JSON Content-Type") + func everyGitWriteRouteCarriesByteEqualOrigin() async throws { + // Arrange + let fixture = try makeFixture() + let ok = Data(#"{"ok":true}"#.utf8) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok + ) + + // Act + _ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt"], stage: true) + _ = try await fixture.client.gitCommit(path: Self.repoPath, message: "feat: x") + _ = try await fixture.client.gitPush(path: Self.repoPath) + _ = try await fixture.client.gitFetch(path: Self.repoPath) + _ = try await fixture.client.createWorktree( + path: Self.repoPath, branch: "wt-x", base: nil + ) + _ = try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "\(Self.repoPath)/.claude/worktrees/x", force: false + ) + _ = try await fixture.client.pruneWorktrees(path: Self.repoPath) + + // Assert + let requests = await fixture.http.recordedRequests + #expect(requests.count == 7) + for request in requests { + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json") + } + } + + @Test("配置了访问令牌时,G 写路由同时带 Cookie 与 Origin(令牌不替代 Origin)") + func gitWriteCarriesCookieAlongsideOrigin() async throws { + // Arrange + let token = "s3cret-token_value.~+/=" + let fixture = try makeFixture(accessToken: token) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), body: Data(#"{"ok":true}"#.utf8) + ) + + // Act + _ = try await fixture.client.gitPush(path: Self.repoPath) + + // Assert + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.value(forHTTPHeaderField: "Cookie") == "webterm_auth=\(token)") + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + } + + // MARK: - body 形状逐字段冻结 + + @Test("body 形状:stage={path,files,stage} · commit={path,message} · push/fetch/prune={path}") + func gitWriteBodyShapesAreExact() async throws { + // Arrange + let fixture = try makeFixture() + let ok = Data(#"{"ok":true}"#.utf8) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/stage"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/commit"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/push"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/git/fetch"), body: ok) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), body: ok + ) + + // Act + _ = try await fixture.client.gitStage(path: Self.repoPath, files: ["a.txt", "b/c.md"], stage: false) + _ = try await fixture.client.gitCommit(path: Self.repoPath, message: "fix: 修中文") + _ = try await fixture.client.gitPush(path: Self.repoPath) + _ = try await fixture.client.gitFetch(path: Self.repoPath) + _ = try await fixture.client.pruneWorktrees(path: Self.repoPath) + + // Assert + let requests = await fixture.http.recordedRequests + let stage = try bodyObject(try #require(requests.first)) + #expect(Set(stage.keys) == Set(["path", "files", "stage"])) + #expect(stage["path"] as? String == Self.repoPath) + #expect(stage["files"] as? [String] == ["a.txt", "b/c.md"]) + #expect(stage["stage"] as? Bool == false) + let commit = try bodyObject(requests[1]) + #expect(Set(commit.keys) == Set(["path", "message"])) + #expect(commit["message"] as? String == "fix: 修中文") + for index in 2...4 { + let single = try bodyObject(requests[index]) + #expect(Set(single.keys) == Set(["path"])) + #expect(single["path"] as? String == Self.repoPath) + } + } + + @Test("worktree body:create 的 base 为 nil 时**不带该键**;remove 恒带 {path,worktreePath,force}") + func worktreeBodyShapesAreExact() async throws { + // Arrange + let fixture = try makeFixture() + let ok = Data(#"{"ok":true}"#.utf8) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess(method: "POST", url: try routeURL("/projects/worktree"), body: ok) + await fixture.http.queueSuccess(method: "DELETE", url: try routeURL("/projects/worktree"), body: ok) + + // Act + _ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-a", base: nil) + _ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "wt-b", base: "develop") + _ = try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "/tmp/wt", force: true + ) + + // Assert + let requests = await fixture.http.recordedRequests + let withoutBase = try bodyObject(try #require(requests.first)) + #expect(Set(withoutBase.keys) == Set(["path", "branch"])) + #expect(withoutBase["branch"] as? String == "wt-a") + let withBase = try bodyObject(requests[1]) + #expect(Set(withBase.keys) == Set(["path", "branch", "base"])) + #expect(withBase["base"] as? String == "develop") + let remove = try bodyObject(requests[2]) + #expect(Set(remove.keys) == Set(["path", "worktreePath", "force"])) + #expect(remove["worktreePath"] as? String == "/tmp/wt") + #expect(remove["force"] as? Bool == true) + #expect(requests[2].httpMethod == "DELETE") // DELETE **带** JSON body + } + + // MARK: - 200 payload 解码(畸形 body 降级为默认值,绝不抛) + + @Test("200 payload 解码:stage/commit/push/fetch/create/remove/prune 各自形状") + func successPayloadsDecodePerRoute() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/stage"), + body: Data(#"{"ok":true,"staged":true,"count":2}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/commit"), + body: Data(#"{"ok":true,"commit":"abc1234"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), + body: Data(#"{"ok":true,"branch":"develop","remote":"origin"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/fetch"), + body: Data(#"{"ok":true,"remote":"origin","lastFetchMs":1785390645813.5327}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree"), + body: Data(#"{"ok":true,"path":"/tmp/wt","branch":"worktree-x"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "DELETE", url: try routeURL("/projects/worktree"), + body: Data(#"{"ok":true,"path":"/tmp/wt"}"#.utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), + body: Data(#"{"ok":true,"pruned":["wt-a","wt-b"]}"#.utf8) + ) + + // Act + Assert + #expect( + try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true) + == .ok(StageResult(staged: true, count: 2)) + ) + #expect( + try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + == .ok(CommitResult(commit: "abc1234")) + ) + #expect( + try await fixture.client.gitPush(path: Self.repoPath) + == .ok(PushResult(branch: "develop", remote: "origin")) + ) + let fetched = try await fixture.client.gitFetch(path: Self.repoPath) + guard case .ok(let fetchResult) = fetched else { + Issue.record("fetch 应为 .ok") + return + } + #expect(fetchResult.remote == "origin") + // lastFetchMs 来自 fs.stat().mtimeMs —— 带小数,必须解出来 + #expect(abs(try #require(fetchResult.lastFetchMs) - 1_785_390_645_813.5327) < 0.001) + #expect( + try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil) + == .ok(CreateWorktreeResult(path: "/tmp/wt", branch: "worktree-x")) + ) + #expect( + try await fixture.client.removeWorktree( + path: Self.repoPath, worktreePath: "/tmp/wt", force: false + ) == .ok(RemoveWorktreeResult(path: "/tmp/wt")) + ) + #expect( + try await fixture.client.pruneWorktrees(path: Self.repoPath) + == .ok(PruneWorktreesResult(pruned: ["wt-a", "wt-b"])) + ) + } + + @Test("200 但 payload 缺失/畸形 → 降级为默认值(空 sha / 空 pruned),绝不抛") + func garbledSuccessPayloadDegradesToDefaults() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/commit"), body: Data("[]".utf8) + ) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree/prune"), + body: Data(#"{"ok":true,"pruned":"not-an-array"}"#.utf8) + ) + + // Act + Assert + #expect( + try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + == .ok(CommitResult(commit: "")) + ) + #expect( + try await fixture.client.pruneWorktrees(path: Self.repoPath) + == .ok(PruneWorktreesResult(pruned: [])) + ) + } + + // MARK: - 失败映射(429 独立;其余原样带出服务端安全 message) + + @Test("429 → .rateLimited(stage/commit 共用一个限流器,push/fetch 各有更紧的),不得自动重试") + func rateLimitedIsItsOwnOutcome() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), status: 429, + body: Data(#"{"error":"Too many requests."}"#.utf8) + ) + + // Act + let outcome = try await fixture.client.gitPush(path: Self.repoPath) + + // Assert + #expect(outcome == .rateLimited) + } + + @Test("403/409/400/500 → .rejected(status, 服务端已脱敏 message)逐字带出(403 重载:Origin 或开关)") + func failuresCarryTheServerSafeMessageVerbatim() async throws { + // Arrange + let fixture = try makeFixture() + let cases: [(Int, String)] = [ + (403, "Git operations are disabled."), + (409, "Nothing staged to commit."), + (400, "Set a git author identity (user.name / user.email) first."), + (500, "Git operation failed."), + ] + for (status, message) in cases { + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/commit"), status: status, + body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8) + ) + } + + // Act + Assert + for (status, message) in cases { + let outcome = try await fixture.client.gitCommit(path: Self.repoPath, message: "m") + #expect(outcome == .rejected(status: status, message: message)) + } + } + + @Test("失败 body 无法解析 → .rejected(status, nil),不编造原因") + func unparseableFailureBodyYieldsNilMessage() async throws { + // Arrange + let fixture = try makeFixture() + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/worktree"), status: 500, + body: Data("oops".utf8) + ) + + // Act + let outcome = try await fixture.client.createWorktree( + path: Self.repoPath, branch: "x", base: nil + ) + + // Assert + #expect(outcome == .rejected(status: 500, message: nil)) + } + + @Test("push 的 401 是**路由自身**语义('Push authentication required on the host.'),不得当成访问令牌 401") + func push401IsRouteClassifiedNotTheAccessTokenGate() async throws { + // Arrange — src/http/git-ops.ts:108 把主机侧 git 凭据失败分类成 401 + let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=") + let message = "Push authentication required on the host." + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/projects/git/push"), status: 401, + body: Data(#"{"ok":false,"error":"\#(message)"}"#.utf8) + ) + + // Act + let outcome = try await fixture.client.gitPush(path: Self.repoPath) + + // Assert — 若被 gate 规则吞掉就会抛 .unauthorized,那会误导用户去补令牌 + #expect(outcome == .rejected(status: 401, message: message)) + } + + @Test("空 path 联网前拒(projectPathInvalid),七条写路由一致") + func emptyPathIsRejectedBeforeNetworkOnEveryWriteRoute() async throws { + // Arrange + let fixture = try makeFixture() + + // Act + Assert + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitStage(path: "", files: ["a"], stage: true) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitCommit(path: "", message: "m") + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitPush(path: "") + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.gitFetch(path: "") + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.createWorktree(path: "", branch: "x", base: nil) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.removeWorktree(path: "", worktreePath: "/tmp/wt", force: false) + } + await #expect(throws: APIClientError.projectPathInvalid) { + _ = try await fixture.client.pruneWorktrees(path: "") + } + #expect(await fixture.http.recordedRequests.isEmpty) + } + + // MARK: - POST /live-sessions/:id/queue(w2 pty 注入队列) + + @Test("queue 路由:POST /live-sessions/<小写 UUID>/queue,带 Origin,body 恰为 {text,appendEnter}") + func queueRouteShapeIsExact() async throws { + // Arrange + let fixture = try makeFixture() + let id = try #require(UUID(uuidString: Self.sessionIdString)) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue") + await fixture.http.queueSuccess( + method: "POST", url: url, body: Data(#"{"length":2}"#.utf8) + ) + + // Act + let depth = try await fixture.client.enqueueFollowup( + sessionId: id, text: "继续", appendEnter: true + ) + + // Assert + #expect(depth == 2) + let request = try #require(await fixture.http.recordedRequests.first) + #expect(request.httpMethod == "POST") + #expect(request.url == url) // :id 按字符串精确匹配 ⇒ 必须小写 + #expect(request.value(forHTTPHeaderField: "Origin") == fixture.endpoint.originHeader) + let body = try bodyObject(request) + #expect(Set(body.keys) == Set(["text", "appendEnter"])) + #expect(body["text"] as? String == "继续") + #expect(body["appendEnter"] as? Bool == true) + } + + @Test("queue 错误映射:400/403/404/409/413/429/503 各自类型化,话术非空") + func queueMapsEveryServerStatusToATypedError() async throws { + // Arrange + let fixture = try makeFixture() + let id = try #require(UUID(uuidString: Self.sessionIdString)) + let url = try routeURL("/live-sessions/\(Self.sessionIdString)/queue") + let cases: [(Int, APIClientError)] = [ + (400, .queueTextInvalid), + (403, .forbidden), + (404, .sessionNotFound), + (409, .queueFull), + (413, .queueTextTooLarge), + (429, .rateLimited), + (503, .queueDisabled), + (500, .unexpectedStatus(500)), + ] + for (status, _) in cases { + await fixture.http.queueSuccess(method: "POST", url: url, status: status) + } + + // Act + Assert + for (_, expected) in cases { + await #expect(throws: expected) { + _ = try await fixture.client.enqueueFollowup( + sessionId: id, text: "x", appendEnter: false + ) + } + #expect(!expected.message.isEmpty) + } + } + + @Test("queue 空 text 联网前拒(镜像服务器 400 规则);200 但 body 无 length → invalidResponseBody") + func queueRejectsEmptyTextAndGarbledSuccessBody() async throws { + // Arrange + let fixture = try makeFixture() + let id = try #require(UUID(uuidString: Self.sessionIdString)) + await fixture.http.queueSuccess( + method: "POST", url: try routeURL("/live-sessions/\(Self.sessionIdString)/queue"), + body: Data(#"{"ok":true}"#.utf8) + ) + + // Act + Assert + await #expect(throws: APIClientError.queueTextInvalid) { + _ = try await fixture.client.enqueueFollowup(sessionId: id, text: "", appendEnter: true) + } + await #expect(throws: APIClientError.invalidResponseBody) { + _ = try await fixture.client.enqueueFollowup(sessionId: id, text: "x", appendEnter: false) + } + } +} diff --git a/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift b/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift index 09cfabb..0b638cd 100644 --- a/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift +++ b/ios/Packages/APIClient/Tests/APIClientTests/ProjectsTests.swift @@ -156,6 +156,67 @@ struct ProjectsTests { #expect(projects.last?.sessions.isEmpty == true) // sessions 缺失 → [] } + @Test("回归:lastActiveMs 来自 fs.stat().mtimeMs —— **带小数**必须解出来(否则真机上排序键永远为 nil)") + func lastActiveMsDecodesFractionalStatMtime() async throws { + // Arrange — 真实服务器值形如 1785390645813.5327(APFS 纳秒精度); + // 旧实现 decode(Int.self) 对小数直接失败,被 try? 吞成 nil。 + let body = """ + [{"name":"a","path":"/a","isGit":true,"lastActiveMs":1785390645813.5327,\ + "lastCommitMs":1720000000000,"ahead":2,"behind":0,"sessions":[]}] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + let project = try #require(projects.first) + #expect(project.lastActiveMs == 1_785_390_645_813) + #expect(project.lastCommitMs == 1_720_000_000_000) + #expect(project.ahead == 2) + #expect(project.behind == 0) + } + + @Test("session ref 的 cwd(w6/G7)可选解码:有则解出,缺失 → nil") + func sessionRefDecodesOptionalCwd() async throws { + // Arrange + let body = """ + [{"name":"a","path":"/a","isGit":true,"sessions":[\ + {"id":"\(Self.sessionIdString)","status":"idle","clientCount":0,"createdAt":1,\ + "exited":false,"cwd":"/a/.claude/worktrees/x"}]}] + """ + + // Act + let projects = try await fetchProjects(try makeFixture(), body: body) + + // Assert + #expect(projects.first?.sessions.first?.cwd == "/a/.claude/worktrees/x") + } + + @Test("detail 的 dirtyCount / sync(w6/G1)可选解码,与 worktree/state 用同一 SyncState") + func projectDetailDecodesDirtyCountAndSync() async throws { + // Arrange + let fixture = try makeFixture() + let body = """ + {"name":"a","path":"/a","isGit":true,"dirty":true,"dirtyCount":7,\ + "sync":{"upstream":"origin/main","ahead":1,"behind":0,"lastFetchMs":1785390645813.5327},\ + "worktrees":[],"sessions":[],"hasClaudeMd":false} + """ + await fixture.http.queueSuccess( + url: try routeURL("/projects/detail?path=%2Fa"), body: Data(body.utf8) + ) + + // Act + let detail = try await fixture.client.projectDetail(path: "/a") + + // Assert + #expect(detail.dirtyCount == 7) + let sync = try #require(detail.sync) + #expect(sync.upstream == "origin/main") + #expect(sync.ahead == 1) + #expect(sync.behind == 0) + #expect(sync.lastFetchMs != nil) + } + @Test("/projects 非数组 body → invalidResponseBody;非 200 → unexpectedStatus") func projectsRejectsNonArrayBodyAndBadStatus() async throws { // Act + Assert — 非数组 diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift new file mode 100644 index 0000000..1430ab3 --- /dev/null +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/AccessToken.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Complete failure taxonomy for a per-host access token: the three validation +/// verdicts plus the one store-level miss. One enum so the UI has a single +/// thing to switch over when it saves a token (never a generic `Error`). +public enum AccessTokenError: Error, Equatable { + case tooShort(length: Int) + case tooLong(length: Int) + /// Deliberately payload-free. An error value gets logged, wrapped in an + /// alert string, and attached to crash reports — carrying the rejected + /// token here would turn every one of those into a leak (§5.3). The + /// length cases carry only a length, which is not secret. + case invalidCharacters + /// `setAccessToken` for an id that is not in the store. Explicit error + /// rather than a silent no-op: a UI that thinks it saved a token but + /// didn't would send unauthenticated requests forever. + case unknownHost(UUID) +} + +/// The host's shared access token (`WEBTERM_TOKEN`), validated at the boundary. +/// +/// Charset and length are the FROZEN server contract (coordination doc §1.1), +/// byte-for-byte the same rule the server enforces at config load — +/// `/^[A-Za-z0-9._~+/=-]{16,512}$/` (src/config.ts:178). Validating here means +/// a token that cannot possibly authenticate is rejected at the keyboard +/// instead of turning into a mystery 401 later, and a value of this type is +/// always safe to place verbatim in a `Cookie: webterm_auth=` header (the +/// charset is exactly the cookie-safe one — nothing to escape). +/// +/// SECRET MATERIAL (§5.3). Three deliberate properties keep it out of logs: +/// - `description`/`debugDescription` are redacted, so string interpolation +/// (`"\(token)"`) — the way a token usually reaches a log — cannot leak it. +/// - `customMirror` hides the storage, so `dump()` and reflection-based crash +/// reporters see `` too. +/// - it is intentionally NOT `Codable`: nothing can serialize a token by +/// accident. `Host` encodes it explicitly (one audited call site) into the +/// Keychain item, which is the only place a token is ever persisted. +public struct AccessToken: Sendable, Hashable, RawRepresentable, + CustomStringConvertible, CustomDebugStringConvertible, + CustomReflectable { + /// §1.1 length window (server: `{16,512}`). + public static let minLength = 16 + public static let maxLength = 512 + + /// §1.1 charset `[A-Za-z0-9._~+/=-]`. A `Set` so validation is O(n) in the + /// token length with no regex engine in the hot path. + private static let allowedCharacters: Set = Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~+/=-" + ) + + /// The token, verbatim as it must appear in the `Cookie` header. + public let rawValue: String + + /// Validating init — the ONLY way a token enters the app. + /// + /// Leading/trailing whitespace is trimmed first: tokens arrive by paste, + /// and whitespace is never a legal token character, so trimming can never + /// change the meaning of a valid token. Interior whitespace is NOT + /// tolerated (that would be silent normalization of a secret). + public init(validating raw: String) throws { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let length = trimmed.count + guard length >= Self.minLength else { throw AccessTokenError.tooShort(length: length) } + guard length <= Self.maxLength else { throw AccessTokenError.tooLong(length: length) } + guard trimmed.allSatisfy(Self.allowedCharacters.contains) else { + throw AccessTokenError.invalidCharacters + } + self.rawValue = trimmed + } + + /// Lenient seam over the same rules (`RawRepresentable`): invalid → nil. + /// Used where a rejected value must degrade instead of failing a whole + /// operation — decoding a tampered Keychain record (see `Host`). + /// + /// Note: whitespace-padded input normalizes, so `rawValue` round-trips + /// only for already-trimmed strings. + public init?(rawValue: String) { + guard let token = try? AccessToken(validating: rawValue) else { return nil } + self = token + } + + public var description: String { "AccessToken(redacted, \(rawValue.count) chars)" } + + public var debugDescription: String { description } + + public var customMirror: Mirror { + Mirror(self, children: ["rawValue": ""], displayStyle: .struct) + } +} diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift index 8dd3206..deae786 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/Host.swift @@ -8,16 +8,90 @@ import WireProtocol /// Note: on macOS this shadows `Foundation.Host` (NSHost) inside this module; /// downstream test/app targets that import Foundation should alias /// `typealias Host = HostRegistry.Host` or qualify. -public struct Host: Sendable, Equatable, Codable, Identifiable { +public struct Host: Sendable, Equatable, Codable, Identifiable, + CustomStringConvertible, CustomDebugStringConvertible { public let id: UUID public let name: String /// Single point of Origin/wsURL derivation — from WireProtocol, never /// redeclared here (plan §6 rule 1). public let endpoint: HostEndpoint + /// This host's access token (`WEBTERM_TOKEN`), or nil when the host has + /// no auth configured — which is the zero-config LAN default, so nil is + /// the normal state, not an error state. + /// + /// Why it lives ON the host record instead of in its own Keychain item: + /// plan §5.3 assigns the host list to the Keychain precisely as the place + /// for "host 列表(含未来 authMaterial 占位)". Piggy-backing on that one + /// item means the token inherits the audited protection policy + /// (`AfterFirstUnlockThisDeviceOnly`, never synchronizable — see + /// `KeychainItemSpec`) and, just as importantly, that removing a host + /// removes its secret in the same write — a separate item could be + /// orphaned in the Keychain forever. + public let accessToken: AccessToken? - public init(id: UUID, name: String, endpoint: HostEndpoint) { + /// `accessToken` defaults to nil so every existing three-argument call + /// site (pairing, tests, fixtures) keeps compiling and keeps meaning + /// "no token configured". + public init(id: UUID, name: String, endpoint: HostEndpoint, accessToken: AccessToken? = nil) { self.id = id self.name = name self.endpoint = endpoint + self.accessToken = accessToken } + + /// Immutable update: returns a NEW host with the token set (or cleared with + /// nil). Never mutates the receiver — the store persists the returned value. + public func withAccessToken(_ token: AccessToken?) -> Host { + Host(id: id, name: name, endpoint: endpoint, accessToken: token) + } + + // MARK: - Codable (hand-written: migration + tamper behaviour is the point) + + private enum CodingKeys: String, CodingKey { + case id, name, endpoint, accessToken + } + + /// MIGRATION SAFETY: a host paired by an earlier build has no + /// `accessToken` key at all. `decodeIfPresent` maps both "key absent" and + /// "key is null" to nil, so those records keep loading unchanged. + /// + /// TAMPER SAFETY: a stored value that no longer passes §1.1 validation + /// degrades to nil (the user re-enters a token) instead of throwing — + /// throwing here would surface as `KeychainHostStoreError.corruptedData` + /// for the WHOLE list, i.e. every paired host lost over one bad field. + /// The `endpoint` stays strictly validated (a host without a dialable URL + /// is not a host), so this leniency is scoped to the one optional field. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decode(UUID.self, forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.endpoint = try container.decode(HostEndpoint.self, forKey: .endpoint) + let stored = try container.decodeIfPresent(String.self, forKey: .accessToken) + self.accessToken = stored.flatMap(AccessToken.init(rawValue:)) + } + + /// Encodes the token as a plain string under `accessToken`, and omits the + /// key entirely when there is none (an absent key is exactly what the + /// decoder above treats as "no token", so old and new shapes stay + /// interchangeable in both directions). + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(endpoint, forKey: .endpoint) + try container.encodeIfPresent(accessToken?.rawValue, forKey: .accessToken) + } + + // MARK: - Redacted description (§5.3) + + /// Hand-written so the token can never ride along into a log line: the + /// synthesized reflection dump would print the `accessToken` child, and a + /// `Host` is the natural thing to interpolate when logging a connection. + /// Only the token's PRESENCE is reported. + public var description: String { + "Host(id: \(id), name: \(name), origin: \(endpoint.originHeader), " + + "hasAccessToken: \(accessToken != nil))" + } + + public var debugDescription: String { description } } diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift index f2a9018..8796c62 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/HostStore.swift @@ -10,8 +10,44 @@ public protocol HostStore: Sendable { /// Returns the new collection. func upsert(_ host: Host) async throws -> [Host] /// Removing an unknown `id` is an explicit no-op: returns the unchanged - /// collection, never throws for "not found". + /// collection, never throws for "not found". Removing a host also removes + /// its `accessToken` — the token is part of the record (see `Host`), so no + /// orphaned secret can survive in storage. func remove(id: UUID) async throws -> [Host] + + /// This host's access token, or nil when none is configured / the host is + /// unknown. A read never throws for "not found" (mirrors `remove`). + func accessToken(host: UUID) async throws -> AccessToken? + /// Sets (or with nil, clears) the host's access token and returns the new + /// collection. Throws `AccessTokenError.unknownHost` rather than silently + /// dropping a token the UI believes it saved. + func setAccessToken(_ token: AccessToken?, host: UUID) async throws -> [Host] +} + +/// Generic token read/write built from `loadAll`/`upsert`, so every conformer — +/// including test doubles outside this package — gets the behaviour for free +/// and cannot drift from it. `KeychainHostStore`/`InMemoryHostStore` override +/// both to do the read-modify-write INSIDE their actor (atomic against a +/// concurrent upsert/remove); this default's two suspension points cannot be. +extension HostStore { + public func accessToken(host id: UUID) async throws -> AccessToken? { + try await loadAll().first { $0.id == id }?.accessToken + } + + public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] { + let current = try await loadAll() + guard let target = current.first(where: { $0.id == id }) else { + throw AccessTokenError.unknownHost(id) + } + guard target.accessToken != token else { return current } // no-op: unchanged + return try await upsert(target.withAccessToken(token)) + } + + /// Clearing is writing nil — named so the intent reads at the call site + /// (the token-settings UI removes a token; it does not "set nil"). + public func clearAccessToken(host id: UUID) async throws -> [Host] { + try await setAccessToken(nil, host: id) + } } // Pure collection transforms shared by all HostStore implementations (DRY); @@ -26,4 +62,12 @@ extension [Host] { func removing(id: UUID) -> [Host] { filter { $0.id != id } } + + /// Sets/clears one host's token. Returns nil when `id` is not in the + /// collection so callers can raise `AccessTokenError.unknownHost` — a + /// sentinel-free way to distinguish "unchanged" from "unknown host". + func settingAccessToken(_ token: AccessToken?, host id: UUID) -> [Host]? { + guard contains(where: { $0.id == id }) else { return nil } + return map { $0.id == id ? $0.withAccessToken(token) : $0 } + } } diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift index b8d490e..9852112 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/InMemoryHostStore.swift @@ -29,4 +29,18 @@ public actor InMemoryHostStore: HostStore { hosts = updated return updated } + + public func accessToken(host id: UUID) async throws -> AccessToken? { + hosts.first { $0.id == id }?.accessToken + } + + /// Atomic override for the same reason as `KeychainHostStore`'s: the + /// read-modify-write stays inside the actor. + public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] { + guard let updated = hosts.settingAccessToken(token, host: id) else { + throw AccessTokenError.unknownHost(id) + } + hosts = updated + return updated + } } diff --git a/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift b/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift index 13fc5c5..0c999fe 100644 --- a/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift +++ b/ios/Packages/HostRegistry/Sources/HostRegistry/KeychainHostStore.swift @@ -65,6 +65,27 @@ public actor KeychainHostStore: HostStore { return updated } + // MARK: - Access token (stored inside the same host record — see Host) + + public func accessToken(host id: UUID) async throws -> AccessToken? { + try readHosts().first { $0.id == id }?.accessToken + } + + /// Atomic override of the `HostStore` default: the read-modify-write runs + /// inside the actor, so a concurrent `upsert`/`remove` cannot interleave + /// and lose the token (the default implementation awaits twice). + public func setAccessToken(_ token: AccessToken?, host id: UUID) async throws -> [Host] { + let current = try readHosts() + guard let updated = current.settingAccessToken(token, host: id) else { + throw AccessTokenError.unknownHost(id) + } + guard updated != current else { + return current // explicit no-op: same token, nothing persisted + } + try persist(updated) + return updated + } + // MARK: - Keychain I/O (dictionaries built solely by KeychainItemSpec) private func readHosts() throws -> [Host] { diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift new file mode 100644 index 0000000..e0be83b --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/AccessTokenTests.swift @@ -0,0 +1,210 @@ +import Foundation +import Testing +import HostRegistry + +// AccessToken:边界校验 + "零泄漏"表示。 +// 契约来源:协调 doc §1.1 —— 字符集 `[A-Za-z0-9._~+/=-]`、长度 16–512 +// (与服务端 src/config.ts:178 的 WEBTERM_TOKEN_RE 逐字一致);令牌是密级材料, +// 绝不进日志 / URL / 崩溃报告(§5.3)。 + +// MARK: - 边界校验(字符集) + +@Test("合法令牌:覆盖全字符集 → 构造成功且 rawValue 逐字保真") +func validTokenPreservesRawValueVerbatim() throws { + // Arrange + let raw = Fixtures.validTokenString + + // Act + let token = try AccessToken(validating: raw) + + // Assert + #expect(token.rawValue == raw) +} + +@Test("非法字符 → .invalidCharacters", arguments: [ + "abcdefghijklmno p", // 空格 + "abcdefghijklmnop!", // 感叹号 + "abcdefghijklmnop%", // 百分号(需 URL 编码 → 不在 cookie-safe 集里) + "abcdefghijklmnop,", // 逗号(cookie 分隔符) + "abcdefghijklmnop;", // 分号(cookie 分隔符) + "abcdefghijklmnop令", // 非 ASCII +]) +func invalidCharactersAreRejectedWithTypedError(raw: String) { + // Act / Assert + #expect(throws: AccessTokenError.invalidCharacters) { + _ = try AccessToken(validating: raw) + } +} + +@Test("非法字符错误不携带令牌内容(错误对象本身不得成为泄漏面)") +func invalidCharactersErrorCarriesNoTokenPayload() { + // Arrange + let secretish = "supersecret-tail!!!!!!" + + // Act + var captured = "" + do { + _ = try AccessToken(validating: secretish) + Issue.record("期望抛错,实际构造成功") + } catch { + captured = String(describing: error) + String(reflecting: error) + } + + // Assert + #expect(captured.isEmpty == false) + #expect(captured.contains("supersecret") == false) +} + +// MARK: - 边界校验(长度) + +@Test("长度下限:15 → .tooShort(15)") +func tokenBelowMinimumLengthIsRejected() { + // Act / Assert + #expect(throws: AccessTokenError.tooShort(length: 15)) { + _ = try AccessToken(validating: Fixtures.tokenString(length: 15)) + } +} + +@Test("长度下限:恰好 16 → 通过") +func tokenAtMinimumLengthIsAccepted() throws { + // Act + let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.minLength)) + + // Assert + #expect(token.rawValue.count == 16) +} + +@Test("长度上限:恰好 512 → 通过") +func tokenAtMaximumLengthIsAccepted() throws { + // Act + let token = try AccessToken(validating: Fixtures.tokenString(length: AccessToken.maxLength)) + + // Assert + #expect(token.rawValue.count == 512) +} + +@Test("长度上限:513 → .tooLong(513)") +func tokenAboveMaximumLengthIsRejected() { + // Act / Assert + #expect(throws: AccessTokenError.tooLong(length: 513)) { + _ = try AccessToken(validating: Fixtures.tokenString(length: 513)) + } +} + +@Test("空串 → .tooShort(0)(不是 invalidCharacters:先判长度更贴近用户话术)") +func emptyTokenIsRejectedAsTooShort() { + // Act / Assert + #expect(throws: AccessTokenError.tooShort(length: 0)) { + _ = try AccessToken(validating: "") + } +} + +// MARK: - 粘贴 UX:首尾空白裁剪 + +@Test("首尾空白/换行被裁剪后再校验:合法令牌不受影响(空白永非合法令牌字符)") +func surroundingWhitespaceIsTrimmedBeforeValidation() throws { + // Arrange + let pasted = " \n\t" + Fixtures.validTokenString + " \n" + + // Act + let token = try AccessToken(validating: pasted) + + // Assert + #expect(token.rawValue == Fixtures.validTokenString) +} + +@Test("中间空白不被裁剪:仍是 .invalidCharacters(不做静默规范化)") +func interiorWhitespaceIsStillInvalid() { + // Act / Assert + #expect(throws: AccessTokenError.invalidCharacters) { + _ = try AccessToken(validating: "abcdefgh ijklmnop") + } +} + +@Test("init?(rawValue:) 宽松入口:非法 → nil,不抛") +func lenientRawValueInitReturnsNilForInvalidToken() { + // Act / Assert + #expect(AccessToken(rawValue: "too-short") == nil) + #expect(AccessToken(rawValue: Fixtures.validTokenString)?.rawValue == Fixtures.validTokenString) +} + +// MARK: - §5.3 零泄漏表示 + +@Test("§5.3 description/debugDescription 不含令牌,只给长度") +func stringRepresentationsRedactTheToken() { + // Arrange + let token = Fixtures.makeToken() + + // Act + let description = token.description + let debugDescription = token.debugDescription + + // Assert + #expect(description.contains(Fixtures.validTokenString) == false) + #expect(debugDescription.contains(Fixtures.validTokenString) == false) + #expect(description.contains("redacted")) + #expect(description.contains("\(Fixtures.validTokenString.count)")) +} + +@Test("§5.3 String(describing:)/String(reflecting:) 也不泄漏(插值即日志的默认路径)") +func interpolationPathsRedactTheToken() { + // Arrange + let token = Fixtures.makeToken() + + // Act + let interpolated = "token=\(token)" + + // Assert + #expect(interpolated.contains(Fixtures.validTokenString) == false) + #expect(String(describing: token).contains(Fixtures.validTokenString) == false) + #expect(String(reflecting: token).contains(Fixtures.validTokenString) == false) +} + +@Test("§5.3 Mirror/dump 反射不泄漏令牌(崩溃报告与 dump 是常见旁路)") +func reflectionDoesNotExposeTheToken() { + // Arrange + let token = Fixtures.makeToken() + + // Act + let children = Mirror(reflecting: token).children.map { String(describing: $0.value) } + var dumped = "" + dump(token, to: &dumped) + + // Assert + #expect(children.contains(where: { $0.contains(Fixtures.validTokenString) }) == false) + #expect(dumped.contains(Fixtures.validTokenString) == false) +} + +@Test("§5.3 Host 的字符串表示不含令牌,只标注是否已配置") +func hostStringRepresentationRedactsTheToken() { + // Arrange + let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) + + // Act + let described = String(describing: host) + let reflected = String(reflecting: host) + var dumped = "" + dump(host, to: &dumped) + + // Assert + #expect(described.contains(Fixtures.validTokenString) == false) + #expect(reflected.contains(Fixtures.validTokenString) == false) + #expect(dumped.contains(Fixtures.validTokenString) == false) + #expect(described.contains("hasAccessToken: true")) + #expect(String(describing: Fixtures.makeHost()).contains("hasAccessToken: false")) +} + +// MARK: - 值语义 + +@Test("Equatable/Hashable:同值相等且同 hash,异值不等") +func tokenValueSemanticsAreByRawValue() { + // Arrange + let a = Fixtures.makeToken() + let b = Fixtures.makeToken() + let other = Fixtures.makeToken(Fixtures.otherValidTokenString) + + // Assert + #expect(a == b) + #expect(a.hashValue == b.hashValue) + #expect(a != other) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift new file mode 100644 index 0000000..0bda1d5 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostAccessTokenStoreTests.swift @@ -0,0 +1,337 @@ +import Foundation +import Security +import Testing +import HostRegistry + +// 按主机存访问令牌的 store 语义(B2):read / write / clear,以及 +// "移除主机必须一起移除其令牌"(Keychain 里不留孤儿密文)。 +// 令牌就存在既有的 host 列表 Keychain item 里 —— 计划 §5.3 明写 +// "Keychain:host 列表(含未来 authMaterial 占位)",故 remove(id:) 天然连带清除。 + +/// 取出某次 keychain 写入的 JSON 载荷文本(用于"载荷里不得再出现该令牌"断言)。 +/// +/// 必须先把 `\/` 还原成 `/`:JSONEncoder 默认转义正斜杠,而令牌字符集里恰好含 `/` —— +/// 不还原的话 `contains(token) == false` 这类"无残留密文"断言会因为转义而**假通过**。 +/// 令牌字符集 `[A-Za-z0-9._~+/=-]` 中只有 `/` 会被 JSON 转义,故这一步足够。 +private func payloadText(_ attributes: [String: any Sendable]?) -> String { + guard let data = attributes?[kSecValueData as String] as? Data else { return "" } + return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\\/", with: "/") +} + +/// 第三方 conformer:只实现 §3.3 原有三个方法,不 override 令牌方法 —— +/// 钉死协议默认实现(App 层已有的自定义 HostStore 替身走的就是这条路)。 +private actor MinimalHostStore: HostStore { + private var hosts: [Host] + + init(hosts: [Host]) { self.hosts = hosts } + + func loadAll() async throws -> [Host] { hosts } + + func upsert(_ host: Host) async throws -> [Host] { + hosts = hosts.contains(where: { $0.id == host.id }) + ? hosts.map { $0.id == host.id ? host : $0 } + : hosts + [host] + return hosts + } + + func remove(id: UUID) async throws -> [Host] { + hosts = hosts.filter { $0.id != id } + return hosts + } +} + +// MARK: - 写入 / 读取 / 清除(Keychain 实现) + +@Test("setAccessToken 后 accessToken(host:) 读回同值,且 loadAll 的 Host 带上令牌") +func setAccessTokenPersistsAndReadsBack() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + let token = Fixtures.makeToken() + + // Act + let updated = try await store.setAccessToken(token, host: host.id) + + // Assert + #expect(updated.first?.accessToken == token) + #expect(try await store.accessToken(host: host.id) == token) + #expect(try await store.loadAll().first?.accessToken == token) +} + +@Test("令牌跨 store 实例存活:同一 shim 上的新 store 读回令牌") +func accessTokenSurvivesAcrossStoreInstances() async throws { + // Arrange + let shim = FakeSecItemShim() + let host = Fixtures.makeHost() + let writer = KeychainHostStore(shim: shim) + _ = try await writer.upsert(host) + _ = try await writer.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let reader = KeychainHostStore(shim: shim) + + // Assert + #expect(try await reader.accessToken(host: host.id) == Fixtures.makeToken()) +} + +@Test("未配置令牌的主机:accessToken(host:) 为 nil(不是错误)") +func accessTokenIsNilWhenNeverSet() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + + // Act / Assert + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("未知 host 的 accessToken 查询:nil(不 throw —— 只读路径宽松)") +func accessTokenForUnknownHostIsNil() async throws { + // Arrange + let store = KeychainHostStore(shim: FakeSecItemShim()) + + // Act / Assert + #expect(try await store.accessToken(host: UUID()) == nil) +} + +@Test("setAccessToken(nil) 清除令牌,但主机本身保留") +func setAccessTokenNilClearsTokenAndKeepsHost() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.setAccessToken(nil, host: host.id) + + // Assert + #expect(updated.map(\.id) == [host.id]) + #expect(updated.first?.accessToken == nil) + #expect(try await store.accessToken(host: host.id) == nil) + #expect(payloadText(shim.recordedUpdates.last?.attributes).contains(Fixtures.validTokenString) == false) +} + +@Test("clearAccessToken(host:) 等价于 setAccessToken(nil, host:)") +func clearAccessTokenRemovesTheToken() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.clearAccessToken(host: host.id) + + // Assert + #expect(updated.first?.accessToken == nil) + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("替换令牌:旧值不再出现在写入载荷里") +func replacingTokenLeavesNoTraceOfTheOldValue() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.setAccessToken( + Fixtures.makeToken(Fixtures.otherValidTokenString), host: host.id + ) + + // Assert + #expect(updated.first?.accessToken?.rawValue == Fixtures.otherValidTokenString) + let payload = payloadText(shim.recordedUpdates.last?.attributes) + #expect(payload.contains(Fixtures.otherValidTokenString)) + #expect(payload.contains(Fixtures.validTokenString) == false) +} + +@Test("重复写同一令牌:显式 no-op,零额外持久化调用") +func settingTheSameTokenTwiceIsAnExplicitNoOp() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + let addsBefore = shim.recordedAdds.count + let updatesBefore = shim.recordedUpdates.count + + // Act + let updated = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Assert + #expect(updated.first?.accessToken == Fixtures.makeToken()) + #expect(shim.recordedAdds.count == addsBefore) + #expect(shim.recordedUpdates.count == updatesBefore) +} + +@Test("给未知 host 写令牌:抛 .unknownHost,零持久化(绝不静默丢弃)") +func setAccessTokenForUnknownHostThrowsAndPersistsNothing() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + _ = try await store.upsert(Fixtures.makeHost()) + let addsBefore = shim.recordedAdds.count + let unknownId = UUID() + + // Act / Assert + await #expect(throws: AccessTokenError.unknownHost(unknownId)) { + _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) + } + #expect(shim.recordedAdds.count == addsBefore) + #expect(shim.recordedUpdates.isEmpty) + #expect(shim.recordedDeletes.isEmpty) +} + +// MARK: - 移除主机连带清除令牌(无孤儿密文) + +@Test("remove 最后一个主机:整个 keychain item 被删,令牌随之消失") +func removingLastHostDeletesTheItemAndItsToken() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let host = Fixtures.makeHost() + _ = try await store.upsert(host) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + let updated = try await store.remove(id: host.id) + + // Assert + #expect(updated.isEmpty) + #expect(shim.recordedDeletes.count == 1) + #expect(try await store.accessToken(host: host.id) == nil) + #expect(try await KeychainHostStore(shim: shim).loadAll().isEmpty) +} + +@Test("remove 其中一个主机:新载荷不含其令牌,另一主机的令牌完好") +func removingOneHostStripsOnlyItsTokenFromThePayload() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim) + let doomed = Fixtures.makeHost(name: "doomed") + let keeper = Fixtures.makeHost(name: "keeper", urlString: "https://mac.ts.net") + _ = try await store.upsert(doomed) + _ = try await store.upsert(keeper) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: doomed.id) + _ = try await store.setAccessToken( + Fixtures.makeToken(Fixtures.otherValidTokenString), host: keeper.id + ) + + // Act + let updated = try await store.remove(id: doomed.id) + + // Assert + #expect(updated.map(\.id) == [keeper.id]) + let payload = payloadText(shim.recordedUpdates.last?.attributes) + #expect(payload.contains(Fixtures.validTokenString) == false) + #expect(payload.contains(Fixtures.otherValidTokenString)) + #expect(try await store.accessToken(host: keeper.id)?.rawValue == Fixtures.otherValidTokenString) +} + +// MARK: - §5.3 属性字典不因令牌而改变 + +@Test("§5.3 带令牌写入时 add 属性字典仍是策略集:6 键、无 synchronizable、accessible 重申") +func storingATokenDoesNotWeakenTheKeychainPolicy() async throws { + // Arrange + let shim = FakeSecItemShim() + let store = KeychainHostStore(shim: shim, service: "svc.test", account: "acct.test") + let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) + + // Act + _ = try await store.upsert(host) + + // Assert + let attrs = try #require(shim.recordedAdds.first) + #expect(attrs[kSecUseDataProtectionKeychain as String] as? Bool == true) + #expect(attrs[kSecAttrAccessible as String] as? String + == kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String) + #expect(attrs[kSecAttrSynchronizable as String] == nil) + #expect(attrs.count == 6) +} + +// MARK: - 实现间契约对等 + +@Test("InMemoryHostStore:read/write/clear 与 Keychain 实现语义一致") +func inMemoryStoreHasTheSameAccessTokenSemantics() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = InMemoryHostStore(hosts: [host]) + let token = Fixtures.makeToken() + + // Act + let afterSet = try await store.setAccessToken(token, host: host.id) + let read = try await store.accessToken(host: host.id) + let afterClear = try await store.clearAccessToken(host: host.id) + + // Assert + #expect(afterSet.first?.accessToken == token) + #expect(read == token) + #expect(afterClear.first?.accessToken == nil) + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("InMemoryHostStore:未知 host 写令牌同样抛 .unknownHost") +func inMemoryStoreRejectsUnknownHostToo() async { + // Arrange + let store = InMemoryHostStore() + let unknownId = UUID() + + // Act / Assert + await #expect(throws: AccessTokenError.unknownHost(unknownId)) { + _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) + } +} + +@Test("InMemoryHostStore:remove 主机 → 令牌一并消失") +func inMemoryStoreRemovingHostDropsItsToken() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = InMemoryHostStore(hosts: [host]) + _ = try await store.setAccessToken(Fixtures.makeToken(), host: host.id) + + // Act + _ = try await store.remove(id: host.id) + + // Assert + #expect(try await store.accessToken(host: host.id) == nil) +} + +@Test("协议默认实现:只实现原三方法的 conformer 也能 read/write/clear") +func protocolDefaultImplementationsWorkForMinimalConformers() async throws { + // Arrange + let host = Fixtures.makeHost() + let store = MinimalHostStore(hosts: [host]) + let token = Fixtures.makeToken() + + // Act + let afterSet = try await store.setAccessToken(token, host: host.id) + let read = try await store.accessToken(host: host.id) + let afterClear = try await store.clearAccessToken(host: host.id) + + // Assert + #expect(afterSet.first?.accessToken == token) + #expect(read == token) + #expect(afterClear.first?.accessToken == nil) +} + +@Test("协议默认实现:未知 host 写令牌抛 .unknownHost") +func protocolDefaultImplementationRejectsUnknownHost() async { + // Arrange + let store = MinimalHostStore(hosts: []) + let unknownId = UUID() + + // Act / Assert + await #expect(throws: AccessTokenError.unknownHost(unknownId)) { + _ = try await store.setAccessToken(Fixtures.makeToken(), host: unknownId) + } +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift new file mode 100644 index 0000000..3210d81 --- /dev/null +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/HostTokenMigrationTests.swift @@ -0,0 +1,192 @@ +import Foundation +import Testing +import WireProtocol +import HostRegistry + +// 迁移安全 + 持久化形状(B2)。 +// 已配对的主机是**当前线上构建**写进 Keychain 的旧形状记录(没有 accessToken 字段); +// 新构建必须照常读出它们 —— 缺字段 = nil,绝不是解码失败(否则用户的主机列表整体变 +// .corruptedData,等于要求重新配对所有主机)。 + +/// 上一版 `Host` 的编码形状:只有 id/name/endpoint,合成 Codable。 +/// 用它来生成"旧记录",比手写 JSON 更能保真上一版编码器的输出。 +private struct LegacyHostRecord: Encodable { + let id: UUID + let name: String + let endpoint: HostEndpoint +} + +private func encodedLegacyPayload(_ host: Host) throws -> Data { + try JSONEncoder().encode([ + LegacyHostRecord(id: host.id, name: host.name, endpoint: host.endpoint), + ]) +} + +// MARK: - 旧形状读取(迁移回归) + +@Test("迁移:旧形状记录(无 accessToken 字段)照常读出,令牌为 nil") +func legacyRecordWithoutTokenFieldStillLoads() async throws { + // Arrange + let legacy = Fixtures.makeHost(name: "paired-before-tokens") + let shim = FakeSecItemShim() + shim.forceCopyData(try encodedLegacyPayload(legacy)) + let store = KeychainHostStore(shim: shim) + + // Act + let loaded = try await store.loadAll() + + // Assert + #expect(loaded.count == 1) + #expect(loaded.first?.id == legacy.id) + #expect(loaded.first?.name == "paired-before-tokens") + #expect(loaded.first?.endpoint == legacy.endpoint) + #expect(loaded.first?.accessToken == nil) + #expect(loaded == [legacy]) // 旧记录 == 令牌为 nil 的新值 +} + +@Test("迁移:逐字旧 JSON(键名钉死 id/name/endpoint.baseURL)解码成功且令牌为 nil") +func literalLegacyJSONDecodesWithNilToken() throws { + // Arrange —— 逐字旧形状,钉死上一版的键名(键名一改就红) + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F",\ + "name":"mac-studio","endpoint":{"baseURL":"http://192.168.1.5:3000"}}] + """ + + // Act + let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) + + // Assert + #expect(hosts.count == 1) + #expect(hosts.first?.name == "mac-studio") + #expect(hosts.first?.endpoint.originHeader == "http://192.168.1.5:3000") + #expect(hosts.first?.accessToken == nil) +} + +@Test("被篡改的非法令牌值:降级为 nil,不把整张主机表打成 .corruptedData") +func tamperedTokenValueDegradesToNilInsteadOfCorruptingTheList() async throws { + // Arrange —— 只有外部篡改才可能出现非法值(写路径已在边界校验) + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\ + "endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":"short!"}] + """ + let shim = FakeSecItemShim() + shim.forceCopyData(Data(json.utf8)) + let store = KeychainHostStore(shim: shim) + + // Act + let loaded = try await store.loadAll() + + // Assert + #expect(loaded.count == 1) + #expect(loaded.first?.name == "mac-studio") + #expect(loaded.first?.accessToken == nil) +} + +@Test("accessToken 为 JSON null:等同缺字段,令牌为 nil") +func explicitNullTokenDecodesAsNil() throws { + // Arrange + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"mac-studio",\ + "endpoint":{"baseURL":"http://192.168.1.5:3000"},"accessToken":null}] + """ + + // Act + let hosts = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) + + // Assert + #expect(hosts.first?.accessToken == nil) +} + +@Test("endpoint 仍是硬校验:非 http(s) 的 baseURL → 解码失败(令牌宽松不放宽 endpoint)") +func endpointRemainsStrictlyValidatedOnDecode() { + // Arrange + let json = """ + [{"id":"E621E1F8-C36C-495A-93FC-0C247A3E6E5F","name":"bad",\ + "endpoint":{"baseURL":"ftp://192.168.1.5"}}] + """ + + // Act / Assert + #expect(throws: (any Error).self) { + _ = try JSONDecoder().decode([Host].self, from: Data(json.utf8)) + } +} + +// MARK: - 新形状 + +@Test("新形状:令牌以纯字符串存在 accessToken 键下(键名/形状钉死)") +func tokenIsPersistedAsAPlainStringUnderAccessTokenKey() throws { + // Arrange + let host = Fixtures.makeHost().withAccessToken(Fixtures.makeToken()) + + // Act + let data = try JSONEncoder().encode(host) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + // Assert + #expect(object["accessToken"] as? String == Fixtures.validTokenString) +} + +@Test("新形状:令牌为 nil 时不写 accessToken 键(不留空字段)") +func nilTokenIsOmittedFromTheEncodedForm() throws { + // Arrange + let host = Fixtures.makeHost() + + // Act + let data = try JSONEncoder().encode(host) + let object = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + + // Assert + #expect(object.keys.contains("accessToken") == false) + #expect(object.keys.sorted() == ["endpoint", "id", "name"]) +} + +@Test("Codable 往返:带令牌的 Host 全字段保真") +func codableRoundTripPreservesTheToken() throws { + // Arrange + let host = Fixtures.makeHost(name: "studio", urlString: "https://mac.ts.net:8443") + .withAccessToken(Fixtures.makeToken()) + + // Act + let decoded = try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) + + // Assert + #expect(decoded == host) + #expect(decoded.accessToken == Fixtures.makeToken()) + #expect(decoded.endpoint.originHeader == "https://mac.ts.net:8443") +} + +// MARK: - 不可变性 + +@Test("withAccessToken 不可变:返回新值,原 Host 不变") +func withAccessTokenReturnsANewValueWithoutMutatingTheOriginal() { + // Arrange + let original = Fixtures.makeHost() + + // Act + let withToken = original.withAccessToken(Fixtures.makeToken()) + let cleared = withToken.withAccessToken(nil) + + // Assert + #expect(original.accessToken == nil) + #expect(withToken.accessToken == Fixtures.makeToken()) + #expect(cleared.accessToken == nil) + #expect(withToken.id == original.id) + #expect(withToken.name == original.name) + #expect(withToken.endpoint == original.endpoint) + #expect(cleared == original) +} + +@Test("Equatable 含令牌:令牌不同的同 id 主机不相等(UI 才能感知令牌变化)") +func hostsDifferingOnlyByTokenAreNotEqual() { + // Arrange + let base = Fixtures.makeHost() + + // Act / Assert + #expect(base.withAccessToken(Fixtures.makeToken()) != base) + #expect(base.withAccessToken(Fixtures.makeToken()) + != base.withAccessToken(Fixtures.makeToken(Fixtures.otherValidTokenString))) +} diff --git a/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift b/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift index 20957a0..499a2f2 100644 --- a/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift +++ b/ios/Packages/HostRegistry/Tests/HostRegistryTests/TestFixtures.swift @@ -25,4 +25,28 @@ enum Fixtures { static func makeSuiteName() -> String { "HostRegistryTests." + UUID().uuidString } + + // MARK: - Access-token fixtures (coordination doc §1.1) + + /// 22 chars exercising EVERY class of the frozen charset + /// `[A-Za-z0-9._~+/=-]` (upper/lower/digit/`.`/`_`/`~`/`+`/`/`/`=`/`-`), + /// comfortably above the 16-char minimum. Test-only value — not a secret. + static let validTokenString = "A9z._~+/=-Abcdef012345" + + /// A second, distinct valid token (for "replaced/other host" assertions). + static let otherValidTokenString = "Zy8-=/+~_.9876543210fedcba" + + /// Builds a valid token; traps loudly if a fixture string is malformed + /// (a broken fixture must fail the suite, not silently skip assertions). + static func makeToken(_ raw: String = Fixtures.validTokenString) -> AccessToken { + guard let token = AccessToken(rawValue: raw) else { + fatalError("test fixture token invalid: length \(raw.count)") + } + return token + } + + /// A repeated-character token of an exact length (length-boundary tests). + static func tokenString(length: Int) -> String { + String(repeating: "a", count: length) + } } diff --git a/ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift b/ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift new file mode 100644 index 0000000..f44a886 --- /dev/null +++ b/ios/Packages/SessionCore/Sources/SessionCore/AuthCookie.swift @@ -0,0 +1,42 @@ +/// The ONE place SessionCore turns a configured access token (`WEBTERM_TOKEN`) +/// into an HTTP request header (B3 · ios-completion §1.1, FROZEN). +/// +/// Why hand-written and not `HTTPCookieStorage`: the native client already KNOWS +/// the token, and `URLSession`'s cookie jar behaves inconsistently on a +/// WebSocket upgrade (and is barely testable there). A hand-written header is +/// the same pattern as the hand-written `Origin` (plan §5.1) and can be pinned +/// by pure unit tests. `Set-Cookie` is never parsed. +/// +/// SECURITY: the token is secret material. It exists here only long enough to be +/// handed to `URLRequest.setValue`; it is never logged, never interpolated into a +/// URL/query, and never carried by any error this module throws. +enum AuthCookie { + /// The server's `AUTH_COOKIE_NAME` (src/http/auth.ts:30). + static let name = "webterm_auth" + + /// `webterm_auth=` for a well-formed token; `nil` when no token is + /// configured OR the configured value is off-policy. + /// + /// Off-policy ⇒ OMIT rather than throw: a host with auth DISABLED ignores + /// the cookie entirely, so a junk stored token must not break a working + /// connection. When the host DOES enforce auth, the omitted cookie yields + /// the server's 401 → `TermTransportError.unauthorized` → the UI asks for a + /// correct token. Omitting a secret is never a leak. + static func headerValue(token: String?) -> String? { + guard let token, isValidToken(token) else { return nil } + return "\(name)=\(token)" + } + + /// True iff `candidate` satisfies the server's token policy: a byte-identical + /// port of `WEBTERM_TOKEN_RE` (src/config.ts:178) — URL/cookie-safe charset, + /// 16–512 chars. A token the server would reject at startup can never be a + /// valid credential, and enforcing the charset HERE is also what keeps a + /// hostile stored value from injecting CRLF into the request headers. + /// + /// The literal is built per call (as in `WireProtocol.Validation`): `Regex` + /// is not `Sendable`, so it cannot be hoisted into a `static let`. + static func isValidToken(_ candidate: String) -> Bool { + let tokenPolicy = /^[A-Za-z0-9._~+\/=-]{16,512}$/ + return candidate.wholeMatch(of: tokenPolicy) != nil + } +} diff --git a/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift b/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift index 51aeada..af67347 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/SessionEngine.swift @@ -19,7 +19,8 @@ import WireProtocol /// frame goes through `MessageCodec.decodeServer`; nil → dropped + counted, /// never a crash. /// - Terminal states never re-enter backoff: `exit` (session over, including -/// spawn failure -1, M4) and `.replayTooLarge` (deterministic failure). +/// spawn failure -1, M4), `.replayTooLarge` (deterministic failure), and +/// `.unauthorized` (401 on the upgrade — B3 · ios-completion §1.1). /// /// Sizing (documented decision, plan §7 T-iOS-10): the server spawns every PTY /// at 80×24 (src/server.ts:714-717) and the frozen `open` signature carries no @@ -324,9 +325,9 @@ public actor SessionEngine { eventsContinuation.finish() return false } - if let error, isReplayTooLarge(error) { + if let error, let reason = Self.terminalFailure(for: error) { hasFailedTerminally = true - emit(.connection(.failed(.replayTooLarge))) // NEVER backoff (plan §1) + emit(.connection(.failed(reason))) // NEVER backoff (plan §1 / §1.1) eventsContinuation.finish() return false } @@ -470,8 +471,17 @@ public actor SessionEngine { generation == gen } - private func isReplayTooLarge(_ error: any Error) -> Bool { - (error as? TermTransportError) == .replayTooLarge + /// The NON-retryable transport classifications, in ONE place. Retrying + /// either is pointless: `replayTooLarge` fails deterministically forever + /// (plan §1) and `unauthorized` (401 on the upgrade — Origin or access + /// token, ios-completion §1.1) does not become correct by waiting, while a + /// retry storm on 401 reads as a brute-force attempt. `nil` = retryable. + private static func terminalFailure(for error: any Error) -> FailureReason? { + switch error as? TermTransportError { + case .replayTooLarge: return .replayTooLarge + case .unauthorized: return .unauthorized + case .sendAfterClose, .none: return nil + } } private func emit(_ event: SessionEvent) { diff --git a/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift b/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift index dde7281..65dc6e3 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/SessionEvent.swift @@ -57,4 +57,12 @@ public enum FailureReason: Sendable, Equatable { /// enters backoff. UI copy: lower the server's SCROLLBACK_BYTES or raise /// the client cap (plan §3.2.1 coupling warning). case replayTooLarge + /// The WS upgrade was rejected with **401** (B3 · ios-completion §1.1): + /// either the host's Origin whitelist does not contain our dialed origin or + /// the access token is missing/wrong (src/server.ts:1367-1379 checks Origin + /// first, then the `webterm_auth` cookie — the status is the same, so the UI + /// must offer BOTH remedies). Never retried: the credential does not become + /// correct by waiting, and a retry storm on 401 is a brute-force signal. + /// UI copy: re-enter the access token, or fix `ALLOWED_ORIGINS` on the host. + case unauthorized } diff --git a/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift b/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift index 638b1ff..0d7e61d 100644 --- a/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift +++ b/ios/Packages/SessionCore/Sources/SessionCore/URLSessionTermTransport.swift @@ -26,6 +26,15 @@ public enum TermTransportError: Error, Equatable, Sendable { /// backoff loop (plan §1 / §3.2.1 coupling warning), otherwise the retry /// is deterministic and infinite. case replayTooLarge + /// The WS upgrade was answered **401** (B3 · ios-completion §1.1). Both + /// server-side upgrade gates write that status — the Origin/CSWSH check + /// first, then the access-token cookie (src/server.ts:1367-1379) — and + /// neither becomes true by waiting. NON-RETRYABLE, exactly like + /// `replayTooLarge`: the engine surfaces `connection(.failed(.unauthorized))` + /// and must never feed it into the backoff loop, because retrying a wrong + /// token forever is useless AND indistinguishable from a brute-force probe. + /// Carries NO payload — the token must never ride on an error. + case unauthorized /// `send` on a connection that already terminated (client `close()`, /// server close frame, or transport error). Mirrors /// `FakeTransportError.sendAfterClose` (TestSupport). @@ -74,6 +83,8 @@ protocol ConnectionPinger: Sendable { /// snapshot frame → every task gets `Tunables.maxWSMessageBytes` (16 MiB). /// - `receive()` delivers ONE message per call → the loop re-arms forever. /// - No automatic ping → `ConnectionPinger` (driven by `PingScheduler`). +/// - Access token: a hand-written `Cookie: webterm_auth=` next to (never +/// instead of) `Origin` — B3 · ios-completion §1.1, see `AuthCookie`. public struct URLSessionTermTransport: TermTransport { /// `URLSessionWebSocketTask.maximumMessageSize` applied to every task. private let maxMessageBytes: Int @@ -86,19 +97,32 @@ public struct URLSessionTermTransport: TermTransport { /// cleanly. Local (non-mTLS) hosts never issue the challenge, so a `nil` /// result is inert there. private let identityProvider: @Sendable () -> ClientIdentity? + /// B3 · access token, resolved LAZILY once per `connect` for the same + /// no-relaunch reason as `identityProvider`: a token entered (or rotated) + /// mid-run takes effect on the NEXT connection. `nil` ⇒ no `Cookie` header + /// at all, so LAN zero-config hosts are byte-identical to before. + /// The value is passed straight to `AuthCookie` — never logged, never stored. + private let tokenProvider: @Sendable () -> String? /// Fixed-identity convenience (snapshot callers / tests): wraps a constant /// provider, so behaviour is identical to capturing the identity directly. - public init(identity: ClientIdentity? = nil) { - self.init(identityProvider: { identity }) + public init( + identity: ClientIdentity? = nil, + tokenProvider: @escaping @Sendable () -> String? = { nil } + ) { + self.init(identityProvider: { identity }, tokenProvider: tokenProvider) } /// C-iOS-2 (MEDIUM no-relaunch fix) · per-connect identity resolution: the /// provider is re-consulted on every `connect`, so a nil→installed /// transition is picked up without relaunch. - public init(identityProvider: @escaping @Sendable () -> ClientIdentity?) { + public init( + identityProvider: @escaping @Sendable () -> ClientIdentity?, + tokenProvider: @escaping @Sendable () -> String? = { nil } + ) { self.init( - maxMessageBytes: Tunables.maxWSMessageBytes, identityProvider: identityProvider + maxMessageBytes: Tunables.maxWSMessageBytes, + identityProvider: identityProvider, tokenProvider: tokenProvider ) } @@ -107,15 +131,20 @@ public struct URLSessionTermTransport: TermTransport { /// through `init()` / `init(identityProvider:)` and the frozen /// `Tunables.maxWSMessageBytes`. init(maxMessageBytes: Int, identity: ClientIdentity? = nil) { - self.init(maxMessageBytes: maxMessageBytes, identityProvider: { identity }) + self.init( + maxMessageBytes: maxMessageBytes, identityProvider: { identity }, + tokenProvider: { nil } + ) } init( maxMessageBytes: Int, - identityProvider: @escaping @Sendable () -> ClientIdentity? + identityProvider: @escaping @Sendable () -> ClientIdentity?, + tokenProvider: @escaping @Sendable () -> String? ) { self.maxMessageBytes = maxMessageBytes self.identityProvider = identityProvider + self.tokenProvider = tokenProvider } public func connect(to endpoint: HostEndpoint) async throws -> TransportConnection { @@ -123,12 +152,12 @@ public struct URLSessionTermTransport: TermTransport { } /// Internal: concrete-typed connect (tests assert task configuration; - /// `connectPingable` builds on it). The identity is resolved HERE, per - /// connect, from `identityProvider` (no-relaunch pickup). + /// `connectPingable` builds on it). Identity AND token are resolved HERE, + /// per connect, from their providers (no-relaunch pickup). func openConnection(to endpoint: HostEndpoint) async throws -> WSConnection { try await WSConnection.open( endpoint: endpoint, maxMessageBytes: maxMessageBytes, - identity: identityProvider() + identity: identityProvider(), token: tokenProvider() ) } } @@ -185,11 +214,13 @@ final class WSConnection: NSObject, @unchecked Sendable { /// handshake (didOpen / didCompleteWithError — no receive-error guessing), /// then start the re-arming receive loop. static func open( - endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil + endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? = nil, + token: String? = nil ) async throws -> WSConnection { let connection = WSConnection() connection.configure( - endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity + endpoint: endpoint, maxMessageBytes: maxMessageBytes, identity: identity, + token: token ) try await connection.performHandshake() connection.startReceiveLoop() @@ -209,21 +240,52 @@ final class WSConnection: NSObject, @unchecked Sendable { // MARK: Setup private func configure( - endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity? + endpoint: HostEndpoint, maxMessageBytes: Int, identity: ClientIdentity?, token: String? ) { // Set BEFORE building the task/resume: the mTLS challenge can only fire // after `task.resume()`, so this write happens-before any read of it. self.identity = identity - // Origin: SINGLE source of truth = endpoint.originHeader (plan §5.1). - var request = URLRequest(url: endpoint.wsURL) - request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin") - let session = URLSession(configuration: .ephemeral, delegate: self, delegateQueue: nil) - let task = session.webSocketTask(with: request) + let session = URLSession( + configuration: Self.sessionConfiguration(), delegate: self, delegateQueue: nil + ) + let task = session.webSocketTask( + with: Self.upgradeRequest(endpoint: endpoint, token: token) + ) task.maximumMessageSize = maxMessageBytes self.session = session self.task = task } + /// THE single choke point for upgrade request headers. + /// + /// - `Origin`: SINGLE source of truth = `endpoint.originHeader` (plan §5.1) + /// — always sent. + /// - `Cookie`: `webterm_auth=` when a token is configured + /// (B3 · ios-completion §1.1). ADDITIVE and ORTHOGONAL: the server checks + /// Origin first and the cookie second (src/server.ts:1367-1379), so the + /// cookie never replaces Origin. Absent/off-policy token ⇒ header omitted + /// (see `AuthCookie.headerValue`). + private static func upgradeRequest(endpoint: HostEndpoint, token: String?) -> URLRequest { + var request = URLRequest(url: endpoint.wsURL) + request.setValue(endpoint.originHeader, forHTTPHeaderField: "Origin") + if let cookie = AuthCookie.headerValue(token: token) { + request.setValue(cookie, forHTTPHeaderField: "Cookie") + } + return request + } + + /// Ephemeral, with the cookie jar switched OFF: §1.1 freezes the + /// hand-written `Cookie` header as the ONLY authority, so no `Set-Cookie` + /// from any host can override it, outlive the connection, or park the secret + /// in shared storage. + private static func sessionConfiguration() -> URLSessionConfiguration { + let configuration = URLSessionConfiguration.ephemeral + configuration.httpShouldSetCookies = false + configuration.httpCookieAcceptPolicy = .never + configuration.httpCookieStorage = nil + return configuration + } + private func performHandshake() async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -331,6 +393,34 @@ final class WSConnection: NSObject, @unchecked Sendable { } } + /// HTTP status both server-side upgrade gates answer with: the Origin/CSWSH + /// check, then the access-token cookie (src/server.ts:1367-1379). + private static let unauthorizedStatusCode = 401 + + /// The upgrade never opened — decide what `connect` throws. + /// + /// A **401** is non-retryable by construction (see + /// `TermTransportError.unauthorized`), so it becomes the typed error the + /// engine treats as terminal. EVERYTHING else is rethrown VERBATIM, because + /// `PairingError.classify` (T-iOS-8) keys off the underlying POSIX/NSURLError + /// identity to tell localNetworkDenied / atsBlocked / tlsFailure apart — + /// wrapping those would break that taxonomy (see `TermTransportError` doc). + private static func handshakeFailure(task: URLSessionTask, error: (any Error)?) -> any Error { + if httpStatusCode(of: task) == unauthorizedStatusCode { + return TermTransportError.unauthorized + } + return error ?? URLError(.cannotConnectToHost) + } + + /// The HTTP status of a rejected upgrade: `URLSessionWebSocketTask` fails the + /// task on a non-101 answer and leaves the `HTTPURLResponse` on `task` + /// (mutation-verified against a scripted 401 vs 500 in + /// `URLSessionTermTransportTests`; without it the 401 arrives as the opaque + /// NSURLErrorDomain -1011 that WOULD enter the backoff loop). + private static func httpStatusCode(of task: URLSessionTask) -> Int? { + (task.response as? HTTPURLResponse)?.statusCode + } + /// T-iOS-2 spike (measured on a real server): exceeding /// `maximumMessageSize` fails `receive()` with NSPOSIXErrorDomain /// EMSGSIZE(40) "Message too long" — not a 1009 close. ENOBUFS(55) is the @@ -399,10 +489,7 @@ extension WSConnection: URLSessionWebSocketDelegate { didCompleteWithError error: (any Error)? ) { if let handshake = takeHandshakeContinuation() { - // Handshake never opened. Rethrow the UNDERLYING error verbatim — - // PairingError.classify (T-iOS-8) depends on its POSIX/NSURLError - // identity (see TermTransportError doc). - handshake.resume(throwing: error ?? URLError(.cannotConnectToHost)) + handshake.resume(throwing: Self.handshakeFailure(task: task, error: error)) session.finishTasksAndInvalidate() return } diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift new file mode 100644 index 0000000..a6dd6c6 --- /dev/null +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/AuthCookieTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import SessionCore + +/// B3 · `AuthCookie` — the ONE place SessionCore turns a configured access +/// token into an upgrade request header (`ios-completion` §1.1, FROZEN: +/// hand-written `Cookie`, never `Set-Cookie`/`HTTPCookieStorage`). +/// +/// The policy mirrored here is the server's, verbatim: +/// - cookie name `webterm_auth` (src/http/auth.ts:30 `AUTH_COOKIE_NAME`) +/// - token charset/length `^[A-Za-z0-9._~+/=-]{16,512}$` (src/config.ts:178) +@Suite("AuthCookie") +struct AuthCookieTests { + /// A well-formed token exercising every allowed character class. + private static let validToken = "Ab9._~+/=-Ab9._~+/=-" + private static let minLengthToken = String(repeating: "a", count: 16) + private static let maxLengthToken = String(repeating: "a", count: 512) + + @Test("cookie name matches the server's AUTH_COOKIE_NAME") + func cookieNameMatchesServer() { + #expect(AuthCookie.name == "webterm_auth") + } + + @Test("a valid token becomes exactly `webterm_auth=`") + func validTokenBecomesHeaderValue() { + // Arrange / Act + let header = AuthCookie.headerValue(token: Self.validToken) + + // Assert + #expect(header == "webterm_auth=\(Self.validToken)") + } + + @Test("no token configured (nil / empty) ⇒ no Cookie header at all") + func absentTokenYieldsNoHeader() { + #expect(AuthCookie.headerValue(token: nil) == nil) + #expect(AuthCookie.headerValue(token: "") == nil) + } + + @Test("length policy: 16 and 512 accepted, 15 and 513 rejected (server charset rule)") + func lengthPolicyMatchesServer() { + #expect(AuthCookie.headerValue(token: Self.minLengthToken) != nil) + #expect(AuthCookie.headerValue(token: Self.maxLengthToken) != nil) + #expect(AuthCookie.headerValue(token: String(repeating: "a", count: 15)) == nil) + #expect(AuthCookie.headerValue(token: String(repeating: "a", count: 513)) == nil) + } + + @Test( + "off-charset tokens are refused, never smuggled into the header (CRLF injection included)", + arguments: [ + "aaaaaaaaaaaaaaaa\r\nX-Evil: 1", // header injection attempt + "aaaaaaaaaaaaaaaa;path=/", // cookie-attribute injection attempt + "aaaaaaaaaaaaaaaa aaaa", // space + "aaaaaaaaaaaaaaaa\"quoted\"", + "aaaaaaaaaaaaaaaa\u{00E9}", // non-ASCII + "aaaaaaaaaaaaaaaa\u{0000}", + ] + ) + func offCharsetTokensRefused(token: String) { + #expect(AuthCookie.headerValue(token: token) == nil) + } +} diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift index 57caf7d..f0d838c 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/ScriptedWSServer.swift @@ -28,6 +28,22 @@ final class ScriptedWSServer: @unchecked Sendable { let headers: [String: String] } + /// A scripted NON-101 answer to the upgrade, byte-shaped like the real + /// server's reject path: it writes a bare status line and destroys the + /// socket — no headers, no body (src/server.ts:1367-1379, both the Origin + /// gate and the access-token cookie gate). + struct UpgradeRejection: Sendable { + let statusCode: Int + let reasonPhrase: String + + /// What BOTH server-side upgrade gates write (Origin, then cookie). + static let unauthorized = UpgradeRejection(statusCode: 401, reasonPhrase: "Unauthorized") + /// A non-401 rejection: must stay in the RETRYABLE class. + static let serverError = UpgradeRejection( + statusCode: 500, reasonPhrase: "Internal Server Error" + ) + } + /// RFC 6455 wire constants (named — no magic numbers). private enum Wire { static let finBit: UInt8 = 0x80 @@ -63,6 +79,7 @@ final class ScriptedWSServer: @unchecked Sendable { private var connection: NWConnection? private var rxBuffer = [UInt8]() private var isHandshakeDone = false + private var upgradeRejection: UpgradeRejection? private var startContinuation: CheckedContinuation? private var stopContinuation: CheckedContinuation? @@ -131,6 +148,13 @@ final class ScriptedWSServer: @unchecked Sendable { lock.withLock { connection }?.forceCancel() } + /// Answer every subsequent upgrade with `rejection` instead of the 101. + /// Call before `connect` (the upgrade request is still recorded, so tests + /// can assert the headers a REJECTED handshake carried). + func rejectUpgrades(with rejection: UpgradeRejection) { + lock.withLock { upgradeRejection = rejection } + } + // MARK: - Connection handling private func adopt(connection newConnection: NWConnection) { @@ -184,22 +208,31 @@ final class ScriptedWSServer: @unchecked Sendable { // MARK: - Handshake private func tryCompleteHandshake(on connection: NWConnection) { - let requestBytes: [UInt8]? = lock.withLock { + let pending: (requestBytes: [UInt8], rejection: UpgradeRejection?)? = lock.withLock { guard !isHandshakeDone, let end = Self.headerEndIndex(in: rxBuffer) else { return nil } let request = Array(rxBuffer[.. Int? { @@ -235,6 +268,10 @@ final class ScriptedWSServer: @unchecked Sendable { return Data(response.utf8) } + private static func rejectionResponse(_ rejection: UpgradeRejection) -> Data { + Data("HTTP/1.1 \(rejection.statusCode) \(rejection.reasonPhrase)\r\n\r\n".utf8) + } + // MARK: - Client frame parsing private func drainClientFrames(on connection: NWConnection) { diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift index 395011b..4231660 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/SessionEngineTests.swift @@ -476,6 +476,70 @@ struct SessionEngineTests { #expect(await harness.transport.connectAttempts.count == 1) } + @Test("401 on the upgrade → connection(.failed(.unauthorized)) and NOT ONE reconnect attempt") + func unauthorizedIsTerminalAndNeverBacksOff() async throws { + // Arrange: the transport rejects the handshake with the typed 401 error + // (B3 · ios-completion §1.1 "WS upgrade 收 401 ⇒ 终态,不进退避环"). + let harness = try Harness() + await harness.transport.scriptConnectFailure(TermTransportError.unauthorized) + + // Act + await harness.engine.open(sessionId: nil, cwd: nil) + + // Assert: terminal failure instead of a backoff rung — retrying a wrong + // token forever is useless AND a brute-force signal. + #expect(await harness.next() == .connection(.connecting)) + #expect(await harness.next() == .connection(.failed(.unauthorized))) + #expect(await harness.next() == nil) + #expect(harness.clock.pendingSleeperCount == 0) + harness.clock.advance(by: .seconds(60)) + #expect(await harness.transport.connectAttempts.count == 1) + + // Assert: the engine is inert afterwards (same shape as .replayTooLarge). + await harness.engine.send(.input(data: "too late")) + #expect(await harness.engine.droppedOutboundFrameCount == 1) + } + + @Test("ordinary failure still backs off; a 401 on the retry stops the ladder for good") + func ordinaryFailureBacksOffThen401EndsIt() async throws { + // Arrange: attempt #1 fails the ordinary (retryable) way, attempt #2 401s. + let harness = try Harness() + await harness.transport.scriptConnectFailure() + await harness.transport.scriptConnectFailure(TermTransportError.unauthorized) + + // Act / Assert: rung 1 is unchanged by the new classification. + await harness.engine.open(sessionId: nil, cwd: nil) + #expect(await harness.next() == .connection(.connecting)) + #expect(await harness.next() == .connection(.reconnecting(attempt: 1, next: .seconds(1)))) + await harness.clock.waitForSleepers(count: 1) + harness.clock.advance(by: .seconds(1)) + + // Assert: the 401 is terminal — no rung 2, no third attempt, ever. + #expect(await harness.next() == .connection(.failed(.unauthorized))) + #expect(await harness.next() == nil) + #expect(harness.clock.pendingSleeperCount == 0) + harness.clock.advance(by: .seconds(60)) + #expect(await harness.transport.connectAttempts.count == 2) + } + + @Test("unauthorized surfacing mid-stream is classified terminally too (one classifier)") + func unauthorizedOnStreamIsAlsoTerminal() async throws { + // Arrange + let sessionId = UUID() + let harness = try Harness() + await harness.openAndAdopt(adopting: sessionId) + + // Act + await harness.transport.emitError(TermTransportError.unauthorized) + + // Assert + #expect(await harness.next() == .connection(.failed(.unauthorized))) + #expect(await harness.next() == nil) + #expect(harness.clock.pendingSleeperCount == 0) + harness.clock.advance(by: .seconds(60)) + #expect(await harness.transport.connectAttempts.count == 1) + } + // MARK: - Close @Test("close() closes the transport, finishes the events stream, and leaks no tasks") diff --git a/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift b/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift index df2ddf3..5b60f5c 100644 --- a/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift +++ b/ios/Packages/SessionCore/Tests/SessionCoreTests/URLSessionTermTransportTests.swift @@ -30,6 +30,12 @@ struct URLSessionTermTransportTests { static let normalCloseCode: UInt16 = 1000 /// Arbitrary non-text payload for the binary-frame drop test. static let binaryPayload: [UInt8] = [0xDE, 0xAD, 0xBE, 0xEF] + /// A well-formed access token (server charset, src/config.ts:178). + static let accessToken = "Ab9._~+/=-Ab9._~+/=-" + /// A second well-formed token — proves per-connect token resolution. + static let rotatedAccessToken = "Zz0-=/+~._Zz0-=/+~._" + /// Off-charset token: must NEVER reach the wire (header injection). + static let malformedAccessToken = "aaaaaaaaaaaaaaaa\r\nX-Evil: 1" } private struct TimedOut: Error {} @@ -74,6 +80,22 @@ struct URLSessionTermTransportTests { } } + /// The first `count` upgrade requests, drained through ONE iterator + /// (`AsyncStream` is single-consumer — buffered yields are never lost). + private static func firstUpgrades( + count: Int, from server: ScriptedWSServer + ) async throws -> [ScriptedWSServer.Upgrade] { + try await withTimeout { + var collected: [ScriptedWSServer.Upgrade] = [] + var iterator = server.upgrades.makeAsyncIterator() + while collected.count < count { + guard let upgrade = await iterator.next() else { throw TimedOut() } + collected.append(upgrade) + } + return collected + } + } + private static func collect( _ count: Int, from frames: AsyncThrowingStream ) async throws -> [String] { @@ -278,6 +300,135 @@ struct URLSessionTermTransportTests { } } + // MARK: - Access token (B3 · ios-completion §1.1) + + @Test("configured token ⇒ upgrade carries Cookie: webterm_auth= AND Origin (additive, §1.1)") + func upgradeCarriesAuthCookieAlongsideOrigin() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken }) + + let connection = try await transport.connect(to: endpoint) + let upgrade = try await Self.firstUpgrade(server) + + // The cookie NEVER replaces Origin — the server checks Origin first, + // then the cookie (src/server.ts:1367-1379). + #expect(upgrade.headers["origin"] == endpoint.originHeader) + #expect(upgrade.headers["cookie"] == "\(AuthCookie.name)=\(TestTunables.accessToken)") + await connection.close() + } + + @Test("no token configured ⇒ NO Cookie header at all (LAN zero-config unchanged)") + func upgradeOmitsCookieWhenNoTokenConfigured() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + + let connection = try await URLSessionTermTransport().connect(to: endpoint) + let upgrade = try await Self.firstUpgrade(server) + + #expect(upgrade.headers["cookie"] == nil) + #expect(upgrade.headers["origin"] == endpoint.originHeader) + await connection.close() + } + + @Test("off-charset token is dropped, never smuggled onto the wire; Origin still sent") + func malformedTokenIsNeverSentButOriginIs() async throws { + // A malformed stored token must not break a host that has auth DISABLED + // (it ignores the cookie), and must not become a header-injection vector. + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + let transport = URLSessionTermTransport( + tokenProvider: { TestTunables.malformedAccessToken } + ) + + let connection = try await transport.connect(to: endpoint) + let upgrade = try await Self.firstUpgrade(server) + + #expect(upgrade.headers["cookie"] == nil) + #expect(upgrade.headers["x-evil"] == nil) + #expect(upgrade.headers["origin"] == endpoint.originHeader) + await connection.close() + } + + @Test("token provider is resolved PER CONNECT — a token added/rotated mid-run needs no relaunch") + func tokenProviderResolvedPerConnect() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + let tokens = TokenSequence([TestTunables.accessToken, TestTunables.rotatedAccessToken]) + let transport = URLSessionTermTransport(tokenProvider: { tokens.next() }) + + let first = try await transport.connect(to: endpoint) + await first.close() + let second = try await transport.connect(to: endpoint) + await second.close() + let upgrades = try await Self.firstUpgrades(count: 2, from: server) + + #expect(upgrades.map { $0.headers["cookie"] } == [ + "\(AuthCookie.name)=\(TestTunables.accessToken)", + "\(AuthCookie.name)=\(TestTunables.rotatedAccessToken)", + ]) + } + + @Test("401 on the WS upgrade ⇒ typed TermTransportError.unauthorized (terminal, never retried)") + func unauthorizedUpgradeThrowsTypedUnauthorized() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + server.rejectUpgrades(with: .unauthorized) + let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken }) + + var thrown: (any Error)? + do { + _ = try await Self.withTimeout { try await transport.connect(to: endpoint) } + } catch { + thrown = error + } + + #expect( + (thrown as? TermTransportError) == .unauthorized, + "expected .unauthorized, got \(String(describing: thrown))" + ) + } + + @Test("a NON-401 upgrade rejection stays in the retryable class (not .unauthorized)") + func nonUnauthorizedUpgradeRejectionStaysRetryable() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + server.rejectUpgrades(with: .serverError) + + var thrown: (any Error)? + do { + _ = try await Self.withTimeout { + try await URLSessionTermTransport().connect(to: endpoint) + } + } catch { + thrown = error + } + + let failure = try #require(thrown, "a 500 upgrade must still fail the connect") + #expect(!(failure is TimedOut), "connect neither opened nor failed in time") + #expect((failure as? TermTransportError) != .unauthorized) + } + + @Test("the token never appears in a thrown error (nothing loggable carries the secret)") + func thrownErrorNeverCarriesTheToken() async throws { + let (server, endpoint) = try await Self.startServer() + defer { server.stop() } + server.rejectUpgrades(with: .unauthorized) + let transport = URLSessionTermTransport(tokenProvider: { TestTunables.accessToken }) + + var thrown: (any Error)? + do { + _ = try await Self.withTimeout { try await transport.connect(to: endpoint) } + } catch { + thrown = error + } + + let failure = try #require(thrown) + #expect(!String(describing: failure).contains(TestTunables.accessToken)) + #expect(!String(reflecting: failure).contains(TestTunables.accessToken)) + #expect(!failure.localizedDescription.contains(TestTunables.accessToken)) + } + /// Thread-safe invocation counter for the `@Sendable` identity provider /// (URLSession may consult it off the test's isolation domain). private final class CallCounter: @unchecked Sendable { @@ -286,5 +437,24 @@ struct URLSessionTermTransportTests { func increment() { lock.withLock { count += 1 } } var value: Int { lock.withLock { count } } } + + /// Thread-safe token script: hands out one token per `connect` (the last + /// value repeats), proving the provider is re-consulted every time. + private final class TokenSequence: @unchecked Sendable { + private let lock = NSLock() + private var remaining: [String] + + init(_ tokens: [String]) { + remaining = tokens + } + + func next() -> String? { + lock.withLock { + guard let first = remaining.first else { return nil } + if remaining.count > 1 { remaining.removeFirst() } + return first + } + } + } } #endif