Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,25 @@ Use standard file headers with copyright:
- Use `async throws` for async error handling
- Report issues using `IssueReporting` from xctest-dynamic-overlay

#### When trapping is allowed

The dividing line is *when the value is fixed*, not who supplied it.

- **Fixed once, at construction** — an initializer argument, a configuration field, a `package`
tuning constant. `precondition`/`preconditionFailure` is the right tool: the value cannot change
afterwards, so a bad one is a programmer error, and trapping reports it at the exact point it
was introduced. `SupabaseClient.init` traps on a `supabaseURL` with no host; `StorageApi` traps
on a URL it cannot decompose; `RetryRequestInterceptor` traps on a backoff base below 2.
Degrading instead would bury the mistake behind an unrelated failure much later.
- **Varies at runtime, or comes from the server** — a per-call parameter, a response header, a
decoded payload, a WebSocket close code. Never trap. Throw if the context already throws;
otherwise `reportIssue` and fall back. `HTTPFields.init(_:)` drops invalid field names rather
than trapping precisely because `HTTPResponse.init` builds it from `response.allHeaderFields`,
which a proxy or a hostile server controls.

A value being "user input" is not on its own a reason to avoid trapping — `supabaseURL` is user
input and traps. A value being *dynamic* is. See SDK-1793.

### Testing Conventions

