Merge ios-completion: device builds unblocked, access token on both clients, P2 wave
Closes the six remediation items from the 2026-07-29 iOS completion audit plus the whole P2 wave and Android access-token parity. The audit's headline was that the client was code-complete but stuck at the device door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware once, and it had fallen two months behind the server (Android had the git panel, iOS had none) while neither native client could connect at all once WEBTERM_TOKEN was set. Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49% and is now actually in the coverage gate, which it never was. Device build now succeeds on the free personal team. src/ and public/ are untouched — the git-panel endpoints already existed server-side; iOS simply never consumed them. # Conflicts: # android/.gitignore # android/README.md # android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt # android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt # android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt # android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt # android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt # docs/PROGRESS_LOG.md
This commit is contained in:
@@ -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)" : "<redacted>)")
|
||||
}
|
||||
|
||||
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<T: Decodable>(_ 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
|
||||
}
|
||||
|
||||
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
132
ios/Packages/APIClient/Sources/APIClient/AccessToken.swift
Normal file
@@ -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":"<t>"}` + 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=<t>` 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=<token>`. 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,31 @@ 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. This is also the
|
||||
/// correct reading for the WORKTREE writes: `src/http/worktrees.ts` has no
|
||||
/// 401 of its own (403 kill-switch, else 400/404/500).
|
||||
case accessTokenGate
|
||||
/// The route owns its 401: `POST /auth`'s wrong-token answer
|
||||
/// (src/server.ts:418) and the four **git-ops** writes — stage, commit,
|
||||
/// push, fetch — where the server CLASSIFIES a host-side git credential
|
||||
/// failure as 401 (src/http/git-ops.ts:108 "Push authentication required on
|
||||
/// the host."). Nothing else reaches that classifier.
|
||||
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 +62,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=<t>` 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 +114,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 +131,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 +157,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=<strictly-encoded>` — 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 +219,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()
|
||||
}
|
||||
|
||||
|
||||
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
83
ios/Packages/APIClient/Sources/APIClient/FollowupQueue.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
110
ios/Packages/APIClient/Sources/APIClient/GitLog.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
398
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
398
ios/Packages/APIClient/Sources/APIClient/GitWrite.swift
Normal file
@@ -0,0 +1,398 @@
|
||||
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<Payload: Sendable & Equatable>: 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 write here shares the shape: JSON body + `Origin`. What
|
||||
/// differs is only how a **401** must be read, so that is the parameter.
|
||||
private static func guardedWriteRoute<Body: Encodable>(
|
||||
_ method: HTTPMethod, _ path: String, _ body: Body,
|
||||
unauthorizedPolicy: UnauthorizedPolicy
|
||||
) throws -> APIRoute {
|
||||
APIRoute(
|
||||
method: method, path: path, originPolicy: .guarded,
|
||||
body: try JSONEncoder().encode(body),
|
||||
unauthorizedPolicy: unauthorizedPolicy
|
||||
)
|
||||
}
|
||||
|
||||
/// The four **git-ops** writes — and ONLY these four. `src/http/git-ops.ts:108`
|
||||
/// classifies a HOST-side git credential failure as 401 ("Push authentication
|
||||
/// required on the host."), and that classifier is reached only from
|
||||
/// `stageFiles`/`commit`/`push`/`fetch`. Their 401 must not be mistaken for
|
||||
/// the access-token gate telling us to enter a token.
|
||||
private static func gitOpsRoute<Body: Encodable>(
|
||||
_ method: HTTPMethod, _ path: String, _ body: Body
|
||||
) throws -> APIRoute {
|
||||
try guardedWriteRoute(method, path, body, unauthorizedPolicy: .routeDefined)
|
||||
}
|
||||
|
||||
/// The worktree trio. Guarded, but the handler (`src/http/worktrees.ts`,
|
||||
/// routed at src/server.ts:1095-1183) emits **no 401 at all** — 403 for the
|
||||
/// kill-switch, else 400/404/500. So the only 401 these can ever see is the
|
||||
/// access-token gate, and they take the DEFAULT reading: pinning them
|
||||
/// `.routeDefined` made a gated host's 401 surface as a worktree failure
|
||||
/// instead of the token flow.
|
||||
private static func worktreeRoute<Body: Encodable>(
|
||||
_ method: HTTPMethod, _ path: String, _ body: Body
|
||||
) throws -> APIRoute {
|
||||
try guardedWriteRoute(method, path, body, unauthorizedPolicy: .accessTokenGate)
|
||||
}
|
||||
|
||||
static func gitStage(path: String, files: [String], stage: Bool) throws -> APIRoute {
|
||||
try gitOpsRoute(.post, "/projects/git/stage", StageBody(path: path, files: files, stage: stage))
|
||||
}
|
||||
|
||||
static func gitCommit(path: String, message: String) throws -> APIRoute {
|
||||
try gitOpsRoute(.post, "/projects/git/commit", CommitBody(path: path, message: message))
|
||||
}
|
||||
|
||||
static func gitPush(path: String) throws -> APIRoute {
|
||||
try gitOpsRoute(.post, "/projects/git/push", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func gitFetch(path: String) throws -> APIRoute {
|
||||
try gitOpsRoute(.post, "/projects/git/fetch", RepoPathBody(path: path))
|
||||
}
|
||||
|
||||
static func createWorktree(path: String, branch: String, base: String?) throws -> APIRoute {
|
||||
try worktreeRoute(
|
||||
.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 worktreeRoute(
|
||||
.delete, "/projects/worktree",
|
||||
RemoveWorktreeBody(path: path, worktreePath: worktreePath, force: force)
|
||||
)
|
||||
}
|
||||
|
||||
static func pruneWorktrees(path: String) throws -> APIRoute {
|
||||
try worktreeRoute(.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<StageResult> {
|
||||
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 <msg>` 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<CommitResult> {
|
||||
try await performGitWrite(path: path, CommitResult.self) {
|
||||
try Endpoints.gitCommit(path: path, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Push the current branch to its existing upstream, or `-u <sole-remote>
|
||||
/// <branch>` 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<PushResult> {
|
||||
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<FetchResult> {
|
||||
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<CreateWorktreeResult> {
|
||||
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<RemoveWorktreeResult> {
|
||||
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<PruneWorktreesResult> {
|
||||
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<Payload: GitWritePayload>(
|
||||
path: String,
|
||||
_ payload: Payload.Type,
|
||||
route build: () throws -> APIRoute
|
||||
) async throws -> GitWriteOutcome<Payload> {
|
||||
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
|
||||
}
|
||||
}
|
||||
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
90
ios/Packages/APIClient/Sources/APIClient/History.swift
Normal file
@@ -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 <id>` 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)
|
||||
}
|
||||
}
|
||||
@@ -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)。"
|
||||
}
|
||||
|
||||
@@ -28,21 +28,33 @@ import WireProtocol
|
||||
/// deadline (the transport's own timeouts still apply). The production
|
||||
/// default is a UI-layer decision (T-iOS-12); a shared
|
||||
/// `Tunables.pairingProbeTimeout` would be a T-iOS-3 contract addition.
|
||||
/// - accessToken: candidate `WEBTERM_TOKEN` for a host that gates its HTTP
|
||||
/// surface (C1 over B1, ios-completion §1.1). It is stamped as
|
||||
/// `Cookie: webterm_auth=…` on the probe's TWO HTTP legs by `APIClient`;
|
||||
/// the WS leg's cookie comes from the transport the caller passes (the
|
||||
/// pairing flow builds a probe-scoped transport carrying the same
|
||||
/// candidate), because `TermTransport.connect` is a frozen contract with no
|
||||
/// credential parameter. `nil` = probe unauthenticated (the LAN default).
|
||||
func runPairingProbeCore(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
ws: any TermTransport,
|
||||
clock: any Clock<Duration>,
|
||||
timeout: Duration?
|
||||
timeout: Duration?,
|
||||
accessToken: String? = nil
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
guard let timeout else {
|
||||
return await performProbe(endpoint: endpoint, http: http, ws: ws)
|
||||
return await performProbe(
|
||||
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
|
||||
)
|
||||
}
|
||||
return await withTaskGroup(
|
||||
of: Result<HostEndpoint, PairingError>.self
|
||||
) { group in
|
||||
group.addTask {
|
||||
await performProbe(endpoint: endpoint, http: http, ws: ws)
|
||||
await performProbe(
|
||||
endpoint: endpoint, http: http, ws: ws, accessToken: accessToken
|
||||
)
|
||||
}
|
||||
group.addTask {
|
||||
// Cancellation (probe won) also lands here; the value is discarded.
|
||||
@@ -63,14 +75,16 @@ func runPairingProbeCore(
|
||||
public func runPairingProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
ws: any TermTransport
|
||||
ws: any TermTransport,
|
||||
accessToken: String? = nil
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
await runPairingProbeCore(
|
||||
endpoint: endpoint,
|
||||
http: http,
|
||||
ws: ws,
|
||||
clock: ContinuousClock(),
|
||||
timeout: Tunables.pairingProbeTimeout
|
||||
timeout: Tunables.pairingProbeTimeout,
|
||||
accessToken: accessToken
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,23 +93,31 @@ public func runPairingProbe(
|
||||
private func performProbe(
|
||||
endpoint: HostEndpoint,
|
||||
http: any HTTPTransport,
|
||||
ws: any TermTransport
|
||||
ws: any TermTransport,
|
||||
accessToken: String?
|
||||
) async -> Result<HostEndpoint, PairingError> {
|
||||
let api = APIClient(endpoint: endpoint, http: http)
|
||||
let api = APIClient(endpoint: endpoint, http: http, accessToken: accessToken)
|
||||
|
||||
// ① Reachability + shape. Any HTTP-level answer that isn't the
|
||||
// /live-sessions array shape means "some other service" → 端口对吗?
|
||||
do {
|
||||
_ = try await api.liveSessions()
|
||||
} catch APIClientError.unauthorized {
|
||||
// C1 · 401 on the RO leg is NOT "some other service": this host gates
|
||||
// its HTTP surface and our candidate token was absent or wrong. The
|
||||
// status alone cannot say which of the two gates rejected us, so the
|
||||
// copy offers both remedies (see `unauthorizedPairingHint`).
|
||||
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
|
||||
} catch is APIClientError {
|
||||
return .failure(.httpOkButNotWebTerminal)
|
||||
} catch {
|
||||
return .failure(PairingError.classify(error, endpoint: endpoint))
|
||||
}
|
||||
|
||||
// ② WS upgrade — the server's ONLY upgrade-reject path is the Origin 401
|
||||
// (src/server.ts:646-651), so after ① passed, an unrecognizable connect
|
||||
// failure is classified as originRejected.
|
||||
// ② WS upgrade — the server rejects an upgrade with 401 from TWO gates:
|
||||
// the Origin/CSWSH check and then the `webterm_auth` cookie
|
||||
// (src/server.ts:1367-1379). After ① passed, an unrecognizable connect
|
||||
// failure is one of those two, so the fallback names both.
|
||||
let connection: TransportConnection
|
||||
do {
|
||||
connection = try await ws.connect(to: endpoint)
|
||||
@@ -103,7 +125,7 @@ private func performProbe(
|
||||
return .failure(PairingError.classify(
|
||||
error, endpoint: endpoint,
|
||||
unrecognizedFallback: .originRejected(
|
||||
hint: PairingError.originRejectedHint(for: endpoint)
|
||||
hint: unauthorizedPairingHint(for: endpoint)
|
||||
)
|
||||
))
|
||||
}
|
||||
@@ -152,9 +174,14 @@ private func killProbeSession(
|
||||
} catch APIClientError.sessionNotFound {
|
||||
// Already gone (exited between attach and kill) — the goal state.
|
||||
} catch APIClientError.forbidden {
|
||||
// 403 is UNAMBIGUOUS: only the guarded-HTTP Origin check answers 403
|
||||
// (src/server.ts:332-339) — the token gate answers 401. So this one
|
||||
// keeps the pure Origin copy.
|
||||
return .failure(.originRejected(
|
||||
hint: PairingError.originRejectedHint(for: endpoint)
|
||||
))
|
||||
} catch APIClientError.unauthorized {
|
||||
return .failure(.originRejected(hint: unauthorizedPairingHint(for: endpoint)))
|
||||
} catch let apiError as APIClientError {
|
||||
return .failure(.hostUnreachable(underlying: apiError.message))
|
||||
} catch {
|
||||
@@ -162,3 +189,27 @@ private func killProbeSession(
|
||||
}
|
||||
return .success(endpoint)
|
||||
}
|
||||
|
||||
// MARK: - The ambiguous 401 (C1 · fixes the pre-token "Origin rejected" verdict)
|
||||
|
||||
/// Copy for a **401** met during pairing.
|
||||
///
|
||||
/// Before the access token existed, 401 had exactly one cause on this path, so
|
||||
/// the probe reported `originRejected` with Origin-only copy. With
|
||||
/// `WEBTERM_TOKEN` live there are TWO causes and one status code: the server
|
||||
/// checks Origin first and the `webterm_auth` cookie second — both write 401
|
||||
/// (src/server.ts:1367-1379; the RO HTTP gate likewise, src/server.ts:459).
|
||||
/// A client provably cannot tell them apart, so guessing one remedy sends half
|
||||
/// the users chasing the wrong knob. Both are named, token first (it is the
|
||||
/// one the user can fix from the phone).
|
||||
///
|
||||
/// The `ALLOWED_ORIGINS=` value is still derived from `endpoint.originHeader` —
|
||||
/// the single point (plan §5.1), never hand-assembled, default ports omitted.
|
||||
/// The error case stays `originRejected` because `PairingError` is a frozen
|
||||
/// contract (§3.4) and its payload is exactly "the hint the UI shows verbatim".
|
||||
func unauthorizedPairingHint(for endpoint: HostEndpoint) -> String {
|
||||
"主机以 401 拒绝了这次配对,而两种原因会得到同一个状态码:"
|
||||
+ "① 该主机启用了访问令牌(WEBTERM_TOKEN),本次配对没带或带错了令牌——请输入访问令牌后重试;"
|
||||
+ "② 主机的来源白名单不含本 App 拨号的地址——请在主机上设置 "
|
||||
+ "ALLOWED_ORIGINS=\(endpoint.originHeader)(与 App 连接的 URL 完全一致)后重启 web-terminal。"
|
||||
}
|
||||
|
||||
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
155
ios/Packages/APIClient/Sources/APIClient/PrStatus.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<ProjectInfo>].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<Wrapped: Decodable>: 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<Element: Decodable, Key: CodingKey>(
|
||||
_ type: Element.Type,
|
||||
in container: KeyedDecodingContainer<Key>,
|
||||
forKey key: Key
|
||||
) -> [Element] {
|
||||
let boxes = (try? container.decode([LossyBox<Element>].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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
64
ios/Packages/APIClient/Sources/APIClient/Tolerance.swift
Normal file
@@ -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<Wrapped: Decodable>: 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<Element: Decodable, Key: CodingKey>(
|
||||
_ type: Element.Type,
|
||||
in container: KeyedDecodingContainer<Key>,
|
||||
forKey key: Key
|
||||
) -> [Element] {
|
||||
let boxes = (try? container.decode([LossyBox<Element>].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<Element: Decodable>(
|
||||
_ type: Element.Type, from data: Data
|
||||
) throws -> [Element] {
|
||||
guard let boxes = try? JSONDecoder().decode([LossyBox<Element>].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<Key: CodingKey>(
|
||||
in container: KeyedDecodingContainer<Key>, 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)
|
||||
}
|
||||
}
|
||||
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
120
ios/Packages/APIClient/Sources/APIClient/WorktreeState.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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":"<t>"}`,`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=<t>`,不解析
|
||||
/// `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)
|
||||
}
|
||||
}
|
||||
@@ -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 <id>` 的实参,缺失/空 ⇒ 该条无用,丢弃
|
||||
#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()
|
||||
}
|
||||
}
|
||||
}
|
||||
526
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
526
ios/Packages/APIClient/Tests/APIClientTests/GitWriteTests.swift
Normal file
@@ -0,0 +1,526 @@
|
||||
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("<html>oops</html>".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))
|
||||
}
|
||||
|
||||
/// F5 · 401 的归属必须**逐路由**钉死,不能按"写路由"一刀切。
|
||||
///
|
||||
/// 服务器只有一处会自己产出 401:`src/http/git-ops.ts:108`(主机侧 git 凭据失败),
|
||||
/// 而它只被 `stageFiles`/`commit`/`push`/`fetch` 走到。worktree 三条落在
|
||||
/// `src/http/worktrees.ts`,那里根本不产 401(403 开关 / 400 / 404 / 500),
|
||||
/// `src/server.ts:1095-1183` 也没加。所以在启用访问令牌的主机上,worktree 的 401
|
||||
/// 只可能是 gate —— 当成 git 失败会把用户卡在"创建 worktree 失败",而不是补令牌。
|
||||
@Test("worktree 三条的 401 是访问令牌 gate(不是 git 拒绝);git-ops 四条才是路由自身语义")
|
||||
func worktreeRoutes401IsTheAccessTokenGateNotAGitRejection() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||||
let gateBody = Data(#"{"error":"authentication required"}"#.utf8)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree"), status: 401, body: gateBody
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "DELETE", url: try routeURL("/projects/worktree"), status: 401, body: gateBody
|
||||
)
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL("/projects/worktree/prune"), status: 401, body: gateBody
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await fixture.client.createWorktree(path: Self.repoPath, branch: "x", base: nil)
|
||||
}
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await fixture.client.removeWorktree(
|
||||
path: Self.repoPath, worktreePath: "\(Self.repoPath)/wt", force: false
|
||||
)
|
||||
}
|
||||
await #expect(throws: APIClientError.unauthorized) {
|
||||
_ = try await fixture.client.pruneWorktrees(path: Self.repoPath)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("git-ops 四条(stage/commit/push/fetch)全部保留路由自身的 401")
|
||||
func allFourGitOpsRoutesKeepTheirOwn401() async throws {
|
||||
// Arrange
|
||||
let fixture = try makeFixture(accessToken: "s3cret-token_value.~+/=")
|
||||
let message = "Push authentication required on the host."
|
||||
let body = Data(#"{"ok":false,"error":"\#(message)"}"#.utf8)
|
||||
for path in ["/projects/git/stage", "/projects/git/commit", "/projects/git/push", "/projects/git/fetch"] {
|
||||
await fixture.http.queueSuccess(
|
||||
method: "POST", url: try routeURL(path), status: 401, body: body
|
||||
)
|
||||
}
|
||||
|
||||
// Act + Assert — 服务器自己的话术必须原样到达 UI
|
||||
#expect(
|
||||
try await fixture.client.gitStage(path: Self.repoPath, files: ["a"], stage: true)
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitCommit(path: Self.repoPath, message: "m")
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitPush(path: Self.repoPath)
|
||||
== .rejected(status: 401, message: message)
|
||||
)
|
||||
#expect(
|
||||
try await fixture.client.gitFetch(path: Self.repoPath)
|
||||
== .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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 — 非数组
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
// B4 · Read back the fields of a PKCS#10 CSR so tests can assert on what was
|
||||
// ACTUALLY sent to the control plane (subject CN, embedded public key) instead
|
||||
// of trusting the encoder's own view. Uses the throwaway `TestDER` reader from
|
||||
// CertificateSigningRequestTests.
|
||||
|
||||
/// The two CSR fields the store/rotation invariants are stated in terms of.
|
||||
struct ParsedCSR: Equatable {
|
||||
/// `CertificationRequestInfo.subject` — the single CN RDN.
|
||||
let subjectCommonName: String
|
||||
/// `subjectPKInfo` BIT STRING content: the X9.63 uncompressed point.
|
||||
let publicPointX963: Data
|
||||
/// The exact `CertificationRequestInfo` DER (the bytes that were signed).
|
||||
let certificationRequestInfoDER: Data
|
||||
}
|
||||
|
||||
/// Parse a `CertificationRequest` DER. `nil` when the shape is not the canonical
|
||||
/// `SEQUENCE { info, algId, BIT STRING }` this package emits.
|
||||
func parseCSR(_ der: Data) -> ParsedCSR? {
|
||||
let bytes = [UInt8](der)
|
||||
guard let outer = TestDER.read(bytes, at: 0), outer.end == bytes.count else { return nil }
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
guard parts.count == 3 else { return nil }
|
||||
|
||||
let info = parts[0]
|
||||
let infoChildren = TestDER.children(bytes, info)
|
||||
guard infoChildren.count == 4 else { return nil }
|
||||
|
||||
// subject: SEQUENCE { SET { SEQUENCE { OID commonName, UTF8String } } }
|
||||
let rdnSequence = TestDER.children(bytes, infoChildren[1])
|
||||
guard let rdnSet = rdnSequence.first else { return nil }
|
||||
let attributes = TestDER.children(bytes, rdnSet)
|
||||
guard let attribute = attributes.first else { return nil }
|
||||
let attributeParts = TestDER.children(bytes, attribute)
|
||||
guard attributeParts.count == 2 else { return nil }
|
||||
let commonNameBytes = Array(bytes[attributeParts[1].valueStart..<attributeParts[1].valueEnd])
|
||||
guard let commonName = String(bytes: commonNameBytes, encoding: .utf8) else { return nil }
|
||||
|
||||
// subjectPKInfo: SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||
let spkiChildren = TestDER.children(bytes, infoChildren[2])
|
||||
guard spkiChildren.count == 2 else { return nil }
|
||||
let bitString = spkiChildren[1]
|
||||
guard bitString.valueEnd > bitString.valueStart + 1 else { return nil }
|
||||
let point = Data(bytes[(bitString.valueStart + 1)..<bitString.valueEnd])
|
||||
|
||||
return ParsedCSR(
|
||||
subjectCommonName: commonName,
|
||||
publicPointX963: point,
|
||||
certificationRequestInfoDER: Data(bytes[info.start..<info.end])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · Pins the hand-written PKCS#10 encoder against an EXTERNAL known-good
|
||||
// vector (OpenSSL) plus the two boundary behaviours the structural tests in
|
||||
// CertificateSigningRequestTests don't reach: a signer that hands back a
|
||||
// non-X9.63 public key, and a subject long enough to need a long-form DER
|
||||
// length. `verifyCsrPoPEc` re-serializes `CertificationRequestInfo` server-side
|
||||
// before checking the PoP, so a single byte of drift breaks enrollment.
|
||||
|
||||
@Test("CertificationRequestInfo is byte-identical to OpenSSL's for the same key + subject")
|
||||
func csrRequestInfoMatchesOpenSSLVector() throws {
|
||||
// Arrange — the exact key OpenSSL used for the embedded vector.
|
||||
let signer = try KnownGoodCSR.fixedKey()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: KnownGoodCSR.subjectCommonName, signer: signer
|
||||
)
|
||||
|
||||
// Assert — the signed half must match OpenSSL byte for byte.
|
||||
let parsed = try #require(parseCSR(der))
|
||||
#expect(parsed.certificationRequestInfoDER == KnownGoodCSR.certificationRequestInfoDER)
|
||||
}
|
||||
|
||||
@Test("the vector's self-signature verifies over the exact CertificationRequestInfo bytes")
|
||||
func csrVectorSignatureVerifies() throws {
|
||||
// Arrange
|
||||
let signer = try KnownGoodCSR.fixedKey()
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: KnownGoodCSR.subjectCommonName, signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
let signature = Data(bytes[(parts[2].valueStart + 1)..<parts[2].valueEnd])
|
||||
|
||||
// Act — verify against the public point embedded in the CSR itself.
|
||||
let parsed = try #require(parseCSR(der))
|
||||
let publicKey = try #require(
|
||||
SecKeyCreateWithData(
|
||||
parsed.publicPointX963 as CFData,
|
||||
[
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
] as CFDictionary,
|
||||
nil
|
||||
)
|
||||
)
|
||||
var error: Unmanaged<CFError>?
|
||||
let verified = SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.ecdsaSignatureMessageX962SHA256,
|
||||
KnownGoodCSR.certificationRequestInfoDER as CFData,
|
||||
signature as CFData,
|
||||
&error
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(verified, "\(String(describing: error?.takeRetainedValue()))")
|
||||
}
|
||||
|
||||
@Test("a signer whose public key is not a 65-byte uncompressed point is rejected")
|
||||
func csrRejectsNonX963PublicKey() {
|
||||
// Arrange — a compressed (33-byte) point: valid EC, wrong encoding for SPKI.
|
||||
let compressed = Data([0x02] + [UInt8](repeating: 0x11, count: 32))
|
||||
let signer = StubSigner(publicKey: compressed)
|
||||
|
||||
// Act / Assert — must fail BEFORE signing (no PoP over a bogus SPKI).
|
||||
#expect(throws: CertificateSigningRequest.CSRError.invalidPublicKey) {
|
||||
_ = try CertificateSigningRequest.der(subjectCommonName: "device", signer: signer)
|
||||
}
|
||||
#expect(signer.signCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("a subject long enough to need a 2-byte DER length still round-trips")
|
||||
func csrLongSubjectUsesLongFormLength() throws {
|
||||
// Arrange — 300 chars pushes CertificationRequestInfo past 255 bytes, so its
|
||||
// length must be encoded long-form as 0x82 <hi> <lo>.
|
||||
let longCommonName = String(repeating: "d", count: 300)
|
||||
let signer = try KnownGoodCSR.fixedKey()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: longCommonName, signer: signer
|
||||
)
|
||||
|
||||
// Assert
|
||||
let parsed = try #require(parseCSR(der))
|
||||
#expect(parsed.subjectCommonName == longCommonName)
|
||||
let infoBytes = [UInt8](parsed.certificationRequestInfoDER)
|
||||
#expect(infoBytes[1] == 0x82) // long form, two length bytes
|
||||
let declaredLength = Int(infoBytes[2]) << 8 | Int(infoBytes[3])
|
||||
#expect(declaredLength == infoBytes.count - 4) // minimal + accurate
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A `P256HardwareKey` that returns a caller-chosen public key and records
|
||||
/// whether it was ever asked to sign.
|
||||
private final class StubSigner: P256HardwareKey, @unchecked Sendable {
|
||||
private let publicKey: Data
|
||||
private let lock = NSLock()
|
||||
private var signCalls = 0
|
||||
|
||||
init(publicKey: Data) {
|
||||
self.publicKey = publicKey
|
||||
}
|
||||
|
||||
var signCallCount: Int { lock.withLock { signCalls } }
|
||||
|
||||
func publicKeyX963() throws -> Data { publicKey }
|
||||
|
||||
func sign(_ message: Data) throws -> Data {
|
||||
lock.withLock { signCalls += 1 }
|
||||
return Data([0x30, 0x00])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The URLSession seam. `ClientTLSSessionDelegate` is the object every HTTP
|
||||
// call to an mTLS host hangs off, so the one behaviour that matters is that the
|
||||
// session-level callback ALWAYS invokes the completion handler exactly once with
|
||||
// the responder's decision — a delegate that forgets to call back hangs the
|
||||
// request forever (no timeout maps to a user-visible error).
|
||||
|
||||
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||
let space = URLProtectionSpace(
|
||||
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||
realm: nil, authenticationMethod: method
|
||||
)
|
||||
return URLAuthenticationChallenge(
|
||||
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||
failureResponse: nil, error: nil, sender: DelegateTestSender()
|
||||
)
|
||||
}
|
||||
|
||||
/// The responder never calls back into the sender; it only reads the protection
|
||||
/// space. Present because the designated initializer demands a non-optional one.
|
||||
private final class DelegateTestSender: NSObject, URLAuthenticationChallengeSender {
|
||||
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||
}
|
||||
|
||||
/// Drive the delegate and capture what it handed back.
|
||||
private func resolve(
|
||||
identity: ClientIdentity?, method: String
|
||||
) -> (calls: Int, disposition: URLSession.AuthChallengeDisposition?, credential: URLCredential?) {
|
||||
let delegate = ClientTLSSessionDelegate(identity: identity)
|
||||
var calls = 0
|
||||
var disposition: URLSession.AuthChallengeDisposition?
|
||||
var credential: URLCredential?
|
||||
delegate.urlSession(
|
||||
URLSession.shared, didReceive: makeChallenge(method: method)
|
||||
) { receivedDisposition, receivedCredential in
|
||||
calls += 1
|
||||
disposition = receivedDisposition
|
||||
credential = receivedCredential
|
||||
}
|
||||
return (calls, disposition, credential)
|
||||
}
|
||||
|
||||
@Test("a ClientCertificate challenge answers once with the installed identity")
|
||||
func delegateAnswersClientCertificateWithIdentity() throws {
|
||||
// Arrange
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = resolve(
|
||||
identity: identity, method: NSURLAuthenticationMethodClientCertificate
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(result.calls == 1) // exactly once — never zero (hang) or twice (crash)
|
||||
#expect(result.disposition == .useCredential)
|
||||
#expect(result.credential?.identity != nil)
|
||||
}
|
||||
|
||||
@Test("a ClientCertificate challenge with no identity cancels instead of hanging")
|
||||
func delegateCancelsWithoutIdentity() {
|
||||
// Act
|
||||
let result = resolve(identity: nil, method: NSURLAuthenticationMethodClientCertificate)
|
||||
|
||||
// Assert
|
||||
#expect(result.calls == 1)
|
||||
#expect(result.disposition == .cancelAuthenticationChallenge)
|
||||
#expect(result.credential == nil)
|
||||
}
|
||||
|
||||
@Test("server-trust challenges fall through to the system evaluation")
|
||||
func delegateDefersServerTrust() throws {
|
||||
// Arrange
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Act / Assert — the client cert must never be offered as a server-trust
|
||||
// answer, and pinning is NOT this package's job (plan §5: default handling).
|
||||
for candidate in [identity, nil] as [ClientIdentity?] {
|
||||
let result = resolve(identity: candidate, method: NSURLAuthenticationMethodServerTrust)
|
||||
#expect(result.calls == 1)
|
||||
#expect(result.disposition == .performDefaultHandling)
|
||||
#expect(result.credential == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// B4 · Two real, DER-decodable leaf certificates for the enrolled-identity
|
||||
// persistence tests, plus the cleanup they need.
|
||||
//
|
||||
// Both carry the SAME public key (the `KnownGoodCSR` fixed key) and the SAME
|
||||
// subject, and differ only in serial + validity — exactly the shape of a
|
||||
// rotation: `SecItemAdd` treats them as two distinct items, while re-adding one
|
||||
// of them byte-for-byte is `errSecDuplicateItem`.
|
||||
//
|
||||
// CLEANUP, and why it needs its own path: on macOS the file-based keychain
|
||||
// OVERRIDES the `kSecAttrLabel` supplied at add time with the certificate's own
|
||||
// subject summary (verified 2026-07-30: added under
|
||||
// `…test-XYZ.device-leaf`, found only under `enrolled-device-fixture`). So the
|
||||
// production `KeychainClientIdentityStore` label-keyed delete cannot remove them
|
||||
// on macOS, and the tests must sweep by subject instead — `purgeFixtureLeaves()`
|
||||
// runs both BEFORE (in case a previous run was killed) and AFTER every test that
|
||||
// installs one. `SecItemDelete` removes ONE match per call here, hence the loop.
|
||||
//
|
||||
// Regenerated with (OpenSSL 3.0.18, key = KnownGoodCSR.privateKeyX963Base64):
|
||||
// openssl req -x509 -new -key fixed.key.pem -subj "/CN=enrolled-device-fixture" \
|
||||
// -days 3650 -sha256 -outform DER -out leaf1.der # then -days 3651 → leaf2
|
||||
// openssl req -x509 -new -key chain.key.pem \
|
||||
// -subj "/CN=webterm-enrollment-ca-fixture" -days 3650 -sha256 -outform DER
|
||||
enum EnrolledLeafFixtures {
|
||||
/// The subject summary macOS files these certificates under.
|
||||
static let subjectSummary = "enrolled-device-fixture"
|
||||
|
||||
static var leaf: Data { der(leafBase64) }
|
||||
/// A second, DISTINCT leaf (different serial) for the rotation case.
|
||||
static var rotatedLeaf: Data { der(rotatedLeafBase64) }
|
||||
/// An issuer certificate for the stored `caChain`.
|
||||
static var issuer: Data { der(issuerBase64) }
|
||||
|
||||
private static func der(_ base64: String) -> Data {
|
||||
Data(base64Encoded: base64, options: .ignoreUnknownCharacters)!
|
||||
}
|
||||
|
||||
private static let leafBase64 = """
|
||||
MIIBmTCCAT+gAwIBAgIUcHFyXdi5QFelGKhdzcsbFlgnYzUwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAxWhcNMzYwNzI3MDc1NTAx\
|
||||
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||
MEUCIQCoLSypPlXvtMsQRMpztt/RpbYKc6iTmBLypqOZc0MxTAIgF/MVWpaLLYVCmcKBPxf7y9yQ\
|
||||
NX3c6BrYB/fi/WzJsmA=
|
||||
"""
|
||||
|
||||
private static let rotatedLeafBase64 = """
|
||||
MIIBmTCCAT+gAwIBAgIUarD2zMEMcxZJNQevMMJenCAlLpkwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI4MDc1NTAy\
|
||||
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||
MEUCID2FFmKH2+qHB8sICyDuxQPtyj/wOLE24t4ghBEitLRvAiEAnTQQ2FP8Wei+8ITL/K57UP2Z\
|
||||
YtYbEokKkBCj2bMr3vk=
|
||||
"""
|
||||
|
||||
private static let issuerBase64 = """
|
||||
MIIBpTCCAUugAwIBAgIUIkl7VDf6o9lBxtWxh7zFxPYTpo8wCgYIKoZIzj0EAwIwKDEmMCQGA1UE\
|
||||
Awwdd2VidGVybS1lbnJvbGxtZW50LWNhLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI3\
|
||||
MDc1NTAyWjAoMSYwJAYDVQQDDB13ZWJ0ZXJtLWVucm9sbG1lbnQtY2EtZml4dHVyZTBZMBMGByqG\
|
||||
SM49AgEGCCqGSM49AwEHA0IABM4iWgauVe86+SKZg+jlHkh8VbVf70pgMdGOcpJW+xWb5oqfzxY3\
|
||||
fW6pIdn3SCo9PG7X22qGtq/PLgpv97wtJvajUzBRMB0GA1UdDgQWBBTT9mdiVZTWcatuYz3NkxCb\
|
||||
DxDSAjAfBgNVHSMEGDAWgBTT9mdiVZTWcatuYz3NkxCbDxDSAjAPBgNVHRMBAf8EBTADAQH/MAoG\
|
||||
CCqGSM49BAMCA0gAMEUCIQCxfF8zyRHMW7nSmieDoBxIsOXkMFVPko86THW3TbwrUwIgMZlBP5rz\
|
||||
FT4Y/G2oA+87xceEDjCrA7FnRZrCrZ4AADk=
|
||||
"""
|
||||
|
||||
private static func labelQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassCertificate,
|
||||
kSecAttrLabel as String: subjectSummary,
|
||||
]
|
||||
}
|
||||
|
||||
/// How many fixture leaves are currently installed.
|
||||
static func installedCount() -> Int {
|
||||
var query = labelQuery()
|
||||
query[kSecReturnRef as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
|
||||
return 0
|
||||
}
|
||||
if let array = result as? [Any] { return array.count }
|
||||
return result == nil ? 0 : 1
|
||||
}
|
||||
|
||||
/// Remove every installed fixture leaf. Idempotent; safe to call when none
|
||||
/// are installed.
|
||||
static func purgeFixtureLeaves() {
|
||||
var guardCounter = 0
|
||||
let maxSweeps = 16 // a leaf per enroll in a test; never unbounded
|
||||
while SecItemDelete(labelQuery() as CFDictionary) == errSecSuccess,
|
||||
guardCounter < maxSweeps {
|
||||
guardCounter += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · Shared enrollment-transport double for the store / flow tests.
|
||||
//
|
||||
// Unlike the per-file stubs in DeviceEnrollmentClientTests (which only need the
|
||||
// LAST request), the store tests must re-parse the CSR that was actually sent —
|
||||
// that is how "renew re-signs with the SAME device key" is pinned — so this one
|
||||
// records every request body and replays a scripted reply per call.
|
||||
|
||||
/// A recording, scriptable `EnrollmentTransport`.
|
||||
///
|
||||
/// `@unchecked Sendable`: all mutable state is behind `lock`.
|
||||
final class RecordingEnrollmentTransport: EnrollmentTransport, @unchecked Sendable {
|
||||
struct Reply {
|
||||
let status: Int
|
||||
let body: Data
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pendingReplies: [Reply]
|
||||
private var recorded: [URLRequest] = []
|
||||
|
||||
init(replies: [Reply]) {
|
||||
pendingReplies = replies
|
||||
}
|
||||
|
||||
convenience init(status: Int, body: Data) {
|
||||
self.init(replies: [Reply(status: status, body: body)])
|
||||
}
|
||||
|
||||
var requests: [URLRequest] { lock.withLock { recorded } }
|
||||
var callCount: Int { lock.withLock { recorded.count } }
|
||||
|
||||
/// The JSON body of call `index`, decoded as a `[String: Any]` object.
|
||||
func jsonBody(at index: Int) -> [String: Any]? {
|
||||
guard let data = requests.indices.contains(index)
|
||||
? requests[index].httpBody : nil
|
||||
else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
}
|
||||
|
||||
/// The `csr` field of call `index`, base64-decoded back to DER.
|
||||
func csrDER(at index: Int) -> Data? {
|
||||
guard let base64 = jsonBody(at: index)?["csr"] as? String else { return nil }
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let reply: Reply = lock.withLock {
|
||||
recorded.append(request)
|
||||
guard !pendingReplies.isEmpty else { return Reply(status: 500, body: Data()) }
|
||||
return pendingReplies.removeFirst()
|
||||
}
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||
)!
|
||||
return (reply.body, response)
|
||||
}
|
||||
}
|
||||
|
||||
/// DER bytes that `SecCertificateCreateWithData` rejects (a truncated SEQUENCE).
|
||||
///
|
||||
/// Every store test that drives `enroll` / `renew` to the persistence step uses
|
||||
/// this as the server-returned leaf ON PURPOSE: it makes `storeEnrolledLeaf`
|
||||
/// fail at the very first line — BEFORE any `SecItemAdd(kSecClassCertificate)`.
|
||||
/// That matters on macOS, where `swift test` talks to the file-based login
|
||||
/// keychain: it OVERRIDES the `kSecAttrLabel` of an added certificate with the
|
||||
/// cert's own subject summary, so `KeychainClientIdentityStore`'s label-keyed
|
||||
/// delete can never remove it again (verified 2026-07-30: adding the leaf, then
|
||||
/// `SecItemDelete(kSecClass: kSecClassCertificate, kSecAttrLabel: …)` →
|
||||
/// `errSecItemNotFound (-25300)`, and the cert had to be swept manually with
|
||||
/// `security delete-certificate -Z <sha1>`). A test that installed a leaf would
|
||||
/// therefore permanently pollute the developer's login keychain.
|
||||
let undecodableCertificateDER = Data([0x30, 0x01, 0x02])
|
||||
|
||||
/// A `POST /device/enroll` / `…/renew` 201 body in the A4 wire shape.
|
||||
func enrollmentResponseJSON(
|
||||
deviceId: String = "dev-b4",
|
||||
certDER: Data = undecodableCertificateDER,
|
||||
caChain: [Data] = [],
|
||||
notBefore: String? = "2026-07-30T00:00:00.000Z",
|
||||
notAfter: String? = "2026-10-28T00:00:00.000Z",
|
||||
renewAfter: String? = "2026-09-27T00:00:00.000Z"
|
||||
) -> Data {
|
||||
var json: [String: Any] = [
|
||||
"deviceId": deviceId,
|
||||
"cert": certDER.base64EncodedString(),
|
||||
"caChain": caChain.map { $0.base64EncodedString() },
|
||||
]
|
||||
if let notBefore { json["notBefore"] = notBefore }
|
||||
if let notAfter { json["notAfter"] = notAfter }
|
||||
if let renewAfter { json["renewAfter"] = renewAfter }
|
||||
return try! JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The Secure-Enclave enrollment half of `KeychainClientIdentityStore`:
|
||||
// `enroll`, `renew`, `renewalState`. What is pinned here is the ORCHESTRATION —
|
||||
// which key signs the CSR, which endpoint it goes to, what the request body is
|
||||
// allowed to contain, and what happens to local state when the server's answer
|
||||
// is unusable. The signing key is injected (`keyProvider`) or pre-installed
|
||||
// under the store's derived tag, so no Secure Enclave is needed.
|
||||
//
|
||||
// Every test stops at the leaf-installation step by having the server return an
|
||||
// undecodable certificate (`undecodableCertificateDER`) — see the note there for
|
||||
// why installing a real leaf is not possible in a macOS `swift test` run. The
|
||||
// happy-path leaf install + `SecItemCopyMatching(kSecClassIdentity)` assembly
|
||||
// stays a device/simulator concern.
|
||||
|
||||
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
@Test("renewalState is nil before any enrollment", .keychainSerialized)
|
||||
func renewalStateNilWhenNotEnrolled() async throws {
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
@Test("renewalState reads back the persisted enrollment record verbatim", .keychainSerialized)
|
||||
func renewalStateReadsPersistedRecord() async throws {
|
||||
// Arrange — the record layout is a migration contract, so it is seeded
|
||||
// directly and read back through the public API.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let notAfter = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
let renewAfter = Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(
|
||||
deviceId: "dev-42", deviceName: "Yaojia iPhone",
|
||||
caChain: [Data([0x30, 0xAA])], notAfter: notAfter, renewAfter: renewAfter
|
||||
)
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
let state = try #require(try store.renewalState())
|
||||
|
||||
// Assert — deviceId drives POST /device/:id/renew; the dates drive the
|
||||
// scheduler's decision.
|
||||
#expect(state.deviceId == "dev-42")
|
||||
#expect(state.notAfter == notAfter)
|
||||
#expect(state.renewAfter == renewAfter)
|
||||
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(1)))
|
||||
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(-1)) == false)
|
||||
}
|
||||
|
||||
@Test("a record with no renewAfter never reports renewal due (fail-safe)", .keychainSerialized)
|
||||
func renewalStateWithoutRenewAfterNeverDue() async throws {
|
||||
// Arrange — an older server that omitted the advisory field.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-legacy", deviceName: "iPad")
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
let state = try #require(try store.renewalState())
|
||||
|
||||
// Assert — the TLS stack stays the real gate; the scheduler must not guess.
|
||||
#expect(state.notAfter == nil)
|
||||
#expect(state.renewAfter == nil)
|
||||
#expect(state.isRenewalDue(asOf: Date.distantFuture) == false)
|
||||
}
|
||||
|
||||
@Test("an undecodable enrollment record surfaces .corruptStoredBlob", .keychainSerialized)
|
||||
func renewalStateCorruptRecord() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: Data("{\"deviceId\":".utf8) // truncated JSON
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act / Assert — never a silent "not enrolled" (that would make the
|
||||
// scheduler skip rotation forever on a device that IS enrolled).
|
||||
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try store.renewalState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("enroll signs the CSR with the injected key and posts it to /device/enroll", .keychainSerialized)
|
||||
func enrollPostsCSRSignedByTheDeviceKey() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let deviceKey = try SecureEnclaveKeyFactory.generateSoftware()
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act — the response's leaf is undecodable, so persistence fails; the CSR has
|
||||
// already been built and sent, which is what this test is about.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||
keyProvider: { deviceKey }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — one request, to the enroll endpoint, carrying the A4 body.
|
||||
#expect(transport.callCount == 1)
|
||||
let request = try #require(transport.requests.first)
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
|
||||
let body = try #require(transport.jsonBody(at: 0))
|
||||
#expect(body["subdomain"] as? String == "yaojia")
|
||||
#expect(body["deviceName"] as? String == "Yaojia iPhone")
|
||||
#expect(body["keyAlg"] as? String == "ec-p256")
|
||||
|
||||
// …and the CSR is bound to the injected key, with the device name as CN.
|
||||
let csrDER = try #require(transport.csrDER(at: 0))
|
||||
let csr = try #require(parseCSR(csrDER))
|
||||
#expect(csr.subjectCommonName == "Yaojia iPhone")
|
||||
#expect(csr.publicPointX963 == (try deviceKey.publicKeyX963()))
|
||||
}
|
||||
|
||||
@Test("an undecodable leaf leaves NO enrollment record behind", .keychainSerialized)
|
||||
func enrollDoesNotPersistOnUndecodableLeaf() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — a half-written record would make `renewalState` claim an
|
||||
// enrollment that has no leaf, and the scheduler would renew a phantom.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
@Test("a rejected enrollment (403) propagates the server error and stores nothing", .keychainSerialized)
|
||||
func enrollPropagatesServerRejection() async throws {
|
||||
// Arrange — subdomain not owned by the account.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let body = try JSONSerialization.data(withJSONObject: ["error": "subdomain_not_owned"])
|
||||
let transport = RecordingEnrollmentTransport(status: 403, body: body)
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(
|
||||
throws: DeviceEnrollmentError.http(status: 403, code: "subdomain_not_owned")
|
||||
) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "someone-else", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
}
|
||||
|
||||
@Test("renew without an enrollment record fails locally and never calls the server", .keychainSerialized)
|
||||
func renewWithoutRecordDoesNotCallServer() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act / Assert — nothing to renew: a fresh install or a legacy .p12-only
|
||||
// device. The scheduler must not fire a request it cannot authenticate.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
#expect(transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("renew without the device key fails locally and never calls the server", .keychainSerialized)
|
||||
func renewWithoutDeviceKeyDoesNotCallServer() async throws {
|
||||
// Arrange — a record exists but the Secure-Enclave key is gone (restored
|
||||
// backup / manually cleared keychain).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-99", deviceName: "iPhone")
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act / Assert — a renew CSR signed by a NEW key would be rejected by the
|
||||
// server (PoP against the enrolled key), so it must not be attempted.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
#expect(transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("renew re-signs with the SAME device key and posts a csr-only body to /device/:id/renew", .keychainSerialized)
|
||||
func renewReusesTheEnrolledDeviceKey() async throws {
|
||||
// Arrange — an enrolled device: record + the permanent key under the store's
|
||||
// derived tag (the shape `enroll` leaves behind).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let enrolledKey = try SecureEnclaveKeyFactory.generateSoftware(
|
||||
tag: keys.deviceKeyTag, permanent: true
|
||||
)
|
||||
let originalRecord = storedEnrollmentJSON(
|
||||
deviceId: "dev-77", deviceName: "Yaojia iPad",
|
||||
notAfter: Date(timeIntervalSinceReferenceDate: 800_000_000),
|
||||
renewAfter: Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||
)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount, data: originalRecord
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
// Nil bearer: the renew endpoint authenticates by the CURRENT client cert.
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
|
||||
// Assert — the endpoint carries the stored deviceId…
|
||||
#expect(transport.callCount == 1)
|
||||
let request = try #require(transport.requests.first)
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-77/renew")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
|
||||
// …the body is `{ csr }` ONLY (a stray field 400s every silent renewal)…
|
||||
let body = try #require(transport.jsonBody(at: 0))
|
||||
#expect(Set(body.keys) == ["csr"])
|
||||
|
||||
// …and the CSR re-uses the ENROLLED key (a new key would fail the server's
|
||||
// PoP-against-the-enrolled-key check) with the stored device name as CN.
|
||||
let csrDER = try #require(transport.csrDER(at: 0))
|
||||
let csr = try #require(parseCSR(csrDER))
|
||||
#expect(csr.publicPointX963 == (try enrolledKey.publicKeyX963()))
|
||||
#expect(csr.subjectCommonName == "Yaojia iPad")
|
||||
|
||||
// …and a failed rotation leaves the EXISTING enrollment intact.
|
||||
let state = try #require(try store.renewalState())
|
||||
#expect(state.deviceId == "dev-77")
|
||||
#expect(state.renewAfter == Date(timeIntervalSinceReferenceDate: 790_000_000))
|
||||
}
|
||||
|
||||
@Test("the flow's production install step is wired to the keychain store's SE enroll", .keychainSerialized)
|
||||
func enrollmentFlowWiresTheKeychainStore() async throws {
|
||||
// Arrange — the `store:` convenience initializer is the composition the app
|
||||
// uses: login → bearer → enroll → install. Its install step deliberately
|
||||
// takes NO keyProvider, i.e. it always asks for a real Secure-Enclave key.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let loginBody = try JSONSerialization.data(withJSONObject: [
|
||||
"enrollToken": "enroll-bearer", "accountId": "acct-1", "expiresIn": 300,
|
||||
])
|
||||
let transport = RecordingEnrollmentTransport(replies: [
|
||||
.init(status: 201, body: loginBody),
|
||||
.init(status: 201, body: enrollmentResponseJSON()),
|
||||
])
|
||||
let flow = DeviceEnrollmentFlow(
|
||||
baseURL: controlPlaneURL, transport: transport, store: store
|
||||
)
|
||||
|
||||
// Act
|
||||
var thrown: Error?
|
||||
do {
|
||||
_ = try await flow.run(
|
||||
password: "account-password", subdomain: "yaojia", deviceName: "iPhone"
|
||||
)
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
// Assert — login happened first, with the password and nothing else…
|
||||
let loginRequest = try #require(transport.requests.first)
|
||||
#expect(loginRequest.url?.path == "/auth/login")
|
||||
#expect(Set(try #require(transport.jsonBody(at: 0)).keys) == ["password"])
|
||||
|
||||
// …and the failure came from the INSTALL step, not the login step: the
|
||||
// convenience initializer really does route into the keychain store. Off
|
||||
// device (macOS `swift test`, Simulator) `generateSecureEnclave` cannot mint
|
||||
// a key, so `.secureEnclaveUnavailable` is the expected stop; an entitled
|
||||
// host gets as far as the undecodable leaf (`.corruptStoredBlob`).
|
||||
let error = try #require(thrown)
|
||||
#expect(error is SecureEnclaveKeyError || error is ClientIdentityStoreError)
|
||||
#expect(error is ControlPlaneLoginError == false)
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The persistence half of enrollment: what `storeEnrolledLeaf` actually
|
||||
// leaves in the keychain. Three cases, all reachable off-device because the
|
||||
// server's answer is a REAL certificate here (`EnrolledLeafFixtures`):
|
||||
//
|
||||
// 1. first enroll → leaf installed + enrollment record ADDED
|
||||
// 2. rotation (new leaf) → distinct leaf added, record UPDATED in place
|
||||
// 3. retry (same leaf) → errSecDuplicateItem tolerated, record still correct
|
||||
//
|
||||
// Case 2/3 matter because the record write and the leaf write are separate
|
||||
// keychain operations: a device whose record says "dev-B" while the installed
|
||||
// leaf is "dev-A" cannot renew (the server checks the PoP against the enrolled
|
||||
// key for THAT deviceId).
|
||||
//
|
||||
// These tests install certificates into the real keychain, so each one sweeps the
|
||||
// fixture leaves before AND after itself — see `EnrolledLeafFixtures`.
|
||||
|
||||
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
/// Run `body` with the fixture leaves swept on both sides. Actor-isolated like
|
||||
/// its callers so nothing crosses a concurrency boundary.
|
||||
private func withCleanLeafKeychain(
|
||||
_ body: (StoreKeychainKeys, KeychainClientIdentityStore) async throws -> Void
|
||||
) async throws {
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
EnrolledLeafFixtures.purgeFixtureLeaves()
|
||||
defer {
|
||||
EnrolledLeafFixtures.purgeFixtureLeaves()
|
||||
KeychainProbe.purge(keys)
|
||||
}
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
try await body(keys, store)
|
||||
}
|
||||
|
||||
private func enrollmentClient(
|
||||
_ transport: RecordingEnrollmentTransport
|
||||
) -> DeviceEnrollmentClient {
|
||||
DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
@Test("a first enroll installs the leaf and records the server's rotation timing", .keychainSerialized)
|
||||
func enrollInstallsLeafAndRecord() async throws {
|
||||
try await withCleanLeafKeychain { keys, store in
|
||||
// Arrange
|
||||
#expect(try store.renewalState() == nil)
|
||||
let transport = RecordingEnrollmentTransport(
|
||||
status: 201,
|
||||
body: enrollmentResponseJSON(
|
||||
deviceId: "dev-first",
|
||||
certDER: EnrolledLeafFixtures.leaf,
|
||||
caChain: [EnrolledLeafFixtures.issuer],
|
||||
notAfter: "2026-10-28T00:00:00.000Z",
|
||||
renewAfter: "2026-09-27T00:00:00.000Z"
|
||||
)
|
||||
)
|
||||
|
||||
// Act — the returned summary is NOT asserted: reading it goes through
|
||||
// `SecItemCopyMatching(kSecClassIdentity)`, which on macOS ignores the
|
||||
// key tag and can hand back an unrelated login-keychain identity (see
|
||||
// `defaultKeychainYieldsForeignIdentity`). What is persisted IS asserted.
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(transport), subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
|
||||
// Assert — exactly one leaf installed…
|
||||
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
||||
// …and the record carries what the server said, so a later renew can
|
||||
// address the right device.
|
||||
let state = try #require(try store.renewalState())
|
||||
#expect(state.deviceId == "dev-first")
|
||||
#expect(state.notAfter != nil)
|
||||
#expect(state.renewAfter != nil)
|
||||
#expect(state.isRenewalDue(asOf: try #require(state.renewAfter)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("rotation installs the new leaf and updates the record in place", .keychainSerialized)
|
||||
func rotationUpdatesRecordInPlace() async throws {
|
||||
try await withCleanLeafKeychain { keys, store in
|
||||
// Arrange — already enrolled.
|
||||
let first = RecordingEnrollmentTransport(
|
||||
status: 201,
|
||||
body: enrollmentResponseJSON(
|
||||
deviceId: "dev-old", certDER: EnrolledLeafFixtures.leaf
|
||||
)
|
||||
)
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
#expect(try store.renewalState()?.deviceId == "dev-old")
|
||||
|
||||
// Act — a rotation returns a DISTINCT leaf.
|
||||
let second = RecordingEnrollmentTransport(
|
||||
status: 201,
|
||||
body: enrollmentResponseJSON(
|
||||
deviceId: "dev-new", certDER: EnrolledLeafFixtures.rotatedLeaf
|
||||
)
|
||||
)
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(second), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
|
||||
// Assert — the record is UPDATED (one item, new value), never duplicated:
|
||||
// two records at the same (service, account) would make renew pick one at
|
||||
// random.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 1)
|
||||
#expect(try store.renewalState()?.deviceId == "dev-new")
|
||||
// …and a leaf is installed at every step — the anti-lockout invariant
|
||||
// (ordering itself is pinned by KeychainItemReplaceTests).
|
||||
#expect(EnrolledLeafFixtures.installedCount() >= 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("re-storing a byte-identical leaf is tolerated and keeps the record correct", .keychainSerialized)
|
||||
func reEnrollingSameLeafIsIdempotent() async throws {
|
||||
try await withCleanLeafKeychain { keys, store in
|
||||
// Arrange — an interrupted enroll that is retried: the server re-issues
|
||||
// the SAME certificate.
|
||||
let body = enrollmentResponseJSON(
|
||||
deviceId: "dev-retry", certDER: EnrolledLeafFixtures.leaf
|
||||
)
|
||||
let first = RecordingEnrollmentTransport(status: 201, body: body)
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
|
||||
// Act — `SecItemAdd` now answers errSecDuplicateItem, which must NOT be
|
||||
// treated as a failure (the leaf we wanted installed IS installed).
|
||||
let retry = RecordingEnrollmentTransport(status: 201, body: body)
|
||||
await #expect(throws: Never.self) {
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(retry), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — still one leaf, and the record is intact.
|
||||
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
||||
#expect(try store.renewalState()?.deviceId == "dev-retry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · `KeychainClientIdentityStore` against the REAL keychain — the legacy
|
||||
// `.p12` half (the dual-trust migration window: devices enrolled before the
|
||||
// Secure-Enclave path still carry an imported `.p12`).
|
||||
//
|
||||
// This package has no `SecItemShim` seam (unlike HostRegistry), so these tests
|
||||
// drive the actual `SecItem*` calls. Each test gets a UUID-scoped service and
|
||||
// purges it afterwards, so runs never collide and nothing is left behind.
|
||||
//
|
||||
// WHAT THIS LAYER CANNOT CHECK: the `kSecAttrAccessible` protection class. On
|
||||
// macOS `swift test` reaches the FILE-BASED keychain, which has no
|
||||
// data-protection class at all — a stored item's attributes come back as only
|
||||
// `acct/cdat/class/labl/mdat/svce`, with no `pdmn` (verified 2026-07-30). The
|
||||
// "AfterFirstUnlockThisDeviceOnly, never synchronized" assertion therefore
|
||||
// belongs to a SIGNED simulator host, exactly as plan §9 already records for
|
||||
// `KeychainHostStore`. It is NOT covered by this package's gate.
|
||||
|
||||
@Test("save persists exactly one item holding the .p12 and its passphrase", .keychainSerialized)
|
||||
func keychainStoreSavePersistsSingleItem() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Assert — one item, and the blob carries BOTH halves (the passphrase is
|
||||
// required to re-import at every launch, so storing the .p12 alone would
|
||||
// brick the identity after a relaunch).
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||
let stored = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||
#expect(stored.p12 == ClientTLSFixtures.deviceP12Data.base64EncodedString())
|
||||
#expect(stored.passphrase == ClientTLSFixtures.passphrase)
|
||||
}
|
||||
|
||||
@Test("saving again REPLACES the stored blob instead of duplicating the item", .keychainSerialized)
|
||||
func keychainStoreSaveReplaces() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
let first = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||
|
||||
// Act — same coordinates, re-saved (the rotation / re-install path).
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Assert — still exactly one item (a duplicate would make loads
|
||||
// order-dependent, i.e. an old cert could resurface after rotation) and the
|
||||
// content is intact.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||
let second = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||
#expect(second == first)
|
||||
}
|
||||
|
||||
@Test("a wrong passphrase throws and writes NOTHING to the keychain", .keychainSerialized)
|
||||
func keychainStoreSaveRejectsWrongPassphrase() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act / Assert — validation happens BEFORE persistence.
|
||||
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
|
||||
}
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||
}
|
||||
|
||||
@Test("a corrupt .p12 throws .corruptFile and leaves the PRIOR identity installed", .keychainSerialized)
|
||||
func keychainStoreSaveKeepsPriorIdentityOnCorruptFile() async throws {
|
||||
// Arrange — a good identity is already installed.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
let good = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||
|
||||
// Act — a truncated file arrives (bad download / wrong file picked).
|
||||
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data.prefix(64),
|
||||
passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — the working identity is untouched, not wiped by the failed install.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||
let survivor = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||
#expect(survivor == good)
|
||||
}
|
||||
|
||||
@Test("remove deletes the stored blob and a second remove is a no-op", .keychainSerialized)
|
||||
func keychainStoreRemoveIsIdempotent() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||
|
||||
// Act
|
||||
try store.remove()
|
||||
|
||||
// Assert — gone, and removing again must NOT throw errSecItemNotFound
|
||||
// (removal is reachable from "delete host" with no identity installed).
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||
#expect(throws: Never.self) { try store.remove() }
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||
}
|
||||
|
||||
@Test("remove also clears the enrollment record and the device key", .keychainSerialized)
|
||||
func keychainStoreRemoveClearsEnrolledState() async throws {
|
||||
// Arrange — a device that enrolled (record + device key) AND carries a
|
||||
// legacy .p12, i.e. mid-migration.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-remove", deviceName: "iPhone")
|
||||
)
|
||||
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: keys.deviceKeyTag, permanent: true)
|
||||
|
||||
// Act
|
||||
try store.remove()
|
||||
|
||||
// Assert — removal is unconditional across BOTH paths; a leftover device key
|
||||
// would make a later enroll bind a new leaf to a stale key.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: keys.deviceKeyTag) == nil)
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
// The `.p12` fallback inside `loadIdentity()` is only reachable when the default
|
||||
// keychain holds no identities: on macOS the `kSecClassIdentity` lookup ignores
|
||||
// `kSecAttrApplicationTag` and returns an arbitrary foreign identity, which
|
||||
// short-circuits the fallback (see `defaultKeychainYieldsForeignIdentity`). The
|
||||
// test is gated rather than fudged — it runs on a clean runner and is reported as
|
||||
// skipped on a developer machine with identities in the login keychain.
|
||||
@Test(
|
||||
"save → loadIdentity → loadSummary roundtrips through the real keychain",
|
||||
.enabled(if: !defaultKeychainYieldsForeignIdentity())
|
||||
, .keychainSerialized)
|
||||
func keychainStoreLoadsBackTheStoredIdentity() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
#expect(store.hasInstalledIdentity() == false)
|
||||
#expect(try store.loadIdentity() == nil)
|
||||
|
||||
// Act
|
||||
try store.save(
|
||||
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Assert — re-imported from the stored bytes + passphrase.
|
||||
#expect(store.hasInstalledIdentity() == true)
|
||||
let identity = try #require(try store.loadIdentity())
|
||||
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||
let summary = try #require(try store.loadSummary())
|
||||
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||
|
||||
// Act / Assert — and after removal the gate closes again.
|
||||
try store.remove()
|
||||
#expect(store.hasInstalledIdentity() == false)
|
||||
#expect(store.loadedIdentityOrNil() == nil)
|
||||
}
|
||||
|
||||
@Test(
|
||||
"a corrupt stored blob surfaces .corruptStoredBlob, and loadedIdentityOrNil swallows it",
|
||||
.enabled(if: !defaultKeychainYieldsForeignIdentity())
|
||||
, .keychainSerialized)
|
||||
func keychainStoreCorruptBlobIsTyped() async throws {
|
||||
// Arrange — bytes that are not the stored JSON envelope (a partial write, or
|
||||
// an item written by an older schema).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.account, data: Data("not-json".utf8)
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act / Assert — typed error for the install UI…
|
||||
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try store.loadIdentity()
|
||||
}
|
||||
// …and launch does not crash: the convenience wrapper logs and degrades.
|
||||
#expect(store.loadedIdentityOrNil() == nil)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import Foundation
|
||||
import Security
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · Direct `SecItem*` access for the store tests. Two jobs:
|
||||
// 1. INSPECT what `KeychainClientIdentityStore` actually wrote — the protection
|
||||
// class and the no-iCloud-sync flag are security requirements (plan §5.3 /
|
||||
// contract §1.1) that only a raw read can confirm.
|
||||
// 2. SEED / CORRUPT the persisted records so the read-side error paths and the
|
||||
// renew path can be driven without first performing a real enrollment
|
||||
// (which would have to install a certificate — see `undecodableCertificateDER`).
|
||||
|
||||
/// The keychain coordinates a `KeychainClientIdentityStore(service:account:)`
|
||||
/// derives internally. Mirrored here ON PURPOSE: the persisted layout is a
|
||||
/// migration contract, so the tests pin it rather than infer it.
|
||||
struct StoreKeychainKeys {
|
||||
let service: String
|
||||
let account: String
|
||||
|
||||
/// `KeychainClientIdentityStore.enrollmentAccount`.
|
||||
var enrollmentAccount: String { "\(account).enrollment" }
|
||||
/// `KeychainClientIdentityStore.deviceKeyTag`.
|
||||
var deviceKeyTag: Data { Data("\(service).device-key".utf8) }
|
||||
/// `KeychainClientIdentityStore.leafLabel`.
|
||||
var leafLabel: String { "\(service).device-leaf" }
|
||||
|
||||
/// A fresh, collision-free coordinate set for one test.
|
||||
static func unique() -> StoreKeychainKeys {
|
||||
StoreKeychainKeys(
|
||||
service: "com.yaojia.webterm.clienttls.test-\(UUID().uuidString)",
|
||||
account: "device-identity"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainProbe {
|
||||
private static func query(service: String, account: String) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
}
|
||||
|
||||
/// How many generic-password items sit at `(service, account)`. `0` = nothing
|
||||
/// stored; `> 1` = a write duplicated instead of replacing.
|
||||
///
|
||||
/// Attributes-only, `kSecMatchLimitAll`: asking for `kSecReturnData`
|
||||
/// together with `kSecMatchLimitAll` is `errSecParam (-50)` on the macOS
|
||||
/// file-based keychain (verified 2026-07-30), so counting and reading are
|
||||
/// separate calls.
|
||||
static func count(service: String, account: String) -> Int {
|
||||
var request = query(service: service, account: account)
|
||||
request[kSecReturnAttributes as String] = true
|
||||
request[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
|
||||
return 0
|
||||
}
|
||||
if let array = result as? [Any] { return array.count }
|
||||
return result == nil ? 0 : 1
|
||||
}
|
||||
|
||||
/// The stored bytes at `(service, account)`; `nil` when no item exists.
|
||||
static func data(service: String, account: String) -> Data? {
|
||||
var request = query(service: service, account: account)
|
||||
request[kSecReturnData as String] = true
|
||||
request[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
|
||||
return nil
|
||||
}
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Write raw bytes at `(service, account)` with the store's own protection
|
||||
/// class — used to seed or corrupt a record.
|
||||
@discardableResult
|
||||
static func write(service: String, account: String, data: Data) -> OSStatus {
|
||||
SecItemDelete(query(service: service, account: account) as CFDictionary)
|
||||
var attributes = query(service: service, account: account)
|
||||
attributes[kSecValueData as String] = data
|
||||
attributes[kSecAttrAccessible as String] =
|
||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func delete(service: String, account: String) -> OSStatus {
|
||||
SecItemDelete(query(service: service, account: account) as CFDictionary)
|
||||
}
|
||||
|
||||
/// Remove everything a test could have created under `keys`.
|
||||
static func purge(_ keys: StoreKeychainKeys) {
|
||||
delete(service: keys.service, account: keys.account)
|
||||
delete(service: keys.service, account: keys.enrollmentAccount)
|
||||
try? SecureEnclaveKeyFactory.delete(tag: keys.deviceKeyTag)
|
||||
}
|
||||
}
|
||||
|
||||
/// The two fields of the stored `.p12` envelope, decoded from the keychain item.
|
||||
///
|
||||
/// Compared field-by-field rather than byte-by-byte because Foundation's
|
||||
/// `JSONEncoder` serializes through an unordered dictionary on Darwin: two
|
||||
/// encodings of the SAME value differ in key order (verified 2026-07-30 — both
|
||||
/// blobs 2109 bytes, not equal), so byte equality is not a valid invariant.
|
||||
func storedP12Fields(service: String, account: String) -> (p12: String, passphrase: String)? {
|
||||
guard let data = KeychainProbe.data(service: service, account: account),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let p12 = json["p12"] as? String,
|
||||
let passphrase = json["passphrase"] as? String
|
||||
else { return nil }
|
||||
return (p12, passphrase)
|
||||
}
|
||||
|
||||
/// The JSON `StoredEnrollment` shape (`JSONEncoder` defaults: `Data` → base64
|
||||
/// string, `Date` → seconds since the reference date). Written directly so the
|
||||
/// read path can be exercised without installing a leaf certificate.
|
||||
func storedEnrollmentJSON(
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
caChain: [Data] = [],
|
||||
notAfter: Date? = nil,
|
||||
renewAfter: Date? = nil
|
||||
) -> Data {
|
||||
var json: [String: Any] = [
|
||||
"deviceId": deviceId,
|
||||
"deviceName": deviceName,
|
||||
"caChain": caChain.map { $0.base64EncodedString() },
|
||||
]
|
||||
if let notAfter { json["notAfter"] = notAfter.timeIntervalSinceReferenceDate }
|
||||
if let renewAfter { json["renewAfter"] = renewAfter.timeIntervalSinceReferenceDate }
|
||||
return try! JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
|
||||
// B4 · Why every real-keychain test in this package runs one at a time.
|
||||
//
|
||||
// `KeychainClientIdentityStore` / `SecureEnclaveKeyFactory` call `SecItem*` and
|
||||
// `SecKeyCreateRandomKey` WITHOUT `kSecUseDataProtectionKeychain`, so on macOS
|
||||
// `swift test` reaches the FILE-BASED login keychain. Two problems follow from
|
||||
// Swift Testing's default parallel execution:
|
||||
//
|
||||
// 1. That backend is not safe for concurrent writes from one process: two
|
||||
// simultaneous permanent-key generations intermittently fail with
|
||||
// keyGenerationFailed("… Code=-25300 \"failed to generate CDSA key\" …")
|
||||
// (observed 2026-07-30 — green under `--no-parallel`, red in parallel).
|
||||
// 2. Certificate items are keyed by the cert's own subject on macOS, so the
|
||||
// fixture-leaf sweep is GLOBAL: one test's cleanup would delete a
|
||||
// concurrently-running test's freshly installed leaf.
|
||||
//
|
||||
// A global actor is NOT enough: an actor releases its executor at every `await`,
|
||||
// and these tests await network-stub calls in the middle of their critical
|
||||
// section (proved by exactly failure mode 2 appearing when a `@globalActor` was
|
||||
// used). What is needed is a mutex held ACROSS suspension points — applied as a
|
||||
// custom trait so the whole test body, setup and cleanup included, is inside it.
|
||||
|
||||
/// A FIFO async mutex.
|
||||
actor KeychainTestMutex {
|
||||
static let shared = KeychainTestMutex()
|
||||
|
||||
private var isHeld = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func acquire() async {
|
||||
guard isHeld else {
|
||||
isHeld = true
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
|
||||
func release() {
|
||||
guard waiters.isEmpty else {
|
||||
waiters.removeFirst().resume() // hand the lock straight to the next waiter
|
||||
return
|
||||
}
|
||||
isHeld = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes a test against every other keychain-touching test.
|
||||
struct KeychainSerializedTrait: TestTrait, SuiteTrait, TestScoping {
|
||||
func provideScope(
|
||||
for test: Test,
|
||||
testCase: Test.Case?,
|
||||
performing function: @Sendable () async throws -> Void
|
||||
) async throws {
|
||||
await KeychainTestMutex.shared.acquire()
|
||||
do {
|
||||
try await function()
|
||||
} catch {
|
||||
await KeychainTestMutex.shared.release()
|
||||
throw error
|
||||
}
|
||||
await KeychainTestMutex.shared.release()
|
||||
}
|
||||
}
|
||||
|
||||
extension Trait where Self == KeychainSerializedTrait {
|
||||
/// Apply to every test that touches the real keychain.
|
||||
static var keychainSerialized: Self { Self() }
|
||||
}
|
||||
|
||||
/// Does the default keychain hand back an identity for a tag that was never
|
||||
/// installed?
|
||||
///
|
||||
/// On macOS's file-based keychain, `SecItemCopyMatching(kSecClass:
|
||||
/// kSecClassIdentity, kSecAttrApplicationTag: …)` IGNORES the tag and returns an
|
||||
/// arbitrary identity from the login keychain (verified 2026-07-30 with a random
|
||||
/// tag: `errSecSuccess` + an unrelated `localhost` identity). On iOS's
|
||||
/// data-protection keychain the tag IS honored, which is why the production code
|
||||
/// is correct on the shipping platform — but it means the `.p12` fallback branch
|
||||
/// of `loadIdentity()` is only reachable in a `swift test` run whose default
|
||||
/// keychain holds no identities (a clean CI runner). Tests that depend on that
|
||||
/// branch are gated on this probe instead of being silently wrong.
|
||||
func defaultKeychainYieldsForeignIdentity() -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassIdentity,
|
||||
kSecAttrApplicationTag as String: Data("clienttls-probe-\(UUID().uuidString)".utf8),
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Security
|
||||
@testable import ClientTLS
|
||||
|
||||
/// B4 · A KNOWN-GOOD PKCS#10 vector produced by OpenSSL, used to pin the
|
||||
/// hand-written DER encoder in `CertificateSigningRequest` byte-for-byte.
|
||||
///
|
||||
/// The ECDSA signature is randomized, so the stable part of a CSR is its
|
||||
/// `CertificationRequestInfo` — for a fixed key + fixed subject it is fully
|
||||
/// deterministic. That is what is pinned here: if our encoder ever drifts (a
|
||||
/// non-minimal length, a PrintableString instead of UTF8String, a wrong curve
|
||||
/// OID, an attributes SET that is not `A0 00`), the bytes stop matching
|
||||
/// OpenSSL's and this test fails.
|
||||
///
|
||||
/// Regenerate (OpenSSL 3.0.18, 2026-07-30):
|
||||
/// openssl ecparam -name prime256v1 -genkey -noout -out fixed.key.pem
|
||||
/// openssl req -new -key fixed.key.pem -subj "/CN=web-terminal-device" \
|
||||
/// -outform DER -out csr.der
|
||||
/// # CertificationRequestInfo = the first child of the outer SEQUENCE:
|
||||
/// openssl asn1parse -inform DER -in csr.der -i # → offset 3, hl 3, l 128
|
||||
/// dd if=csr.der bs=1 skip=3 count=131 | base64
|
||||
/// # private key as X9.63 (0x04||X||Y||K) for SecKeyCreateWithData:
|
||||
/// openssl ec -in fixed.key.pem -text -noout # → pub(65) || priv(32)
|
||||
///
|
||||
/// SECURITY: `privateKeyX963Base64` is a THROWAWAY key generated solely for this
|
||||
/// vector. It protects nothing, is never enrolled, and is never written to a
|
||||
/// keychain (`SecKeyCreateWithData` keeps it in-process). Same convention as the
|
||||
/// embedded `device.p12` + passphrase in `ClientTLSFixtures`.
|
||||
enum KnownGoodCSR {
|
||||
static let subjectCommonName = "web-terminal-device"
|
||||
|
||||
/// OpenSSL's `CertificationRequestInfo` DER for `privateKeyX963Base64` +
|
||||
/// `CN=web-terminal-device` (131 bytes).
|
||||
static let certificationRequestInfoBase64 = """
|
||||
MIGAAgEAMB4xHDAaBgNVBAMME3dlYi10ZXJtaW5hbC1kZXZpY2UwWTATBgcqhkjOPQIBBggqhkjO\
|
||||
PQMBBwNCAARiJ8iQGXzCgho1TRYPNNcnqtgK/mrsNf+CAvoPbSH+12v1Q2OfVRgVPVYeS2AO3y87\
|
||||
Oel9Uxe2QsHluJnGoifSoAA=
|
||||
"""
|
||||
|
||||
/// `0x04 || X(32) || Y(32) || K(32)` — the format `SecKeyCreateWithData`
|
||||
/// expects for an EC private key.
|
||||
static let privateKeyX963Base64 = """
|
||||
BGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs56X1TF7ZC\
|
||||
weW4mcaiJ9Ko07ifqUA6io//Czd0XRMztqg+nY0OJcA1Y1LwoBMBbA==
|
||||
"""
|
||||
|
||||
static var certificationRequestInfoDER: Data {
|
||||
Data(base64Encoded: certificationRequestInfoBase64, options: .ignoreUnknownCharacters)!
|
||||
}
|
||||
|
||||
/// The fixed key as a `P256HardwareKey`, in-process only (no keychain item).
|
||||
static func fixedKey() throws -> SecureEnclaveKey {
|
||||
let blob = Data(
|
||||
base64Encoded: privateKeyX963Base64, options: .ignoreUnknownCharacters
|
||||
)!
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
]
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(blob as CFData, attributes as CFDictionary, &error)
|
||||
else {
|
||||
throw SecureEnclaveKeyError.keyGenerationFailed(
|
||||
String(describing: error?.takeRetainedValue())
|
||||
)
|
||||
}
|
||||
return SecureEnclaveKey(privateKey: key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The device key factory. Two things must hold for the enrollment path to
|
||||
// work at all: (1) a PERMANENT tagged key survives and is found again by tag —
|
||||
// that is what lets the enrolled leaf bind to it and `kSecClassIdentity`
|
||||
// assemble; (2) when the Secure Enclave is not usable (Simulator, missing
|
||||
// entitlement) the failure is CLASSIFIED as `.secureEnclaveUnavailable`, because
|
||||
// that is the signal callers use to fall back to a software key. A
|
||||
// `.keyGenerationFailed` there would look like a bug instead of a platform
|
||||
// limit and the fallback would never happen.
|
||||
//
|
||||
// Runs against the REAL keychain (no shim exists in this package): each test
|
||||
// uses a UUID-scoped tag and deletes it again, so nothing leaks between runs.
|
||||
|
||||
/// A per-test keychain tag — never collides with another test or another run.
|
||||
private func uniqueTag() -> Data {
|
||||
Data("com.yaojia.webterm.clienttls.test.key-\(UUID().uuidString)".utf8)
|
||||
}
|
||||
|
||||
@Test("a software key exposes a 65-byte uncompressed X9.63 point and signs verifiably")
|
||||
func softwareKeySignsVerifiably() throws {
|
||||
// Arrange
|
||||
let key = try SecureEnclaveKeyFactory.generateSoftware()
|
||||
|
||||
// Act
|
||||
let point = try key.publicKeyX963()
|
||||
let message = Data("certificationRequestInfo".utf8)
|
||||
let signature = try key.sign(message)
|
||||
|
||||
// Assert — X9.63 uncompressed form, and an X9.62 DER ECDSA signature that
|
||||
// verifies under the same algorithm the server's PoP check uses.
|
||||
#expect(point.count == 65)
|
||||
#expect(point.first == 0x04)
|
||||
let publicKey = try #require(
|
||||
SecKeyCreateWithData(
|
||||
point as CFData,
|
||||
[
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
] as CFDictionary,
|
||||
nil
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
SecKeyVerifySignature(
|
||||
publicKey, .ecdsaSignatureMessageX962SHA256,
|
||||
message as CFData, signature as CFData, nil
|
||||
)
|
||||
)
|
||||
#expect(signature.first == 0x30) // SEQUENCE { r, s }
|
||||
}
|
||||
|
||||
@Test("a non-permanent key is NOT stored in the keychain", .keychainSerialized)
|
||||
func nonPermanentKeyIsNotPersisted() async throws {
|
||||
// Arrange
|
||||
let tag = uniqueTag()
|
||||
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
|
||||
// Act — tagged but permanent: false (the unit-test / Simulator shape).
|
||||
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: false)
|
||||
|
||||
// Assert
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil)
|
||||
}
|
||||
|
||||
@Test("a permanent tagged key is found again by tag and deleted idempotently", .keychainSerialized)
|
||||
func permanentKeyRoundtripsByTag() async throws {
|
||||
// Arrange
|
||||
let tag = uniqueTag()
|
||||
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil) // pre-enroll state
|
||||
|
||||
// Act
|
||||
let generated = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: true)
|
||||
let loaded = try SecureEnclaveKeyFactory.load(tag: tag)
|
||||
|
||||
// Assert — the SAME key comes back (rotation must re-sign with it, never
|
||||
// mint a new one).
|
||||
let reloaded = try #require(loaded)
|
||||
#expect(try reloaded.publicKeyX963() == (try generated.publicKeyX963()))
|
||||
|
||||
// Act — delete, then delete again.
|
||||
try SecureEnclaveKeyFactory.delete(tag: tag)
|
||||
|
||||
// Assert — gone, and a second delete is a no-op (not a throw).
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil)
|
||||
#expect(throws: Never.self) { try SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
}
|
||||
|
||||
@Test("two keys under different tags stay independent", .keychainSerialized)
|
||||
func tagsScopeKeysIndependently() async throws {
|
||||
// Arrange
|
||||
let tagA = uniqueTag()
|
||||
let tagB = uniqueTag()
|
||||
defer {
|
||||
try? SecureEnclaveKeyFactory.delete(tag: tagA)
|
||||
try? SecureEnclaveKeyFactory.delete(tag: tagB)
|
||||
}
|
||||
|
||||
// Act
|
||||
let keyA = try SecureEnclaveKeyFactory.generateSoftware(tag: tagA, permanent: true)
|
||||
let keyB = try SecureEnclaveKeyFactory.generateSoftware(tag: tagB, permanent: true)
|
||||
|
||||
// Assert
|
||||
#expect(try keyA.publicKeyX963() != (try keyB.publicKeyX963()))
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagA)?.publicKeyX963()
|
||||
== (try keyA.publicKeyX963()))
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagB)?.publicKeyX963()
|
||||
== (try keyB.publicKeyX963()))
|
||||
|
||||
// Deleting one leaves the other installed.
|
||||
try SecureEnclaveKeyFactory.delete(tag: tagA)
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagA) == nil)
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagB) != nil)
|
||||
}
|
||||
|
||||
@Test("Secure-Enclave keygen without the entitlement fails as .secureEnclaveUnavailable", .keychainSerialized)
|
||||
func secureEnclaveKeygenClassifiesUnavailability() async throws {
|
||||
// Arrange
|
||||
let tag = uniqueTag()
|
||||
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
|
||||
// Act / Assert — an UNSIGNED test binary has no
|
||||
// `com.apple.developer.kernel...`/keychain-access-group entitlement, so
|
||||
// SecKeyCreateRandomKey over the SE token fails with -34018. What is under
|
||||
// test is the CLASSIFICATION: it must be `.secureEnclaveUnavailable`
|
||||
// (callers' fallback signal), never `.keyGenerationFailed`.
|
||||
//
|
||||
// On a host that CAN mint an SE key (entitled build on real hardware) the
|
||||
// call legitimately succeeds; then the key must be a usable signer. Both
|
||||
// outcomes are asserted so this test is honest on every machine.
|
||||
do {
|
||||
let key = try SecureEnclaveKeyFactory.generateSecureEnclave(tag: tag)
|
||||
let signature = try key.sign(Data("probe".utf8))
|
||||
#expect(signature.first == 0x30)
|
||||
} catch let error as SecureEnclaveKeyError {
|
||||
guard case let .secureEnclaveUnavailable(description) = error else {
|
||||
Issue.record("SE keygen failure must classify as .secureEnclaveUnavailable: \(error)")
|
||||
return
|
||||
}
|
||||
#expect(description.isEmpty == false) // the underlying CFError is kept for logs
|
||||
}
|
||||
}
|
||||
|
||||
@Test("signing with a public-only key surfaces .signatureFailed instead of crashing")
|
||||
func signingWithPublicKeyFails() throws {
|
||||
// Arrange — wrap a PUBLIC key in the signer (the shape a mis-wired
|
||||
// composition root could produce).
|
||||
let point = try SecureEnclaveKeyFactory.generateSoftware().publicKeyX963()
|
||||
let publicKey = try #require(
|
||||
SecKeyCreateWithData(
|
||||
point as CFData,
|
||||
[
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
] as CFDictionary,
|
||||
nil
|
||||
)
|
||||
)
|
||||
let key = SecureEnclaveKey(privateKey: publicKey)
|
||||
|
||||
// Act / Assert
|
||||
do {
|
||||
_ = try key.sign(Data("m".utf8))
|
||||
Issue.record("signing with a public key must throw")
|
||||
} catch let error as SecureEnclaveKeyError {
|
||||
guard case let .signatureFailed(description) = error else {
|
||||
Issue.record("expected .signatureFailed, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(description.isEmpty == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a nil CFError is described as 'unknown' rather than crashing the error path")
|
||||
func describeNilError() {
|
||||
#expect(SecureEnclaveKey.describe(nil) == "unknown")
|
||||
}
|
||||
@@ -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=<t>` 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 `<redacted>` 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<Character> = 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": "<redacted>"], displayStyle: .struct)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=<token>` 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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=<t>` 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=<token>` 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<Void, any Error>) 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
|
||||
}
|
||||
|
||||
@@ -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=<token>`")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<UInt16, any Error>?
|
||||
private var stopContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
@@ -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[..<end])
|
||||
rxBuffer.removeFirst(end + Wire.headerTerminator.count)
|
||||
isHandshakeDone = true
|
||||
return request
|
||||
return (request, upgradeRejection)
|
||||
}
|
||||
guard let requestBytes else { return }
|
||||
let upgrade = Self.parseUpgrade(String(decoding: requestBytes, as: UTF8.self))
|
||||
connection.send(
|
||||
content: Self.handshakeResponse(for: upgrade),
|
||||
completion: .contentProcessed { _ in }
|
||||
)
|
||||
guard let pending else { return }
|
||||
let upgrade = Self.parseUpgrade(String(decoding: pending.requestBytes, as: UTF8.self))
|
||||
// Record BEFORE answering, so a REJECTED upgrade is still observable.
|
||||
upgradesContinuation.yield(upgrade)
|
||||
guard let rejection = pending.rejection else {
|
||||
connection.send(
|
||||
content: Self.handshakeResponse(for: upgrade),
|
||||
completion: .contentProcessed { _ in }
|
||||
)
|
||||
return
|
||||
}
|
||||
// Reject exactly like the server: status line, then destroy the socket.
|
||||
connection.send(
|
||||
content: Self.rejectionResponse(rejection),
|
||||
completion: .contentProcessed { _ in connection.cancel() }
|
||||
)
|
||||
}
|
||||
|
||||
private static func headerEndIndex(in bytes: [UInt8]) -> 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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<String, any Error>
|
||||
) 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=<t> 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
|
||||
|
||||
Reference in New Issue
Block a user