mirror of
https://github.com/signalapp/libsignal.git
synced 2026-04-25 17:25:18 +02:00
Bridge look_up_username_hash to app languages
Co-authored-by: Jordan Rose <jrose@signal.org>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
v0.79.1
|
||||
|
||||
- The first "Typed API" service interface for chat-server, UnauthUsernamesService, has been added to libsignal's app layer.
|
||||
|
||||
- The libsignal-net remote config option `chatRequestConnectionCheckTimeoutMillis` controls a new check: if a chat request hasn't been responded to in this amount of time, libsignal will check if the connection is using the preferred network interface, and close it early if not.
|
||||
|
||||
- Java: `CertificateValidator.validate(SenderCertificate, long)` is once again `open` for testing.
|
||||
|
||||
@@ -3,3 +3,6 @@
|
||||
[Introduction](README.md)
|
||||
|
||||
- [Backups](backups/README.md)
|
||||
- [Networking](net/README.md)
|
||||
- [CDS]()
|
||||
- [Chat](net/chat.md)
|
||||
|
||||
21
doc/src/net/README.md
Normal file
21
doc/src/net/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Networking
|
||||
|
||||
libsignal has a number of networking-related APIs, collectively referred to as "libsignal-net". These currently provide connections to the [chat server][chat], contact discovery service, and SVR-B, with the possibility of eventually handling every connection to a server run by Signal.
|
||||
|
||||
|
||||
## The Net class
|
||||
|
||||
**Net** (or **Network** on Android) is the top-level manager for connections made from libsignal. It records the environment (production or staging), the user agent to use for all connections (appending its own version string), and any configurable options that apply to all connections, such as whether IPv6 networking is enabled. Internally, it also owns a Rust-managed thread pool for dispatching I/O operations and processing responses. Some operations (e.g. CDS) are provided directly on Net; others use a separate connection object (e.g. chat) where the Net instance is merely used to connect.
|
||||
|
||||
|
||||
## Implementation Organization
|
||||
|
||||
In the Rust layer, libsignal-net is broken up into three separate crates:
|
||||
|
||||
- `libsignal-net-infra`: Server- and connection-agnostic implementations of networking protocols
|
||||
- `libsignal-net`: Connections specifically to Signal services, rather than generic reusable work
|
||||
- `libsignal-net-chat`: Presents the high-level request APIs of the Signal chat server in a protocol-agnostic way (see the [Chat][] page for more info)
|
||||
|
||||
(These boundaries are approximate, because ultimately it's all going to be exposed to the apps anyway; these are *not* some of the crates designed to be generally reusable outside Signal.)
|
||||
|
||||
[chat]: ./chat.md
|
||||
55
doc/src/net/chat.md
Normal file
55
doc/src/net/chat.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Chat
|
||||
|
||||
Signal's chat server was historically been built on plain HTTP REST requests; to improve responsiveness for online clients, this was switched over to a pair of persistent WebSocket connections---one authenticated, one unauthenticated. To ease migration, these connections use an HTTP-like protobuf interface to provide the same API that REST used to, along with a dedicated "reverse request" mode for pushing incoming messages and notifying clients when the queue is empty. libsignal has two modes for working with chat connections: plain RPC, and "typed APIs".
|
||||
|
||||
|
||||
## WebSocket RPC
|
||||
|
||||
To directly use this WebSocket RPC from libsignal, use the `connectAuth(enticated)Chat` or `connectUnauth(enticated)Chat` methods on a Net(work) instance. This produces an AuthenticatedChatConnection or an UnauthenticatedChatConnection, respectively, each of which has a `send()` method that can send a REST-like request. Note that for Android or iOS, the connection must be `start()`ed with a listener before it can be used.
|
||||
|
||||
The listener callbacks only cover disconnection and the two message-queue events, though these events are guaranteed to be delivered in order. They do not provide general-purpose server->client communication, even though the underlying protobuf interface would allow it.
|
||||
|
||||
### Preconnecting
|
||||
|
||||
If the time spent establishing a TLS connection becomes significant, Net also has a `preconnectChat()` call, which does the "early" part of connection establishment and then pauses, waiting for a later call to `connectAuthenticatedChat()`. This allows parallelizing the connection attempt with, say, loading the username and password used for the auth socket. This is considered an optimization; if `connectAuthenticatedChat()` isn't called soon after the initial `preconnectChat()` call, or if the connection parameters change in between, the preconnected socket will be silently discarded. If `connectAuthenticatedChat()` isn't called at *all,* the preconnected socket may not ever be cleaned up (but the server will eventually hang up on it).
|
||||
|
||||
|
||||
## High-level Request APIs (a.k.a "Typed APIs")
|
||||
|
||||
To improve on the limitations of the current endpoints and the WebSocket RPC system, the chat server will support a new gRPC-based API that can replace the WebSocket RPC. Rather than have all clients bring up their own gRPC clients, libsignal will provide high-level equivalents for all the APIs currently using `send()` calls, and then transparently switch them to gRPC calls later. Using these high-level APIs differs slightly on each platform.
|
||||
|
||||
### Android
|
||||
|
||||
The typed APIs are provided as "services" that wrap the corresponding ChatConnection. For example:
|
||||
|
||||
```kotlin
|
||||
val usernamesService = UnauthUsernamesService(chatConnection)
|
||||
val response = usernamesService.lookUpUsernameHash(hash).get() // or await()
|
||||
```
|
||||
|
||||
Unlike most libsignal APIs, which throw exceptions, the service APIs produce `RequestResult`s, a sealed interface of `Success` (what you wanted), `NonSuccess` (a request-specific error), and `Failure` (a standard transport error of some kind). This design was based on what Signal-Android was using elsewhere!
|
||||
|
||||
### iOS
|
||||
|
||||
The typed APIs are implemented directly on the corresponding ChatConnection, but also grouped into "service" protocols. Several hoops were jumped through to make it possible to access these in a generic way via the helper `UnauthServiceSelector` type (see there for more details).
|
||||
|
||||
```swift
|
||||
// Assuming a helper accessService(_:as:) method added in the app.
|
||||
try await accessService(chatConnection, as: .usernames) { usernamesService in
|
||||
let response = try await usernamesService.lookUpUsernameHash(hash)
|
||||
}
|
||||
```
|
||||
|
||||
Each request can throw request-specific errors as well as standard transport errors.
|
||||
|
||||
### Desktop
|
||||
|
||||
The typed APIs are implemented directly on the corresponding ChatConnection, but also grouped into "service" interfaces. The libsignal tests contain an example of how to limit access in a generic way (see ServiceTestUtils.ts).
|
||||
|
||||
```typescript
|
||||
// Assuming a helper accessUnauthService() method added in the app.
|
||||
const service = connectionManager.accessUnauthService<UnauthUsernamesService>();
|
||||
const response = await service.lookUpUsernameHash(hash);
|
||||
```
|
||||
|
||||
Each request can throw (reject the promise) with request-specific errors as well as standard transport errors.
|
||||
@@ -226,6 +226,6 @@ target_for_abi() {
|
||||
|
||||
for abi in "${android_abis[@]}"; do
|
||||
rust_target=$(target_for_abi "$abi")
|
||||
echo_then_run cargo build -p libsignal-jni -p libsignal-jni-testing ${RUST_RELEASE:+--release} ${FEATURES:+--features "${FEATURES[*]}"} -Z unstable-options --target "$rust_target" --artifact-dir "${ANDROID_LIB_DIR}/$abi"
|
||||
echo_then_run cargo build -p libsignal-jni -p libsignal-jni-testing ${RUST_RELEASE:+--release} ${FEATURES:+--features "${FEATURES[*]}"} -Z unstable-options --target "$rust_target" --artifact-dir "${ANDROID_LIB_DIR}/$abi" --timings
|
||||
check_for_debug_level_logs_if_needed "${ANDROID_LIB_DIR}/$abi"
|
||||
done
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.signal.libsignal.internal
|
||||
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.concurrent.CancellationException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
@@ -38,6 +39,42 @@ public suspend fun <T> CompletableFuture<T>.await(): T =
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a CompletableFuture<T> into a CompletableFuture<R> with proper bidirectional cancellation.
|
||||
*
|
||||
* This helper wraps a native future and transforms its result, while ensuring that:
|
||||
* - Success values are transformed using the provided mapper
|
||||
* - CancellationExceptions propagate as cancellations (not completions)
|
||||
* - Other exceptions are transformed using the error mapper
|
||||
* - Cancellation of the outer future cancels the inner future
|
||||
*
|
||||
* @param onSuccess Function to transform success values from T to R
|
||||
* @param onError Function to transform non-cancellation exceptions to R
|
||||
* @return A new CompletableFuture<R> with bidirectional cancellation support
|
||||
*/
|
||||
public fun <T, R> CompletableFuture<T>.mapWithCancellation(
|
||||
onSuccess: (T) -> R,
|
||||
onError: (Throwable) -> R,
|
||||
): CompletableFuture<R> {
|
||||
val outer = CompletableFuture<R>()
|
||||
|
||||
this.whenComplete { value, err ->
|
||||
when (err) {
|
||||
null -> outer.complete(onSuccess(value))
|
||||
is CancellationException -> outer.cancel(true)
|
||||
else -> outer.complete(onError(err))
|
||||
}
|
||||
}
|
||||
|
||||
outer.whenComplete { _, t ->
|
||||
if (t is CancellationException) {
|
||||
this.cancel(true)
|
||||
}
|
||||
}
|
||||
|
||||
return outer
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a `CompletableFuture<T>` to a `CompletableFuture<Result<T>>`.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.lang.ref.WeakReference;
|
||||
import java.net.MalformedURLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiFunction;
|
||||
import org.signal.libsignal.internal.CalledFromNative;
|
||||
import org.signal.libsignal.internal.CompletableFuture;
|
||||
import org.signal.libsignal.internal.FilterExceptions;
|
||||
@@ -37,6 +38,21 @@ public abstract class ChatConnection extends NativeHandleGuard.SimpleOwner {
|
||||
this.chatListener = chatListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function with both the chat connection handle and async context handle properly
|
||||
* guarded. This ensures that neither object is finalized while the function is executing.
|
||||
*
|
||||
* @param function the function to execute with the guarded handles
|
||||
* @param <T> the return type of the function
|
||||
* @return the result of the function
|
||||
*/
|
||||
<T> T runWithContextAndConnectionHandles(BiFunction<Long, Long, T> function) {
|
||||
try (final NativeHandleGuard asyncContextHandle = new NativeHandleGuard(tokioAsyncContext);
|
||||
final NativeHandleGuard chatConnectionHandle = new NativeHandleGuard(this)) {
|
||||
return function.apply(asyncContextHandle.nativeHandle(), chatConnectionHandle.nativeHandle());
|
||||
}
|
||||
}
|
||||
|
||||
protected static class ListenerBridge implements BridgeChatListener {
|
||||
// Stored as a weak reference because otherwise we'll have a reference cycle:
|
||||
// - After setting a listener, Rust has a GC GlobalRef to this ListenerBridge
|
||||
|
||||
@@ -19,7 +19,7 @@ public class RateLimitChallengeException : ChatServiceException {
|
||||
public val options: Set<ChallengeOption>
|
||||
|
||||
@CalledFromNative
|
||||
private constructor(message: String, token: String, options: Array<ChallengeOption>) : super(message) {
|
||||
public constructor(message: String, token: String, options: Array<ChallengeOption>) : super(message) {
|
||||
this.token = token
|
||||
this.options = EnumSet.copyOf(options.asList())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net
|
||||
|
||||
import org.signal.libsignal.internal.CompletableFuture
|
||||
import java.io.IOException
|
||||
import java.time.Duration
|
||||
|
||||
/**
|
||||
* High-level result type for chat server requests.
|
||||
*
|
||||
* This sealed interface represents all possible outcomes from network requests:
|
||||
* - [Success]: The request completed successfully with a result
|
||||
* - [NonSuccess]: The request reached the server but returned a business logic error
|
||||
* - [RetryableNetworkError]: A network failure occurred; the request may succeed if retried
|
||||
* - [ApplicationError]: A client-side bug prevented request completion
|
||||
*
|
||||
* @param T The type of successful result data
|
||||
* @param E The type of error that occurred. If a particular API has no possible errors,
|
||||
* we use [Nothing] instead.
|
||||
*/
|
||||
public sealed interface RequestResult<out T, out E : BadRequestError> {
|
||||
/**
|
||||
* The request completed successfully and produced a result.
|
||||
*
|
||||
* @property result The data returned by the successful request. May be nullable
|
||||
* for APIs where absence of data is a valid response.
|
||||
*/
|
||||
public data class Success<T>(
|
||||
val result: T,
|
||||
) : RequestResult<T, Nothing>
|
||||
|
||||
/**
|
||||
* We successfully made the request, but the server returned an error.
|
||||
*
|
||||
* This represents expected error conditions defined by the API, such as
|
||||
* "invalid authorization" or "outdated recipient information". These errors are part of the
|
||||
* API contract and should be handled explicitly by callers.
|
||||
*
|
||||
* @property error The specific error that occurred
|
||||
*/
|
||||
public data class NonSuccess<E : BadRequestError>(
|
||||
val error: E,
|
||||
) : RequestResult<Nothing, E>
|
||||
|
||||
/**
|
||||
* A retryable network failure occurred before receiving a response.
|
||||
*
|
||||
* This includes connection failures, timeouts, server errors, and
|
||||
* rate limiting. Callers may retry these requests, optionally after a delay.
|
||||
*
|
||||
* Possible types for [networkError] include but are not limited to:
|
||||
* - [TimeoutException]: occurs when the request takes too long to complete.
|
||||
* - [ConnectedElsewhereException]: occurs when a client connects elsewhere with
|
||||
* same credentials before the request could complete
|
||||
* - [ConnectionInvalidatedException]: occurs when the connection to the server is
|
||||
* invalidated (e.g. the account is deleted) before the request could complete
|
||||
* - [RetryLaterException]: occurs when the client hits a rate limit, and must wait at least
|
||||
* the duration specified before retrying. [retryAfter] will always be set with this error
|
||||
* - [TransportFailureException]: occurs when the transport layer fails
|
||||
* - [ServerSideErrorException]: occurs when the server returns a response
|
||||
* indicating a server-side error occurred. You may wish to retry with an especially
|
||||
* long timeout on critical paths, like message sending, to avoid worsening a server
|
||||
* outage.
|
||||
*
|
||||
* @property networkError The underlying I/O error that caused the failure
|
||||
* @property retryAfter Optional advisory duration to wait before retrying.
|
||||
* If present, the client should not retry before this duration
|
||||
* elapses, but may choose to wait longer.
|
||||
*/
|
||||
public data class RetryableNetworkError(
|
||||
val networkError: IOException,
|
||||
val retryAfter: Duration? = null,
|
||||
) : RequestResult<Nothing, Nothing>
|
||||
|
||||
/**
|
||||
* A client-side issue prevented the request from completing.
|
||||
*
|
||||
* This likely indicates a bug in libsignal.
|
||||
*
|
||||
* Possible types for [cause] include but are not limited to:
|
||||
* - [UnexpectedResponseException]: occurs when we are unable to parse the response
|
||||
*
|
||||
* @property cause The exception that indicates the bug. May contain stack
|
||||
* traces and other diagnostic information.
|
||||
*/
|
||||
public data class ApplicationError(
|
||||
val cause: Throwable,
|
||||
) : RequestResult<Nothing, Nothing>
|
||||
|
||||
public fun <R> map(transform: (T) -> R): RequestResult<R, E> =
|
||||
when (this) {
|
||||
is Success -> Success(transform(result))
|
||||
is NonSuccess -> this
|
||||
is RetryableNetworkError -> this
|
||||
is ApplicationError -> this
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker interface for business logic errors returned by typed APIs.
|
||||
*
|
||||
* All API-specific error types must implement this interface. Errors can
|
||||
* implement multiple specific error interfaces to indicate they may be
|
||||
* returned by multiple APIs.
|
||||
*
|
||||
* Example:
|
||||
* ```kotlin
|
||||
* sealed interface AciByUsernameFetchError : BadRequestError
|
||||
* object UserNotFound : AciByUsernameFetchError
|
||||
* ```
|
||||
*/
|
||||
public interface BadRequestError
|
||||
|
||||
internal fun <E : BadRequestError> Throwable.toRequestResult(): RequestResult<Nothing, E> =
|
||||
when (this) {
|
||||
is TimeoutException -> RequestResult.RetryableNetworkError(this, null)
|
||||
is ConnectedElsewhereException -> RequestResult.RetryableNetworkError(this)
|
||||
// ConnectionInvalidated is mapped to a network error. Only one legacy API uses its
|
||||
// specific meaning; all other APIs treat it as a generic network error.
|
||||
is ConnectionInvalidatedException -> RequestResult.RetryableNetworkError(this)
|
||||
is RetryLaterException -> RequestResult.RetryableNetworkError(this, duration)
|
||||
is TransportFailureException -> RequestResult.RetryableNetworkError(this)
|
||||
is ServerSideErrorException -> RequestResult.RetryableNetworkError(this)
|
||||
is UnexpectedResponseException -> RequestResult.ApplicationError(this)
|
||||
else -> RequestResult.ApplicationError(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely unwraps a [CompletableFuture]<[RequestResult]> to a [RequestResult].
|
||||
*
|
||||
* This extension function handles the case where the Future itself fails
|
||||
* (as opposed to the request returning an error result). Any exceptions
|
||||
* thrown while waiting for the Future are converted to [ApplicationError].
|
||||
*
|
||||
* @return The [RequestResult] from the Future, or [ApplicationError] if the
|
||||
* Future failed to complete normally
|
||||
*/
|
||||
public fun <T, E : BadRequestError> CompletableFuture<RequestResult<T, E>>.getOrError(): RequestResult<T, E> =
|
||||
try {
|
||||
this.get()
|
||||
} catch (e: Throwable) {
|
||||
RequestResult.ApplicationError(e)
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.signal.libsignal.net;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import org.signal.libsignal.internal.CalledFromNative;
|
||||
|
||||
@@ -15,7 +16,7 @@ import org.signal.libsignal.internal.CalledFromNative;
|
||||
* requests to a number of endpoints. It can also be produced as the result of a websocket close
|
||||
* frame from an enclave service with close code {@code 4008}.
|
||||
*/
|
||||
public class RetryLaterException extends Exception {
|
||||
public class RetryLaterException extends IOException {
|
||||
/** The amount of time to wait before retrying. */
|
||||
public final Duration duration;
|
||||
|
||||
@@ -24,7 +25,7 @@ public class RetryLaterException extends Exception {
|
||||
this(Duration.ofSeconds(retryAfterSeconds));
|
||||
}
|
||||
|
||||
private RetryLaterException(Duration duration) {
|
||||
public RetryLaterException(Duration duration) {
|
||||
super("Retry after " + duration.getSeconds() + " seconds");
|
||||
this.duration = duration;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net;
|
||||
|
||||
import org.signal.libsignal.internal.CalledFromNative;
|
||||
|
||||
/** Server-side error, retryable with backoff. */
|
||||
public class ServerSideErrorException extends ChatServiceException {
|
||||
@CalledFromNative
|
||||
public ServerSideErrorException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net;
|
||||
|
||||
import org.signal.libsignal.internal.CalledFromNative;
|
||||
|
||||
/** Request timed out. */
|
||||
public class TimeoutException extends ChatServiceException {
|
||||
@CalledFromNative
|
||||
public TimeoutException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net;
|
||||
|
||||
import org.signal.libsignal.internal.CalledFromNative;
|
||||
|
||||
/** Transport-level error in Chat Service communication. */
|
||||
public class TransportFailureException extends ChatServiceException {
|
||||
@CalledFromNative
|
||||
public TransportFailureException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net
|
||||
|
||||
import org.signal.libsignal.internal.CompletableFuture
|
||||
import org.signal.libsignal.internal.Native
|
||||
import org.signal.libsignal.internal.mapWithCancellation
|
||||
import org.signal.libsignal.protocol.ServiceId
|
||||
|
||||
public class UnauthUsernamesService(
|
||||
private val connection: UnauthenticatedChatConnection,
|
||||
) {
|
||||
/**
|
||||
* Looks up a username hash on the service, like that computed by
|
||||
* [org.signal.libsignal.usernames.Username].
|
||||
*
|
||||
* Produces the corresponding account's ACI, or `null` if the username doesn't correspond to an
|
||||
* account.
|
||||
*
|
||||
* All exceptions are mapped into [RequestResult]; unexpected ones will be treated as
|
||||
* [RequestResult.ApplicationError].
|
||||
*/
|
||||
public fun lookUpUsernameHash(hash: ByteArray): CompletableFuture<RequestResult<ServiceId.Aci?, Nothing>> =
|
||||
try {
|
||||
connection
|
||||
.runWithContextAndConnectionHandles { asyncCtx, conn ->
|
||||
Native.UnauthenticatedChatConnection_look_up_username_hash(asyncCtx, conn, hash)
|
||||
}.mapWithCancellation(
|
||||
onSuccess = { uuid -> RequestResult.Success(uuid?.let(ServiceId::Aci)) },
|
||||
onError = { err -> err.toRequestResult() },
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
CompletableFuture.completedFuture(RequestResult.ApplicationError(e))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net;
|
||||
|
||||
import org.signal.libsignal.internal.CalledFromNative;
|
||||
|
||||
/** Unexpected response from the server. */
|
||||
public class UnexpectedResponseException extends ChatServiceException {
|
||||
@CalledFromNative
|
||||
public UnexpectedResponseException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -387,19 +387,13 @@ public class ChatServiceTest {
|
||||
// If it hangs for more than five seconds, consider that a failure.
|
||||
@Test(timeout = 5000)
|
||||
public void testListenerCleanup() throws Exception {
|
||||
class Listener implements ChatConnectionListener {
|
||||
class Listener extends NoOpListener {
|
||||
CountDownLatch latch;
|
||||
|
||||
Listener(CountDownLatch latch) {
|
||||
this.latch = latch;
|
||||
}
|
||||
|
||||
public void onIncomingMessage(
|
||||
ChatConnection chat,
|
||||
byte[] envelope,
|
||||
long serverDeliveryTimestamp,
|
||||
ServerMessageAck sendAck) {}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("deprecation")
|
||||
protected void finalize() {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net
|
||||
|
||||
open class NoOpListener : ChatConnectionListener {
|
||||
override fun onIncomingMessage(
|
||||
chat: ChatConnection,
|
||||
envelope: ByteArray,
|
||||
serverDeliveryTimestamp: Long,
|
||||
sendAck: ChatConnectionListener.ServerMessageAck,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
package org.signal.libsignal.net
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.signal.libsignal.internal.TokioAsyncContext
|
||||
import org.signal.libsignal.protocol.ServiceId.Aci
|
||||
import org.signal.libsignal.util.Base64
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
import kotlin.test.assertIs
|
||||
|
||||
class UnauthUsernamesServiceTest {
|
||||
@Test
|
||||
fun testLookupUsernameHashSuccess() {
|
||||
val tokioAsyncContext = TokioAsyncContext()
|
||||
val chatAndFakeRemote =
|
||||
UnauthenticatedChatConnection.fakeConnect(
|
||||
tokioAsyncContext,
|
||||
NoOpListener(),
|
||||
Network.Environment.STAGING,
|
||||
)
|
||||
val chat = chatAndFakeRemote.first()
|
||||
val fakeRemote = chatAndFakeRemote.second()
|
||||
|
||||
val accountsService = UnauthUsernamesService(chat)
|
||||
val testHash = byteArrayOf(1, 2, 3, 4)
|
||||
val responseFuture = accountsService.lookUpUsernameHash(testHash)
|
||||
|
||||
// Get the incoming request from the fake remote
|
||||
val requestAndId = fakeRemote.getNextIncomingRequest().get()
|
||||
val request = requestAndId.first()
|
||||
val requestId = requestAndId.second()
|
||||
|
||||
assertEquals("GET", request.method)
|
||||
val expectedPath = "/v1/accounts/username_hash/" + Base64.encodeToStringUrl(testHash)
|
||||
assertEquals(expectedPath, request.pathAndQuery)
|
||||
|
||||
// Send successful response with UUID
|
||||
val uuid = UUID.fromString("4FCFE887-A600-40CD-9AB7-FD2A695E9981")
|
||||
val jsonResponse =
|
||||
"""
|
||||
{
|
||||
"uuid": "$uuid"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
fakeRemote.sendResponse(
|
||||
requestId,
|
||||
200,
|
||||
"OK",
|
||||
arrayOf("content-type: application/json"),
|
||||
jsonResponse.toByteArray(StandardCharsets.UTF_8),
|
||||
)
|
||||
|
||||
// Verify the result
|
||||
val result = responseFuture.get()
|
||||
val successResult = assertIs<RequestResult.Success<Aci?>>(result)
|
||||
assertEquals(Aci(uuid), successResult.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLookupUsernameHashNotFound() {
|
||||
val tokioAsyncContext = TokioAsyncContext()
|
||||
val chatAndFakeRemote =
|
||||
UnauthenticatedChatConnection.fakeConnect(
|
||||
tokioAsyncContext,
|
||||
NoOpListener(),
|
||||
Network.Environment.STAGING,
|
||||
)
|
||||
val chat = chatAndFakeRemote.first()
|
||||
val fakeRemote = chatAndFakeRemote.second()
|
||||
|
||||
val accountsService = UnauthUsernamesService(chat)
|
||||
val testHash = byteArrayOf(1, 2, 3, 4)
|
||||
val responseFuture = accountsService.lookUpUsernameHash(testHash)
|
||||
|
||||
// Get the incoming request from the fake remote
|
||||
val requestAndId = fakeRemote.getNextIncomingRequest().get()
|
||||
val request = requestAndId.first()
|
||||
val requestId = requestAndId.second()
|
||||
|
||||
assertEquals("GET", request.method)
|
||||
val expectedPath = "/v1/accounts/username_hash/" + Base64.encodeToStringUrl(testHash)
|
||||
assertEquals(expectedPath, request.pathAndQuery)
|
||||
|
||||
// Send fake 404 response (user not found)
|
||||
fakeRemote.sendResponse(
|
||||
requestId,
|
||||
404,
|
||||
"Not Found",
|
||||
arrayOf(),
|
||||
byteArrayOf(),
|
||||
)
|
||||
|
||||
// Verify the result
|
||||
val result = responseFuture.get()
|
||||
val successResult = assertIs<RequestResult.Success<Aci?>>(result)
|
||||
assertEquals(null, successResult.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLookupUsernameHashRetryLater() {
|
||||
val tokioAsyncContext = TokioAsyncContext()
|
||||
val chatAndFakeRemote =
|
||||
UnauthenticatedChatConnection.fakeConnect(
|
||||
tokioAsyncContext,
|
||||
NoOpListener(),
|
||||
Network.Environment.STAGING,
|
||||
)
|
||||
val chat = chatAndFakeRemote.first()
|
||||
val fakeRemote = chatAndFakeRemote.second()
|
||||
|
||||
val accountsService = UnauthUsernamesService(chat)
|
||||
val testHash = byteArrayOf(1, 2, 3, 4)
|
||||
val responseFuture = accountsService.lookUpUsernameHash(testHash)
|
||||
|
||||
// Get the incoming request from the fake remote
|
||||
val requestAndId = fakeRemote.getNextIncomingRequest().get()
|
||||
val request = requestAndId.first()
|
||||
val requestId = requestAndId.second()
|
||||
|
||||
assertEquals("GET", request.method)
|
||||
|
||||
// Send 429 response to trigger RetryLater
|
||||
fakeRemote.sendResponse(
|
||||
requestId,
|
||||
429,
|
||||
"Too Many Requests",
|
||||
arrayOf("retry-after: 120"),
|
||||
byteArrayOf(),
|
||||
)
|
||||
|
||||
// Verify the result
|
||||
val result = responseFuture.get()
|
||||
val retryLater = assertIs<RequestResult.RetryableNetworkError>(result)
|
||||
assertEquals(120L, retryLater.retryAfter?.seconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLookupUsernameHashTransportError() {
|
||||
val tokioAsyncContext = TokioAsyncContext()
|
||||
val chatAndFakeRemote =
|
||||
UnauthenticatedChatConnection.fakeConnect(
|
||||
tokioAsyncContext,
|
||||
NoOpListener(),
|
||||
Network.Environment.STAGING,
|
||||
)
|
||||
val chat = chatAndFakeRemote.first()
|
||||
val fakeRemote = chatAndFakeRemote.second()
|
||||
|
||||
val accountsService = UnauthUsernamesService(chat)
|
||||
val testHash = byteArrayOf(1, 2, 3, 4)
|
||||
val responseFuture = accountsService.lookUpUsernameHash(testHash)
|
||||
|
||||
// Get the incoming request from the fake remote
|
||||
val requestAndId = fakeRemote.getNextIncomingRequest().get()
|
||||
val request = requestAndId.first()
|
||||
val requestId = requestAndId.second()
|
||||
|
||||
assertEquals("GET", request.method)
|
||||
|
||||
// Send server error response
|
||||
fakeRemote.sendResponse(
|
||||
requestId,
|
||||
500,
|
||||
"Internal Server Error",
|
||||
arrayOf(),
|
||||
byteArrayOf(),
|
||||
)
|
||||
|
||||
// Verify the result
|
||||
val result = responseFuture.get()
|
||||
val retryableNetworkError = assertIs<RequestResult.RetryableNetworkError>(result)
|
||||
assertIs<ServerSideErrorException>(retryableNetworkError.networkError)
|
||||
}
|
||||
}
|
||||
@@ -1266,6 +1266,8 @@ internal object Native {
|
||||
@JvmStatic
|
||||
public external fun UnauthenticatedChatConnection_init_listener(chat: ObjectHandle, listener: BridgeChatListener): Unit
|
||||
@JvmStatic
|
||||
public external fun UnauthenticatedChatConnection_look_up_username_hash(asyncRuntime: ObjectHandle, chat: ObjectHandle, hash: ByteArray): CompletableFuture<UUID?>
|
||||
@JvmStatic
|
||||
public external fun UnauthenticatedChatConnection_send(asyncRuntime: ObjectHandle, chat: ObjectHandle, httpRequest: ObjectHandle, timeoutMillis: Int): CompletableFuture<Object>
|
||||
|
||||
@JvmStatic @Throws(Exception::class)
|
||||
|
||||
1
node/Native.d.ts
vendored
1
node/Native.d.ts
vendored
@@ -700,6 +700,7 @@ export function UnauthenticatedChatConnection_connect(asyncRuntime: Wrapper<Toki
|
||||
export function UnauthenticatedChatConnection_disconnect(asyncRuntime: Wrapper<TokioAsyncContext>, chat: Wrapper<UnauthenticatedChatConnection>): CancellablePromise<void>;
|
||||
export function UnauthenticatedChatConnection_info(chat: Wrapper<UnauthenticatedChatConnection>): ChatConnectionInfo;
|
||||
export function UnauthenticatedChatConnection_init_listener(chat: Wrapper<UnauthenticatedChatConnection>, listener: ChatListener): void;
|
||||
export function UnauthenticatedChatConnection_look_up_username_hash(asyncRuntime: Wrapper<TokioAsyncContext>, chat: Wrapper<UnauthenticatedChatConnection>, hash: Uint8Array): CancellablePromise<Uuid | null>;
|
||||
export function UnauthenticatedChatConnection_send(asyncRuntime: Wrapper<TokioAsyncContext>, chat: Wrapper<UnauthenticatedChatConnection>, httpRequest: Wrapper<HttpRequest>, timeoutMillis: number): CancellablePromise<ChatResponse>;
|
||||
export function UnidentifiedSenderMessageContent_Deserialize(data: Uint8Array): UnidentifiedSenderMessageContent;
|
||||
export function UnidentifiedSenderMessageContent_GetContentHint(m: Wrapper<UnidentifiedSenderMessageContent>): number;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SvrB } from './net/SvrB';
|
||||
import { BridgedStringMap, newNativeHandle } from './internal';
|
||||
export * from './net/CDSI';
|
||||
export * from './net/Chat';
|
||||
export * from './net/chat/UnauthUsernamesService';
|
||||
export * from './net/Registration';
|
||||
export * from './net/SvrB';
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ export type ChatRequest = Readonly<{
|
||||
timeoutMillis?: number;
|
||||
}>;
|
||||
|
||||
export type RequestOptions = {
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ConnectionManager = Native.Wrapper<Native.ConnectionManager>;
|
||||
|
||||
export class ChatServerMessageAck {
|
||||
@@ -90,7 +94,7 @@ export type ChatConnection = {
|
||||
*/
|
||||
fetch(
|
||||
chatRequest: ChatRequest,
|
||||
options?: { abortSignal?: AbortSignal }
|
||||
options?: RequestOptions
|
||||
): Promise<Native.ChatResponse>;
|
||||
|
||||
/**
|
||||
@@ -197,8 +201,9 @@ export class UnauthenticatedChatConnection implements ChatConnection {
|
||||
}
|
||||
|
||||
private constructor(
|
||||
private readonly asyncContext: TokioAsyncContext,
|
||||
private readonly chatService: Wrapper<Native.UnauthenticatedChatConnection>,
|
||||
// Not true-private so that they can be accessed by the "Service" interfaces in chat/.
|
||||
readonly _asyncContext: TokioAsyncContext,
|
||||
readonly _chatService: Wrapper<Native.UnauthenticatedChatConnection>,
|
||||
// Unused except to keep the listener alive since the Rust code only holds a
|
||||
// weak reference to the same object.
|
||||
private readonly chatListener: Native.ChatListener,
|
||||
@@ -207,13 +212,13 @@ export class UnauthenticatedChatConnection implements ChatConnection {
|
||||
|
||||
fetch(
|
||||
chatRequest: ChatRequest,
|
||||
options?: { abortSignal?: AbortSignal }
|
||||
options?: RequestOptions
|
||||
): Promise<Native.ChatResponse> {
|
||||
return this.asyncContext.makeCancellable(
|
||||
return this._asyncContext.makeCancellable(
|
||||
options?.abortSignal,
|
||||
Native.UnauthenticatedChatConnection_send(
|
||||
this.asyncContext,
|
||||
this.chatService,
|
||||
this._asyncContext,
|
||||
this._chatService,
|
||||
buildHttpRequest(chatRequest),
|
||||
chatRequest.timeoutMillis ?? DEFAULT_CHAT_REQUEST_TIMEOUT_MILLIS
|
||||
)
|
||||
@@ -222,14 +227,14 @@ export class UnauthenticatedChatConnection implements ChatConnection {
|
||||
|
||||
disconnect(): Promise<void> {
|
||||
return Native.UnauthenticatedChatConnection_disconnect(
|
||||
this.asyncContext,
|
||||
this.chatService
|
||||
this._asyncContext,
|
||||
this._chatService
|
||||
);
|
||||
}
|
||||
|
||||
connectionInfo(): ConnectionInfo {
|
||||
return new ConnectionInfoImpl(
|
||||
Native.UnauthenticatedChatConnection_info(this.chatService)
|
||||
Native.UnauthenticatedChatConnection_info(this._chatService)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -237,7 +242,7 @@ export class UnauthenticatedChatConnection implements ChatConnection {
|
||||
if (this.env == null) {
|
||||
throw new Error('KeyTransparency is not supported on local test server');
|
||||
}
|
||||
return new KT.ClientImpl(this.asyncContext, this.chatService, this.env);
|
||||
return new KT.ClientImpl(this._asyncContext, this._chatService, this.env);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
52
node/ts/net/chat/UnauthUsernamesService.ts
Normal file
52
node/ts/net/chat/UnauthUsernamesService.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import * as Native from '../../../Native';
|
||||
import { Aci } from '../../Address';
|
||||
import { RequestOptions, UnauthenticatedChatConnection } from '../Chat';
|
||||
|
||||
// For documentation
|
||||
import type * as usernames from '../../usernames';
|
||||
|
||||
declare module '../Chat' {
|
||||
interface UnauthenticatedChatConnection extends UnauthUsernamesService {}
|
||||
}
|
||||
|
||||
export interface UnauthUsernamesService {
|
||||
/**
|
||||
* Looks up a username hash on the service, like that computed by {@link usernames.hash}.
|
||||
*
|
||||
* Returns the corresponding account's ACI, or `null` if the username doesn't correspond to an
|
||||
* account.
|
||||
*
|
||||
* Throws / completes with failure only if the request can't be completed, potentially including
|
||||
* if the hash is structurally invalid.
|
||||
*/
|
||||
lookUpUsernameHash(
|
||||
request: {
|
||||
hash: Uint8Array;
|
||||
},
|
||||
options?: RequestOptions
|
||||
): Promise<Aci | null>;
|
||||
}
|
||||
|
||||
UnauthenticatedChatConnection.prototype.lookUpUsernameHash = async function (
|
||||
{
|
||||
hash,
|
||||
}: {
|
||||
hash: Uint8Array;
|
||||
},
|
||||
options?: RequestOptions
|
||||
): Promise<Aci | null> {
|
||||
const response = await this._asyncContext.makeCancellable(
|
||||
options?.abortSignal,
|
||||
Native.UnauthenticatedChatConnection_look_up_username_hash(
|
||||
this._asyncContext,
|
||||
this._chatService,
|
||||
Buffer.from(hash)
|
||||
)
|
||||
);
|
||||
return response ? Aci.fromUuidBytes(response) : null;
|
||||
};
|
||||
45
node/ts/test/chat/ServiceTestUtils.ts
Normal file
45
node/ts/test/chat/ServiceTestUtils.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import * as Native from '../../../Native';
|
||||
import { TokioAsyncContext, UnauthenticatedChatConnection } from '../../net';
|
||||
|
||||
/**
|
||||
* A requirement that `Sub` not contain any properties that aren't in `Super`, or properties with
|
||||
* different types.
|
||||
*
|
||||
* Use {@link PickSubset} to generate a type that a `Super` instance can be assigned to. This will
|
||||
* be equivalent to `Sub` in practice, but TypeScript can only figure that out in concrete contexts.
|
||||
*/
|
||||
type Subset<Super, Sub> = Partial<Super> & {
|
||||
[K in Exclude<keyof Sub, keyof Super>]: never;
|
||||
};
|
||||
|
||||
/** See {@link Subset}. */
|
||||
type PickSubset<Super, Sub extends Subset<Super, Sub>> = Pick<
|
||||
Super,
|
||||
keyof Super & keyof Sub
|
||||
>;
|
||||
|
||||
/**
|
||||
* Makes an unauth connection with a fake remote, but forces the caller to specify which APIs they
|
||||
* need from the connection.
|
||||
*/
|
||||
export function connectUnauth<
|
||||
// The default of `object` forces the caller to provide a type explicitly to access any members of
|
||||
// the result.
|
||||
Api extends Subset<UnauthenticatedChatConnection, Api> = object
|
||||
>(
|
||||
tokio: TokioAsyncContext
|
||||
): [
|
||||
PickSubset<UnauthenticatedChatConnection, Api>,
|
||||
Native.Wrapper<Native.FakeChatRemoteEnd>
|
||||
] {
|
||||
return UnauthenticatedChatConnection.fakeConnect(tokio, {
|
||||
onConnectionInterrupted: () => {},
|
||||
onIncomingMessage: () => {},
|
||||
onQueueEmpty: () => {},
|
||||
});
|
||||
}
|
||||
186
node/ts/test/chat/UnauthUsernamesServiceTest.ts
Normal file
186
node/ts/test/chat/UnauthUsernamesServiceTest.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import { assert, config, expect, use } from 'chai';
|
||||
import * as chaiAsPromised from 'chai-as-promised';
|
||||
import * as Native from '../../../Native';
|
||||
import * as util from '../util';
|
||||
import { TokioAsyncContext, UnauthUsernamesService } from '../../net';
|
||||
import { connectUnauth } from './ServiceTestUtils';
|
||||
import { InternalRequest } from '../NetTest';
|
||||
import { newNativeHandle } from '../../internal';
|
||||
import { ErrorCode, LibSignalErrorBase } from '../../Errors';
|
||||
import { Aci } from '../../Address';
|
||||
|
||||
use(chaiAsPromised);
|
||||
|
||||
util.initLogger();
|
||||
config.truncateThreshold = 0;
|
||||
|
||||
describe('UnauthUsernamesService', () => {
|
||||
it('can look up hashes', async () => {
|
||||
const tokio = new TokioAsyncContext(Native.TokioAsyncContext_new());
|
||||
const [chat, fakeRemote] = connectUnauth<UnauthUsernamesService>(tokio);
|
||||
|
||||
const hash = Uint8Array.of(1, 2, 3, 4);
|
||||
const responseFuture = chat.lookUpUsernameHash({ hash });
|
||||
|
||||
const rawRequest =
|
||||
await Native.TESTING_FakeChatRemoteEnd_ReceiveIncomingRequest(
|
||||
tokio,
|
||||
fakeRemote
|
||||
);
|
||||
assert(rawRequest !== null);
|
||||
const request = new InternalRequest(rawRequest);
|
||||
expect(request.verb).to.eq('GET');
|
||||
expect(request.path).to.eq(
|
||||
`/v1/accounts/username_hash/${Buffer.from(hash).toString('base64url')}`
|
||||
);
|
||||
|
||||
const uuid = '4fcfe887-a600-40cd-9ab7-fd2a695e9981';
|
||||
|
||||
Native.TESTING_FakeChatRemoteEnd_SendServerResponse(
|
||||
fakeRemote,
|
||||
newNativeHandle(
|
||||
Native.TESTING_FakeChatResponse_Create(
|
||||
request.requestId,
|
||||
200,
|
||||
'OK',
|
||||
['content-type: application/json'],
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
uuid,
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const responseFromServer = await responseFuture;
|
||||
assert(responseFromServer !== null);
|
||||
assert(Aci.fromUuid(uuid).isEqual(responseFromServer));
|
||||
});
|
||||
|
||||
it('can look up unknown hashes', async () => {
|
||||
const tokio = new TokioAsyncContext(Native.TokioAsyncContext_new());
|
||||
const [chat, fakeRemote] = connectUnauth<UnauthUsernamesService>(tokio);
|
||||
|
||||
const hash = Uint8Array.of(1, 2, 3, 4);
|
||||
const responseFuture = chat.lookUpUsernameHash({ hash });
|
||||
|
||||
const rawRequest =
|
||||
await Native.TESTING_FakeChatRemoteEnd_ReceiveIncomingRequest(
|
||||
tokio,
|
||||
fakeRemote
|
||||
);
|
||||
assert(rawRequest !== null);
|
||||
const request = new InternalRequest(rawRequest);
|
||||
expect(request.verb).to.eq('GET');
|
||||
expect(request.path).to.eq(
|
||||
`/v1/accounts/username_hash/${Buffer.from(hash).toString('base64url')}`
|
||||
);
|
||||
|
||||
Native.TESTING_FakeChatRemoteEnd_SendServerResponse(
|
||||
fakeRemote,
|
||||
newNativeHandle(
|
||||
Native.TESTING_FakeChatResponse_Create(
|
||||
request.requestId,
|
||||
404,
|
||||
'Not Found',
|
||||
[],
|
||||
Buffer.of()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const responseFromServer = await responseFuture;
|
||||
assert.isNull(responseFromServer);
|
||||
});
|
||||
|
||||
it('can handle challenge errors', async () => {
|
||||
const tokio = new TokioAsyncContext(Native.TokioAsyncContext_new());
|
||||
const [chat, fakeRemote] = connectUnauth<UnauthUsernamesService>(tokio);
|
||||
|
||||
const hash = Uint8Array.of(1, 2, 3, 4);
|
||||
const responseFuture = chat.lookUpUsernameHash({ hash });
|
||||
|
||||
const rawRequest =
|
||||
await Native.TESTING_FakeChatRemoteEnd_ReceiveIncomingRequest(
|
||||
tokio,
|
||||
fakeRemote
|
||||
);
|
||||
assert(rawRequest !== null);
|
||||
const request = new InternalRequest(rawRequest);
|
||||
expect(request.verb).to.eq('GET');
|
||||
expect(request.path).to.eq(
|
||||
`/v1/accounts/username_hash/${Buffer.from(hash).toString('base64url')}`
|
||||
);
|
||||
|
||||
Native.TESTING_FakeChatRemoteEnd_SendServerResponse(
|
||||
fakeRemote,
|
||||
newNativeHandle(
|
||||
Native.TESTING_FakeChatResponse_Create(
|
||||
request.requestId,
|
||||
428,
|
||||
'Precondition Required',
|
||||
['content-type: application/json'],
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
token: 'not-legal-tender',
|
||||
options: ['pushChallenge'],
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await expect(responseFuture)
|
||||
.to.eventually.be.rejectedWith(LibSignalErrorBase)
|
||||
.and.deep.include({
|
||||
code: ErrorCode.RateLimitChallengeError,
|
||||
token: 'not-legal-tender',
|
||||
options: new Set(['pushChallenge']),
|
||||
});
|
||||
});
|
||||
|
||||
it('can handle server errors', async () => {
|
||||
const tokio = new TokioAsyncContext(Native.TokioAsyncContext_new());
|
||||
const [chat, fakeRemote] = connectUnauth<UnauthUsernamesService>(tokio);
|
||||
|
||||
const hash = Uint8Array.of(1, 2, 3, 4);
|
||||
const responseFuture = chat.lookUpUsernameHash({ hash });
|
||||
|
||||
const rawRequest =
|
||||
await Native.TESTING_FakeChatRemoteEnd_ReceiveIncomingRequest(
|
||||
tokio,
|
||||
fakeRemote
|
||||
);
|
||||
assert(rawRequest !== null);
|
||||
const request = new InternalRequest(rawRequest);
|
||||
expect(request.verb).to.eq('GET');
|
||||
expect(request.path).to.eq(
|
||||
`/v1/accounts/username_hash/${Buffer.from(hash).toString('base64url')}`
|
||||
);
|
||||
|
||||
Native.TESTING_FakeChatRemoteEnd_SendServerResponse(
|
||||
fakeRemote,
|
||||
newNativeHandle(
|
||||
Native.TESTING_FakeChatResponse_Create(
|
||||
request.requestId,
|
||||
500,
|
||||
'Internal Server Error',
|
||||
[],
|
||||
Buffer.of()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await expect(responseFuture)
|
||||
.to.eventually.be.rejectedWith(LibSignalErrorBase)
|
||||
.and.deep.include({
|
||||
code: ErrorCode.IoError,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
|
||||
use http::uri::InvalidUri;
|
||||
@@ -13,6 +14,9 @@ use libsignal_bridge_types::net::{ConnectionManager, TokioAsyncContext};
|
||||
use libsignal_bridge_types::support::AsType;
|
||||
use libsignal_net::auth::Auth;
|
||||
use libsignal_net::chat::{self, ConnectError, LanguageList, Response as ChatResponse, SendError};
|
||||
use libsignal_net_chat::api::usernames::UnauthenticatedChatApi;
|
||||
use libsignal_net_chat::api::RequestError;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::support::*;
|
||||
use crate::*;
|
||||
@@ -114,6 +118,17 @@ fn UnauthenticatedChatConnection_info(chat: &UnauthenticatedChatConnection) -> C
|
||||
chat.info()
|
||||
}
|
||||
|
||||
#[bridge_io(TokioAsyncContext)]
|
||||
async fn UnauthenticatedChatConnection_look_up_username_hash(
|
||||
chat: &UnauthenticatedChatConnection,
|
||||
hash: Box<[u8]>,
|
||||
) -> Result<Option<Uuid>, RequestError<Infallible>> {
|
||||
Ok(chat
|
||||
.as_typed(|chat| chat.look_up_username_hash(&hash))
|
||||
.await?
|
||||
.map(|aci| aci.into()))
|
||||
}
|
||||
|
||||
#[bridge_io(TokioAsyncContext)]
|
||||
async fn AuthenticatedChatConnection_preconnect(
|
||||
connection_manager: &ConnectionManager,
|
||||
|
||||
@@ -265,6 +265,16 @@ impl<T: FfiError> IntoFfiError for T {
|
||||
}
|
||||
}
|
||||
|
||||
impl FfiError for std::convert::Infallible {
|
||||
fn describe(&self) -> Cow<'_, str> {
|
||||
match *self {}
|
||||
}
|
||||
|
||||
fn code(&self) -> SignalErrorCode {
|
||||
match *self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: IntoFfiError> From<T> for SignalFfiError {
|
||||
fn from(value: T) -> Self {
|
||||
value.into_ffi_error().into()
|
||||
@@ -678,6 +688,33 @@ impl IntoFfiError for libsignal_net::chat::SendError {
|
||||
}
|
||||
}
|
||||
|
||||
// Special case for api::RequestError<Infallible, DisconnectedError>
|
||||
// (used outside the registration module)
|
||||
impl IntoFfiError
|
||||
for libsignal_net_chat::api::RequestError<
|
||||
std::convert::Infallible,
|
||||
libsignal_net_chat::api::DisconnectedError,
|
||||
>
|
||||
where
|
||||
libsignal_net_chat::api::RequestError<std::convert::Infallible>: std::fmt::Display,
|
||||
{
|
||||
fn into_ffi_error(self) -> impl Into<SignalFfiError> {
|
||||
match self {
|
||||
libsignal_net_chat::api::RequestError::Timeout => SignalFfiError::from(
|
||||
SimpleError::new(SignalErrorCode::RequestTimedOut, self.to_string()),
|
||||
),
|
||||
libsignal_net_chat::api::RequestError::ServerSideError
|
||||
| libsignal_net_chat::api::RequestError::Unexpected { log_safe: _ } => {
|
||||
SimpleError::new(SignalErrorCode::NetworkProtocol, self.to_string()).into()
|
||||
}
|
||||
libsignal_net_chat::api::RequestError::Other(err) => match err {},
|
||||
libsignal_net_chat::api::RequestError::RetryLater(retry_later) => retry_later.into(),
|
||||
libsignal_net_chat::api::RequestError::Challenge(challenge) => challenge.into(),
|
||||
libsignal_net_chat::api::RequestError::Disconnected(d) => d.into_ffi_error().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoFfiError for libsignal_net_chat::api::DisconnectedError {
|
||||
fn into_ffi_error(self) -> impl Into<SignalFfiError> {
|
||||
let code = match self {
|
||||
@@ -732,7 +769,6 @@ impl FfiError for RateLimitChallenge {
|
||||
}
|
||||
|
||||
mod registration {
|
||||
use libsignal_net::infra::errors::LogSafeDisplay;
|
||||
use libsignal_net_chat::api::registration::{
|
||||
CheckSvr2CredentialsError, CreateSessionError, RegisterAccountError,
|
||||
RequestVerificationCodeError, ResumeSessionError, SubmitVerificationError,
|
||||
@@ -760,7 +796,7 @@ mod registration {
|
||||
RequestError::Other(err) => err.into_ffi_error().into(),
|
||||
RequestError::RetryLater(retry_later) => retry_later.into(),
|
||||
RequestError::Challenge(challenge) => challenge.into(),
|
||||
RequestError::Disconnected(d) => match d {},
|
||||
RequestError::Disconnected(d) => d.into_ffi_error().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Copyright 2020-2021 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
use std::convert::Infallible;
|
||||
use std::error::Error;
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::io::Error as IoError;
|
||||
@@ -26,7 +27,7 @@ use libsignal_net::chat::{ConnectError as ChatConnectError, SendError as ChatSen
|
||||
use libsignal_net::infra::errors::RetryLater;
|
||||
use libsignal_net::infra::ws::WebSocketError;
|
||||
use libsignal_net::svrb::Error as SvrbError;
|
||||
use libsignal_net_chat::api::RateLimitChallenge;
|
||||
use libsignal_net_chat::api::{RateLimitChallenge, RequestError as ChatRequestError};
|
||||
use libsignal_protocol::*;
|
||||
use signal_crypto::Error as SignalCryptoError;
|
||||
use usernames::{UsernameError, UsernameLinkError};
|
||||
@@ -846,8 +847,10 @@ impl MessageOnlyExceptionJniError for ChatSendError {
|
||||
ChatSendError::ConnectedElsewhere => {
|
||||
ClassName("org.signal.libsignal.net.ConnectedElsewhereException")
|
||||
}
|
||||
ChatSendError::WebSocket(_)
|
||||
| ChatSendError::IncomingDataInvalid
|
||||
ChatSendError::WebSocket(_) => {
|
||||
ClassName("org.signal.libsignal.net.TransportFailureException")
|
||||
}
|
||||
ChatSendError::IncomingDataInvalid
|
||||
| ChatSendError::RequestHasInvalidHeader
|
||||
| ChatSendError::RequestTimedOut => {
|
||||
ClassName("org.signal.libsignal.net.ChatServiceException")
|
||||
@@ -908,7 +911,9 @@ impl MessageOnlyExceptionJniError for libsignal_net_chat::api::DisconnectedError
|
||||
match self {
|
||||
Self::ConnectedElsewhere => ChatSendError::ConnectedElsewhere.exception_class(),
|
||||
Self::ConnectionInvalidated => ChatSendError::ConnectionInvalidated.exception_class(),
|
||||
Self::Transport { .. } => ClassName("org.signal.libsignal.net.ChatServiceException"),
|
||||
Self::Transport { .. } => {
|
||||
ClassName("org.signal.libsignal.net.TransportFailureException")
|
||||
}
|
||||
Self::Closed => ChatSendError::Disconnected.exception_class(),
|
||||
}
|
||||
}
|
||||
@@ -950,6 +955,12 @@ impl MessageOnlyExceptionJniError for TestingError {
|
||||
}
|
||||
}
|
||||
|
||||
impl JniError for Infallible {
|
||||
fn to_throwable<'a>(&self, _env: &mut JNIEnv<'a>) -> Result<JThrowable<'a>, BridgeLayerError> {
|
||||
match *self {}
|
||||
}
|
||||
}
|
||||
|
||||
impl JniError for RetryLater {
|
||||
fn to_throwable<'a>(&self, env: &mut JNIEnv<'a>) -> Result<JThrowable<'a>, BridgeLayerError> {
|
||||
let Self {
|
||||
@@ -983,6 +994,32 @@ impl JniError for RateLimitChallenge {
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: JniError> JniError for ChatRequestError<E> {
|
||||
fn to_throwable<'a>(&self, env: &mut JNIEnv<'a>) -> Result<JThrowable<'a>, BridgeLayerError> {
|
||||
match self {
|
||||
ChatRequestError::Timeout => make_single_message_throwable(
|
||||
env,
|
||||
"Request timed out",
|
||||
ClassName("org.signal.libsignal.net.TimeoutException"),
|
||||
),
|
||||
ChatRequestError::Disconnected(disconnected) => disconnected.to_throwable(env),
|
||||
ChatRequestError::RetryLater(retry_later) => retry_later.to_throwable(env),
|
||||
ChatRequestError::Challenge(challenge) => challenge.to_throwable(env),
|
||||
ChatRequestError::ServerSideError => make_single_message_throwable(
|
||||
env,
|
||||
"Server-side error",
|
||||
ClassName("org.signal.libsignal.net.ServerSideErrorException"),
|
||||
),
|
||||
ChatRequestError::Unexpected { log_safe } => make_single_message_throwable(
|
||||
env,
|
||||
&format!("Unexpected error: {}", log_safe),
|
||||
ClassName("org.signal.libsignal.net.UnexpectedResponseException"),
|
||||
),
|
||||
ChatRequestError::Other(inner) => inner.to_throwable(env),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translates errors into Java exceptions.
|
||||
///
|
||||
/// Exceptions thrown in callbacks will be rethrown; all other errors will be mapped to an
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::time::Duration;
|
||||
|
||||
use atomic_take::AtomicTake;
|
||||
use bytes::Bytes;
|
||||
use futures_util::future::BoxFuture;
|
||||
use futures_util::FutureExt as _;
|
||||
use http::status::InvalidStatusCode;
|
||||
use http::uri::{InvalidUri, PathAndQuery};
|
||||
@@ -37,6 +38,7 @@ use static_assertions::assert_impl_all;
|
||||
|
||||
use crate::net::remote_config::RemoteConfigKey;
|
||||
use crate::net::ConnectionManager;
|
||||
use crate::support::LimitedLifetimeRef;
|
||||
use crate::*;
|
||||
|
||||
pub type ChatConnectionInfo = ConnectionInfo;
|
||||
@@ -98,6 +100,27 @@ impl UnauthenticatedChatConnection {
|
||||
.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Provides access to the inner ChatConnection using the [`Unauth`] wrapper of
|
||||
/// libsignal-net-chat.
|
||||
///
|
||||
/// This callback signature unfortunately requires boxing; there is not yet Rust syntax to say
|
||||
/// "I return an unknown Future that might capture from its arguments" in closure position
|
||||
/// specifically. It's also extra complicated to promise that the result doesn't have to outlive
|
||||
/// &self; unfortunately there doesn't seem to be a simpler way to express this at this time!
|
||||
/// (e.g. `for<'inner where 'outer: 'inner>`)
|
||||
pub async fn as_typed<'outer, F, R>(&'outer self, callback: F) -> R
|
||||
where
|
||||
F: for<'inner> FnOnce(
|
||||
LimitedLifetimeRef<'outer, 'inner, Unauth<ChatConnection>>,
|
||||
) -> BoxFuture<'inner, R>,
|
||||
{
|
||||
let guard = self.as_ref().read().await;
|
||||
let MaybeChatConnection::Running(inner) = &*guard else {
|
||||
panic!("listener was not set")
|
||||
};
|
||||
callback(LimitedLifetimeRef::from(<&Unauth<_>>::from(inner))).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthenticatedChatConnection {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::fmt;
|
||||
|
||||
#[cfg(feature = "signal-media")]
|
||||
@@ -649,6 +650,56 @@ impl SignalNodeError for libsignal_net::cdsi::LookupError {
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: SignalNodeError> SignalNodeError for libsignal_net_chat::api::RequestError<E> {
|
||||
fn into_throwable<'a, C: Context<'a>>(
|
||||
self,
|
||||
cx: &mut C,
|
||||
module: Handle<'a, JsObject>,
|
||||
operation_name: &str,
|
||||
) -> Handle<'a, JsError> {
|
||||
let io_error_message: Cow<'static, str> = match self {
|
||||
Self::Other(inner) => return inner.into_throwable(cx, module, operation_name),
|
||||
Self::Challenge(challenge) => {
|
||||
return challenge.into_throwable(cx, module, operation_name)
|
||||
}
|
||||
Self::RetryLater(retry_later) => {
|
||||
return retry_later.into_throwable(cx, module, operation_name)
|
||||
}
|
||||
Self::Disconnected(disconnected) => {
|
||||
return disconnected.into_throwable(cx, module, operation_name)
|
||||
}
|
||||
Self::Timeout => {
|
||||
return libsignal_net::chat::SendError::RequestTimedOut.into_throwable(
|
||||
cx,
|
||||
module,
|
||||
operation_name,
|
||||
)
|
||||
}
|
||||
Self::Unexpected { log_safe } => log_safe.into(),
|
||||
Self::ServerSideError => "server-side error".into(),
|
||||
};
|
||||
new_js_error(
|
||||
cx,
|
||||
module,
|
||||
Some(IO_ERROR),
|
||||
&io_error_message,
|
||||
operation_name,
|
||||
no_extra_properties,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl SignalNodeError for std::convert::Infallible {
|
||||
fn into_throwable<'a, C: Context<'a>>(
|
||||
self,
|
||||
_cx: &mut C,
|
||||
_module: Handle<'a, JsObject>,
|
||||
_operation_name: &str,
|
||||
) -> Handle<'a, JsError> {
|
||||
match self {}
|
||||
}
|
||||
}
|
||||
|
||||
mod registration {
|
||||
use libsignal_net_chat::api::registration::{
|
||||
CheckSvr2CredentialsError, CreateSessionError, RegisterAccountError, RegistrationLock,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::future::Future;
|
||||
use std::marker::PhantomData;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
mod as_type;
|
||||
@@ -36,6 +37,32 @@ pub(crate) unsafe fn extend_lifetime<'a, 'b: 'a, T: ?Sized>(some_ref: &'a T) ->
|
||||
std::mem::transmute::<&'a T, &'b T>(some_ref)
|
||||
}
|
||||
|
||||
/// A wrapper around `&'inner T` that promises `'outer: 'inner`.
|
||||
///
|
||||
/// Meant to be used with _higher-ranked type bounds_ `for<'inner>` in the context of some broader
|
||||
/// lifetime `'outer`. Can usually be consumed like a normal reference because of the Deref impl.
|
||||
#[derive(derive_more::Deref)]
|
||||
pub struct LimitedLifetimeRef<'outer, 'inner, T>
|
||||
where
|
||||
'outer: 'inner,
|
||||
{
|
||||
#[deref]
|
||||
inner: &'inner T,
|
||||
parent: PhantomData<&'outer ()>,
|
||||
}
|
||||
|
||||
impl<'outer, 'inner, T> From<&'inner T> for LimitedLifetimeRef<'outer, 'inner, T>
|
||||
where
|
||||
'outer: 'inner,
|
||||
{
|
||||
fn from(inner: &'inner T) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
parent: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An error indicating the caller passed an invalid argument (and they should have known it was
|
||||
/// invalid ahead of time).
|
||||
///
|
||||
|
||||
@@ -68,6 +68,10 @@ extension SignalCPromiseMutPointerRegisterAccountResponse: PromiseStruct {
|
||||
typealias Result = SignalMutPointerRegisterAccountResponse
|
||||
}
|
||||
|
||||
extension SignalCPromiseOptionalUuid: PromiseStruct {
|
||||
typealias Result = SignalOptionalUuid
|
||||
}
|
||||
|
||||
extension SignalCPromiseMutPointerBackupStoreResponse: PromiseStruct {
|
||||
typealias Result = SignalMutPointerBackupStoreResponse
|
||||
}
|
||||
|
||||
142
swift/Sources/LibSignalClient/chat/Services.swift
Normal file
142
swift/Sources/LibSignalClient/chat/Services.swift
Normal file
@@ -0,0 +1,142 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A helper type used to select a particular \*Service protocol (like ``UnauthUsernamesService``)
|
||||
/// from ``UnauthenticatedChatConnection``.
|
||||
///
|
||||
/// Expected usage:
|
||||
///
|
||||
/// ```swift
|
||||
/// func accessService<Service: UnauthServiceSelector>(
|
||||
/// _ chatConnection: UnauthenticatedChatConnection,
|
||||
/// as serviceSelector: Service,
|
||||
/// ) -> Service.Api {
|
||||
/// // Have to force-cast here, unfortunately.
|
||||
/// chatConnection as! Service.Api
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// ```swift
|
||||
/// accessService(chatConnection, as: .usernames)
|
||||
/// ```
|
||||
///
|
||||
/// ## Explanation
|
||||
///
|
||||
/// What we'd *like* to write is something like
|
||||
///
|
||||
/// ```swift
|
||||
/// func accessService<ServiceApi>(
|
||||
/// _ chatConnection: UnauthenticatedChatConnection,
|
||||
/// as service: ServiceApi.Type,
|
||||
/// ) -> any ServiceApi where UnauthenticatedChatConnection: ServiceApi
|
||||
/// ```
|
||||
/// ```swift
|
||||
/// accessService(chatConnection, as: UnauthUsernamesService.self)
|
||||
/// ```
|
||||
///
|
||||
/// However, Swift doesn't support that kind of `where` clause, where the generic parameter
|
||||
/// represents a protocol *and* the set of valid choices is limited based on which protocols a type
|
||||
/// implements.
|
||||
///
|
||||
/// Our next try might look something like this:
|
||||
///
|
||||
/// ```swift
|
||||
/// func accessService<ServiceApi: UnauthServiceBase>(
|
||||
/// _ chatConnection: UnauthenticatedChatConnection,
|
||||
/// as service: ServiceApi.Type,
|
||||
/// ) -> any ServiceApi
|
||||
/// ```
|
||||
/// ```swift
|
||||
/// accessService(chatConnection, as: UnauthUsernamesService.self)
|
||||
/// ```
|
||||
///
|
||||
/// This loses the connection between `ServiceApi` and `UnauthenticatedChatConnection`, so the
|
||||
/// implementation will have to do a force-cast, but that's okay; we still have static checking
|
||||
/// through the presence of the "Base" protocol. But alas, this also fails, because
|
||||
/// `ServiceApi: UnauthServiceBase` doesn't mean "ServiceApi inherits from UnauthServiceBase" in
|
||||
/// this context; it means "the concrete type used for ServiceApi is a valid UnauthServiceBase". And
|
||||
/// the concrete type is `any UnauthUsernamesService`, and unfortunately `any` types don't conform
|
||||
/// to their own protocol in Swift, except for a few special cases. (This is a complicated sentence,
|
||||
/// the details of which don't really matter here; if you aren't already familiar with this, just
|
||||
/// think of it as `any` types not being "concrete enough" to count.)
|
||||
///
|
||||
/// So, failing this, we instead move to some kind of marker type:
|
||||
///
|
||||
/// ```swift
|
||||
/// func accessService<Service: UnauthServiceSelector>(
|
||||
/// _ chatConnection: UnauthenticatedChatConnection,
|
||||
/// as service: Service.Type,
|
||||
/// ) -> Service.Api
|
||||
/// ```
|
||||
/// ```swift
|
||||
/// enum UnauthUsernamesServiceMarker: UnauthServiceSelector {
|
||||
/// typealias Api = any UnauthUsernamesService
|
||||
/// }
|
||||
/// ```
|
||||
/// ```swift
|
||||
/// accessService(chatConnection, as: UnauthUsernamesServiceMarker.self)
|
||||
/// ```
|
||||
///
|
||||
/// This works! It's a bit wordy, in that we have to define a new Marker type for every protocol,
|
||||
/// but now we've separated "is a known unauth service" from "the type that actually represents the
|
||||
/// service", and that gives us more flexibility. Now we just want to see if we can make it more
|
||||
/// convenient to use.
|
||||
///
|
||||
/// We can pick up a technique originally added for SwiftUI: static member lookup in generic
|
||||
/// contexts ([SE-0299][]). This lets us put members on the protocol that can be accessed through
|
||||
/// `.member` shorthand (most commonly seen with enum cases)---basically "factory methods" for the
|
||||
/// concrete implementors of a protocol.
|
||||
///
|
||||
/// ```swift
|
||||
/// extension UnauthServiceSelector where Self == UnauthUsernamesServiceMarker {
|
||||
/// static var usernames: Self.Type { UnauthUsernamesServiceMarker.self }
|
||||
/// }
|
||||
/// ```
|
||||
/// ```swift
|
||||
/// accessService(chatConnection, as: .usernames)
|
||||
/// ```
|
||||
///
|
||||
/// Alas, context-based type inference doesn't kick in in this case. We need to actually produce a
|
||||
/// value with type `Self`, not just a related type. But that's okay; we can make our marker type be
|
||||
/// an empty struct rather than a caseless enum, and then it doesn't cost anything to instantiate
|
||||
/// it.
|
||||
///
|
||||
/// ```swift
|
||||
/// extension UnauthServiceSelector where Self == UnauthUsernamesServiceMarker {
|
||||
/// static var usernames: Self { .init() }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Finally, we observe that having a unique marker type is no longer needed if the shorthand names
|
||||
/// are always used; we can make the marker generic over *any* type, and enforce that it's only used
|
||||
/// with valid services by only defining static members for valid services. This is
|
||||
/// ``UnauthServiceSelectorHelper``, though a client should never need to interact with it directly.
|
||||
///
|
||||
/// [SE-0299]: https://github.com/swiftlang/swift-evolution/blob/main/proposals/0299-extend-generic-static-member-lookup.md
|
||||
public protocol UnauthServiceSelector {
|
||||
associatedtype Api
|
||||
}
|
||||
|
||||
/// A type used to declare new services for APIs taking ``UnauthServiceSelector``.
|
||||
///
|
||||
/// See ``UnauthServiceSelector/usernames`` for an example of how this is used.
|
||||
public struct UnauthServiceSelectorHelper<Api>: UnauthServiceSelector {
|
||||
/// An escape hatch in case libsignal adds a new service protocol but forgets to add a selector
|
||||
/// for it.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```swift
|
||||
/// extension UnauthServiceSelector where Self == UnauthServiceSelectorHelper<any OopsNewService> {
|
||||
/// static var oopsNewService: Self { .workaroundBecauseLibsignalForgotToExposeASelector() }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// (We could just make `init` public as well, but this way it's more obvious that the
|
||||
/// workaround is meant to be temporary.)
|
||||
public static func workaroundBecauseLibsignalForgotToExposeASelector() -> Self { .init() }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SignalFfi
|
||||
|
||||
public protocol UnauthUsernamesService: Sendable {
|
||||
/// Looks up a username hash on the service, like that computed by ``Username``.
|
||||
///
|
||||
/// Returns the corresponding account's ACI, or `nil` if the username doesn't correspond to an
|
||||
/// account.
|
||||
///
|
||||
/// Throws only if the request can't be completed, potentially including if the hash is
|
||||
/// structurally invalid.
|
||||
func lookUpUsernameHash(_ hash: Data) async throws -> Aci?
|
||||
}
|
||||
|
||||
extension UnauthenticatedChatConnection: UnauthUsernamesService {
|
||||
public func lookUpUsernameHash(_ hash: Data) async throws -> Aci? {
|
||||
let rawResponse: SignalOptionalUuid = try await self.tokioAsyncContext
|
||||
.invokeAsyncFunction { promise, tokioAsyncContext in
|
||||
withNativeHandle { chatService in
|
||||
hash.withUnsafeBorrowedBuffer { hash in
|
||||
signal_unauthenticated_chat_connection_look_up_username_hash(
|
||||
promise,
|
||||
tokioAsyncContext.const(),
|
||||
chatService.const(),
|
||||
hash
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
let uuid = try! invokeFnReturningOptionalUuid { out in
|
||||
out?.pointee = rawResponse
|
||||
return nil
|
||||
}
|
||||
return uuid.map { Aci(fromUUID: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
extension UnauthServiceSelector where Self == UnauthServiceSelectorHelper<any UnauthUsernamesService> {
|
||||
public static var usernames: Self { .init() }
|
||||
}
|
||||
@@ -1383,6 +1383,21 @@ typedef struct {
|
||||
SignalCancellationId cancellation_id;
|
||||
} SignalCPromiseMutPointerUnauthenticatedChatConnection;
|
||||
|
||||
/**
|
||||
* A C callback used to report the results of Rust futures.
|
||||
*
|
||||
* cbindgen will produce independent C types like `SignalCPromisei32` and
|
||||
* `SignalCPromiseProtocolAddress`.
|
||||
*
|
||||
* This derives Copy because it behaves like a C type; nevertheless, a promise should still only be
|
||||
* completed once.
|
||||
*/
|
||||
typedef struct {
|
||||
void (*complete)(SignalFfiError *error, const SignalOptionalUuid *result, const void *context);
|
||||
const void *context;
|
||||
SignalCancellationId cancellation_id;
|
||||
} SignalCPromiseOptionalUuid;
|
||||
|
||||
typedef struct {
|
||||
SignalValidatingMac *raw;
|
||||
} SignalMutPointerValidatingMac;
|
||||
@@ -2493,6 +2508,8 @@ SignalFfiError *signal_unauthenticated_chat_connection_info(SignalMutPointerChat
|
||||
|
||||
SignalFfiError *signal_unauthenticated_chat_connection_init_listener(SignalConstPointerUnauthenticatedChatConnection chat, SignalConstPointerFfiChatListenerStruct listener);
|
||||
|
||||
SignalFfiError *signal_unauthenticated_chat_connection_look_up_username_hash(SignalCPromiseOptionalUuid *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerUnauthenticatedChatConnection chat, SignalBorrowedBuffer hash);
|
||||
|
||||
SignalFfiError *signal_unauthenticated_chat_connection_send(SignalCPromiseFfiChatResponse *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerUnauthenticatedChatConnection chat, SignalConstPointerHttpRequest http_request, uint32_t timeout_millis);
|
||||
|
||||
SignalFfiError *signal_unidentified_sender_message_content_deserialize(SignalMutPointerUnidentifiedSenderMessageContent *out, SignalBorrowedBuffer data);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import LibSignalClient
|
||||
|
||||
// These testing endpoints aren't generated in device builds, to save on code size.
|
||||
#if !os(iOS) || targetEnvironment(simulator)
|
||||
|
||||
class UnauthChatServiceTestBase<Service: Sendable>: TestCaseBase {
|
||||
typealias SelectorCheck = UnauthServiceSelectorHelper<Service>
|
||||
class var selector: SelectorCheck! { nil }
|
||||
|
||||
override class func setUp() {
|
||||
super.setUp()
|
||||
precondition(self.selector != nil, "must override the selector property")
|
||||
}
|
||||
|
||||
// XCTestCase does unusual things with its initializers for test case discovery,
|
||||
// so we can't override init(). Instead, we'll put our shared state in a helper type.
|
||||
// We specifically hide this to force tests to use the limited set of APIs in ``api``.
|
||||
private let state: UnauthChatServiceTestState = .init()
|
||||
|
||||
internal var api: Service {
|
||||
// swiftlint:disable:next force_cast
|
||||
state.connection as! Service
|
||||
}
|
||||
internal var fakeRemote: FakeChatRemote {
|
||||
state.fakeRemote
|
||||
}
|
||||
}
|
||||
|
||||
// Defined outside UnauthChatServiceTestBase because it's not generic.
|
||||
private struct UnauthChatServiceTestState {
|
||||
let tokioAsyncContext = TokioAsyncContext()
|
||||
let connection: UnauthenticatedChatConnection
|
||||
let fakeRemote: FakeChatRemote
|
||||
|
||||
init() {
|
||||
class NoOpListener: ConnectionEventsListener {
|
||||
func connectionWasInterrupted(_: UnauthenticatedChatConnection, error: Error?) {}
|
||||
}
|
||||
|
||||
(connection, fakeRemote) = UnauthenticatedChatConnection.fakeConnect(
|
||||
tokioAsyncContext: tokioAsyncContext,
|
||||
listener: NoOpListener()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// Copyright 2025 Signal Messenger, LLC.
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
@testable import LibSignalClient
|
||||
|
||||
// These testing endpoints aren't generated in device builds, to save on code size.
|
||||
#if !os(iOS) || targetEnvironment(simulator)
|
||||
|
||||
class UnauthUsernamesServiceTests: UnauthChatServiceTestBase<any UnauthUsernamesService> {
|
||||
override class var selector: SelectorCheck { .usernames }
|
||||
|
||||
func testUsernameLookup() async throws {
|
||||
let api = self.api
|
||||
async let responseFuture = api.lookUpUsernameHash(Data([1, 2, 3, 4]))
|
||||
|
||||
let (request, id) = try await fakeRemote.getNextIncomingRequest()
|
||||
XCTAssertEqual(request.method, "GET")
|
||||
|
||||
let uuid = UUID(uuidString: "4FCFE887-A600-40CD-9AB7-FD2A695E9981")!
|
||||
|
||||
try fakeRemote.sendResponse(
|
||||
requestId: id,
|
||||
ChatResponse(
|
||||
status: 200,
|
||||
headers: ["content-type": "application/json"],
|
||||
body: Data(
|
||||
"""
|
||||
{
|
||||
"uuid": "\(uuid)"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
let responseFromServer = try await responseFuture
|
||||
XCTAssertEqual(responseFromServer, Aci(fromUUID: uuid))
|
||||
}
|
||||
|
||||
func testUsernameLookupMissing() async throws {
|
||||
let api = self.api
|
||||
async let responseFuture = api.lookUpUsernameHash(Data([1, 2, 3, 4]))
|
||||
|
||||
let (request, id) = try await fakeRemote.getNextIncomingRequest()
|
||||
XCTAssertEqual(request.method, "GET")
|
||||
|
||||
try fakeRemote.sendResponse(
|
||||
requestId: id,
|
||||
ChatResponse(status: 404)
|
||||
)
|
||||
|
||||
let responseFromServer = try await responseFuture
|
||||
XCTAssertNil(responseFromServer)
|
||||
}
|
||||
|
||||
func testChallengeError() async throws {
|
||||
let api = self.api
|
||||
async let responseFuture = api.lookUpUsernameHash(Data([1, 2, 3, 4]))
|
||||
|
||||
let (request, id) = try await fakeRemote.getNextIncomingRequest()
|
||||
XCTAssertEqual(request.method, "GET")
|
||||
|
||||
try fakeRemote.sendResponse(
|
||||
requestId: id,
|
||||
ChatResponse(
|
||||
status: 428,
|
||||
headers: ["content-type": "application/json"],
|
||||
body: Data(
|
||||
"""
|
||||
{
|
||||
"token": "not-legal-tender",
|
||||
"options": ["pushChallenge"]
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await responseFuture
|
||||
XCTFail("should have failed")
|
||||
} catch SignalError.rateLimitChallengeError(let token, let options, _) {
|
||||
XCTAssertEqual(token, "not-legal-tender")
|
||||
XCTAssertEqual(options, [.pushChallenge])
|
||||
}
|
||||
}
|
||||
|
||||
func testServerSideError() async throws {
|
||||
let api = self.api
|
||||
async let responseFuture = api.lookUpUsernameHash(Data([1, 2, 3, 4]))
|
||||
|
||||
let (request, id) = try await fakeRemote.getNextIncomingRequest()
|
||||
XCTAssertEqual(request.method, "GET")
|
||||
|
||||
try fakeRemote.sendResponse(
|
||||
requestId: id,
|
||||
ChatResponse(status: 500)
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await responseFuture
|
||||
XCTFail("should have failed")
|
||||
} catch SignalError.networkProtocolError(_) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user