This project uses the [Swift Testing](https://developer.apple.com/documentation/testing) framework, and only Swift Testing — do not use XCTest or `XCTestCase`.
Expand Down
27 changes: 20 additions & 7 deletions Sources/Auth/AuthClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@
// task below, capturing `self` would resurrect this client while it is being deallocated.
let sessionManager = Dependencies.instances.value[clientID]?.sessionManager

Dependencies.instances.withValue { $0.removeValue(forKey: clientID) }

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (MAC_CATALYST, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (MAC_CATALYST, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (test, MACOS, 26.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (MACOS, 26.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (MACOS, 26.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (test, MAC_CATALYST, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (MACOS, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (MACOS, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (test, MACOS, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / Examples (SlackClone)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / Examples (UserManagement)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (IOS, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (IOS, 16.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (IOS, 26.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS latest) (IOS, 26.4)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / Examples (Examples)

result of call to 'withValue' is unused

Check warning on line 251 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (legacy) (test, IOS, 16.4)

result of call to 'withValue' is unused

// The auto-refresh loop retains the session manager, so it keeps ticking forever unless it is
// explicitly stopped. `observeAppLifecycleChanges()` only stops it when the app resigns active,
Expand Down Expand Up @@ -856,9 +856,15 @@
) { @MainActor url in
try await withCheckedThrowingContinuation { [configuration] continuation in
guard let callbackScheme = (configuration.redirectToURL ?? redirectTo)?.scheme else {
preconditionFailure(
"Please, provide a valid redirect URL, either through `redirectTo` param, or globally through `AuthClient.Configuration.redirectToURL`."
continuation.resume(
throwing: AuthError.oauthFlowFailed(
message: """
Provide a redirect URL with a scheme, either through the `redirectTo` parameter \
or globally through `AuthClient.Configuration.redirectToURL`.
"""
)
)
return
}

#if !os(tvOS) && !os(watchOS)
Expand All @@ -874,7 +880,14 @@
} else if let url {
continuation.resume(returning: url)
} else {
fatalError("Expected url or error, but got none.")
// `ASWebAuthenticationSession` always reports a URL or an error. Surface a broken
// contract as a thrown error rather than taking the host app down with it.
reportIssue("ASWebAuthenticationSession returned neither a URL nor an error.")
continuation.resume(
throwing: AuthError.oauthFlowFailed(
message: "ASWebAuthenticationSession returned neither a URL nor an error."
)
)
}

#if !os(tvOS) && !os(watchOS)
Expand Down Expand Up @@ -991,9 +1004,9 @@
}
}

/// Only reached from ``session(from:)``, under `case .implicit` of its switch on
/// `configuration.flowType`, which is what guarantees the flow type here.
private func handleImplicitGrantFlow(params: [String: String]) async throws -> Session {
precondition(configuration.flowType == .implicit, "Method only allowed for implicit flow.")

if let errorMessage = params["error_description"] ?? params["error"] {
throw AuthError.implicitGrantRedirect(message: errorMessage)
}
Expand Down Expand Up @@ -1040,9 +1053,9 @@
return session
}

/// Only reached from ``session(from:)``, under `case .pkce` of its switch on
/// `configuration.flowType`, which is what guarantees the flow type here.
private func handlePKCEFlow(params: [String: String], flowId: String?) async throws -> Session {
precondition(configuration.flowType == .pkce, "Method only allowed for PKCE flow.")

if params["error"] != nil || params["error_description"] != nil || params["error_code"] != nil {
throw AuthError.pkceGrantCodeExchange(
message: params["error_description"]
Expand Down
8 changes: 7 additions & 1 deletion Sources/Auth/AuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ extension ErrorCode {
/// ### OAuth flow errors
/// - ``pkceGrantCodeExchange(message:error:code:)``
/// - ``implicitGrantRedirect(message:)``
/// - ``oauthFlowFailed(message:)``
///
/// ### JWT errors
/// - ``jwtVerificationFailed(message:)``
Expand All @@ -255,6 +256,10 @@ public enum AuthError: LocalizedError, Equatable {
/// Error thrown when an error happens during implicit grant flow.
case implicitGrantRedirect(message: String)

/// Error thrown when an OAuth flow cannot start or finish on the client, before any request
/// reaches the server — most often because no redirect URL with a scheme is configured.
case oauthFlowFailed(message: String)

/// Error thrown when JWT verification fails.
case jwtVerificationFailed(message: String)

Expand All @@ -265,6 +270,7 @@ public enum AuthError: LocalizedError, Equatable {
.api(let message, _, _, _),
.pkceGrantCodeExchange(let message, _, _),
.implicitGrantRedirect(let message),
.oauthFlowFailed(let message),
.jwtVerificationFailed(let message):
message
}
Expand All @@ -275,7 +281,7 @@ public enum AuthError: LocalizedError, Equatable {
case .sessionMissing: .sessionNotFound
case .weakPassword: .weakPassword
case .api(_, let errorCode, _, _): errorCode
case .pkceGrantCodeExchange, .implicitGrantRedirect: .unknown
case .pkceGrantCodeExchange, .implicitGrantRedirect, .oauthFlowFailed: .unknown
case .jwtVerificationFailed: .invalidJWT
}
}
Expand Down
7 changes: 2 additions & 5 deletions Sources/Auth/Internal/PKCE.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,9 @@ extension PKCE {
return Data(buffer).pkceBase64EncodedString()
},
generateCodeChallenge: { codeVerifier in
guard let data = codeVerifier.data(using: .utf8) else {
preconditionFailure("provided string should be utf8 encoded.")
}

// `Data(_:)` over a String's UTF-8 view cannot fail, unlike `data(using: .utf8)`.
var hasher = SHA256()
hasher.update(data: data)
hasher.update(data: Data(codeVerifier.utf8))
let hashed = hasher.finalize()
return Data(hashed).pkceBase64EncodedString()
}
Expand Down
2 changes: 2 additions & 0 deletions Sources/Helpers/HTTP/RetryRequestInterceptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ package actor RetryRequestInterceptor: HTTPClientInterceptor {
retryableErrorCodes: Set<URLError.Code> = RetryRequestInterceptor.defaultRetryableURLErrorCodes,
clock: any Clock<Duration> = ContinuousClock()
) {
// A base below 2 makes each wait shorter than the last instead of longer. The value is fixed
// at construction, so this is a programmer error, not a runtime condition.
precondition(
exponentialBackoffBase >= 2,
"The `exponentialBackoffBase` must be a minimum of 2."
Expand Down
14 changes: 5 additions & 9 deletions Sources/RealtimeV2/CallbackManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -151,15 +151,11 @@ final class CallbackManager: Sendable {
}

func triggerBroadcastData(event: String, data: Data) {
let callbacks = mutableState.callbacks.filter {
isBroadcastDataCallback(callback: $0, for: event)
}
.map { callback -> BroadcastDataCallback in
if case .broadcastData(let callback) = callback {
return callback
} else {
fatalError("Expected broadcast data callback")
}
let callbacks = mutableState.callbacks.compactMap { callback -> BroadcastDataCallback? in
guard isBroadcastDataCallback(callback: callback, for: event),
case .broadcastData(let broadcastData) = callback
else { return nil }
return broadcastData
}
callbacks.forEach { $0.callback(data) }
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/RealtimeV2/RealtimeChannelV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,15 @@ public final class RealtimeChannelV2: Sendable, RealtimeChannelProtocol {
version: socket.options.headers[.xClientInfo]
)

guard let encodedPayload = try? JSONObject(payload) else {
reportIssue("Failed to encode the phx_join payload for channel '\(topic)'. Skipping join.")
return
}

await push(
ChannelEvent.join,
ref: ref,
payload: try! JSONObject(payload)
payload: encodedPayload
)
}

Expand Down
4 changes: 0 additions & 4 deletions Sources/RealtimeV2/RealtimeClientV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ public final class RealtimeClientV2: Sendable, RealtimeClientProtocol {
let wsTransport: WebSocketTransport
let mutableState = LockIsolated(MutableState())
let http: any HTTPClientType
let apikey: String
let serializer = RealtimeSerializer()
let clock: any Clock<Duration>

Expand Down Expand Up @@ -193,9 +192,6 @@ public final class RealtimeClientV2: Sendable, RealtimeClientProtocol {
self.http = http
self.clock = clock

precondition(options.apikey != nil, "API key is required to connect to Realtime")
apikey = options.apikey!

mutableState.withValue { [options] in
if let accessToken = options.headers[.authorization]?.split(separator: " ").last {
$0.accessToken = String(accessToken)
Expand Down
75 changes: 61 additions & 14 deletions Sources/RealtimeV2/WebSocket/URLSessionWebSocket.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ConcurrencyExtras
import Foundation
import IssueReporting

#if canImport(FoundationNetworking)
import FoundationNetworking
Expand Down Expand Up @@ -70,7 +71,10 @@ final class URLSessionWebSocket: WebSocket {
session: URLSession? = nil
) async throws -> URLSessionWebSocket {
guard url.scheme == "ws" || url.scheme == "wss" else {
preconditionFailure("only ws: and wss: schemes are supported")
throw WebSocketError.connection(
message: "only ws: and wss: schemes are supported, got \(url.scheme ?? "no scheme").",
error: URLError(.unsupportedURL)
)
}

struct MutableState {
Expand Down Expand Up @@ -406,33 +410,76 @@ final class URLSessionWebSocket: WebSocket {
return
}

// Validate close code per RFC 6455
if let code = code, code != 1000, !(code >= 3000 && code <= 4999) {
preconditionFailure(
"Invalid close code: \(code). Must be 1000 or in range 3000-4999"
let validatedCode = Self.validatedCloseCode(code)
if let code, validatedCode == nil {
reportIssue(
"Invalid close code \(code). Must be 1000 or in 3000...4999. Closing without a code."
)
}

// Validate reason length per RFC 6455
if let reason = reason, reason.utf8.count > 123 {
preconditionFailure("Close reason must be ≤ 123 bytes when UTF-8 encoded")
let validatedReason = Self.validatedCloseReason(reason)
if let reason, validatedReason != reason {
reportIssue(
"Close reason is \(reason.utf8.count) bytes, over the 123-byte limit. Truncating it."
)
}

// The two platforms disagree about what `CloseCode` accepts. On Darwin it is imported from
// Objective-C as a non-exhaustive `NS_ENUM`, so any `Int` round-trips and an application code
// like 4001 goes out as 4001. In swift-corelibs-foundation it is a plain Swift enum holding
// only the named cases, so everything in 3000...4999 converts to `nil` and cannot be sent.
// Close without a status there rather than substituting a different code — reporting 1000
// ("normal closure") for what was meant to be an application error would misinform the peer.
let closeCode = validatedCode.flatMap(URLSessionWebSocketTask.CloseCode.init(rawValue:))
if let validatedCode, closeCode == nil {
reportIssue(
"""
Close code \(validatedCode) is not representable by \
`URLSessionWebSocketTask.CloseCode` on this platform. Closing without a code.
"""
)
}

mutableState.withValue {
guard !$0.isClosed else { return }

if let code = code {
let closeReason = reason ?? ""
_task.cancel(
with: URLSessionWebSocketTask.CloseCode(rawValue: code)!,
reason: Data(closeReason.utf8)
)
if let closeCode {
_task.cancel(with: closeCode, reason: Data((validatedReason ?? "").utf8))
} else {
_task.cancel()
}
}
}

/// Returns `code` if RFC 6455 §7.4 allows an endpoint to send it, otherwise `nil`.
///
/// Only 1000 and the application-defined range 3000...4999 may be sent. Anything else closes
/// without a code (the peer sees 1005) rather than trapping — ``close(code:reason:)`` is called
/// from user code and cannot throw.
///
/// Pure by design: the caller reports the rejection. Driving `reportIssue` from a `@Test`
/// function segfaults under `xcodebuild test` (SDK-435), so keeping it out of here is what lets
/// this be tested directly on both runners.
static func validatedCloseCode(_ code: Int?) -> Int? {
guard let code else { return nil }
return code == 1000 || (3000...4999).contains(code) ? code : nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Returns `reason` truncated to the 123-byte close-frame payload limit of RFC 6455 §5.5.
///
/// Truncation happens on whole characters, so the frame never carries a split UTF-8 scalar.
/// Pure for the same reason as ``validatedCloseCode(_:)``.
static func validatedCloseReason(_ reason: String?) -> String? {
guard let reason, reason.utf8.count > 123 else { return reason }

var truncated = ""
for character in reason {
guard truncated.utf8.count + character.utf8.count <= 123 else { break }
truncated.append(character)
}
return truncated
}

/// The WebSocket subprotocol negotiated with the peer.
///
/// Returns an empty string if no subprotocol was negotiated during the handshake.
Expand Down
36 changes: 28 additions & 8 deletions Sources/Storage/StorageApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import HTTPTypes
/// requests. Each of the types above holds a ``StorageApi`` value and delegates to it rather than
/// inheriting from it.
struct StorageApi: Sendable {
/// The apex domains the storage hostname rewrite applies to, each carrying a leading dot so the
/// match has to land on a hostname-label boundary.
///
/// Without the dot, any host merely *ending* in the apex matches: a caller-owned domain like
/// `not-supabase.co` would be rewritten to `not-storage.supabase.co`, pointing requests at a
/// domain the caller does not control. The bare apex `supabase.co` is excluded for the same
/// reason — it is not a project host.
private static let legacySupabaseHostSuffixes = [".supabase.co", ".supabase.in", ".supabase.red"]

/// Reports whether `host` is a Supabase project host that has not already been pointed at
/// storage.
private static func isLegacySupabaseHost(_ host: String) -> Bool {
!host.contains("storage.supabase.")
&& legacySupabaseHostSuffixes.contains(where: host.hasSuffix)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// The configuration used to initialize this client instance.
let configuration: StorageClientConfiguration

Expand All @@ -30,23 +46,27 @@ struct StorageApi: Sendable {
// if legacy uri is used, replace with new storage host (disables request buffering to allow > 50GB uploads)
// "project-ref.supabase.co" becomes "project-ref.storage.supabase.co"
if configuration.useNewHostname == true {
// `configuration.url` is supplied once, at construction, so a URL that cannot be decomposed
// into host components is a programmer error, not a runtime condition. Trap here, where the
// offending value is, rather than letting it fail later as an opaque `URLError`.
guard
var components = URLComponents(url: configuration.url, resolvingAgainstBaseURL: false),
let host = components.host
else {
fatalError("Client initialized with invalid URL: \(configuration.url)")
preconditionFailure("Storage client initialized with an invalid URL: \(configuration.url)")
}

let regex = try! NSRegularExpression(pattern: "supabase.(co|in|red)$")
if Self.isLegacySupabaseHost(host) {
// Substitute on the same label boundary the check used, so a host that happens to start
// with `supabase.` is not rewritten at that leading position too.
components.host = host.replacingOccurrences(of: ".supabase.", with: ".storage.supabase.")

let isSupabaseHost =
regex.firstMatch(in: host, range: NSRange(location: 0, length: host.utf16.count)) != nil
guard let rewritten = components.url else {
preconditionFailure("Rewriting the storage host produced an invalid URL: \(components)")
}

if isSupabaseHost, !host.contains("storage.supabase.") {
components.host = host.replacingOccurrences(of: "supabase.", with: "storage.supabase.")
configuration.url = rewritten
}

configuration.url = components.url!
}

self.configuration = configuration
Expand Down
Loading
Loading