Compare commits

..

17 Commits

Author SHA1 Message Date
moiseev-signal
7c8cb0c5fc
keytrans: Add reset account data field function
Some checks failed
[CI] Check Versions / Check version number consistency (push) Has been cancelled
2026-05-16 12:07:54 -07:00
moiseev-signal
c41e917d4e
keytrans: Log data versions before and after monitor 2026-05-15 16:33:05 -07:00
marc-signal
4d43a6270a
Use linkme, not macro expansion, for Native.ts generation 2026-05-15 14:16:26 -04:00
andrew-signal
7543c3d35b
net: partially implement reflector proxy type 2026-05-14 16:53:47 -04:00
Jordan Rose
bd383e51f0 testing: Add a timeout for FakeChatRemote receive operations
These shouldn't come up in normal use; they only stop tests from
waiting indefinitely when the test author has made a mistake.
2026-05-14 11:30:51 -07:00
Jordan Rose
fb9407cbcb chat: Expose a raw_grpc endpoint 2026-05-14 10:57:50 -07:00
Jordan Rose
9903175a51 Expose gRPC+JSON testing to Swift 2026-05-14 09:49:36 -07:00
Jordan Rose
af55da7bbd bridge: Expose FakeChatRemote gRPC testing endpoints to apps 2026-05-14 09:49:36 -07:00
Jordan Rose
875f93019b Add JSON conversion and gRPC framing APIs for testing 2026-05-14 09:49:36 -07:00
Jordan Rose
73bcc78e12 net: Add an H2/gRPC connection to FakeChatRemote 2026-05-14 09:49:36 -07:00
Jordan Rose
d0b3edc0f1 Add grpcOverrides to FakeChatRemote
Unused until we have H2 support as well (coming soon!)
2026-05-14 09:49:36 -07:00
Jordan Rose
ec67c55017 taplo: Don't try to vertically align comments in toml files 2026-05-14 09:49:36 -07:00
Jordan Rose
ee47959258 Add grpc.BackupsAnonymousGetUploadForm remote config 2026-05-14 09:47:19 -07:00
moiseev-signal
7b399f26d8
Update mac setup script 2026-05-13 15:10:27 -07:00
Jordan Rose
f70d1faaa0 grpc: Drop idea of "JSON codec", just provide binproto<->JSON by name
Besides being simpler, it also makes it easier to avoid additional
code size costs in non-testing builds (by simply not referencing these
functions).
2026-05-13 14:54:07 -07:00
Jordan Rose
b8f2aaf5dc java: Silence unchecked cast in UploadForm.fromNative
...and make it private.
2026-05-12 15:59:50 -07:00
Max Moiseev
2af375875b Reset for version v0.94.1 2026-05-08 16:26:57 -07:00
90 changed files with 4374 additions and 2599 deletions

View File

@ -589,7 +589,7 @@ jobs:
node-version-file: '.nvmrc'
- name: Verify that the Node bindings are up to date
run: rust/bridge/node/bin/gen_ts_decl.py --verify
run: cargo run -p libsignal-node-native_ts -- --verify
if: startsWith(matrix.os, 'ubuntu-')
- run: npm ci

View File

@ -154,7 +154,7 @@ jobs:
- run: sudo apt-get install -U protobuf-compiler
- name: Verify that the Node bindings are up to date
run: rust/bridge/node/bin/gen_ts_decl.py --verify
run: cargo run -p libsignal-node-native_ts -- --verify
publish:
name: Publish

View File

@ -1,13 +1,14 @@
include = ["Cargo.toml", "rust/**/*.toml"]
[formatting]
reorder_keys = false
align_comments = false
indent_string = ' '
reorder_keys = false
[[rule]]
include = ["**/Cargo.toml"]
keys = ["dependencies", "workspace.dependencies", "dev-dependencies", "build-dependencies"]
[rule.formatting]
reorder_keys = true
inline_table_expand = false
reorder_keys = true

46
Cargo.lock generated
View File

@ -2494,6 +2494,7 @@ dependencies = [
"libsignal-message-backup",
"libsignal-net",
"libsignal-net-chat",
"libsignal-net-grpc",
"libsignal-protocol",
"linkme",
"neon",
@ -2608,14 +2609,14 @@ dependencies = [
[[package]]
name = "libsignal-debug"
version = "0.94.0"
version = "0.94.1"
dependencies = [
"cfg-if",
]
[[package]]
name = "libsignal-ffi"
version = "0.94.0"
version = "0.94.1"
dependencies = [
"cpufeatures 0.2.17",
"hex",
@ -2636,7 +2637,7 @@ dependencies = [
[[package]]
name = "libsignal-jni"
version = "0.94.0"
version = "0.94.1"
dependencies = [
"libsignal-debug",
"libsignal-jni-impl",
@ -2644,7 +2645,7 @@ dependencies = [
[[package]]
name = "libsignal-jni-impl"
version = "0.94.0"
version = "0.94.1"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
@ -2661,7 +2662,7 @@ dependencies = [
[[package]]
name = "libsignal-jni-testing"
version = "0.94.0"
version = "0.94.1"
dependencies = [
"jni 0.21.1",
"libsignal-bridge-testing",
@ -2795,6 +2796,7 @@ dependencies = [
"hkdf",
"hmac",
"http",
"http-body-util",
"hyper",
"hyper-util",
"itertools 0.14.0",
@ -2916,7 +2918,6 @@ dependencies = [
"serde",
"serde_json",
"strum",
"tokio",
"tonic",
"tonic-prost",
"tonic-prost-build",
@ -2985,12 +2986,13 @@ dependencies = [
[[package]]
name = "libsignal-node"
version = "0.94.0"
version = "0.94.1"
dependencies = [
"futures",
"libsignal-bridge",
"libsignal-bridge-macros",
"libsignal-bridge-testing",
"libsignal-bridge-types",
"libsignal-protocol",
"linkme",
"log",
@ -3004,6 +3006,19 @@ dependencies = [
"uuid",
]
[[package]]
name = "libsignal-node-native_ts"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"libsignal-bridge",
"libsignal-bridge-testing",
"libsignal-bridge-types",
"libsignal-node",
"minijinja",
]
[[package]]
name = "libsignal-protocol"
version = "0.1.0"
@ -3205,6 +3220,12 @@ dependencies = [
"libc",
]
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "mime"
version = "0.3.17"
@ -3290,6 +3311,17 @@ dependencies = [
"tracing",
]
[[package]]
name = "minijinja"
version = "2.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "805bfd7352166bae857ee569628b52bcd85a1cecf7810861ebceb1686b72b75d"
dependencies = [
"indexmap 2.13.0",
"memo-map",
"serde",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"

View File

@ -22,6 +22,7 @@ members = [
"rust/bridge/jni/impl",
"rust/bridge/jni/testing",
"rust/bridge/node",
"rust/bridge/node/native_ts",
]
default-members = [
"rust/crypto",
@ -38,7 +39,7 @@ default-members = [
resolver = "2" # so that our dev-dependency features don't leak into products
[workspace.package]
version = "0.94.0"
version = "0.94.1"
authors = ["Signal Messenger LLC"]
license = "AGPL-3.0-only"
rust-version = "1.88"
@ -67,6 +68,7 @@ libsignal-message-backup = { path = "rust/message-backup" }
libsignal-net = { path = "rust/net" }
libsignal-net-chat = { path = "rust/net/chat" }
libsignal-net-grpc = { path = "rust/net/grpc" }
libsignal-node = { path = "rust/bridge/node" }
libsignal-protocol = { path = "rust/protocol" }
libsignal-svrb = { path = "rust/svrb" }
poksho = { path = "rust/poksho" }
@ -160,6 +162,7 @@ mediasan-common = "0.5.3"
minidump = { version = "0.22.1", default-features = false }
minidump-processor = { version = "0.22.1", default-features = false }
minidump-unwind = { version = "0.22.1", default-features = false }
minijinja = "2.19.0"
mp4san = "0.5.3"
neon = { version = "1.1.0", default-features = false }
nonzero_ext = "0.3.0"

View File

@ -5,7 +5,7 @@
Pod::Spec.new do |s|
s.name = 'LibSignalClient'
s.version = '0.94.0'
s.version = '0.94.1'
s.summary = 'A Swift wrapper library for communicating with the Signal messaging service.'
s.homepage = 'https://github.com/signalapp/libsignal'

View File

@ -214,7 +214,7 @@ $ npm run test
When testing changes locally, you can use `npm run build` to do an incremental rebuild of the Rust library. Alternately, `npm run build-with-debug-level-logs` will rebuild without filtering out debug- and verbose-level logs.
When exposing new APIs to Node, you will need to run `rust/bridge/node/bin/gen_ts_decl.py` in
When exposing new APIs to Node, you will need to run `just generate-node` in
addition to rebuilding.
[nvm]: https://github.com/nvm-sh/nvm

View File

@ -1,5 +1,5 @@
v0.94.0
v0.94.1
- keytrans: Detect version changes sooner
- Expose PQ session archiving ratio API
- Remove unused SignalMessage.verifyMac function
- Add `grpc.BackupsAnonymousGetUploadForm` remote config, for both backup and backup media uploads. This is separate from the `grpc.AttachmentsGetUploadForm` config added previously, which applies to regular attachment uploads.
- keytrans: Add reset account data field functionality for all platforms.

View File

@ -4120,6 +4120,31 @@ DEALINGS IN THE SOFTWARE.
```
## httpdate 1.0.3
```
Copyright (c) 2016 Pyfisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## rustc_version 0.4.1
```

View File

@ -4120,6 +4120,31 @@ DEALINGS IN THE SOFTWARE.
```
## httpdate 1.0.3
```
Copyright (c) 2016 Pyfisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## rustc_version 0.4.1
```

View File

@ -4241,6 +4241,31 @@ DEALINGS IN THE SOFTWARE.
```
## httpdate 1.0.3
```
Copyright (c) 2016 Pyfisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## rustc_version 0.4.1
```

View File

@ -4320,6 +4320,35 @@ DEALINGS IN THE SOFTWARE.
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>Copyright (c) 2016 Pyfisch
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the &quot;Software&quot;), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
</string>
<key>License</key>
<string>MIT License</string>
<key>Title</key>
<string>httpdate 1.0.3</string>
<key>Type</key>
<string>PSGroupSpecifier</string>
</dict>
<dict>
<key>FooterText</key>
<string>Copyright (c) 2016 The Rust Project Developers

View File

@ -47,8 +47,8 @@
<h2>Overview of licenses:</h2>
<ul class="licenses-overview">
<li><a href="#MIT">MIT License</a> (349)</li>
<li><a href="#AGPL-3.0-only">GNU Affero General Public License v3.0 only</a> (36)</li>
<li><a href="#Apache-2.0">Apache License 2.0</a> (25)</li>
<li><a href="#AGPL-3.0-only">GNU Affero General Public License v3.0 only</a> (37)</li>
<li><a href="#Apache-2.0">Apache License 2.0</a> (27)</li>
<li><a href="#BSD-3-Clause">BSD 3-Clause &quot;New&quot; or &quot;Revised&quot; License</a> (9)</li>
<li><a href="#ISC">ISC License</a> (4)</li>
<li><a href="#MPL-2.0">Mozilla Public License 2.0</a> (2)</li>
@ -741,6 +741,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see
<li><a href="https://crates.io/crates/libsignal-node">libsignal-node</a></li>
<li><a href="https://crates.io/crates/signal-neon-futures">signal-neon-futures</a></li>
<li><a href="https://crates.io/crates/signal-neon-futures-tests">signal-neon-futures-tests</a></li>
<li><a href="https://crates.io/crates/libsignal-node-native_ts">libsignal-node-native_ts</a></li>
<li><a href="https://crates.io/crates/libsignal-bridge">libsignal-bridge</a></li>
<li><a href="https://crates.io/crates/libsignal-bridge-macros">libsignal-bridge-macros</a></li>
<li><a href="https://crates.io/crates/libsignal-bridge-testing">libsignal-bridge-testing</a></li>
@ -2937,6 +2938,8 @@ END OF TERMS AND CONDITIONS
<h4>Used by:</h4>
<ul class="license-used-by">
<li><a href="https://github.com/getsentry/rust-debugid">debugid 0.8.0</a></li>
<li><a href="https://github.com/mitsuhiko/memo-map">memo-map 0.3.3</a></li>
<li><a href="https://github.com/mitsuhiko/minijinja">minijinja 2.19.0</a></li>
<li><a href="https://github.com/tokio-rs/prost">prost-build 0.14.1</a></li>
<li><a href="https://github.com/tokio-rs/prost">prost-derive 0.14.1</a></li>
<li><a href="https://github.com/tokio-rs/prost">prost-types 0.14.1</a></li>

View File

@ -21,15 +21,14 @@ brew "rustup"
brew "shellcheck"
brew "swiftlint"
brew "taplo"
brew "terraform"
brew "yamllint"
cask "google-cloud-sdk"
cask "gcloud-cli"
EOF
# Install Python tools using pipx.
# This keeps their dependencies isolated from other things on your system,
# but is still global state for each tool. We may some day want to switch this to a venv instead.
"$(brew --prefix pipx)/bin/pipx" install mypy
"$(brew --prefix pipx)/bin/pipx" install "mypy<2.0"
"$(brew --prefix pipx)/bin/pipx" install flake8
"$(brew --prefix pipx)/bin/pipx" inject flake8 \
flake8-comprehensions \

View File

@ -23,7 +23,7 @@ repositories {
}
allprojects {
version = "0.94.0"
version = "0.94.1"
group = "org.signal"
tasks.withType(JavaCompile) {

View File

@ -63,7 +63,7 @@ public class AuthenticatedChatConnection extends ChatConnection {
*/
public static Pair<AuthenticatedChatConnection, FakeChatRemote> fakeConnect(
final TokioAsyncContext tokioAsyncContext, ChatConnectionListener listener) {
return fakeConnect(tokioAsyncContext, listener, new String[0]);
return fakeConnect(tokioAsyncContext, listener, new String[0], new String[0]);
}
/**
@ -72,14 +72,20 @@ public class AuthenticatedChatConnection extends ChatConnection {
* <p>The returned {@link FakeChatRemote} can be used to send messages to the connection.
*/
public static Pair<AuthenticatedChatConnection, FakeChatRemote> fakeConnect(
final TokioAsyncContext tokioAsyncContext, ChatConnectionListener listener, String[] alerts) {
final TokioAsyncContext tokioAsyncContext,
ChatConnectionListener listener,
String[] grpcOverrides,
String[] alerts) {
return tokioAsyncContext.guardedMap(
asyncContextHandle -> {
SetChatLaterListenerBridge bridgeListener = new SetChatLaterListenerBridge();
long fakeChatConnection =
NativeTesting.TESTING_FakeChatConnection_Create(
asyncContextHandle, bridgeListener, String.join("\n", alerts));
asyncContextHandle,
bridgeListener,
String.join("\n", grpcOverrides),
String.join("\n", alerts));
AuthenticatedChatConnection chat =
new AuthenticatedChatConnection(
tokioAsyncContext,

View File

@ -4,7 +4,11 @@
//
package org.signal.libsignal.net
public abstract class KeyTransparency {
import org.signal.libsignal.internal.Native
import org.signal.libsignal.keytrans.Store
import org.signal.libsignal.protocol.ServiceId
public object KeyTransparency {
/**
* Mode of the key transparency operation.
*
@ -32,4 +36,44 @@ public abstract class KeyTransparency {
public fun isSelf(): Boolean = this is Self
}
/**
* A tag identifying an optional field of the account data.
*
* (Must be in sync with the Rust counterpart)
*/
public enum class AccountDataField(
public val value: Int,
) {
E164(0),
USERNAME_HASH(1),
}
/**
* Resets a particular field in the data associated with given ACI.
*
* Must only be called for the "self" account when either E.164 or username change is performed.
*
* Upon successful completion the data associated with the account will be updated in the store, if it
* was present to begin with, noop if it was not.
*
* @param aci An ACI of "self" account.
* @param field Account data field to be reset (E.164 or username hash)
* @param store local persistent storage for key transparency-related data.
* @throws IllegalArgumentException if the stored data cannot be decoded correctly, which means data corruption.
*/
@JvmStatic
public fun resetField(
aci: ServiceId.Aci,
field: AccountDataField,
store: Store,
) {
store.getAccountData(aci).map {
val updated = Native.KeyTransparency_ResetDataField(it, field.value)
if (updated.isEmpty()) {
throw IllegalArgumentException("failed to decode account data")
}
store.setAccountData(aci, updated)
}
}
}

View File

@ -77,13 +77,26 @@ public class UnauthenticatedChatConnection extends ChatConnection {
final TokioAsyncContext tokioAsyncContext,
ChatConnectionListener listener,
Network.Environment ktEnvironment) {
return fakeConnect(tokioAsyncContext, listener, new String[0], ktEnvironment);
}
/**
* Test-only method to create a {@code UnauthenticatedChatConnection} connected to a fake remote.
*
* <p>The returned {@link FakeChatRemote} can be used to send messages to the connection.
*/
public static Pair<UnauthenticatedChatConnection, FakeChatRemote> fakeConnect(
final TokioAsyncContext tokioAsyncContext,
ChatConnectionListener listener,
String[] grpcOverrides,
Network.Environment ktEnvironment) {
return tokioAsyncContext.guardedMap(
asyncContextHandle -> {
SetChatLaterListenerBridge bridgeListener = new SetChatLaterListenerBridge();
long fakeChatConnection =
NativeTesting.TESTING_FakeChatConnection_Create(
asyncContextHandle, bridgeListener, "");
asyncContextHandle, bridgeListener, String.join("\n", grpcOverrides), "");
UnauthenticatedChatConnection chat =
new UnauthenticatedChatConnection(
tokioAsyncContext,

View File

@ -18,7 +18,8 @@ public data class UploadForm(
public companion object {
@JvmStatic
@CalledFromNative
public fun fromNative(
@Suppress("UNCHECKED_CAST")
private fun fromNative(
cdn: Int,
key: String,
headers: Array<*>,

View File

@ -169,7 +169,6 @@ class AuthMessagesServiceTest {
AuthenticatedChatConnection.fakeConnect(
tokioAsyncContext,
NoOpListener(),
emptyArray(),
)
listOf(
@ -199,7 +198,6 @@ class AuthMessagesServiceTest {
AuthenticatedChatConnection.fakeConnect(
tokioAsyncContext,
NoOpListener(),
emptyArray(),
)
val (responseFuture, requestId) = sendTestMessage(chat, syncMessage = false, fakeRemote)
@ -224,7 +222,6 @@ class AuthMessagesServiceTest {
AuthenticatedChatConnection.fakeConnect(
tokioAsyncContext,
NoOpListener(),
emptyArray(),
)
val (responseFuture, requestId) = sendTestMessage(chat, syncMessage = false, fakeRemote)
@ -268,7 +265,6 @@ class AuthMessagesServiceTest {
AuthenticatedChatConnection.fakeConnect(
tokioAsyncContext,
NoOpListener(),
emptyArray(),
)
val (responseFuture, requestId) = sendTestMessage(chat, syncMessage = false, fakeRemote)
@ -310,7 +306,6 @@ class AuthMessagesServiceTest {
AuthenticatedChatConnection.fakeConnect(
tokioAsyncContext,
NoOpListener(),
emptyArray(),
)
val (responseFuture, requestId) = sendTestMessage(chat, syncMessage = false, fakeRemote)

View File

@ -263,7 +263,7 @@ public class ChatServiceTest {
final Listener listener = new Listener();
final Pair<AuthenticatedChatConnection, FakeChatRemote> chatAndFakeRemote =
AuthenticatedChatConnection.fakeConnect(
tokioAsyncContext, listener, new String[] {"UPPERcase", "lowercase"});
tokioAsyncContext, listener, new String[0], new String[] {"UPPERcase", "lowercase"});
final AuthenticatedChatConnection chat = chatAndFakeRemote.getFirst();
final FakeChatRemote fakeRemote = chatAndFakeRemote.getSecond();

View File

@ -11,6 +11,7 @@ import java.util.UUID;
import org.junit.Test;
import org.signal.libsignal.internal.NativeTesting;
import org.signal.libsignal.keytrans.KeyTransparencyException;
import org.signal.libsignal.keytrans.TestStore;
import org.signal.libsignal.keytrans.VerificationFailedException;
import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.InvalidKeyException;
@ -60,4 +61,29 @@ public class KeyTransparencyTest {
public void canBridgeChatSendError() {
assertThrows(TimeoutException.class, NativeTesting::TESTING_KeyTransChatSendError);
}
@Test
public void resetFieldThrowsOnCorruptData() {
var store = new TestStore();
store.setAccountData(TEST_ACI, new byte[] {1, 2, 3});
assertThrows(
IllegalArgumentException.class,
() -> KeyTransparency.resetField(TEST_ACI, KeyTransparency.AccountDataField.E164, store));
}
@Test
public void resetFieldIsNoopWhenDataIsMissing() {
var store = new TestStore();
KeyTransparency.resetField(TEST_ACI, KeyTransparency.AccountDataField.E164, store);
assert (store.storage.get(TEST_ACI).isEmpty());
}
@Test
public void resetFieldUpdatesStoreOnSuccess() {
var store = new TestStore();
store.setAccountData(TEST_ACI, NativeTesting.TESTING_KeyTransStoredAccountData());
assertEquals(1, store.storage.get(TEST_ACI).size());
KeyTransparency.resetField(TEST_ACI, KeyTransparency.AccountDataField.E164, store);
assertEquals(2, store.storage.get(TEST_ACI).size());
}
}

View File

@ -902,5 +902,9 @@
{
"version": "v0.93.2",
"size": 7515184
},
{
"version": "v0.94.0",
"size": 7522920
}
]

View File

@ -217,6 +217,8 @@ internal object Native {
@JvmStatic
public external fun AuthenticatedChatConnection_send_message_java(asyncRuntime: ObjectHandle, chat: ObjectHandle, destination: ByteArray, timestamp: Long, deviceIds: IntArray, registrationIds: IntArray, contents: Array<Object>, onlineOnly: Boolean, isUrgent: Boolean): CompletableFuture<Void?>
@JvmStatic
public external fun AuthenticatedChatConnection_send_raw_grpc(asyncRuntime: ObjectHandle, chat: ObjectHandle, service: String, method: String, payload: ByteArray): CompletableFuture<ByteArray>
@JvmStatic
public external fun AuthenticatedChatConnection_send_sync_message_java(asyncRuntime: ObjectHandle, chat: ObjectHandle, timestamp: Long, deviceIds: IntArray, registrationIds: IntArray, contents: Array<Object>, isUrgent: Boolean): CompletableFuture<Void?>
@JvmStatic @Throws(Exception::class)
@ -627,6 +629,8 @@ internal object Native {
@JvmStatic
public external fun KeyTransparency_E164SearchKey(e164: String): ByteArray
@JvmStatic
public external fun KeyTransparency_ResetDataField(accountData: ByteArray, field: Int): ByteArray
@JvmStatic
public external fun KeyTransparency_UsernameHashSearchKey(hash: ByteArray): ByteArray
@JvmStatic
@ -1307,6 +1311,8 @@ internal object Native {
public external fun UnauthenticatedChatConnection_send_message(asyncRuntime: ObjectHandle, chat: ObjectHandle, destination: ByteArray, timestamp: Long, deviceIds: IntArray, registrationIds: IntArray, contents: Array<ByteArray>, authKind: Int, authBuffer: ByteArray?, onlineOnly: Boolean, isUrgent: Boolean): CompletableFuture<Void?>
@JvmStatic
public external fun UnauthenticatedChatConnection_send_multi_recipient_message(asyncRuntime: ObjectHandle, chat: ObjectHandle, payload: ByteArray, timestamp: Long, auth: ByteArray?, onlineOnly: Boolean, isUrgent: Boolean): CompletableFuture<Array<Object>>
@JvmStatic
public external fun UnauthenticatedChatConnection_send_raw_grpc(asyncRuntime: ObjectHandle, chat: ObjectHandle, service: String, method: String, payload: ByteArray): CompletableFuture<ByteArray>
@JvmStatic @Throws(Exception::class)
public external fun UnidentifiedSenderMessageContent_Deserialize(data: ByteArray): ObjectHandle

View File

@ -116,7 +116,7 @@ public object NativeTesting {
@JvmStatic
public external fun TESTING_ErrorOnReturnSync(needsCleanup: Object): Object
@JvmStatic
public external fun TESTING_FakeChatConnection_Create(tokio: ObjectHandle, listener: BridgeChatListener, alertsJoinedByNewlines: String): ObjectHandle
public external fun TESTING_FakeChatConnection_Create(tokio: ObjectHandle, listener: BridgeChatListener, grpcOverridesJoinedByNewlines: String, alertsJoinedByNewlines: String): ObjectHandle
@JvmStatic
public external fun TESTING_FakeChatConnection_CreateProvisioning(tokio: ObjectHandle, listener: BridgeProvisioningListener): ObjectHandle
@JvmStatic
@ -128,14 +128,26 @@ public object NativeTesting {
@JvmStatic
public external fun TESTING_FakeChatConnection_TakeUnauthenticatedChat(chat: ObjectHandle): ObjectHandle
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_BinprotoToJson(name: String, input: ByteArray): String
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_GrpcFrameForMessageLength(len: Int): ByteArray
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_InjectConnectionInterrupted(chat: ObjectHandle): Unit
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_JsonToBinproto(name: String, input: String): ByteArray
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_NextGrpcMessage(input: ByteArray, offset: Int): Pair<Int, Int>
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_ReceiveIncomingGrpcRequest(asyncRuntime: ObjectHandle, chat: ObjectHandle): CompletableFuture<Pair<ObjectHandle, Long>?>
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_ReceiveIncomingRequest(asyncRuntime: ObjectHandle, chat: ObjectHandle): CompletableFuture<Pair<ObjectHandle, Long>?>
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_SendRawServerRequest(chat: ObjectHandle, bytes: ByteArray): Unit
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_SendRawServerResponse(chat: ObjectHandle, bytes: ByteArray): Unit
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_SendServerGrpcResponse(asyncRuntime: ObjectHandle, chat: ObjectHandle, response: ObjectHandle): CompletableFuture<Void?>
@JvmStatic
public external fun TESTING_FakeChatRemoteEnd_SendServerResponse(chat: ObjectHandle, response: ObjectHandle): Unit
@JvmStatic
public external fun TESTING_FakeChatResponse_Create(id: Long, status: Int, message: String, headers: Array<Object>, body: ByteArray?): ObjectHandle
@ -174,6 +186,8 @@ public object NativeTesting {
@JvmStatic @Throws(Exception::class)
public external fun TESTING_KeyTransNonFatalVerificationFailure(): Unit
@JvmStatic
public external fun TESTING_KeyTransStoredAccountData(): ByteArray
@JvmStatic
public external fun TESTING_NonSuspendingBackgroundThreadRuntime_Destroy(handle: ObjectHandle): Unit
@JvmStatic
public external fun TESTING_NonSuspendingBackgroundThreadRuntime_New(): ObjectHandle

View File

@ -14,7 +14,7 @@ generate-ffi:
swift/build_ffi.sh --generate-ffi
generate-node:
rust/bridge/node/bin/gen_ts_decl.py
cargo run -p libsignal-node-native_ts
alias generate-java := generate-jni
alias generate-swift := generate-ffi

View File

@ -1,12 +1,12 @@
{
"name": "@signalapp/libsignal-client",
"version": "0.94.0",
"version": "0.94.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@signalapp/libsignal-client",
"version": "0.94.0",
"version": "0.94.1",
"hasInstallScript": true,
"license": "AGPL-3.0-only",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@signalapp/libsignal-client",
"version": "0.94.0",
"version": "0.94.1",
"repository": "github:signalapp/libsignal",
"license": "AGPL-3.0-only",
"type": "module",
@ -24,8 +24,8 @@
"build": "python3 build_node_bridge.py",
"build-with-debug-level-logs": "python3 build_node_bridge.py --debug-level-logs",
"clean": "rimraf dist build prebuilds",
"format": "p() { prettier ${@:- --write} '**/*.{css,js,json,md,scss,ts,tsx}' ../rust/bridge/node/bin/Native.ts.in; }; p",
"format-check": "p() { prettier ${@:- --check} '**/*.{css,js,json,md,scss,ts,tsx}' ../rust/bridge/node/bin/Native.ts.in; }; p",
"format": "p() { prettier ${@:- --write} '**/*.{css,js,json,md,scss,ts,tsx}'; }; p",
"format-check": "p() { prettier ${@:- --check} '**/*.{css,js,json,md,scss,ts,tsx}'; }; p",
"install": "echo Use \\`npm run build\\` to build the native library if needed",
"lint": "eslint .",
"prepack": "cp ../acknowledgments/acknowledgments-desktop.md dist/acknowledgments.md",

File diff suppressed because it is too large Load Diff

View File

@ -203,12 +203,15 @@ export class UnauthenticatedChatConnection implements ChatConnection {
*
* @param asyncContext the async runtime to use
* @param listener the listener to send events to
* @param grpcOverrides gRPC method names to prefer for typed APIs that have both WS and gRPC
* implementations.
* @returns an {@link UnauthenticatedChatConnection} and handle for the remote
* end of the fake connection.
*/
public static fakeConnect(
asyncContext: TokioAsyncContext,
listener: ChatServiceListener
listener: ChatServiceListener,
grpcOverrides?: ReadonlyArray<string>
): [UnauthenticatedChatConnection, FakeChatRemote] {
const nativeChatListener = makeNativeChatListener(asyncContext, listener);
@ -216,6 +219,7 @@ export class UnauthenticatedChatConnection implements ChatConnection {
Native.TESTING_FakeChatConnection_Create(
asyncContext,
new WeakListenerWrapper(nativeChatListener),
grpcOverrides?.join('\n') ?? '',
''
)
);
@ -319,13 +323,16 @@ export class AuthenticatedChatConnection implements ChatConnection {
*
* @param asyncContext the async runtime to use
* @param listener the listener to send events to
* @param grpcOverrides gRPC method names to prefer for typed APIs that have both WS and gRPC
* implementations.
* @param alerts alerts to send immediately upon connect
* @returns an {@link AuthenticatedChatConnection} and handle for the remote
* end of the fake connection.
* @returns an {@link AuthenticatedChatConnection} and handle for the remote end of the fake
* connection.
*/
public static fakeConnect(
asyncContext: TokioAsyncContext,
listener: ChatServiceListener,
grpcOverrides?: ReadonlyArray<string>,
alerts?: ReadonlyArray<string>
): [AuthenticatedChatConnection, FakeChatRemote] {
const nativeChatListener = makeNativeChatListener(asyncContext, listener);
@ -334,6 +341,7 @@ export class AuthenticatedChatConnection implements ChatConnection {
Native.TESTING_FakeChatConnection_Create(
asyncContext,
new WeakListenerWrapper(nativeChatListener),
grpcOverrides?.join('\n') ?? '',
alerts?.join('\n') ?? ''
)
);

View File

@ -168,6 +168,46 @@ export interface Client {
) => Promise<void>;
}
/**
* A tag identifying an optional field of the account data.
*
* (Must be in sync with the Rust counterpart)
*/
export enum AccountDataField {
E164 = 0,
UsernameHash = 1,
}
/**
* Resets a particular field in the data associated with given ACI.
*
* Must only be called for the "self" account when either E.164 or username
* change is performed.
*
* Upon successful completion the data associated with the account will be
* updated in the store, if it was present to begin with, noop if it was not.
*
* @param aci - An ACI of "self" account.
* @param field - Account data field to be reset (E.164 or username hash).
* @param store - local persistent storage for key transparency-related data.
* @throws {TypeError} if the stored data cannot be decoded correctly, which means data corruption.
*/
export async function resetField(
aci: Aci,
field: AccountDataField,
store: Store
): Promise<void> {
const accountData = await store.getAccountData(aci);
if (accountData === null) {
return;
}
const updated = Native.KeyTransparency_ResetDataField(accountData, field);
if (updated.length === 0) {
throw new TypeError('failed to decode account data');
}
await store.setAccountData(aci, updated);
}
export class ClientImpl implements Client {
constructor(
private readonly asyncContext: TokioAsyncContext,

View File

@ -90,6 +90,33 @@ describe('KeyTransparency bridging', () => {
});
});
describe('KeyTransparency.resetField', () => {
it('throws on corrupt data', async () => {
const store = new InMemoryKtStore();
await store.setAccountData(testAci, new Uint8Array([1, 2, 3]));
await expect(
KT.resetField(testAci, KT.AccountDataField.E164, store)
).to.be.rejectedWith(TypeError);
});
it('is a noop when data is missing', async () => {
const store = new InMemoryKtStore();
await KT.resetField(testAci, KT.AccountDataField.E164, store);
expect(store.storage.get(testAci)).to.equal(undefined);
});
it('updates store on success', async () => {
const store = new InMemoryKtStore();
await store.setAccountData(
testAci,
Native.TESTING_KeyTransStoredAccountData()
);
expect(store.storage.get(testAci)).to.have.lengthOf(1);
await KT.resetField(testAci, KT.AccountDataField.E164, store);
expect(store.storage.get(testAci)).to.have.lengthOf(2);
});
});
describe('KeyTransparency network errors', () => {
it('can bridge network errors', async () => {
async function run(statusCode: number, headers: string[] = []) {

View File

@ -529,6 +529,7 @@ describe('chat service api', () => {
const [_chat, fakeRemote] = AuthenticatedChatConnection.fakeConnect(
tokio,
listener,
[],
['UPPERcase', 'lowercase']
);

View File

@ -80,8 +80,8 @@ class definition. For Swift, the `cbindgen` output is saved directly to a
C-style `.h` file that the Swift toolchain can consume.
For TypeScript, the [`libsignal-node`] crate is expanded and processed by
[`gen_ts_decl.py`](./node/bin/gen_ts_decl.py) and the output is interpolated into
[`Native.ts.in`](./node/bin/Native.ts.in). The output, however, only
[`libsignal-node-native_ts`](./node/native_ts/src/main.rs) and the output is interpolated into
[`Native.ts.in`](./node/native_ts/src/Native.ts.in). The output, however, only
declares the function signatures; to make them accessible to the JavaScript
runtime, additional machinery is used. This takes the form of `#[linkme]`
annotations on to the generated entry points; the [`linkme`] crate is used to

View File

@ -43,6 +43,7 @@ exclude = [
"CPromisebool",
"CPromiseFfiCdsiLookupResponse",
"CPromiseMutPointerRegistrationService",
"CPromiseOwnedBufferOfc_uchar",
"FfiCdsiLookupResponse",
"FfiCdsiLookupResponseEntry",
"FfiChatListenerStruct",

View File

@ -15,17 +15,23 @@ workspace = true
[lib]
name = "signal_node"
crate-type = ["cdylib"]
crate-type = ["cdylib", "lib"]
[features]
# Here for bridge_fn uniformity
node = []
default = ["node"]
metadata = [
"libsignal-bridge/metadata",
"libsignal-bridge-testing/metadata",
"libsignal-bridge-types/metadata",
]
[dependencies]
libsignal-bridge = { workspace = true, features = ["node", "signal-media"] }
libsignal-bridge-macros = { workspace = true }
libsignal-bridge-testing = { workspace = true, features = ["node", "signal-media"] }
libsignal-bridge-types = { workspace = true, features = ["node"] }
libsignal-protocol = { workspace = true }
futures = { workspace = true }

View File

@ -1,358 +0,0 @@
#!/usr/bin/env python3
#
# Copyright (C) 2020-2021 Signal Messenger, LLC.
# SPDX-License-Identifier: AGPL-3.0-only
#
import collections
import difflib
import itertools
import os
import re
import subprocess
import sys
from typing import Iterable, Iterator, Tuple
Args = collections.namedtuple('Args', ['verify'])
def parse_args() -> Args:
def print_usage_and_exit() -> None:
print('usage: %s [--verify]' % sys.argv[0], file=sys.stderr)
sys.exit(2)
# If the command-line handling below gets any more complicated, this should be switched to argparse.
mode = None
if len(sys.argv) > 2:
print_usage_and_exit()
elif len(sys.argv) == 2:
mode = sys.argv[1]
if mode != '--verify':
print_usage_and_exit()
return Args(verify=mode is not None)
def split_rust_args(args: str) -> Iterator[Tuple[str, str]]:
"""
Split Rust `arg: Type` pairs separated by commas.
Account for templates, tuples, and slices.
"""
while ': ' in args:
(name, args) = args.split(': ', maxsplit=1)
if name.startswith('mut '):
name = name[4:]
open_pairs = 0
for (i, c) in enumerate(args):
if c == ',' and open_pairs == 0:
ty = args[:i]
args = args[i + 1:]
yield (name.strip(), ty.strip())
break
elif c in ['<', '(', '[']:
open_pairs += 1
elif c in ['>', ')', ']']:
open_pairs -= 1
else:
yield (name.strip(), args.strip())
def translate_to_ts(typ: str) -> str:
typ = typ.replace(' ', '')
type_map = {
'()': 'void',
'&[u8]': 'Uint8Array<ArrayBuffer>',
'i32': 'number',
'u8': 'number',
'u16': 'number',
'u32': 'number',
'u64': 'bigint',
'f64': 'number',
'bool': 'boolean',
'String': 'string',
'&str': 'string',
'Vec<u8>': 'Uint8Array<ArrayBuffer>',
'Box<[u8]>': 'Uint8Array<ArrayBuffer>',
'Box<[u32]>': 'Uint32Array<ArrayBuffer>',
'bytes::Bytes': 'Uint8Array<ArrayBuffer>',
'ServiceId': 'Uint8Array<ArrayBuffer>',
'Aci': 'Uint8Array<ArrayBuffer>',
'Pni': 'Uint8Array<ArrayBuffer>',
'E164': 'string',
"ServiceIdSequence<'_>": 'Uint8Array<ArrayBuffer>',
'PathAndQuery': 'string',
'LanguageList': 'string[]',
'GroupSendFullToken': 'Uint8Array<ArrayBuffer>',
'DeviceSpecifier': 'number',
'&BackupKey': 'Uint8Array<ArrayBuffer>',
'MultiRecipientSendAuthorization': 'Uint8Array<ArrayBuffer> | null',
'DisconnectCause': 'Error | null',
'::zkgroup::backups::BackupAuthCredential': 'Uint8Array<ArrayBuffer>',
'::zkgroup::generic_server_params::GenericServerPublicParams': 'Uint8Array<ArrayBuffer>',
}
if typ in type_map:
return type_map[typ]
if typ.startswith('[u8;') or typ.startswith('&[u8;'):
return 'Uint8Array<ArrayBuffer>'
if typ.startswith('&mutdyn'):
return typ[7:]
if typ.startswith('&dyn'):
return typ[4:]
if typ.startswith('&mut'):
return 'Wrapper<' + typ[4:] + '>'
if typ.startswith('&[&'):
assert typ.endswith(']')
return 'Wrapper<' + translate_to_ts(typ[3:-1]) + '>[]'
if typ.startswith('Box<['):
assert typ.endswith(']>')
return translate_to_ts(typ[5:-2]) + '[]'
if typ.startswith('Box<dyn'):
assert typ.endswith('>')
return translate_to_ts(typ[7:-1])
if typ.startswith('Vec<'):
assert typ.endswith('>')
return translate_to_ts(typ[4:-1]) + '[]'
if typ.startswith('&['):
assert typ.endswith(']')
return 'Wrapper<' + translate_to_ts(typ[2:-1]) + '>[]'
if typ.startswith('&'):
return 'Wrapper<' + typ[1:] + '>'
if typ.startswith('('):
assert typ.endswith(')'), typ
inner = typ[1:-1].split(',')
if len(inner) == 1:
return translate_to_ts(inner[0])
return '[' + ', '.join(translate_to_ts(x) for x in inner) + ']'
if typ.startswith('Option<'):
assert typ.endswith('>')
return translate_to_ts(typ[7:-1]) + ' | null'
if typ.startswith('Result<'):
assert typ.endswith('>')
type_args = typ[7:-1]
(success_type, *failure_type) = type_args.rsplit(',', 1)
if failure_type and ')' in failure_type[0]:
success_type = type_args
return translate_to_ts(success_type)
if typ.startswith('std::result::Result<'):
assert typ.endswith('>')
type_args = typ[20:-1]
(success_type, *failure_type) = type_args.rsplit(',', 1)
if failure_type and ')' in failure_type[0]:
success_type = type_args
return translate_to_ts(success_type)
if typ.startswith('Promise<'):
assert typ.endswith('>')
return 'Promise<' + translate_to_ts(typ[8:-1]) + '>'
if typ.startswith('CancellablePromise<'):
assert typ.endswith('>')
return 'CancellablePromise<' + translate_to_ts(typ[19:-1]) + '>'
if typ.startswith('AsType<'):
assert typ.endswith('>')
assert ',' in typ
return translate_to_ts(typ.split(',')[1][:-1])
if typ.startswith('Ignored<'):
assert typ.endswith('>')
return 'null'
return typ
DIAGNOSTICS_TO_IGNORE = [
r'warning: \d+ warnings? emitted',
r'warning: unused import',
r'warning: field.+ never read',
r'warning: variant.+ never constructed',
r'warning: method.+ never used',
r'warning: associated function.+ never used',
]
SHOULD_IGNORE_PATTERN = re.compile('(' + ')|('.join(DIAGNOSTICS_TO_IGNORE) + ')')
def camelcase(arg: str) -> str:
return re.sub(
# Preserve double-underscores and leading underscores,
# but remove single underscores and capitalize the following letter.
r'([^_])_([^_])',
lambda match: match.group(1) + match.group(2).upper(),
arg)
def rewrite_function_as_property(ts_function: str) -> str:
return ts_function.replace('(', ': (', 1).replace('):', ') =>')
def rewrite_fn(function_match: re.Match[str]) -> str:
(prefix, fn_args, ret_type) = function_match.groups()
ts_ret_type = translate_to_ts(ret_type)
ts_args = []
for (arg_name, arg_type) in split_rust_args(fn_args):
ts_arg_type = translate_to_ts(arg_type)
ts_args.append('%s: %s' % (camelcase(arg_name.strip()), ts_arg_type))
return '%s(%s): %s;' % (prefix, ', '.join(ts_args), ts_ret_type)
def rewrite_trait(decl: str, function_sig: re.Pattern[str]) -> Iterator[str]:
for line in decl.split('\\n'):
if function_match := function_sig.match(line.rstrip(';')):
yield ' ' + rewrite_function_as_property(rewrite_fn(function_match))
continue
# Fix backslash-escaped double-quotes.
yield bytes(line, 'utf-8').decode('unicode_escape')
def collect_decls(crate_dir: str, features: Iterable[str] = ()) -> Iterator[str]:
args = [
'cargo',
'rustc',
'-q',
'--profile=check',
'--features', ','.join(features),
'--message-format=short',
'--color=never',
'--',
'-Zunpretty=expanded']
rustc = subprocess.Popen(args, cwd=crate_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(raw_stdout, raw_stderr) = rustc.communicate()
stdout = str(raw_stdout.decode('utf8'))
stderr = str(raw_stderr.decode('utf8'))
had_error = False
for l in stderr.split('\n'):
if l == '':
continue
if SHOULD_IGNORE_PATTERN.search(l):
continue
print(l, file=sys.stderr)
had_error = True
if had_error:
print('Exiting with error')
sys.exit(1)
comment_decl = re.compile(r'\s*///\s*ts: `(.+)`')
# Note that the doc attribute is sometimes wrapped onto two lines.
attr_decl = re.compile(r'\s*(?:#\[doc\s*=\s*)?"ts: `(.+)`"\]')
# Make sure /not/ to match arguments with nested parentheses,
# which won't survive textual splitting below.
function_sig = re.compile(r'(.+)\(([^()]*)\): (.+)')
for line in stdout.split('\n'):
match = comment_decl.match(line) or attr_decl.match(line)
if match is None:
continue
(decl,) = match.groups()
if decl.startswith('export /*trait*/ type '):
yield '\n'.join(rewrite_trait(decl, function_sig))
continue
if function_match := function_sig.match(decl):
yield rewrite_fn(function_match)
continue
# Fix backslash-escaped double-quotes.
yield bytes(decl, 'utf-8').decode('unicode_escape')
def expand_template(template_file: str, decls: Iterable[str]) -> str:
decls = list(decls)
with open(template_file, 'r') as f:
contents = f.read()
# Rewrite from function syntax to property syntax to take advantage of
# https://www.typescriptlang.org/tsconfig/#strictFunctionTypes.
contents = contents.replace('NATIVE_FNS;', '\n '.join(
rewrite_function_as_property(x.removeprefix('export function '))
for x in decls if x.startswith('export function ')
))
contents = contents.replace('NATIVE_FN_NAMES', ''.join(
'\n ' + x.removeprefix('export function ').split('(')[0] + ','
for x in decls if x.startswith('export function ')
) + '\n')
contents = contents.replace('NATIVE_TYPES;', '\n'.join(
'export ' + x.removeprefix('export ') for x in decls if not x.startswith('export function ')
))
return contents
def verify_contents(expected_output_file: str, expected_contents: str) -> None:
with open(expected_output_file) as fh:
current_contents = fh.readlines()
diff = difflib.unified_diff(current_contents, expected_contents.splitlines(keepends=True))
first_line = next(diff, None)
if first_line:
sys.stdout.write(first_line)
sys.stdout.writelines(diff)
sys.exit(f'error: {expected_output_file} not up to date; re-run {sys.argv[0]}!')
Crate = collections.namedtuple('Crate', ['path', 'features'], defaults=[()])
def convert_to_typescript(rust_crates: Iterable[Crate], ts_in_path: str, ts_out_path: str, verify: bool) -> None:
decls = itertools.chain.from_iterable(collect_decls(crate.path, crate.features) for crate in rust_crates)
contents = expand_template(ts_in_path, decls)
if not os.access(ts_out_path, os.F_OK):
raise Exception(f"Didn't find {ts_out_path} where it was expected")
if not verify:
with open(ts_out_path, 'w') as fh:
fh.write(contents)
else:
verify_contents(ts_out_path, contents)
def main() -> None:
args = parse_args()
our_abs_dir = os.path.dirname(os.path.realpath(__file__))
output_file_name = 'Native.ts'
convert_to_typescript(
rust_crates=[
Crate(path=os.path.join(our_abs_dir, '..')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared'), features=('node', 'signal-media')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared', 'types'), features=('node', 'signal-media')),
Crate(path=os.path.join(our_abs_dir, '..', '..', 'shared', 'testing'), features=('node', 'signal-media')),
],
ts_in_path=os.path.join(our_abs_dir, output_file_name + '.in'),
ts_out_path=os.path.join(our_abs_dir, '..', '..', '..', '..', 'node', 'ts', output_file_name),
verify=args.verify,
)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,19 @@
[package]
name = "libsignal-node-native_ts"
version = "0.1.0"
authors.workspace = true
license.workspace = true
edition = "2024"
[lints]
workspace = true
[dependencies]
libsignal-bridge = { workspace = true, features = ["node", "metadata"] }
libsignal-bridge-testing = { workspace = true, features = ["node", "metadata"] }
libsignal-bridge-types = { workspace = true, features = ["node", "metadata"] }
libsignal-node = { workspace = true, features = ["node", "metadata"] }
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
minijinja = { workspace = true, features = ["preserve_order"] }

View File

@ -1,5 +1,5 @@
//
// Copyright 2020 Signal Messenger, LLC.
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
@ -134,18 +134,60 @@ export type Serialized<T> = Uint8Array<ArrayBuffer>;
type ConnectChatBridge = Wrapper<ConnectionManager>;
type TestingFutureCancellationGuard = Wrapper<TestingFutureCancellationCounter>;
// Keep in sync with rust/bridge/node/src/logging.rs
export const enum LogLevel { Error = 1, Warn, Info, Debug, Trace }
/* eslint-disable comma-dangle */
export const NetRemoteConfigKeys = [
{%- for key in remote_config_keys -%}
'{{ key }}',
{%- endfor -%}
] as const;
import load from 'node-gyp-build';
type NativeFunctions = {
registerErrors: (errorsModule: Record<string, unknown>) => void;
NATIVE_FNS;
initLogger: (maxLevel: LogLevel, callback: (level: LogLevel, target: string, file: string | null, line: number | null, message: string) => void) => void;
{%- for (name, f) in ctx.native_functions|items %}
{{ name }}: (
{%- for (name, ty) in f.arguments -%}
{{ name }}: {{ ty }},
{%- endfor -%}
) => {{ f.return_type }};
{%- endfor %}
};
const { registerErrors, NATIVE_FN_NAMES } = load(
{% macro native_fn_names(ctx) %}
{%- for (name, f) in ctx.native_functions|items %}
{{ name }},
{%- endfor %}
{% endmacro %}
const { registerErrors,
initLogger,
{{ native_fn_names(ctx) }}
} = load(
`${import.meta.dirname}/../`
) as NativeFunctions;
export { registerErrors, NATIVE_FN_NAMES };
export { registerErrors,
initLogger,
{{ native_fn_names(ctx)
}} };
/* eslint-disable comma-dangle */
NATIVE_TYPES;
{% for (name, fns) in ctx.bridge_traits|items %}
export /*trait*/ type {{ name }} = {
{%- for fn in fns %}
{{ fn.name }}: (
{%- for (arg, ty) in fn.body.arguments -%}
{{ arg }}: {{ ty }},
{%- endfor -%}
) => {{ fn.body.return_type }};
{%- endfor %}
};
{% endfor %}
{% for ty in ctx.opaque_types -%}
export interface {{ ty }} { readonly __type: unique symbol; }
{% endfor -%}

View File

@ -0,0 +1,51 @@
//
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
// To make sure the linkmes work
extern crate libsignal_bridge;
extern crate libsignal_bridge_testing;
extern crate signal_node;
use clap::Parser;
use libsignal_bridge_types::metadata::node::TsMetadataContext;
use libsignal_bridge_types::net::remote_config::RemoteConfigKey;
use minijinja::context;
#[derive(Parser)]
/// Regenerate Native.ts
///
/// This command assumes it's being invoked from the workspace root.
struct Cli {
/// Don't actually overwrite Native.ts, just make sure it's up-to-date.
#[clap(long)]
verify: bool,
}
fn main() -> anyhow::Result<()> {
let args = Cli::parse();
let mut env = minijinja::Environment::new();
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
env.add_template("Native.ts.in", include_str!("Native.ts.in"))?;
let tmpl = env.get_template("Native.ts.in")?;
let mut ctx = TsMetadataContext::default();
for item in libsignal_bridge_types::metadata::node::NODE_ITEMS.iter() {
// We don't check item.module_path because, unlike other client languages, we emit both
// testing and non-testing native into the same typescript file.
(item.apply)(&mut ctx);
}
let code = tmpl.render(context! {
ctx => ctx,
remote_config_keys => RemoteConfigKey::KEYS,
})?;
let dst = "./node/ts/Native.ts";
if args.verify {
anyhow::ensure!(
std::fs::read_to_string(dst)? == code,
"Native.ts is not up-to-date"
);
} else {
std::fs::write(dst, code.as_bytes())?;
}
Ok(())
}

View File

@ -6,7 +6,7 @@
#![warn(clippy::unwrap_used)]
use futures::executor;
use libsignal_bridge::node::{AssumedImmutableBuffer, ResultTypeInfo, SignalNodeError};
use libsignal_bridge::node::ResultTypeInfo;
use libsignal_bridge::node_register;
use libsignal_bridge::support::*;
use libsignal_bridge_macros::bridge_fn;
@ -16,7 +16,6 @@ use minidump_processor::ProcessorOptions;
use minidump_unwind::Symbolizer;
use minidump_unwind::symbols::string_symbol_supplier;
use neon::prelude::*;
use neon::types::buffer::TypedArray;
use rand::TryRngCore;
use uuid::Uuid;
@ -31,11 +30,6 @@ use libsignal_bridge_testing::*;
fn main(mut cx: ModuleContext) -> NeonResult<()> {
libsignal_bridge::node::register(&mut cx)?;
cx.export_function("initLogger", logging::init_logger)?;
cx.export_function(
"SealedSenderMultiRecipientMessage_Parse",
sealed_sender_multi_recipient_message_parse,
)?;
cx.export_function("MinidumpToJSONString", minidump_to_json_string)?;
let remote_config_keys = libsignal_bridge::net::RemoteConfigKey::KEYS.convert_into(&mut cx)?;
cx.export_value("NetRemoteConfigKeys", remote_config_keys)?;
Ok(())
@ -67,91 +61,96 @@ impl<'a> From<ArrayBuilder<'a>> for Handle<'a, JsArray> {
}
}
/// ts: `export function SealedSenderMultiRecipientMessage_Parse(buffer: Uint8Array<ArrayBuffer>): SealedSenderMultiRecipientMessage`
fn sealed_sender_multi_recipient_message_parse(mut cx: FunctionContext) -> JsResult<JsObject> {
let buffer_arg = cx.argument::<JsUint8Array>(0)?;
let buffer = AssumedImmutableBuffer::new(&cx, buffer_arg);
let messages = match SealedSenderV2SentMessage::parse(&buffer) {
Ok(messages) => messages,
Err(e) => {
let throwable =
e.into_throwable(&mut cx, "sealed_sender_multi_recipient_parse_sent_message");
cx.throw(throwable)?
}
};
struct SealedSenderMultiRecipientMessage<'a>(SealedSenderV2SentMessage<'a>);
impl<'a, 'b> ResultTypeInfo<'a> for SealedSenderMultiRecipientMessage<'b> {
type ResultType = JsObject;
let recipient_map = cx.empty_object();
let mut excluded_recipients_array = ArrayBuilder::new(&mut cx);
fn convert_into(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::ResultType> {
let messages = self.0;
let recipient_map = cx.empty_object();
let mut excluded_recipients_array = ArrayBuilder::new(cx);
for (service_id, recipient) in &messages.recipients {
let service_id_string = cx.string(service_id.service_id_string());
if recipient.devices.is_empty() {
excluded_recipients_array
.push(service_id_string, &mut cx)
.expect("failed to construct output array");
continue;
for (service_id, recipient) in &messages.recipients {
let service_id_string = cx.string(service_id.service_id_string());
if recipient.devices.is_empty() {
excluded_recipients_array
.push(service_id_string, cx)
.expect("failed to construct output array");
continue;
}
let mut device_ids = ArrayBuilder::new(cx);
let mut registration_ids = ArrayBuilder::new(cx);
for &(device_id, registration_id) in &recipient.devices {
device_ids
.push(cx.number(u32::from(device_id)), cx)
.expect("failed to construct output array");
registration_ids
.push(cx.number(registration_id), cx)
.expect("failed to construct output array");
}
let range = messages.range_for_recipient_key_material(recipient);
let range_start = cx.number(u32::try_from(range.start).expect("message too large"));
let range_len = cx.number(u32::try_from(range.len()).expect("message too large"));
let recipient_object = cx.empty_object();
recipient_object
.set(cx, "deviceIds", device_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(cx, "registrationIds", registration_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(cx, "rangeOffset", range_start)
.expect("failed to construct recipient object");
recipient_object
.set(cx, "rangeLen", range_len)
.expect("failed to construct recipient object");
recipient_map
.set(cx, service_id_string, recipient_object)
.expect("failed to record recipient object");
}
let mut device_ids = ArrayBuilder::new(&mut cx);
let mut registration_ids = ArrayBuilder::new(&mut cx);
let offset_of_shared_bytes =
cx.number(u32::try_from(messages.offset_of_shared_bytes()).expect("message too large"));
for &(device_id, registration_id) in &recipient.devices {
device_ids
.push(cx.number(u32::from(device_id)), &mut cx)
.expect("failed to construct output array");
registration_ids
.push(cx.number(registration_id), &mut cx)
.expect("failed to construct output array");
}
let result = cx.empty_object();
result
.set(cx, "recipientMap", recipient_map)
.expect("failed to construct result object");
result
.set(cx, "excludedRecipients", excluded_recipients_array.into())
.expect("failed to construct result object");
result
.set(cx, "offsetOfSharedData", offset_of_shared_bytes)
.expect("failed to construct result object");
let range = messages.range_for_recipient_key_material(recipient);
let range_start = cx.number(u32::try_from(range.start).expect("message too large"));
let range_len = cx.number(u32::try_from(range.len()).expect("message too large"));
let recipient_object = cx.empty_object();
recipient_object
.set(&mut cx, "deviceIds", device_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(&mut cx, "registrationIds", registration_ids.into())
.expect("failed to construct recipient object");
recipient_object
.set(&mut cx, "rangeOffset", range_start)
.expect("failed to construct recipient object");
recipient_object
.set(&mut cx, "rangeLen", range_len)
.expect("failed to construct recipient object");
recipient_map
.set(&mut cx, service_id_string, recipient_object)
.expect("failed to record recipient object");
Ok(result)
}
let offset_of_shared_bytes =
cx.number(u32::try_from(messages.offset_of_shared_bytes()).expect("message too large"));
let result = cx.empty_object();
result
.set(&mut cx, "recipientMap", recipient_map)
.expect("failed to construct result object");
result
.set(
&mut cx,
"excludedRecipients",
excluded_recipients_array.into(),
)
.expect("failed to construct result object");
result
.set(&mut cx, "offsetOfSharedData", offset_of_shared_bytes)
.expect("failed to construct result object");
Ok(result)
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(
_: &mut libsignal_bridge_types::metadata::node::TsMetadataContext,
) -> String {
"SealedSenderMultiRecipientMessage".into()
}
}
/// ts: `export function MinidumpToJSONString(buffer: Uint8Array<ArrayBuffer>): string`
fn minidump_to_json_string(mut cx: FunctionContext) -> JsResult<JsString> {
let buffer_arg = cx.argument::<JsUint8Array>(0)?;
let dump = Minidump::read(buffer_arg.as_slice(&cx)).expect("Failed to parse minidump");
#[bridge_fn(jni = false, ffi = false)]
fn SealedSenderMultiRecipientMessage_Parse(
buffer: &[u8],
) -> libsignal_protocol::error::Result<SealedSenderMultiRecipientMessage<'_>> {
Ok(SealedSenderMultiRecipientMessage(
SealedSenderV2SentMessage::parse(buffer)?,
))
}
#[bridge_fn(ffi = false, jni = false)]
fn MinidumpToJSONString(buffer: &[u8]) -> String {
let dump = Minidump::read(buffer).expect("Failed to parse minidump");
let provider = Symbolizer::new(string_symbol_supplier(std::collections::HashMap::new()));
let options = ProcessorOptions::default();
@ -165,7 +164,7 @@ fn minidump_to_json_string(mut cx: FunctionContext) -> JsResult<JsString> {
.print_json(&mut json, false)
.expect("Failed to print json");
Ok(cx.string(std::str::from_utf8(&json).expect("Failed to convert JSON to utf8")))
String::from_utf8(json).expect("Failed to convert JSON to utf8")
}
#[bridge_fn(ffi = false, jni = false)]

View File

@ -9,7 +9,7 @@ use std::sync::atomic::AtomicBool;
use libsignal_bridge::node::SimpleArgTypeInfo;
use neon::prelude::*;
/// ts: `export const enum LogLevel { Error = 1, Warn, Info, Debug, Trace }`
// Keep in sync with Native.ts.in
#[derive(Clone, Copy)]
enum LogLevel {
Error = 1,
@ -194,7 +194,6 @@ fn set_max_level_from_js_level(max_level: u32) {
log::set_max_level(log::Level::from(level).to_level_filter());
}
/// ts: `export function initLogger(maxLevel: LogLevel, callback: (level: LogLevel, target: string, file: string | null, line: number | null, message: string) => void): void`
pub(crate) fn init_logger(mut cx: FunctionContext) -> JsResult<JsUndefined> {
let max_level_arg = cx.argument::<JsNumber>(0)?;
let max_level = u32::convert_from(&mut cx, max_level_arg)?;

View File

@ -71,3 +71,4 @@ ffi = ["libsignal-bridge-types/ffi"]
jni = ["dep:jni", "libsignal-bridge-types/jni"]
node = ["neon", "linkme", "libsignal-bridge-types/node"]
signal-media = ["dep:signal-media", "libsignal-bridge-types/signal-media"]
metadata = ["libsignal-bridge-types/metadata"]

View File

@ -52,7 +52,7 @@
//! export function SenderKeyMessage_New(
//! keyId: number,
//! iteration: number,
//! ciphertext: Buffer,
//! ciphertext: Uint8Array<ArrayBuffer>,
//! pk: Wrapper<PrivateKey>
//! ): SenderKeyMessage;
//! ```
@ -132,8 +132,9 @@
//!
//! 1. Argument and result types for FFI and JNI are determined by macros `ffi_arg_type`,
//! `ffi_result_type`, `jni_arg_type`, and `jni_result_type`. You may need to add your new type
//! there. JNI and Node types also undergo some additional transformation in the scripts
//! `gen_java_decl.py` and `gen_ts_decl.py`, which you may need to tweak as well.
//! there. JNI types also undergo some additional transformation in the scripts
//! `gen_java_decl.py`, which you may need to tweak as well. Node types are generated as Strings
//! via the `gen_ts_ffi()` methods on `node::{AsyncArg, Arg, Result}TypeInfo`.
//!
//! 2. Argument types conform to one or more of the following bridge-specific traits:
//!

View File

@ -12,7 +12,7 @@ use syn::*;
use syn_mid::Signature;
use crate::BridgingKind;
use crate::util::{extract_arg_names_and_types, result_type};
use crate::util::{crates, extract_arg_names_and_types, result_type};
fn bridge_fn_body(orig_name: &Ident, input_args: &[(&Ident, &Type)]) -> TokenStream2 {
// Scroll down to the end of the function to see the quote template.
@ -182,9 +182,14 @@ pub(crate) fn bridge_fn(
let name_with_prefix = format_ident!("node_{}", name);
let name_without_prefix = Ident::new(name, Span::call_site());
let ts_signature_comment = generate_ts_signature_comment(name, sig, bridging_kind);
let input_args = extract_arg_names_and_types(sig)?;
let ts_metadata = generate_ts_metadata(
name,
sig.asyncness.is_some(),
&input_args,
result_type(&sig.output),
bridging_kind,
);
let body = match (sig.asyncness, bridging_kind) {
(Some(_), _) => bridge_fn_async_body(&sig.ident, name, bridging_kind, &input_args),
@ -200,51 +205,83 @@ pub(crate) fn bridge_fn(
Ok(quote! {
#[cfg(feature = "node")]
#[allow(non_snake_case)]
#[doc = #ts_signature_comment]
pub fn #name_with_prefix(
mut cx: node::FunctionContext,
) -> node::JsResult<node::JsValue> {
#body
}
#[cfg(all(feature = "metadata", feature = "node"))]
#ts_metadata
#[cfg(feature = "node")]
node_register!(#name_without_prefix);
})
}
/// Generates a string, containing the *Rust* signature of a bridged function, that gen_ts_decl.py
/// can use to generate Native.d.ts.
fn generate_ts_signature_comment(
/// Generates the code to embed `libsignal_bridge_types::metadata` metadata
fn generate_ts_metadata(
name_without_prefix: &str,
sig: &Signature,
asyncness: bool,
input_args: &[(&Ident, &Type)],
result_type: TokenStream2,
bridging_kind: &BridgingKind,
) -> String {
let mut ts_args = vec![];
) -> TokenStream2 {
let krate = crates::libsignal_bridge_types();
let mut input_args: Vec<_> = input_args
.iter()
.map(|(name, ty)| (name.to_string(), ty.to_token_stream()))
.collect();
match bridging_kind {
BridgingKind::Regular => {}
BridgingKind::Io { runtime } => {
ts_args.push(format!("async_runtime: &{}", runtime.to_token_stream()))
let runtime = runtime.to_token_stream();
input_args.insert(0, ("async_runtime".to_string(), quote!(&#runtime)))
}
}
ts_args.extend(
sig.inputs
.iter()
.map(|arg| arg.to_token_stream().to_string().replace('\n', " ")),
);
let result_type_format = match (sig.asyncness, bridging_kind) {
(Some(_), BridgingKind::Io { .. }) => |ty| format!("CancellablePromise<{ty}>"),
(Some(_), _) => |ty| format!("Promise<{ty}>"),
(None, _) => |ty| format!("{ty}"),
let argument_names = input_args
.iter()
.map(|(x, _)| to_lower_camel_case_preserve_underscores(x))
.collect_vec();
let argument_types = input_args.iter().map(|(_, x)| x).collect_vec();
let return_type_format = match (asyncness, bridging_kind) {
(true, BridgingKind::Io { .. }) => "CancellablePromise<{return_type}>",
(true, _) => "Promise<{return_type}>",
(false, _) => "{return_type}",
};
let result_type_str = result_type_format(result_type(&sig.output));
let md = quote!(#krate::metadata);
let metadata_name = format_ident!("_BRIDGE_NODE_METADATA_{name_without_prefix}");
let type_info_trait = if asyncness {
quote!(AsyncArgTypeInfo)
} else {
quote!(ArgTypeInfo)
};
quote! {
#[#md::linkme::distributed_slice(#md::node::NODE_ITEMS)]
#[linkme(crate = #md::linkme)]
static #metadata_name: #md::FnWithModule<#md::node::TsMetadataContext> = #md::FnWithModule {
module_path: module_path!(),
apply: |ctx| {
use #md::node::result_type_helper::*;
let return_type: ResultMetadataTransformHelper<#result_type> = Default::default();
let return_type = return_type.register_ts_ffi_type(ctx);
let mut arguments = Vec::new();
#(arguments.push((
#argument_names.into(),
<#argument_types as #krate::node::#type_info_trait>::register_ts_ffi_type(ctx)
));)*
ctx.native_functions.insert(
#name_without_prefix.into(),
#md::node::NativeFunction { arguments, return_type: format!(#return_type_format) },
);
},
};
}
}
format!(
"ts: `export function {}({}): {}`",
name_without_prefix,
ts_args.join(", "),
result_type_str
)
fn to_lower_camel_case_preserve_underscores(x: &str) -> String {
let x_sans_underscore = x.trim_start_matches('_');
let core = x_sans_underscore.to_lower_camel_case();
format!("{}{core}", &x[0..(x.len() - x_sans_underscore.len())])
}
pub(crate) fn name_from_ident(ident: &Ident) -> String {
@ -259,23 +296,20 @@ pub(crate) fn name_from_ident(ident: &Ident) -> String {
pub(crate) fn bridge_trait(trait_to_bridge: &ItemTrait, js_name: &str) -> Result<TokenStream2> {
let trait_name = &trait_to_bridge.ident;
let wrapper_name = format_ident!("Node{}", trait_to_bridge.ident);
let krate = crates::libsignal_bridge_types();
let callbacks = trait_to_bridge
.items
.iter()
.map(bridge_callback_item)
.map(|x| bridge_callback_item(x, &krate))
.collect::<Result<Vec<_>>>()?;
let callback_impls = callbacks.iter().map(|c| &c.implementation);
let callback_ts_decls = callbacks.iter().map(|c| &c.ts_decl);
let ts_declaration_comment = format!(
"ts: `export /*trait*/ type {js_name} = {{\n{}\n}};`",
callback_ts_decls.format("\n")
);
let callback_bridge_trait_functions = callbacks.iter().map(|c| &c.bridge_trait_function);
let md = quote!(#krate::metadata);
let metadata_name = format_ident!("_BRIDGE_NODE_METADATA_{trait_name}");
Ok(quote! {
#[cfg(feature = "node")]
#[doc = #ts_declaration_comment]
pub struct #wrapper_name(node::RootAndChannel);
#[cfg(feature = "node")]
@ -299,15 +333,29 @@ pub(crate) fn bridge_trait(trait_to_bridge: &ItemTrait, js_name: &str) -> Result
impl #trait_name for #wrapper_name {
#(#callback_impls)*
}
#[cfg(all(feature = "node", feature = "metadata"))]
#[#md::linkme::distributed_slice(#md::node::NODE_ITEMS)]
#[linkme(crate = #md::linkme)]
static #metadata_name: #md::FnWithModule<#md::node::TsMetadataContext> = #md::FnWithModule {
module_path: module_path!(),
apply: |ctx| {
let mut functions = Vec::new();
#(#callback_bridge_trait_functions)*
ctx.bridge_traits.insert(#js_name.to_string(), functions);
},
};
})
}
struct Callback {
implementation: TokenStream2,
ts_decl: String,
/// Push a `node::BridgeTraitFunction` onto the local `functions` Vec
/// `ctx: &mut TsMetadataContext` is in scope
bridge_trait_function: TokenStream2,
}
fn bridge_callback_item(item: &TraitItem) -> Result<Callback> {
fn bridge_callback_item(item: &TraitItem, krate: &TokenStream2) -> Result<Callback> {
let TraitItem::Fn(item) = item else {
return Err(Error::new(item.span(), "only fns are supported"));
};
@ -395,21 +443,35 @@ fn bridge_callback_item(item: &TraitItem) -> Result<Callback> {
}
};
// operation(foo: number): void;
let js_arg_decls = item.sig.inputs.iter().filter_map(|arg| match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(arg) => {
let Pat::Ident(arg_name) = &*arg.pat else {
// Diagnosed elsewhere.
return None;
};
Some(format!("{}: {}", arg_name.ident, arg.ty.to_token_stream()))
}
});
let args = item
.sig
.inputs
.iter()
.filter_map(|arg| match arg {
FnArg::Receiver(_) => None,
FnArg::Typed(arg) => {
let Pat::Ident(arg_name) = &*arg.pat else {
// Diagnosed elsewhere.
return None;
};
Some((&arg_name.ident, &arg.ty))
}
})
.collect_vec();
let arg_names = args
.iter()
.map(|(x, _)| to_lower_camel_case_preserve_underscores(&x.to_string()))
.collect_vec();
let arg_types = args.iter().map(|(_, x)| x).collect_vec();
let result_ty = result_type(&sig.output);
let result_string = if sig.asyncness.is_some() {
let result_ty = result_type(&sig.output);
format!("Promise<{result_ty}>")
let return_type = if sig.asyncness.is_some() {
quote! {{
use #krate::metadata::node::result_type_helper::*;
let return_type: CallbackResultMetadataTransformHelper<#result_ty> = Default::default();
let return_type = return_type.register_ts_ffi_type(ctx);
format!("Promise<{return_type}>")
}}
} else {
if !matches!(sig.output, ReturnType::Default) {
return Err(Error::new(
@ -417,17 +479,25 @@ fn bridge_callback_item(item: &TraitItem) -> Result<Callback> {
"non-async callbacks with results are not supported for Node",
));
}
"void".to_owned()
quote!("void".to_string())
};
let ts_decl = format!(
"{}({}): {};",
js_operation_name,
js_arg_decls.format(", "),
result_string
);
Ok(Callback {
implementation,
ts_decl,
bridge_trait_function: quote! {
let mut arguments = Vec::new();
#(arguments.push((
#arg_names.to_string(),
<#arg_types as #krate::node::ResultTypeInfo>::register_ts_ffi_type(ctx),
));)*
let return_type = #return_type;
functions.push(#krate::metadata::node::BridgeTraitFunction {
name: #js_operation_name.to_string(),
body: #krate::metadata::node::NativeFunction {
arguments,
return_type,
},
});
},
})
}

View File

@ -9,6 +9,20 @@ use syn::spanned::Spanned;
use syn::*;
use syn_mid::{FnArg, Pat, PatType, Signature};
pub(crate) mod crates {
use super::*;
fn pkg_name() -> String {
std::env::var("CARGO_PKG_NAME").expect("Missing CARGO_PKG_NAME")
}
pub(crate) fn libsignal_bridge_types() -> TokenStream2 {
if pkg_name() == "libsignal-bridge-types" {
quote!(crate)
} else {
quote!(::libsignal_bridge_types)
}
}
}
/// Returns the tokens of the type in `output_as_written`, or `()` if no return type was written.
pub(crate) fn result_type(output_as_written: &ReturnType) -> TokenStream2 {
match output_as_written {

View File

@ -125,6 +125,27 @@ async fn UnauthenticatedChatConnection_send(
.await
}
#[bridge_io(TokioAsyncContext)]
async fn UnauthenticatedChatConnection_send_raw_grpc(
chat: &UnauthenticatedChatConnection,
service: String,
method: String,
payload: Box<[u8]>,
) -> Result<Vec<u8>, RequestError<Infallible>> {
chat.as_typed(|chat| {
Box::pin(libsignal_net_chat::grpc::raw_grpc(
"unauth",
chat.0
.shared_h2_connection()
.expect("requires an H2 connection"),
&service,
&method,
payload.into_vec(),
))
})
.await
}
#[bridge_io(TokioAsyncContext)]
async fn UnauthenticatedChatConnection_disconnect(chat: &UnauthenticatedChatConnection) {
chat.disconnect().await
@ -306,6 +327,27 @@ async fn AuthenticatedChatConnection_send(
.await
}
#[bridge_io(TokioAsyncContext)]
async fn AuthenticatedChatConnection_send_raw_grpc(
chat: &AuthenticatedChatConnection,
service: String,
method: String,
payload: Box<[u8]>,
) -> Result<Vec<u8>, RequestError<Infallible>> {
chat.as_typed(|chat| {
Box::pin(libsignal_net_chat::grpc::raw_grpc(
"auth",
chat.0
.shared_h2_connection()
.expect("requires an H2 connection"),
&service,
&method,
payload.into_vec(),
))
})
.await
}
#[bridge_io(TokioAsyncContext)]
async fn AuthenticatedChatConnection_disconnect(chat: &AuthenticatedChatConnection) {
chat.disconnect().await

View File

@ -14,8 +14,8 @@ use libsignal_core::{Aci, E164};
use libsignal_keytrans::{AccountData, StoredAccountData};
use libsignal_net_chat::api::RequestError;
use libsignal_net_chat::api::keytrans::{
CheckMode, Error, KeyTransparencyClient, MaybePartial, SearchKey, TreeHeadWithTimestamp,
UsernameHash, check,
AccountDataField, AccountDataFieldReset as _, CheckMode, Error, KeyTransparencyClient,
MaybePartial, SearchKey, TreeHeadWithTimestamp, UsernameHash, check,
};
use libsignal_protocol::PublicKey;
use prost::{DecodeError, Message};
@ -38,6 +38,20 @@ fn KeyTransparency_UsernameHashSearchKey(hash: &[u8]) -> Vec<u8> {
UsernameHash::from_slice(hash).as_search_key()
}
#[bridge_fn]
fn KeyTransparency_ResetDataField(
account_data: Box<[u8]>,
field: AsType<AccountDataField, u8>,
) -> Vec<u8> {
// The only failure is decoding error, we'll use empty vec for that.
let decoded: Result<StoredAccountData, _> = try_decode(account_data);
let Ok(account_data) = decoded else {
log::warn!("Failed to decode stored account data");
return vec![];
};
account_data.reset(field.into_inner()).encode_to_vec()
}
#[bridge_io(TokioAsyncContext)]
#[expect(clippy::too_many_arguments)]
async fn KeyTransparency_Check(

View File

@ -24,6 +24,7 @@ libsignal-keytrans = { workspace = true }
libsignal-message-backup = { workspace = true, features = ["json"] }
libsignal-net = { workspace = true }
libsignal-net-chat = { workspace = true }
libsignal-net-grpc = { workspace = true, features = ["json"] }
libsignal-protocol = { workspace = true }
zkgroup = { workspace = true }
@ -54,3 +55,4 @@ ffi = ["libsignal-bridge-types/ffi"]
jni = ["dep:jni", "libsignal-bridge-types/jni"]
node = ["dep:linkme", "dep:neon", "libsignal-bridge-types/node"]
signal-media = ["libsignal-bridge-types/signal-media"]
metadata = ["libsignal-bridge-types/metadata"]

View File

@ -73,13 +73,18 @@ async fn TESTING_FakeChatServer_GetNextRemote(server: &FakeChatServer) -> FakeCh
fn TESTING_FakeChatConnection_Create(
tokio: &TokioAsyncContext,
listener: Box<dyn ChatListener>,
grpc_overrides_joined_by_newlines: String,
alerts_joined_by_newlines: String,
) -> FakeChatConnection {
// "".split_terminator(...) produces [], while normal split() produces [""].
// Leaking is unfortunate, but more expedient than mapping to remote config keys or similar.
let grpc_overrides = String::leak(grpc_overrides_joined_by_newlines).split_terminator('\n');
let alerts = alerts_joined_by_newlines.split_terminator('\n');
let (chat, remote) = libsignal_bridge_types::net::chat::FakeChatConnection::new(
tokio.handle(),
listener.into_event_listener(),
grpc_overrides,
alerts,
);
FakeChatConnection {
@ -96,7 +101,8 @@ fn TESTING_FakeChatConnection_CreateProvisioning(
let (chat, remote) = libsignal_bridge_types::net::chat::FakeChatConnection::new(
tokio.handle(),
listener.into_event_listener(),
vec![],
[],
[],
);
FakeChatConnection {
chat: Some(chat).into(),
@ -159,6 +165,37 @@ fn TESTING_FakeChatRemoteEnd_SendServerResponse(
.expect("chat task finished")
}
#[bridge_io(TokioAsyncContext)]
async fn TESTING_FakeChatRemoteEnd_SendServerGrpcResponse(
chat: &FakeChatRemoteEnd,
response: &FakeChatResponse,
) {
let FakeChatResponse(ResponseProto {
id,
status,
message,
headers,
body,
}) = response;
assert!(
message.as_deref().unwrap_or_default().is_empty(),
"messages not supported for gRPC"
);
assert!(headers.is_empty(), "headers not yet implemented for gRPC");
let http_response = http::Response::builder()
.status(u16::try_from(status.unwrap_or_default()).unwrap_or(u16::MAX))
.body(body.as_ref().cloned().unwrap_or_default())
.expect("valid");
chat.0
.grpc()
.await
.send_response(id.unwrap_or_default(), http_response)
.expect("chat task finished");
}
#[bridge_fn]
fn TESTING_FakeChatRemoteEnd_InjectConnectionInterrupted(chat: &FakeChatRemoteEnd) {
chat.0
@ -203,6 +240,40 @@ async fn TESTING_FakeChatRemoteEnd_ReceiveIncomingRequest(
Some((http_request, id.unwrap()))
}
#[bridge_io(TokioAsyncContext)]
async fn TESTING_FakeChatRemoteEnd_ReceiveIncomingGrpcRequest(
chat: &FakeChatRemoteEnd,
) -> Option<(HttpRequest, u64)> {
let (id, request) = chat
.0
.grpc()
.await
.receive_request()
.await
.expect("message was invalid")?;
let (
http::request::Parts {
method,
uri,
headers,
..
},
body,
) = request.into_parts();
let http_request = HttpRequest {
method,
path: uri
.into_parts()
.path_and_query
.unwrap_or(http::uri::PathAndQuery::from_static("")),
body: Some(body),
headers: headers.into(),
};
Some((http_request, id))
}
#[bridge_fn]
fn TESTING_ChatResponseConvert(body_present: bool) -> ChatResponse {
let body = match body_present {
@ -279,6 +350,52 @@ fn TESTING_FakeChatResponse_Create(
})
}
#[bridge_fn]
fn TESTING_FakeChatRemoteEnd_NextGrpcMessage(input: &[u8], offset: u32) -> (u32, u32) {
// Taking an offset avoids extra copies in the streaming input case.
let input = &input[offset.try_into().expect("valid offset for buffer")..];
let message_slice = libsignal_net_grpc::expect_next_grpc_message_for_testing(input);
// We return a (start, end) pair for the app language to slice.
// Unfortunately, getting that back out takes a bit of work.
let message_offset = if let Some(first_elem) = message_slice.first() {
// TODO: replace with slice::element_offset at MSRV 1.94.
let first_elem = std::ptr::from_ref(first_elem);
let slice_range = input.as_ptr_range();
assert!(
slice_range.contains(&first_elem),
"result should be a subslice"
);
// Note: subtracting raw addresses only works because the elements are bytes.
first_elem.addr() - slice_range.start.addr()
} else {
// If the message is empty, the header must have been the entire rest of the input.
input.len()
};
let full_offset = offset + u32::try_from(message_offset).expect("input will never be >1GB");
(
full_offset,
full_offset + u32::try_from(message_slice.len()).expect("input will never be >1GB"),
)
}
#[bridge_fn]
fn TESTING_FakeChatRemoteEnd_GrpcFrameForMessageLength(len: u32) -> Vec<u8> {
let mut result = Vec::with_capacity(5);
result.push(0);
result.extend_from_slice(&len.to_be_bytes());
result
}
#[bridge_fn]
fn TESTING_FakeChatRemoteEnd_BinprotoToJson(name: String, input: &[u8]) -> String {
libsignal_net_grpc::json::expect_binproto_to_json_by_name(&name, input)
}
#[bridge_fn]
fn TESTING_FakeChatRemoteEnd_JsonToBinproto(name: String, input: String) -> Vec<u8> {
libsignal_net_grpc::json::expect_json_to_binproto_by_name(&name, &input)
}
make_error_testing_enum! {
enum TestingChatConnectError for ConnectError {
WebSocket => WebSocketConnectionFailed,

View File

@ -3,8 +3,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
use libsignal_keytrans::{StoredAccountData, StoredMonitoringData};
use libsignal_net_chat::api::RequestError;
use libsignal_net_chat::api::keytrans::Error as KeyTransError;
use prost::Message;
use crate::*;
@ -30,3 +32,23 @@ fn TESTING_KeyTransNonFatalVerificationFailure() -> Result<(), RequestError<KeyT
fn TESTING_KeyTransChatSendError() -> Result<(), RequestError<KeyTransError>> {
Err(RequestError::Timeout)
}
#[bridge_fn]
fn TESTING_KeyTransStoredAccountData() -> Vec<u8> {
StoredAccountData {
aci: Some(StoredMonitoringData {
pos: 1,
..Default::default()
}),
e164: Some(StoredMonitoringData {
pos: 2,
..Default::default()
}),
username_hash: Some(StoredMonitoringData {
pos: 3,
..Default::default()
}),
last_tree_head: None,
}
.encode_to_vec()
}

View File

@ -82,7 +82,7 @@ impl ConnectUnauthChat for ConnectFakeChat {
| libsignal_net::chat::ws::ListenerEvent::ReceivedMessage(_, _) => (),
};
let (chat, remote) = ChatConnection::new_fake(self.0.clone(), Box::new(listener), []);
let (chat, remote) = ChatConnection::new_fake(self.0.clone(), Box::new(listener), [], []);
std::future::ready(
self.1

View File

@ -105,6 +105,10 @@ impl<'storage, 'context: 'storage> node::ArgTypeInfo<'storage, 'context> for Nee
fn load_from(_stored: &'storage mut Self::StoredType) -> Self {
Self::None
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
#[cfg(feature = "node")]
@ -123,6 +127,10 @@ impl<'storage> node::AsyncArgTypeInfo<'storage> for NeedsCleanup {
// We only want to test that the storage is cleaned up, not the value passed into the wrapped function.
Self::None
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ArgTypeInfo but always produces an error when "borrowed" from the
@ -162,6 +170,10 @@ impl node::SimpleArgTypeInfo for ErrorOnBorrow {
) -> node::NeonResult<Self> {
node::Context::throw_type_error(cx, "deliberate error")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ArgTypeInfo but panics as it is "borrowed" from the app-provided
@ -199,6 +211,11 @@ impl node::SimpleArgTypeInfo for PanicOnBorrow {
) -> node::NeonResult<Self> {
panic!("deliberate panic")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ArgTypeInfo but panics on the secondary "load" step after the "borrow"
@ -258,6 +275,11 @@ impl<'storage, 'context: 'storage> node::ArgTypeInfo<'storage, 'context> for Pan
fn load_from(_stored: &'storage mut Self::StoredType) -> Self {
panic!("deliberate panic")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
#[cfg(feature = "node")]
@ -276,6 +298,11 @@ impl<'storage> node::AsyncArgTypeInfo<'storage> for PanicOnLoad {
fn load_async_arg(_stored: &'storage mut Self::StoredType) -> Self {
panic!("deliberate panic")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ResultTypeInfo but always fails to produce a result.
@ -311,6 +338,11 @@ impl<'a> node::ResultTypeInfo<'a> for ErrorOnReturn {
fn convert_into(self, cx: &mut impl node::Context<'a>) -> node::JsResult<'a, Self::ResultType> {
cx.throw_type_error("deliberate error")
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
/// A type that implements ResultTypeInfo but always panics when producing a result.
@ -347,6 +379,11 @@ impl<'a> node::ResultTypeInfo<'a> for PanicOnReturn {
) -> node::JsResult<'a, Self::ResultType> {
panic!("deliberate panic");
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"null".into()
}
}
#[derive(derive_more::Deref)]
@ -430,6 +467,11 @@ impl<'storage> node::AsyncArgTypeInfo<'storage> for TestingFutureCancellationGua
fn load_async_arg(stored: &'storage mut Self::StoredType) -> Self {
stored.take().unwrap().0
}
#[cfg(feature = "metadata")]
fn register_ts_ffi_type(_: &mut metadata::node::TsMetadataContext) -> String {
"TestingFutureCancellationGuard".into()
}
}
bridge_as_handle!(TestingFutureCancellationCounter);

View File

@ -84,6 +84,7 @@ jni-type-tagging = []
jni-invoke-annotated = []
extra-jni-checks = ["jni-type-tagging", "jni-invoke-annotated"]
node = ["neon", "linkme", "signal-neon-futures"]
metadata = ["linkme", "serde/derive"]
[target.'cfg(not(any(windows, target_arch = "x86")))'.dependencies]
# sha2's asm implementation uses standalone .S files that aren't compiled correctly on Windows,

View File

@ -6,6 +6,9 @@
#![allow(clippy::missing_safety_doc)]
#![deny(clippy::unwrap_used)]
#[cfg(feature = "metadata")]
pub mod metadata;
#[cfg(feature = "ffi")]
#[macro_use]
pub mod ffi;

View File

@ -0,0 +1,108 @@
//
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
//! This module provides metadata about the bridge layer which will be consumed downstream for
//! various purposes:
//!
//! - To emit `Native.ts`, see `libsignal-node-native_ts`
//!
//! While some metadata facilities are shared, they're specialized to each client language.
// This is pub so that it can be used in bridge macros.
pub use linkme;
use linkme::distributed_slice;
use serde::Serialize;
#[cfg(feature = "node")]
pub mod node {
use std::collections::{BTreeMap, BTreeSet};
use super::*;
#[derive(Debug, Clone, Serialize, Default)]
pub struct TsMetadataContext {
pub opaque_types: BTreeSet<String>,
pub native_functions: BTreeMap<String, NativeFunction>,
pub bridge_traits: BTreeMap<String, Vec<BridgeTraitFunction>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NativeFunction {
/// (name, type)
pub arguments: Vec<(String, String)>,
pub return_type: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct BridgeTraitFunction {
pub name: String,
pub body: NativeFunction,
}
/// These functions should mutate the attached [TsMetadataContext] to register their item.
#[distributed_slice]
pub static NODE_ITEMS: [FnWithModule<TsMetadataContext>];
/// See [crate::support]'s `transform_helper` for how this works, and the rationale.
///
/// These functions provide the metadata-side (`register_ts_ffi_type()`) of `.ok_if_needed()`
///
/// ```
/// # use libsignal_bridge_types::metadata::node::result_type_helper::*;
/// let x: ResultMetadataTransformHelper<i32> = Default::default();
/// assert_eq!(x.register_ts_ffi_type(&mut Default::default()).as_str(), "number");
/// let y: ResultMetadataTransformHelper<Result<i32, String>> = Default::default();
/// assert_eq!(y.register_ts_ffi_type(&mut Default::default()).as_str(), "number");
/// ```
pub mod result_type_helper {
use std::marker::PhantomData;
use derive_where::derive_where;
use crate::metadata::node::TsMetadataContext;
use crate::node::{CallbackResultTypeInfo, ResultTypeInfo};
#[derive_where(Default)]
pub struct ResultMetadataTransformHelper<T>(PhantomData<T>);
impl<'a, T: ResultTypeInfo<'a>> ResultMetadataTransformHelper<T> {
pub fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
pub trait ResultMetadataTransformHelperTrait {
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String;
}
impl<'a, T: ResultTypeInfo<'a>, E> ResultMetadataTransformHelperTrait
for ResultMetadataTransformHelper<Result<T, E>>
{
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
#[derive_where(Default)]
pub struct CallbackResultMetadataTransformHelper<T>(PhantomData<T>);
impl<T: CallbackResultTypeInfo> CallbackResultMetadataTransformHelper<T> {
pub fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
pub trait CallbackResultMetadataTransformHelperTrait {
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String;
}
impl<T: CallbackResultTypeInfo, E> CallbackResultMetadataTransformHelperTrait
for CallbackResultMetadataTransformHelper<Result<T, E>>
{
fn register_ts_ffi_type(&self, ctx: &mut TsMetadataContext) -> String {
T::register_ts_ffi_type(ctx)
}
}
}
}
pub struct FnWithModule<Ctx> {
/// The module the function is defined in
pub module_path: &'static str,
pub apply: fn(&mut Ctx),
}

View File

@ -423,9 +423,11 @@ impl FakeChatConnection {
pub fn new<'a>(
tokio_runtime: tokio::runtime::Handle,
listener: chat::ws::EventListener,
grpc_overrides: impl IntoIterator<Item = &'static str>,
alerts: impl IntoIterator<Item = &'a str>,
) -> (Self, FakeChatRemote) {
let (inner, remote) = ChatConnection::new_fake(tokio_runtime, listener, alerts);
let (inner, remote) =
ChatConnection::new_fake(tokio_runtime, listener, grpc_overrides, alerts);
(Self(inner), remote)
}
@ -533,8 +535,10 @@ async fn establish_chat_connection(
proxy_mode,
) {
(None, DirectOrProxyModeDiscriminants::DirectOnly)
| (None, DirectOrProxyModeDiscriminants::DirectThenProxy)
| (Some(_), DirectOrProxyModeDiscriminants::ProxyOnly)
| (Some(_), DirectOrProxyModeDiscriminants::ProxyThenDirect) => {
| (Some(_), DirectOrProxyModeDiscriminants::ProxyThenDirect)
| (Some(_), DirectOrProxyModeDiscriminants::DirectThenProxy) => {
log::info!("successfully connected {kind} chat")
}
(None, DirectOrProxyModeDiscriminants::ProxyThenDirect) => log::warn!(

View File

@ -81,7 +81,6 @@ macro_rules! define_keys {
}
impl RemoteConfigKey {
#[doc = concat!("ts: `export const NetRemoteConfigKeys = [", $("'", $key, "', "),* ,"] as const;`")]
pub const KEYS: &[&str] = &[$($key),*];
#[cfg(test)]
const IDENTITIER_KEY_PAIRS: &[(&str, &str)] = &[
@ -119,6 +118,7 @@ pub enum RemoteConfigKey {
MessagesAnonymousSendSingleRecipientMessage => "grpc.MessagesAnonymousSendSingleRecipientMessage",
AttachmentsGetUploadForm => "grpc.AttachmentsGetUploadForm",
MessagesSendMessage => "grpc.MessagesSendMessage",
BackupsAnonymousGetUploadForm => "grpc.BackupsAnonymousGetUploadForm",
}
}
@ -299,6 +299,7 @@ mod tests {
let all_known_grpc_keys: HashSet<&str> = std::iter::empty()
.chain(services::AccountsAnonymous::iter().map(|x| x.into()))
.chain(services::Attachments::iter().map(|x| x.into()))
.chain(services::BackupsAnonymous::iter().map(|x| x.into()))
.chain(services::KeysAnonymous::iter().map(|x| x.into()))
.chain(services::MessagesAnonymous::iter().map(|x| x.into()))
.chain(services::Messages::iter().map(|x| x.into()))

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,9 @@ impl<T, const LEN: usize> Array<T> for [T; LEN] {
pub trait FixedLengthBincodeSerializable: 'static {
/// Should be an actual byte array type, like `[u8; 7]`.
type Array: Array<u8> + for<'a> TryFrom<&'a [u8], Error = std::array::TryFromSliceError>;
#[cfg(feature = "metadata")]
fn name() -> String;
}
/// A wrapper type that indicates that `T` should be serialized across the bridges.

View File

@ -32,11 +32,13 @@ pub fn validate_serialization<'a, T: Deserialize<'a> + PartialDefault>(
macro_rules! bridge_as_fixed_length_serializable {
($typ:ident) => {
::paste::paste! {
// Declare a marker type for TypeScript, the same as bridge_as_handle.
// (This is harmless for the other bridges.)
#[doc = "ts: `interface " $typ " { readonly __type: unique symbol; }`"]
impl FixedLengthBincodeSerializable for $typ {
type Array = [u8; [<$typ:snake:upper _LEN>]];
#[cfg(feature = "metadata")]
fn name() -> String {
let name = stringify!($typ);
name.rsplit_once("::").map(|(_, x)| x).unwrap_or(name).into()
}
}
}
};

View File

@ -5,4 +5,4 @@
// The value of this constant is updated by the script
// and should not be manually modified
pub const VERSION: &str = "0.94.0";
pub const VERSION: &str = "0.94.1";

View File

@ -35,11 +35,15 @@ derive-where = { workspace = true }
derive_more = { workspace = true, features = ["debug", "from", "try_from"] }
displaydoc = { workspace = true }
either = { workspace = true }
futures = { workspace = true }
futures-util = { workspace = true }
hex = { workspace = true }
hkdf = { workspace = true }
hmac = { workspace = true }
http = { workspace = true }
http-body-util = { workspace = true }
hyper = { workspace = true, features = ["server"] } # for FakeChatRemote
hyper-util = { workspace = true }
itertools = { workspace = true }
log = { workspace = true }
nonzero_ext = { workspace = true }
@ -77,9 +81,6 @@ libsignal-net = { path = ".", features = ["test-util"] }
clap = { workspace = true, features = ["derive", "env"] }
either = { workspace = true }
env_logger = { workspace = true }
futures = { workspace = true }
hyper = { workspace = true }
hyper-util = { workspace = true }
proptest = { workspace = true }
rand_chacha = { workspace = true }
test-case = { workspace = true }

View File

@ -9,9 +9,6 @@ license.workspace = true
[lints]
workspace = true
[features]
json-grpc-codec = ["libsignal-net-grpc/json-grpc-codec"]
[dependencies]
libsignal-core = { workspace = true }
libsignal-keytrans = { workspace = true }

View File

@ -19,7 +19,7 @@ use libsignal_keytrans::{
AccountData, ChatDistinguishedResponse, ChatMonitorResponse, ChatSearchResponse,
CondensedTreeSearchResponse, FullSearchResponse, FullTreeHead, KeyTransparency, LastTreeHead,
LocalStateUpdate, MonitorContext, MonitorKey, MonitorProof, MonitorRequest, MonitorResponse,
SearchContext, SearchStateUpdate, SlimSearchRequest,
SearchContext, SearchStateUpdate, SlimSearchRequest, StoredAccountData,
};
use libsignal_net::env::KeyTransConfig;
use libsignal_protocol::PublicKey;
@ -538,6 +538,20 @@ impl UnauthenticatedChatApi for KeyTransparencyClient<'_> {
}
}
pub trait AccountDataFieldReset {
fn reset(self, field: AccountDataField) -> Self;
}
impl AccountDataFieldReset for StoredAccountData {
fn reset(mut self, field: AccountDataField) -> Self {
match field {
AccountDataField::E164 => self.e164 = None,
AccountDataField::UsernameHash => self.username_hash = None,
}
self
}
}
#[cfg(test)]
pub(crate) mod test_support {
use std::cell::Cell;
@ -758,8 +772,9 @@ pub(crate) mod test_support {
#[cfg(test)]
mod test {
use assert_matches::assert_matches;
use libsignal_keytrans::StoredMonitoringData;
use prost::Message as _;
use test_case::test_case;
use test_case::{test_case, test_matrix};
use super::test_support::{
CHAT_SEARCH_RESPONSE, CHAT_SEARCH_RESPONSE_VALID_AT, KEYTRANS_CONFIG_STAGING, test_account,
@ -859,4 +874,37 @@ mod test {
assert_eq!(skip.to_vec(), missing_fields.into_iter().collect::<Vec<_>>())
);
}
#[test_matrix([AccountDataField::E164, AccountDataField::UsernameHash])]
fn reset_account_data_field(field: AccountDataField) {
let field_data = StoredMonitoringData::default();
let data = StoredAccountData {
aci: None,
e164: Some(StoredMonitoringData {
pos: 1,
..field_data.clone()
}),
username_hash: Some(StoredMonitoringData {
pos: 2,
..field_data
}),
last_tree_head: None,
};
let updated = data.clone().reset(field);
match field {
AccountDataField::E164 => {
assert!(updated.e164.is_none());
assert_matches!(
updated.username_hash,
Some(StoredMonitoringData { pos: 2, .. })
);
}
AccountDataField::UsernameHash => {
assert_matches!(updated.e164, Some(StoredMonitoringData { pos: 1, .. }));
assert!(updated.username_hash.is_none());
}
}
}
}

View File

@ -6,12 +6,16 @@
use std::collections::BTreeSet;
/// A tag identifying an optional field in [`libsignal_keytrans::AccountData`]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, displaydoc::Display)]
#[repr(u8)]
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, displaydoc::Display, derive_more::TryFrom,
)]
#[try_from(repr)]
pub enum AccountDataField {
/// E.164
E164,
E164 = 0,
/// Username hash
UsernameHash,
UsernameHash = 1,
}
/// This struct adds to its type parameter a (potentially empty) list of

View File

@ -346,6 +346,25 @@ impl SearchVersions {
.flatten()
.max()
}
fn short_description(&self) -> String {
fn opt(x: &Option<impl ToString>) -> String {
x.as_ref()
.map(ToString::to_string)
.unwrap_or("_".to_string())
}
let Self {
aci,
e164,
username_hash,
} = self;
format!(
"[aci: {}, e164: {}, username_hash: {}]",
opt(aci),
opt(e164),
opt(username_hash)
)
}
}
impl<'a> Action<'a> {
@ -693,6 +712,8 @@ async fn monitor_then_search<'a>(
distinguished_tree_head: &LastTreeHead,
mode: CheckMode,
) -> Result<MaybePartial<AccountData>, RequestError<Error>> {
let stored_versions = SearchVersions::from_account_data(&stored_account_data);
log::info!("Stored versions: {}", stored_versions.short_description());
let monitor_account_data = {
let Parameters {
aci,
@ -711,8 +732,8 @@ async fn monitor_then_search<'a>(
// Call to `monitor` guarantees that the optionality of E.164 and username hash data
// will match between `stored_account_data` and `monitor_account_data`. Meaning, they will
// either both be Some() or both None.
let stored_versions = SearchVersions::from_account_data(&stored_account_data);
let updated_versions = SearchVersions::from_account_data(&monitor_account_data);
log::info!("Updated versions: {}", stored_versions.short_description());
let version_delta = updated_versions
.try_subtract(&stored_versions)
.map_err(|_| {
@ -765,8 +786,8 @@ mod test {
use test_case::{test_case, test_matrix};
use super::{
Action, Parameters, PostMonitorAction, TreeHeadWithTimestamp, VersionChanged, check,
is_too_old, merge_account_data, modal_search, monitor_then_search,
Action, Parameters, PostMonitorAction, SearchVersions, TreeHeadWithTimestamp,
VersionChanged, check, is_too_old, merge_account_data, modal_search, monitor_then_search,
select_baseline_tree_head,
};
use crate::api::RequestError;
@ -2036,4 +2057,17 @@ mod test {
ControlFlow::Break(expected),
);
}
#[test]
fn search_versions_as_short_string() {
assert_eq!(
"[aci: 42, e164: _, username_hash: 73]",
SearchVersions {
aci: Some(42),
e164: None,
username_hash: Some(73),
}
.short_description()
)
}
}

View File

@ -78,6 +78,68 @@ impl<T: GrpcService + Clone + Sync> GrpcServiceProvider for T {
}
}
/// A tonic encoder and decoder that passes byte buffers through unchanged, letting tonic
/// add the gRPC framing and nothing else.
struct PassthroughCodec;
impl tonic::codec::Codec for PassthroughCodec {
type Encode = Vec<u8>;
type Decode = Vec<u8>;
type Encoder = Self;
type Decoder = Self;
fn encoder(&mut self) -> Self::Encoder {
PassthroughCodec
}
fn decoder(&mut self) -> Self::Decoder {
PassthroughCodec
}
}
impl tonic::codec::Encoder for PassthroughCodec {
type Item = Vec<u8>;
type Error = tonic::Status;
fn encode(
&mut self,
item: Self::Item,
dst: &mut tonic::codec::EncodeBuf<'_>,
) -> Result<(), Self::Error> {
use bytes::BufMut;
dst.put(&item[..]);
Ok(())
}
}
impl tonic::codec::Decoder for PassthroughCodec {
type Item = Vec<u8>;
type Error = tonic::Status;
fn decode(
&mut self,
src: &mut tonic::codec::DecodeBuf<'_>,
) -> Result<Option<Self::Item>, Self::Error> {
use bytes::Buf;
Ok(Some(src.copy_to_bytes(src.remaining()).into()))
}
}
pub fn raw_grpc(
log_tag: &'static str,
service_provider: impl GrpcServiceProvider,
service_name: &str,
method: &str,
payload: Vec<u8>,
) -> impl Future<Output = Result<Vec<u8>, RequestError<Infallible>>> {
let mut client = tonic::client::Grpc::new(service_provider.service());
let path = http::uri::PathAndQuery::from_maybe_shared(format!("/{service_name}/{method}"))
.expect("valid URI path");
log_and_send(log_tag, method, || async move {
let response = client
.unary(tonic::Request::new(payload), path, PassthroughCodec)
.await?;
Ok(response.into_inner())
})
}
async fn log_and_send<F, R, E>(
log_tag: &'static str,
log_safe_description: &str,
@ -1111,90 +1173,4 @@ mod test {
) -> Result<RateLimitChallenge, RequestError<Infallible>> {
RateLimitChallenge::try_from(input)
}
#[cfg(feature = "json-grpc-codec")]
#[tokio::test]
async fn test_json_mode() {
use uuid::{Uuid, uuid};
use crate::api::Unauth;
use crate::api::usernames::UnauthenticatedChatApi as _;
let rt = tokio::runtime::Handle::current();
libsignal_net_grpc::json::set_json_mode_for_tokio_runtime(&rt, true);
scopeguard::defer!({
libsignal_net_grpc::json::set_json_mode_for_tokio_runtime(&rt, false);
});
/// A tonic encoder and decoder that passes byte buffers through unchanged, letting tonic
/// add the gRPC framing and nothing else.
struct PassthroughCodec;
impl tonic::codec::Encoder for PassthroughCodec {
type Item = Vec<u8>;
type Error = tonic::Status;
fn encode(
&mut self,
item: Self::Item,
dst: &mut tonic::codec::EncodeBuf<'_>,
) -> Result<(), Self::Error> {
use bytes::BufMut;
dst.put(&item[..]);
Ok(())
}
}
impl tonic::codec::Decoder for PassthroughCodec {
type Item = Vec<u8>;
type Error = tonic::Status;
fn decode(
&mut self,
src: &mut tonic::codec::DecodeBuf<'_>,
) -> Result<Option<Self::Item>, Self::Error> {
use bytes::Buf;
Ok(Some(src.copy_to_bytes(src.remaining()).into()))
}
}
// Not realistic, but not likely to show up by accident.
let hash = &[0x00, 0xff, 0xff, 0xff];
const ACI_UUID: Uuid = uuid!("9d0652a3-dcc3-4d11-975f-74d61598733f");
let validator = testutil::RequestValidator {
expected: testutil::req_typed(
"/org.signal.chat.account.AccountsAnonymous/LookupUsernameHash",
testutil::encode_for_grpc(
PassthroughCodec,
serde_json::to_vec(
&libsignal_net_grpc::proto::chat::account::LookupUsernameHashRequest {
username_hash: hash.to_vec(),
},
)
.expect("can serialize expected request"),
),
),
response: http::Response::new(testutil::encode_for_grpc(
PassthroughCodec,
serde_json::to_vec(&libsignal_net_grpc::proto::chat::account::LookupUsernameHashResponse {
response: Some(
libsignal_net_grpc::proto::chat::account::lookup_username_hash_response::Response::ServiceIdentifier(
libsignal_net_grpc::proto::chat::common::ServiceIdentifier {
identity_type:
libsignal_net_grpc::proto::chat::common::IdentityType::Aci
.into(),
uuid: ACI_UUID.as_bytes().to_vec(),
},
),
),
})
.expect("can serialize response"),
)),
};
let result = Unauth(&validator)
.look_up_username_hash(hash)
.await
.expect("success");
assert_eq!(result, Some(libsignal_core::Aci::from(ACI_UUID)));
}
}

View File

@ -356,6 +356,7 @@ mod testutil {
tokio::runtime::Handle::current(),
DropOnDisconnect::new(on_disconnect).into_listener(),
[],
[],
);
async {
let _ignore_failure = self.remote.send(fake_remote);

View File

@ -554,6 +554,7 @@ mod test {
tokio::runtime::Handle::current(),
DropOnDisconnect::new(on_disconnect).into_listener(),
[],
[],
);
fake_chat_tx.send(fake_remote).unwrap();
Ok(Unauth(fake_chat))
@ -634,7 +635,7 @@ mod test {
remote: fake_chat_remote_tx,
};
let (request_sender, _join_handle) = spawn_connected_chat(&fake_connect)
let (request_sender, join_handle) = spawn_connected_chat(&fake_connect)
.await
.expect("can connect");
let fake_chat_remote = fake_chat_remote_rx.recv().await.unwrap();
@ -686,6 +687,7 @@ mod test {
let _response = first_send_fut.await;
// The task should reach its inactivity timeout and disconnect.
join_handle.await.expect("no panic");
assert_matches!(fake_chat_remote.receive_request().await, Ok(None));
}
}

View File

@ -6,13 +6,12 @@ authors = ["Signal Messenger LLC"]
license = "AGPL-3.0-only"
[features]
json-grpc-codec = [
json = [
"dep:pbjson",
"dep:pbjson-build",
"dep:pbjson-types",
"dep:serde",
"dep:serde_json",
"dep:tokio",
]
[dependencies]
@ -27,7 +26,6 @@ prost-types = { workspace = true }
serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
strum = { workspace = true, features = ["derive"] }
tokio = { workspace = true, optional = true }
tonic = { workspace = true, default-features = false, features = ["codegen"] }
tonic-prost = { workspace = true }

View File

@ -35,7 +35,7 @@ fn main() {
service_method_file.push("service_methods.rs");
std::fs::write(service_method_file, service_method_contents).expect("can write to OUT_DIR");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
{
let mut json_build = pbjson_build::Builder::new();
for fd in &fds.file {
@ -49,9 +49,8 @@ fn main() {
let mut tonic_build = tonic_prost_build::configure()
.build_server(false)
.build_transport(false);
if cfg!(feature = "json-grpc-codec") {
if cfg!(feature = "json") {
tonic_build = tonic_build
.codec_path("crate::json::JsonOrProstCodec")
.compile_well_known_types(true)
.extern_path(".google.protobuf", "::pbjson_types")
// Note that this diverges from proper protobuf JSON in the interest of simplicity and

View File

@ -3,170 +3,67 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
use std::collections::HashSet;
use std::marker::PhantomData;
use std::sync::RwLock;
use std::collections::HashMap;
use std::sync::LazyLock;
use derive_where::derive_where;
use prost::bytes::{Buf as _, BufMut as _};
#[derive_where(Default)]
pub struct Decoder<T>(PhantomData<fn() -> T>);
impl<T: serde::de::DeserializeOwned> tonic::codec::Decoder for Decoder<T> {
type Item = T;
type Error = tonic::Status;
fn decode(
&mut self,
src: &mut tonic::codec::DecodeBuf<'_>,
) -> Result<Option<Self::Item>, Self::Error> {
Ok(Some(
serde_json::from_reader(src.reader()).map_err(std::io::Error::from)?,
))
}
pub fn expect_binproto_to_json<T: prost::Message + Default + serde::Serialize>(
input: &[u8],
) -> String {
serde_json::to_string(&T::decode(input).expect("valid input")).expect("can encode as JSON")
}
#[derive_where(Default)]
pub struct Encoder<T>(PhantomData<fn(T)>);
impl<T: serde::Serialize> tonic::codec::Encoder for Encoder<T> {
type Item = T;
type Error = tonic::Status;
fn encode(
&mut self,
item: Self::Item,
dst: &mut tonic::codec::EncodeBuf<'_>,
) -> Result<(), Self::Error> {
Ok(serde_json::to_writer(dst.writer(), &item).map_err(std::io::Error::from)?)
}
pub fn expect_json_to_binproto<T: prost::Message + serde::de::DeserializeOwned>(
input: &str,
) -> Vec<u8> {
serde_json::from_str::<T>(input)
.expect("valid JSON")
.encode_to_vec()
}
#[derive_where(Default)]
pub struct Codec<T, U>(PhantomData<(Encoder<T>, Decoder<U>)>);
pub fn expect_binproto_to_json_by_name(message_name: &str, input: &[u8]) -> String {
type BinprotoToJsonFn = fn(&[u8]) -> String;
// TODO: generate this
static OPS: LazyLock<HashMap<&'static str, BinprotoToJsonFn>> = LazyLock::new(|| {
HashMap::from_iter([
(
"org.signal.chat.account.LookupUsernameHashRequest",
expect_binproto_to_json::<crate::proto::chat::account::LookupUsernameHashRequest>
as _,
),
(
"org.signal.chat.account.LookupUsernameLinkRequest",
expect_binproto_to_json::<crate::proto::chat::account::LookupUsernameLinkRequest>
as _,
),
])
});
impl<T, U> tonic::codec::Codec for Codec<T, U>
where
T: serde::Serialize + Send + 'static,
U: serde::de::DeserializeOwned + Send + 'static,
{
type Encode = T;
type Decode = U;
type Encoder = Encoder<T>;
type Decoder = Decoder<U>;
fn encoder(&mut self) -> Self::Encoder {
Encoder::default()
}
fn decoder(&mut self) -> Self::Decoder {
Decoder::default()
}
let op = OPS
.get(message_name)
.unwrap_or_else(|| unimplemented!("missing binproto_to_json for {message_name}"));
op(input)
}
pub struct MaybeJson<T> {
json: bool,
fallback: T,
}
/// An alias compatible with tonic-build's `codec_path` option.
pub type JsonOrProstCodec<T, U> = MaybeJson<tonic_prost::ProstCodec<T, U>>;
// From https://doc.rust-lang.org/std/collections/struct.HashSet.html#usage-in-const-and-static
// A HashSet without a random seed, so it can be `const`.
static RUNTIMES_WITH_JSON_MODE: RwLock<
HashSet<tokio::runtime::Id, std::hash::BuildHasherDefault<std::hash::DefaultHasher>>,
> = RwLock::new(HashSet::with_hasher(std::hash::BuildHasherDefault::new()));
pub fn set_json_mode_for_tokio_runtime(runtime: &tokio::runtime::Handle, json_mode: bool) {
let mut state = RUNTIMES_WITH_JSON_MODE.write().expect("not poisoned");
let id = runtime.id();
if json_mode {
state.insert(id);
} else {
state.remove(&id);
}
}
impl<T: Default> Default for MaybeJson<T> {
fn default() -> Self {
let json_mode_active = tokio::runtime::Handle::try_current()
.ok()
.and_then(|rt| {
let id = rt.id();
let state = RUNTIMES_WITH_JSON_MODE.read().ok()?;
Some(state.contains(&id))
})
.unwrap_or_default();
Self {
json: json_mode_active,
fallback: Default::default(),
}
}
}
impl<T, U, C> tonic::codec::Codec for MaybeJson<C>
where
T: serde::Serialize + Send + 'static,
U: serde::de::DeserializeOwned + Send + 'static,
C: tonic::codec::Codec<Encode = T, Decode = U>,
{
type Encode = T;
type Decode = U;
type Encoder = MaybeJson<C::Encoder>;
type Decoder = MaybeJson<C::Decoder>;
fn encoder(&mut self) -> Self::Encoder {
MaybeJson {
json: self.json,
fallback: self.fallback.encoder(),
}
}
fn decoder(&mut self) -> Self::Decoder {
MaybeJson {
json: self.json,
fallback: self.fallback.decoder(),
}
}
}
impl<C> tonic::codec::Encoder for MaybeJson<C>
where
C: tonic::codec::Encoder<Item: serde::Serialize, Error = tonic::Status>,
{
type Item = C::Item;
type Error = tonic::Status;
fn encode(
&mut self,
item: Self::Item,
dst: &mut tonic::codec::EncodeBuf<'_>,
) -> Result<(), Self::Error> {
if self.json {
Encoder::default().encode(item, dst)
} else {
self.fallback.encode(item, dst)
}
}
}
impl<C> tonic::codec::Decoder for MaybeJson<C>
where
C: tonic::codec::Decoder<Item: serde::de::DeserializeOwned, Error = tonic::Status>,
{
type Item = C::Item;
type Error = tonic::Status;
fn decode(
&mut self,
src: &mut tonic::codec::DecodeBuf<'_>,
) -> Result<Option<Self::Item>, Self::Error> {
if self.json {
Decoder::default().decode(src)
} else {
self.fallback.decode(src)
}
}
pub fn expect_json_to_binproto_by_name(message_name: &str, input: &str) -> Vec<u8> {
type JsonToBinprotoFn = fn(&str) -> Vec<u8>;
// TODO: generate this
static OPS: LazyLock<HashMap<&'static str, JsonToBinprotoFn>> = LazyLock::new(|| {
HashMap::from_iter([
(
"org.signal.chat.account.LookupUsernameHashResponse",
expect_json_to_binproto::<crate::proto::chat::account::LookupUsernameHashResponse>
as _,
),
(
"org.signal.chat.account.LookupUsernameLinkResponse",
expect_json_to_binproto::<crate::proto::chat::account::LookupUsernameLinkResponse>
as _,
),
])
});
let op = OPS
.get(message_name)
.unwrap_or_else(|| unimplemented!("missing json_to_binproto for {message_name}"));
op(input)
}

View File

@ -5,44 +5,44 @@
#![warn(clippy::unwrap_used)]
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
pub mod json;
pub mod proto {
pub mod chat {
pub mod common {
tonic::include_proto!("org.signal.chat.common");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.common.serde");
}
pub mod errors {
tonic::include_proto!("org.signal.chat.errors");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.errors.serde");
}
pub mod account {
tonic::include_proto!("org.signal.chat.account");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.account.serde");
}
pub mod attachments {
tonic::include_proto!("org.signal.chat.attachments");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.attachments.serde");
}
pub mod backup {
tonic::include_proto!("org.signal.chat.backup");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.backup.serde");
}
pub mod device {
tonic::include_proto!("org.signal.chat.device");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.device.serde");
}
pub mod messages {
tonic::include_proto!("org.signal.chat.messages");
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
tonic::include_proto!("org.signal.chat.messages.serde");
}
@ -66,9 +66,9 @@ pub mod proto {
}
}
#[cfg(not(feature = "json-grpc-codec"))]
#[cfg(not(feature = "json"))]
pub type Duration = prost_types::Duration;
#[cfg(feature = "json-grpc-codec")]
#[cfg(feature = "json")]
pub type Duration = pbjson_types::Duration;
impl From<libsignal_core::ServiceId> for proto::chat::common::ServiceIdentifier {
@ -161,3 +161,23 @@ impl prost::Name for proto::google::rpc::RetryInfo {
.to_owned()
}
}
/// Manual implementation of the gRPC framing format (Length-Prefixed-Message).
///
/// tonic normally takes care of this for us on the Rust side, but app-level tests (using e.g.
/// `FakeChatRemote`) have to deal with the raw HTTP bodies.
///
/// See <https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md>.
pub fn expect_next_grpc_message_for_testing(input: &[u8]) -> &[u8] {
const HEADER_LEN: usize = 5;
assert!(input.len() >= HEADER_LEN, "unexpected EOF");
assert_eq!(input[0], 0, "compression not supported");
let message_length =
u32::from_be_bytes(*input[1..].first_chunk().expect("already checked length"));
let message_length = usize::try_from(message_length).expect("at least 32-bit usize");
assert!(
message_length + HEADER_LEN <= input.len(),
"message length exceeds remaining input"
);
&input[HEADER_LEN..][..message_length]
}

View File

@ -209,6 +209,10 @@ impl<Transport: UsesTransport<UnresolvedTransportRoute>> DescribeForLog
},
inner: _,
}) => (target_host.as_informational_host(), *target_port),
ConnectionProxyRoute::Reflector(reflector) => (
Host::Domain(reflector.target_host.clone()),
DEFAULT_HTTPS_PORT,
),
},
};

View File

@ -14,8 +14,8 @@ use crate::certs::RootCertificates;
use crate::errors::LogSafeDisplay;
use crate::host::Host;
use crate::route::{
ReplaceFragment, RouteProvider, RouteProviderContext, SimpleRoute, TcpRoute, TlsRoute,
TlsRouteFragment, UnresolvedHost,
HttpsTlsRoute, ReplaceFragment, RouteProvider, RouteProviderContext, SimpleRoute, TcpRoute,
TlsRoute, TlsRouteFragment, UnresolvedHost, WebSocketRoute,
};
use crate::tcp_ssl::proxy::socks;
use crate::{Alpn, OverrideNagleAlgorithm};
@ -53,6 +53,12 @@ pub struct HttpProxyAuth {
pub password: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ReflectorProxyRoute<Addr> {
pub outer: WebSocketRoute<HttpsTlsRoute<TlsRoute<TcpRoute<Addr>>>>,
pub target_host: Arc<str>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, strum::EnumDiscriminants)]
#[strum_discriminants(name(ConnectionProxyKind))]
pub enum ConnectionProxyRoute<Addr> {
@ -66,6 +72,8 @@ pub enum ConnectionProxyRoute<Addr> {
},
Socks(SocksRoute<Addr>),
Https(HttpsProxyRoute<Addr>),
// Boxed because it's much larger than the other variants.
Reflector(Box<ReflectorProxyRoute<Addr>>),
}
/// Target address for proxy protocols that support remote resolution.
@ -95,6 +103,7 @@ pub enum DirectOrProxyMode {
DirectOnly,
ProxyOnly(ConnectionProxyConfig),
ProxyThenDirect(ConnectionProxyConfig),
DirectThenProxy(ConnectionProxyConfig),
}
/// [`RouteProvider`] implementation that returns [`DirectOrProxyRoute`]s.
@ -137,6 +146,9 @@ pub struct HttpProxy {
pub resolve_hostname_locally: bool,
}
#[derive(Debug)]
pub struct ReflectorProviderConfig;
#[derive(Debug, Clone, derive_more::From)]
pub enum ConnectionProxyConfig {
Tls(TlsProxy),
@ -144,6 +156,10 @@ pub enum ConnectionProxyConfig {
Tcp(TcpProxy),
Socks(SocksProxy),
Http(HttpProxy),
/// Reflector tunnel providers to try. The caller is expected to take this
/// slice from the surrounding environment/domain config so prod and staging
/// can't be mispaired.
Reflector(&'static [ReflectorProviderConfig]),
}
#[derive(Debug, thiserror::Error, displaydoc::Display)]
@ -258,11 +274,14 @@ impl ConnectionProxyConfig {
}
pub fn is_signal_transparent_proxy(&self) -> bool {
// Here, a "signal_transparent_proxy" is one we don't want to fall back
// from on connect failure. Currently that's just `Self::Tls`.
// TODO(reflector): clean up this method naming.
match self {
Self::Tls(_) => true,
#[cfg(feature = "dev-util")]
Self::Tcp(_) => true,
Self::Socks(_) | Self::Http(_) => false,
Self::Socks(_) | Self::Http(_) | Self::Reflector(_) => false,
}
}
}
@ -322,7 +341,9 @@ where
let replacer = move |r: D::Route| replacer(r).replace(DirectOrProxyRoute::Proxy);
Either::Right(Either::Left(original_routes.map(replacer)))
}
DirectOrProxyMode::ProxyThenDirect(proxy) => {
DirectOrProxyMode::ProxyThenDirect(proxy)
| DirectOrProxyMode::DirectThenProxy(proxy) => {
let direct_first = matches!(mode, DirectOrProxyMode::DirectThenProxy(_));
let original_routes = original_routes.collect_vec();
let direct_routes = original_routes
.iter()
@ -330,18 +351,22 @@ where
.map(|r| r.replace(DirectOrProxyRoute::Direct))
.collect_vec();
let replacer = proxy.as_replacer();
let replacer = move |r: D::Route| replacer(r).replace(DirectOrProxyRoute::Proxy);
Either::Right(Either::Right(
original_routes
.into_iter()
.map(replacer)
.chain(direct_routes),
))
let proxied_routes = original_routes
.into_iter()
.map(move |r: D::Route| replacer(r).replace(DirectOrProxyRoute::Proxy))
.collect_vec();
let (first, second) = if direct_first {
(direct_routes, proxied_routes)
} else {
(proxied_routes, direct_routes)
};
Either::Right(Either::Right(first.into_iter().chain(second)))
}
}
}
}
// TODO(reflector): deep nesting of `Either` can be optimized away later.
trait AsReplacer {
fn as_replacer<R: ReplaceFragment<TcpRoute<UnresolvedHost>>>(
&self,
@ -354,26 +379,32 @@ impl AsReplacer for ConnectionProxyConfig {
) -> impl Fn(R) -> R::Replacement<ConnectionProxyRoute<Host<UnresolvedHost>>> {
let replacer = match self {
ConnectionProxyConfig::Tls(tls_proxy) => {
Either::Left(Either::Left(tls_proxy.as_replacer()))
Either::Left(Either::Left(Either::Left(tls_proxy.as_replacer())))
}
#[cfg(feature = "dev-util")]
ConnectionProxyConfig::Tcp(tcp_proxy) => {
Either::Right(Either::Left(tcp_proxy.as_replacer()))
Either::Left(Either::Right(Either::Left(tcp_proxy.as_replacer())))
}
ConnectionProxyConfig::Socks(socks_proxy) => {
let replacer = socks_proxy.as_replacer();
#[cfg(feature = "dev-util")]
let replacer = Either::Right(replacer);
Either::Right(replacer)
Either::Left(Either::Right(replacer))
}
ConnectionProxyConfig::Http(http_proxy) => {
Either::Left(Either::Right(http_proxy.as_replacer()))
Either::Left(Either::Left(Either::Right(http_proxy.as_replacer())))
}
// TODO(reflector): reshape replacement API to handle reflectors expansion.
ConnectionProxyConfig::Reflector(_providers) => Either::Right(
|_route: R| -> R::Replacement<ConnectionProxyRoute<Host<UnresolvedHost>>> {
unimplemented!("reflector route expansion not yet implemented")
},
),
};
move |route| match &replacer {
Either::Left(Either::Left(f)) => f(route),
Either::Left(Either::Right(f)) => f(route),
Either::Right(f) => match f {
Either::Left(Either::Left(Either::Left(f))) => f(route),
Either::Left(Either::Left(Either::Right(f))) => f(route),
Either::Left(Either::Right(f)) => match f {
#[cfg(feature = "dev-util")]
Either::Left(f) => f(route),
#[cfg(feature = "dev-util")]
@ -381,6 +412,7 @@ impl AsReplacer for ConnectionProxyConfig {
#[cfg(not(feature = "dev-util"))]
f => f(route),
},
Either::Right(f) => f(route),
}
}
}

View File

@ -17,8 +17,8 @@ use crate::dns::{DnsError, DnsResolver};
use crate::host::Host;
use crate::route::{
ConnectionProxyRoute, DirectOrProxyRoute, HttpProxyRouteFragment, HttpsProxyRoute,
HttpsTlsRoute, ProxyTarget, SocksRoute, TcpRoute, TlsRoute, UdpRoute, UnresolvedHost,
UsePreconnect, WebSocketRoute,
HttpsTlsRoute, ProxyTarget, ReflectorProxyRoute, SocksRoute, TcpRoute, TlsRoute, UdpRoute,
UnresolvedHost, UsePreconnect, WebSocketRoute,
};
/// A route with hostnames that can be resolved.
@ -246,7 +246,10 @@ impl<A: ResolveHostnames> ResolveHostnames for ConnectionProxyRoute<A> {
#[cfg(feature = "dev-util")]
Self::Tcp { proxy } => Either::Left(Either::Right(proxy.hostnames())),
Self::Socks(socks) => Either::Right(Either::Right(socks.hostnames())),
Self::Https(http) => Either::Right(Either::Left(http.hostnames())),
Self::Https(http) => Either::Right(Either::Left(Either::Left(http.hostnames()))),
Self::Reflector(reflector) => {
Either::Right(Either::Left(Either::Right(reflector.outer.hostnames())))
}
}
}
@ -263,6 +266,13 @@ impl<A: ResolveHostnames> ResolveHostnames for ConnectionProxyRoute<A> {
ConnectionProxyRoute::Socks(socks.resolve(lookup))
}
ConnectionProxyRoute::Https(http) => ConnectionProxyRoute::Https(http.resolve(lookup)),
ConnectionProxyRoute::Reflector(reflector) => {
let ReflectorProxyRoute { outer, target_host } = *reflector;
ConnectionProxyRoute::Reflector(Box::new(ReflectorProxyRoute {
outer: outer.resolve(lookup),
target_host,
}))
}
}
}
}
@ -407,6 +417,7 @@ impl<A: ResolvedRoute> ResolvedRoute for ConnectionProxyRoute<A> {
ConnectionProxyRoute::Tcp { proxy } => proxy.immediate_target(),
ConnectionProxyRoute::Socks(proxy) => proxy.immediate_target(),
ConnectionProxyRoute::Https(proxy) => proxy.immediate_target(),
ConnectionProxyRoute::Reflector(proxy) => proxy.outer.immediate_target(),
}
}
}

View File

@ -16,6 +16,7 @@ use crate::route::{
};
pub mod https;
pub mod reflector;
pub mod socks;
mod stream;
@ -94,6 +95,12 @@ impl Connector<ConnectionProxyRoute<IpAddr>, ()> for StatelessProxied {
.map_ok(Into::into)
.await
}
ConnectionProxyRoute::Reflector(route) => {
LoggingConnector::new(self, reflector::LONG_FULL_CONNECT_THRESHOLD, "Reflector")
.connect(route, log_tag)
.map_ok(|stream| ProxyStream::Reflector(Box::new(stream)))
.await
}
}
}
}

View File

@ -0,0 +1,476 @@
//
// Copyright 2026 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
use std::io;
use std::net::IpAddr;
use std::pin::Pin;
use std::task::{Context, Poll, ready};
use std::time::Duration;
use bytes::Bytes;
use futures_util::{Sink, Stream};
use http::{HeaderName, HeaderValue};
use pin_project::pin_project;
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tungstenite::Message;
use crate::errors::{LogSafeDisplay as _, TransportConnectError};
use crate::route::{ComposedConnector, Connector, ConnectorExt as _, ReflectorProxyRoute};
use crate::ws::error::WebSocketConnectError;
use crate::{Connection, ws};
pub(crate) const LONG_FULL_CONNECT_THRESHOLD: Duration = super::LONG_TCP_HANDSHAKE_THRESHOLD
.saturating_add(super::LONG_TLS_HANDSHAKE_THRESHOLD)
.saturating_add(Duration::from_secs(3));
const X_SIGNAL_HOST_HEADER: HeaderName = HeaderName::from_static("x-signal-host");
/// Cap per outbound `Message::Binary`; comfortably fits one TLS record.
const MAX_OUTBOUND_FRAME_SIZE: usize = 64 * 1024;
/// Outbound buffer cap; once exceeded, tungstenite returns `WriteBufferFull`.
const MAX_OUTBOUND_BUFFER_SIZE: usize = 4 * MAX_OUTBOUND_FRAME_SIZE;
fn ws_config() -> tungstenite::protocol::WebSocketConfig {
let mut config = tungstenite::protocol::WebSocketConfig::default();
config.write_buffer_size = MAX_OUTBOUND_FRAME_SIZE;
config.max_write_buffer_size = MAX_OUTBOUND_BUFFER_SIZE;
config
}
type StatelessTcpConnector = crate::tcp_ssl::StatelessTcp;
type StatelessTlsConnector = ComposedConnector<crate::tcp_ssl::StatelessTls, StatelessTcpConnector>;
type ReflectorConnector = ComposedConnector<ws::WithoutResponseHeaders, StatelessTlsConnector>;
#[derive(Debug)]
#[pin_project(project = ReflectorStreamProj)]
pub struct ReflectorStream {
#[pin]
inner: tokio_tungstenite::WebSocketStream<Box<dyn ws::WebSocketTransportStream>>,
pending_read: Bytes,
// Set on I/O error: a Sink failure can drop already-accepted writes.
broken: bool,
}
impl ReflectorStreamProj<'_> {
fn poison_on_err<T>(&mut self, result: Poll<io::Result<T>>) -> Poll<io::Result<T>> {
if matches!(&result, Poll::Ready(Err(_))) {
*self.broken = true;
}
result
}
}
impl ReflectorStream {
fn new(
inner: tokio_tungstenite::WebSocketStream<Box<dyn ws::WebSocketTransportStream>>,
) -> Self {
Self {
inner,
pending_read: Bytes::new(),
broken: false,
}
}
}
fn websocket_error_to_io(error: tungstenite::Error) -> io::Error {
match error {
tungstenite::Error::Io(error) => error,
tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed => {
io::Error::new(io::ErrorKind::UnexpectedEof, error)
}
other => io::Error::other(other),
}
}
impl AsyncRead for ReflectorStream {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
if buf.remaining() == 0 {
return Poll::Ready(Ok(()));
}
let mut this = self.project();
if *this.broken {
return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
}
loop {
if !this.pending_read.is_empty() {
let to_copy = this.pending_read.len().min(buf.remaining());
buf.put_slice(&this.pending_read.split_to(to_copy));
return Poll::Ready(Ok(()));
}
match ready!(this.inner.as_mut().poll_next(cx)) {
Some(Ok(Message::Binary(binary))) => {
*this.pending_read = binary;
}
Some(Ok(Message::Text(_))) => {
*this.broken = true;
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"reflector tunnel received text frame",
)));
}
Some(Ok(Message::Close(_))) | None => return Poll::Ready(Ok(())),
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {}
Some(Ok(Message::Frame(_))) => {
unreachable!("Message::Frame is never returned for a read")
}
Some(Err(error)) => {
*this.broken = true;
return Poll::Ready(Err(websocket_error_to_io(error)));
}
}
}
}
}
impl AsyncWrite for ReflectorStream {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<Result<usize, io::Error>> {
if buf.is_empty() {
return Poll::Ready(Ok(0));
}
let mut this = self.project();
if *this.broken {
return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
}
match this.inner.as_mut().poll_ready(cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(e)) => {
*this.broken = true;
return Poll::Ready(Err(websocket_error_to_io(e)));
}
Poll::Ready(Ok(())) => {}
}
let chunk = &buf[..buf.len().min(MAX_OUTBOUND_FRAME_SIZE)];
if let Err(e) = this
.inner
.as_mut()
.start_send(Message::Binary(Bytes::copy_from_slice(chunk)))
{
*this.broken = true;
return Poll::Ready(Err(websocket_error_to_io(e)));
}
Poll::Ready(Ok(chunk.len()))
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
let mut this = self.project();
if *this.broken {
return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
}
let result = this
.inner
.as_mut()
.poll_flush(cx)
.map_err(websocket_error_to_io);
this.poison_on_err(result)
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), io::Error>> {
let mut this = self.project();
if *this.broken {
return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into()));
}
let result = this
.inner
.as_mut()
.poll_close(cx)
.map_err(websocket_error_to_io);
this.poison_on_err(result)
}
}
impl Connection for ReflectorStream {
fn transport_info(&self) -> crate::TransportInfo {
self.inner.transport_info()
}
}
impl Connector<Box<ReflectorProxyRoute<IpAddr>>, ()> for super::StatelessProxied {
type Connection = ReflectorStream;
type Error = TransportConnectError;
async fn connect_over(
&self,
(): (),
route: Box<ReflectorProxyRoute<IpAddr>>,
log_tag: &str,
) -> Result<Self::Connection, Self::Error> {
let ReflectorProxyRoute {
mut outer,
target_host,
} = *route;
outer.fragment.ws_config = ws_config();
outer.fragment.headers.insert(
X_SIGNAL_HOST_HEADER,
HeaderValue::from_str(&target_host)
.map_err(|_| TransportConnectError::InvalidConfiguration)?,
);
let connector = ReflectorConnector::new(
ws::WithoutResponseHeaders::new(),
StatelessTlsConnector::default(),
);
log::info!("[{log_tag}] attempting connection over reflector proxy");
match connector.connect(outer, log_tag).await {
Ok(websocket) => Ok(ReflectorStream::new(websocket)),
Err(WebSocketConnectError::Transport(error)) => Err(error),
Err(WebSocketConnectError::WebSocketError(error)) => {
log::info!(
"[{log_tag}] failed to connect via reflector proxy: {}",
error.log_safe_display()
);
Err(TransportConnectError::ProxyProtocol)
}
}
}
}
#[cfg(test)]
mod test {
use std::borrow::Cow;
use std::net::SocketAddr;
use assert_matches::assert_matches;
use futures_util::{SinkExt as _, StreamExt as _};
use http::uri::PathAndQuery;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use super::*;
use crate::certs::RootCertificates;
use crate::host::Host;
use crate::route::{
HttpRouteFragment, HttpVersion, HttpsTlsRoute, TcpRoute, TlsRoute, TlsRouteFragment,
WebSocketRoute, WebSocketRouteFragment,
};
use crate::tcp_ssl::proxy::testutil::{
PROXY_CERTIFICATE, PROXY_HOSTNAME, TcpServer, TlsServer,
};
use crate::{Alpn, OverrideNagleAlgorithm};
const TARGET_HOST: &str = "chat.signal.org";
const EXPECTED_PATH: &str = "/tls-tunnel";
fn test_route(server_addr: SocketAddr) -> Box<ReflectorProxyRoute<IpAddr>> {
Box::new(ReflectorProxyRoute {
outer: WebSocketRoute {
fragment: WebSocketRouteFragment {
ws_config: WebSocketConfig::default(),
endpoint: PathAndQuery::from_static(EXPECTED_PATH),
headers: Default::default(),
},
inner: HttpsTlsRoute {
fragment: HttpRouteFragment {
host_header: PROXY_HOSTNAME.into(),
path_prefix: "".into(),
http_version: Some(HttpVersion::Http1_1),
front_name: Some("reflector-test"),
},
inner: TlsRoute {
fragment: TlsRouteFragment {
root_certs: RootCertificates::FromDer(Cow::Borrowed(
PROXY_CERTIFICATE.cert.der(),
)),
sni: Host::Domain(PROXY_HOSTNAME.into()),
alpn: Some(Alpn::Http1_1),
min_protocol_version: None,
},
inner: TcpRoute {
address: server_addr.ip(),
port: server_addr.port().try_into().expect("valid port"),
override_nagle_algorithm: OverrideNagleAlgorithm::UseSystemDefault,
},
},
},
},
target_host: TARGET_HOST.into(),
})
}
#[test_log::test(tokio::test)]
async fn raw_reflector_success_test() {
let tls_server = TlsServer::new(TcpServer::bind_localhost(), &PROXY_CERTIFICATE);
let server_addr = tls_server.tcp.listen_addr;
let (request_tx, mut request_rx) = mpsc::unbounded_channel();
let server_task = tokio::spawn(async move {
let (tls_stream, _remote_addr) = tls_server.accept().await;
let mut websocket = tokio_tungstenite::accept_hdr_async(
tls_stream,
// Tungstenite's handshake callback type; not our code.
#[allow(clippy::result_large_err)]
move |request: &Request, response: Response| {
request_tx
.send((
request.uri().path().to_owned(),
request.headers().get(http::header::HOST).cloned(),
request.headers().get(&X_SIGNAL_HOST_HEADER).cloned(),
))
.expect("receiver still alive");
Ok(response)
},
)
.await
.expect("can upgrade");
websocket
.send(Message::Binary(Bytes::from_static(b"from reflector")))
.await
.expect("can send");
let received = websocket
.next()
.await
.expect("client message")
.expect("websocket ok");
assert_eq!(
received,
Message::Binary(Bytes::from_static(b"from client"))
);
});
let mut stream = super::super::StatelessProxied
.connect(test_route(server_addr), "test")
.await
.expect("can connect");
stream.write_all(b"from client").await.expect("can write");
stream.flush().await.expect("can flush");
let mut received = [0; "from reflector".len()];
stream
.read_exact(&mut received)
.await
.expect("can read tunneled bytes");
assert_eq!(&received, b"from reflector");
let (path, host, x_signal_host) = request_rx.recv().await.expect("captured request");
assert_eq!(path, EXPECTED_PATH);
assert_eq!(host, Some(HeaderValue::from_static(PROXY_HOSTNAME)));
assert_eq!(x_signal_host, Some(HeaderValue::from_static(TARGET_HOST)));
tokio::time::timeout(Duration::from_secs(1), server_task)
.await
.expect("server task finished within 1s")
.expect("server task succeeded");
}
#[test_log::test(tokio::test)]
async fn upgrade_failure_maps_to_proxy_protocol() {
let tls_server = TlsServer::new(TcpServer::bind_localhost(), &PROXY_CERTIFICATE);
let server_addr = tls_server.tcp.listen_addr;
let server_task = tokio::spawn(async move {
let (mut tls_stream, _remote_addr) = tls_server.accept().await;
tls_stream
.write_all(b"HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n")
.await
.expect("can write");
tls_stream.flush().await.expect("can flush");
});
let result = super::super::StatelessProxied
.connect(test_route(server_addr), "test")
.await;
assert_matches!(result, Err(TransportConnectError::ProxyProtocol));
tokio::time::timeout(Duration::from_secs(1), server_task)
.await
.expect("server task finished within 1s")
.expect("server task succeeded");
}
#[tokio::test]
async fn text_frame_rejected() {
let (mut server, client) = crate::ws::testutil::fake_websocket().await;
let mut stream = ReflectorStream::new(client);
server
.send(Message::Text("unexpected".into()))
.await
.expect("can send");
let mut buf = [0; 16];
let error = stream
.read(&mut buf)
.await
.expect_err("text frames are rejected");
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
}
#[tokio::test]
async fn writes_larger_than_frame_cap_are_chunked() {
let (mut server, client) = crate::ws::testutil::fake_websocket().await;
let mut stream = ReflectorStream::new(client);
let server_task =
tokio::spawn(async move { server.next().await.expect("msg").expect("ok") });
let payload = vec![0xAA; MAX_OUTBOUND_FRAME_SIZE + 100];
let n = stream.write(&payload).await.expect("can write");
stream.flush().await.expect("can flush");
assert_eq!(n, MAX_OUTBOUND_FRAME_SIZE);
let msg = server_task.await.expect("task ok");
let bytes = assert_matches!(msg, Message::Binary(bytes) => bytes);
assert_eq!(bytes.len(), MAX_OUTBOUND_FRAME_SIZE);
}
#[tokio::test]
async fn stream_is_poisoned_after_error() {
let (mut server, client) = crate::ws::testutil::fake_websocket().await;
let mut stream = ReflectorStream::new(client);
server
.send(Message::Text("unexpected".into()))
.await
.expect("can send");
let mut buf = [0; 16];
let initial = stream
.read(&mut buf)
.await
.expect_err("text frames are rejected");
assert_eq!(initial.kind(), io::ErrorKind::InvalidData);
assert_eq!(
stream
.read(&mut buf)
.await
.expect_err("read should short-circuit")
.kind(),
io::ErrorKind::BrokenPipe,
);
assert_eq!(
stream
.write(b"x")
.await
.expect_err("write should short-circuit")
.kind(),
io::ErrorKind::BrokenPipe,
);
assert_eq!(
stream
.flush()
.await
.expect_err("flush should short-circuit")
.kind(),
io::ErrorKind::BrokenPipe,
);
}
}

View File

@ -9,6 +9,7 @@ use tokio_boring_signal::SslStream;
use crate::Connection;
use crate::tcp_ssl::TcpStream;
use crate::tcp_ssl::proxy::https::HttpProxyStream;
use crate::tcp_ssl::proxy::reflector::ReflectorStream;
use crate::tcp_ssl::proxy::socks::SocksStream;
#[derive(Debug, derive_more::From)]
@ -18,6 +19,7 @@ pub enum ProxyStream {
Tcp(TcpStream),
Socks(SocksStream<TcpStream>),
Http(HttpProxyStream),
Reflector(Box<ReflectorStream>),
}
impl Connection for ProxyStream {
@ -27,6 +29,7 @@ impl Connection for ProxyStream {
ProxyStream::Tcp(tcp_stream) => tcp_stream.transport_info(),
ProxyStream::Socks(either) => either.transport_info(),
ProxyStream::Http(http) => http.transport_info(),
ProxyStream::Reflector(reflector) => reflector.transport_info(),
}
}
}

View File

@ -61,6 +61,10 @@ impl FakeTransportTarget {
}),
port: *target_port,
},
ConnectionProxyRoute::Reflector(reflector) => Self::TcpThroughProxy {
host: Some(Host::Domain(reflector.target_host.clone())),
port: DEFAULT_HTTPS_PORT,
},
}
}
}

View File

@ -2,21 +2,29 @@
// Copyright 2025 Signal Messenger, LLC.
// SPDX-License-Identifier: AGPL-3.0-only
//
use std::collections::HashMap;
use std::fmt::Debug;
use std::future::Future;
use std::marker::PhantomData;
use std::net::{IpAddr, Ipv4Addr};
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::time::Duration;
use futures_util::{Sink, Stream};
use http_body_util::BodyExt;
use libsignal_net_infra::TransportInfo;
use libsignal_net_infra::route::GetCurrentInterface;
use libsignal_net_infra::http_client::{Http2Client, Http2Connector};
use libsignal_net_infra::route::{Connector, GetCurrentInterface, HttpRouteFragment, HttpVersion};
use libsignal_net_infra::stream::StreamWithFixedTransportInfo;
use libsignal_net_infra::utils::no_network_change_events;
use pin_project::pin_project;
use prost::Message;
use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::chat::{ChatConnection, ConnectionInfo, MessageProto, RequestProto, ResponseProto, ws};
use crate::chat::{
ChatConnection, ConnectionInfo, GrpcBody, GrpcOverride, MessageProto, RequestProto,
ResponseProto, ws,
};
use crate::connect_state::RouteInfo;
use crate::env::ALERT_HEADER_NAME;
@ -25,6 +33,25 @@ use crate::env::ALERT_HEADER_NAME;
pub struct FakeChatRemote {
tx: tokio::sync::mpsc::UnboundedSender<Result<tungstenite::Message, tungstenite::Error>>,
rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<tungstenite::Message>>,
grpc: tokio::sync::Mutex<FakeGrpcRemote>,
}
#[derive(Debug)]
struct GrpcResponseSender(
tokio::sync::oneshot::Sender<http::Response<http_body_util::Full<bytes::Bytes>>>,
);
/// We never use this without consuming it, so unwinding isn't an issue.
impl std::panic::UnwindSafe for GrpcResponseSender {}
/// The remote end of a fake gRPC connection to the chat server.
#[derive(Debug)]
pub struct FakeGrpcRemote {
incoming: tokio::sync::mpsc::UnboundedReceiver<(
http::Request<hyper::body::Incoming>,
GrpcResponseSender,
)>,
response_map: HashMap<u64, GrpcResponseSender>,
next_id: u64,
}
/// Error returned when a send fails because the client end has finished.
@ -44,16 +71,12 @@ impl ChatConnection {
pub fn new_fake<'a>(
tokio_runtime: tokio::runtime::Handle,
listener: ws::EventListener,
grpc_overrides: impl IntoIterator<Item = &'static str>,
alerts: impl IntoIterator<Item = &'a str>,
) -> (Self, FakeChatRemote) {
let (tx_to_local, rx_from_remote) = tokio::sync::mpsc::unbounded_channel();
let (tx_to_remote, rx_from_local) = tokio::sync::mpsc::unbounded_channel();
let remote = FakeChatRemote {
tx: tx_to_local,
rx: rx_from_local.into(),
};
let incoming = UnboundedReceiverStream::new(rx_from_remote);
let outgoing = futures_util::sink::unfold(tx_to_remote, |tx, message| async move {
tx.send(message).map_err(|_send_failed| {
@ -63,6 +86,14 @@ impl ChatConnection {
});
let local = StreamSink(incoming, outgoing, PhantomData);
let (h2_connection, grpc_remote) = Self::h2_connection(&tokio_runtime);
let remote = FakeChatRemote {
tx: tx_to_local,
rx: rx_from_local.into(),
grpc: grpc_remote.into(),
};
let connection_info = ConnectionInfo {
route_info: RouteInfo::fake(),
transport_info: TransportInfo {
@ -97,18 +128,76 @@ impl ChatConnection {
transport_info: connection_info.transport_info.clone(),
get_current_interface: FakeCurrentInterface,
},
None,
Some(h2_connection),
no_network_change_events(),
listener,
),
connection_info,
grpc_overrides: Default::default(),
grpc_overrides: HashMap::from_iter(
grpc_overrides
.into_iter()
.map(|api| (api, GrpcOverride::UseGrpc)),
),
// This isn't perfect, but without it we can't test APIs that rely on knowing the self
// ACI, so it's better that we set it to *something*.
self_aci: Some(libsignal_core::Aci::from_uuid_bytes([0xff; 16])),
};
(chat, remote)
}
fn h2_connection(
tokio_runtime: &tokio::runtime::Handle,
) -> (Http2Client<GrpcBody>, FakeGrpcRemote) {
let (remote_incoming_req_tx, remote_incoming_req_rx) =
tokio::sync::mpsc::unbounded_channel();
let (client_io, server_io) = tokio::io::duplex(65536);
_ = tokio_runtime.spawn(
hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(
hyper_util::rt::TokioIo::new(server_io),
hyper::service::service_fn(move |req| {
let remote_incoming_req_tx = remote_incoming_req_tx.clone();
async move {
let (response_tx, response_rx) = tokio::sync::oneshot::channel::<
http::Response<http_body_util::Full<bytes::Bytes>>,
>();
remote_incoming_req_tx
.send((req, GrpcResponseSender(response_tx)))
.map_err(|_| "server shutdown")?;
response_rx.await.map_err(|_| "server shutdown")
}
}),
),
);
let _make_tokio_runtime_available_for_connect = tokio_runtime.enter();
let client = futures::executor::block_on(Http2Connector::new().connect_over(
StreamWithFixedTransportInfo::new(
client_io,
TransportInfo {
local_addr: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0),
remote_addr: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 0),
},
),
HttpRouteFragment {
host_header: "fake-chat.signal.org".into(),
path_prefix: Default::default(),
http_version: Some(HttpVersion::Http2),
front_name: None,
},
"fake h2",
))
.expect("valid");
let remote = FakeGrpcRemote {
incoming: remote_incoming_req_rx,
response_map: Default::default(),
next_id: 1,
};
(client, remote)
}
}
struct FakeCurrentInterface;
@ -156,9 +245,12 @@ impl FakeChatRemote {
pub async fn receive_request(&self) -> Result<Option<RequestProto>, ReceiveRequestError> {
log::debug!("waiting for next request");
let Some(message) = self.rx.lock().await.recv().await else {
return Ok(None);
};
let message =
match tokio::time::timeout(Duration::from_secs(3), self.rx.lock().await.recv()).await {
Ok(Some(message)) => message,
Ok(None) => return Ok(None),
Err(_) => panic!("receive_request timed out, did you actually send a WS request?"),
};
let proto = match message {
tungstenite::Message::Close(None)
| tungstenite::Message::Close(Some(tungstenite::protocol::CloseFrame {
@ -185,6 +277,53 @@ impl FakeChatRemote {
}))))
.map_err(|_failed_send| Disconnected)
}
pub async fn grpc(&self) -> tokio::sync::MutexGuard<'_, FakeGrpcRemote> {
self.grpc.lock().await
}
}
impl FakeGrpcRemote {
pub async fn receive_request(
&mut self,
) -> Result<Option<(u64, http::Request<bytes::Bytes>)>, ReceiveRequestError> {
log::debug!("waiting for next request");
let (req, response_tx) =
match tokio::time::timeout(Duration::from_secs(3), self.incoming.recv()).await {
Ok(Some(next)) => next,
Ok(None) => return Ok(None),
Err(_) => {
panic!("receive_request timed out, did you actually send a gRPC request?")
}
};
let id = self.next_id;
self.response_map.insert(id, response_tx);
self.next_id += 1;
let (head, body) = req.into_parts();
let body = body
.collect()
.await
.map_err(|_| ReceiveRequestError::InvalidWebsocketMessageType)?
.to_bytes();
Ok(Some((id, http::Request::from_parts(head, body))))
}
pub fn send_response(
&mut self,
which: u64,
response: http::Response<bytes::Bytes>,
) -> Result<(), Disconnected> {
log::debug!("sending response");
let Some(GrpcResponseSender(response_tx)) = self.response_map.remove(&which) else {
// TODO: wrong error
return Err(Disconnected);
};
response_tx
.send(response.map(http_body_util::Full::new))
.map_err(|_| Disconnected)
}
}
impl From<ws::ChatProtoDataError> for ReceiveRequestError {

View File

@ -13,12 +13,14 @@ extension AuthenticatedChatConnection {
internal static func fakeConnect(
tokioAsyncContext: TokioAsyncContext,
listener: any ChatConnectionListener,
grpcOverrides: [String] = [],
alerts: [String] = []
) -> (AuthenticatedChatConnection, FakeChatRemote) {
let (fakeChatConnection, listenerBridge) = failOnError {
try FakeChatConnection.create(
tokioAsyncContext: tokioAsyncContext,
listener: listener,
grpcOverrides: grpcOverrides,
alerts: alerts
)
}
@ -59,12 +61,14 @@ extension AuthenticatedChatConnection {
extension UnauthenticatedChatConnection {
internal static func fakeConnect(
tokioAsyncContext: TokioAsyncContext,
listener: any ConnectionEventsListener<UnauthenticatedChatConnection>
listener: any ConnectionEventsListener<UnauthenticatedChatConnection>,
grpcOverrides: [String] = [],
) -> (UnauthenticatedChatConnection, FakeChatRemote) {
let (fakeChatConnection, listenerBridge) = failOnError {
try FakeChatConnection.create(
tokioAsyncContext: tokioAsyncContext,
listener: listener,
grpcOverrides: grpcOverrides,
alerts: []
)
}
@ -270,6 +274,67 @@ internal class FakeChatRemote: NativeHandleOwner<SignalMutPointerFakeChatRemoteE
}
}
func getNextIncomingGrpcRequest() async throws -> (ChatRequest.InternalRequest, UInt64) {
while true {
let request = try await self.tokioAsyncContext.invokeAsyncFunction { promise, asyncContext in
withNativeHandle { handle in
signal_testing_fake_chat_remote_end_receive_incoming_grpc_request(
promise,
asyncContext.const(),
handle.const()
)
}
}
guard request.present else {
continue
}
let httpRequest = ChatRequest.InternalRequest(owned: NonNull(request.first)!)
let requestId = request.second
return (httpRequest, requestId)
}
}
func sendGrpcResponse(requestId: UInt64, _ response: ChatResponse) async throws {
let fakeResponse = FakeChatResponse(requestId: requestId, response)
_ = try await self.tokioAsyncContext.invokeAsyncFunction { promise, asyncContext in
self.withNativeHandle { nativeHandle in
fakeResponse.withNativeHandle { response in
signal_testing_fake_chat_remote_end_send_server_grpc_response(
promise,
asyncContext.const(),
nativeHandle.const(),
response.const()
)
}
}
}
}
static func encodeSingleGrpcMessage(_ name: String, json: NSDictionary) -> Data {
let message = String(data: try! JSONSerialization.data(withJSONObject: json), encoding: .utf8)
var result = failOnError {
try invokeFnReturningData {
signal_testing_fake_chat_remote_end_json_to_binproto($0, name, message)
}
}
let header = failOnError {
try invokeFnReturningData {
signal_testing_fake_chat_remote_end_grpc_frame_for_message_length($0, UInt32(result.count))
}
}
result.insert(contentsOf: header, at: 0)
return result
}
func sendGrpcResponse(requestId: UInt64, name: String, json: NSDictionary) async throws {
try await sendGrpcResponse(
requestId: requestId,
ChatResponse(status: 200, body: Self.encodeSingleGrpcMessage(name, json: json))
)
}
func injectServerResponse(base64: String) {
self.injectServerResponse(Data(base64Encoded: base64)!)
}
@ -377,26 +442,28 @@ private class FakeChatConnection: NativeHandleOwner<SignalMutPointerFakeChatConn
static func create(
tokioAsyncContext: TokioAsyncContext,
listener: any ChatConnectionListener,
grpcOverrides: [String] = [],
alerts: [String]
) throws -> (FakeChatConnection, SetChatLaterListenerBridge) {
let listenerBridge = SetChatLaterListenerBridge(
chatConnectionListenerForTesting: listener
)
var listenerStruct = listenerBridge.makeListenerStruct()
let chat = try FakeChatConnection.internalCreate(tokioAsyncContext, &listenerStruct, alerts)
let chat = try FakeChatConnection.internalCreate(tokioAsyncContext, &listenerStruct, grpcOverrides, alerts)
return (chat, listenerBridge)
}
static func create(
tokioAsyncContext: TokioAsyncContext,
listener: any ConnectionEventsListener<UnauthenticatedChatConnection>,
grpcOverrides: [String] = [],
alerts: [String]
) throws -> (FakeChatConnection, SetChatLaterUnauthListenerBridge) {
let listenerBridge = SetChatLaterUnauthListenerBridge(
chatConnectionEventsListenerForTesting: listener
)
var listenerStruct = listenerBridge.makeListenerStruct()
let chat = try FakeChatConnection.internalCreate(tokioAsyncContext, &listenerStruct, alerts)
let chat = try FakeChatConnection.internalCreate(tokioAsyncContext, &listenerStruct, grpcOverrides, alerts)
return (chat, listenerBridge)
}
@ -425,6 +492,7 @@ private class FakeChatConnection: NativeHandleOwner<SignalMutPointerFakeChatConn
private static func internalCreate(
_ tokioAsyncContext: TokioAsyncContext,
_ listenerStruct: inout SignalFfiChatListenerStruct,
_ grpcOverrides: [String],
_ alerts: [String]
) throws -> FakeChatConnection {
let connection: FakeChatConnection = try withUnsafePointer(to: &listenerStruct) { listener in
@ -434,6 +502,7 @@ private class FakeChatConnection: NativeHandleOwner<SignalMutPointerFakeChatConn
$0,
asyncContext.const(),
SignalConstPointerFfiChatListenerStruct(raw: listener),
grpcOverrides.joined(separator: "\n"),
alerts.joined(separator: "\n")
)
}

View File

@ -119,6 +119,36 @@ public struct ChatRequest: Equatable, Sendable {
}
}
}
internal static func getNextGrpcMessage(_ name: String, _ body: inout Data) -> NSDictionary {
var messageOffsets = SignalPairOfu32u32()
body.withBorrowed { body in
failOnError(
signal_testing_fake_chat_remote_end_next_grpc_message(&messageOffsets, body, 0)
)
}
let message = body.prefix(Int(messageOffsets.second)).dropFirst(Int(messageOffsets.first))
body = body.dropFirst(Int(messageOffsets.second))
let messageJson = message.withBorrowed { message in
failOnError {
try invokeFnReturningString {
signal_testing_fake_chat_remote_end_binproto_to_json($0, name, message)
}
}
}
// Not only is this essentially a testing API, we also know binproto_to_json will always
// produce a JSON object, which is decoded as a dictionary.
// swiftlint:disable:next force_cast
return try! JSONSerialization.jsonObject(with: messageJson.data(using: .utf8)!) as! NSDictionary
}
internal func getSingleGrpcMessage(_ name: String) -> NSDictionary {
var body = self.body
let result = InternalRequest.getNextGrpcMessage(name, &body)
precondition(body.isEmpty, "message had trailing data, use getNextGrpcMessage instead")
return result
}
#endif
}
}

View File

@ -63,6 +63,45 @@ public enum KeyTransparency {
}
}
/// A tag identifying an optional field of the account data.
///
/// (Must be in sync with the Rust counterpart)
public enum AccountDataField: UInt8 {
case e164 = 0
case usernameHash = 1
}
/// Resets a particular field in the data associated with given ACI.
///
/// Must only be called for the "self" account when either E.164 or username
/// change is performed.
///
/// Upon successful completion the data associated with the account will be
/// updated in the store, if it was present to begin with, noop if it was not.
///
/// - Parameters:
/// - field: Account data field to be reset (E.164 or username hash).
/// - aci: An ACI of "self" account.
/// - store: local persistent storage for key transparency-related data.
/// - Throws: ``SignalError/invalidArgument(_:)`` if the stored data cannot
/// be decoded correctly, which means data corruption.
public static func resetField(
_ field: AccountDataField,
for aci: Aci,
store: some Store
) async throws {
guard let accountData = await store.getAccountData(for: aci) else { return }
let updated = try accountData.withUnsafeBorrowedBuffer { accountDataBuffer in
try invokeFnReturningData {
signal_key_transparency_reset_data_field($0, accountDataBuffer, field.rawValue)
}
}
if updated.isEmpty {
throw SignalError.invalidArgument("failed to decode account data")
}
await store.setAccountData(updated, for: aci)
}
/// Typed API to access the key transparency subsystem using an existing
/// unauthenticated chat connection.
///

View File

@ -658,6 +658,21 @@ typedef struct {
size_t length;
} SignalBorrowedSliceOfConstPointerCiphertextMessage;
/**
* 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 SignalOwnedBuffer *result, const void *context);
const void *context;
SignalCancellationId cancellation_id;
} SignalCPromiseOwnedBufferOfc_uchar;
/**
* A wrapper type for raw UUIDs, because C treats arrays specially in argument position.
*/
@ -1746,6 +1761,8 @@ SignalFfiError *signal_authenticated_chat_connection_send(SignalCPromiseFfiChatR
SignalFfiError *signal_authenticated_chat_connection_send_message(SignalCPromisebool *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerAuthenticatedChatConnection chat, const SignalServiceIdFixedWidthBinaryBytes *destination, uint64_t timestamp, SignalBorrowedSliceOfu32 device_ids, SignalBorrowedSliceOfu32 registration_ids, SignalBorrowedSliceOfConstPointerCiphertextMessage contents, bool online_only, bool is_urgent);
SignalFfiError *signal_authenticated_chat_connection_send_raw_grpc(SignalCPromiseOwnedBufferOfc_uchar *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerAuthenticatedChatConnection chat, const char *service, const char *method, SignalBorrowedBuffer payload);
SignalFfiError *signal_authenticated_chat_connection_send_sync_message(SignalCPromisebool *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerAuthenticatedChatConnection chat, uint64_t timestamp, SignalBorrowedSliceOfu32 device_ids, SignalBorrowedSliceOfu32 registration_ids, SignalBorrowedSliceOfConstPointerCiphertextMessage contents, bool is_urgent);
SignalFfiError *signal_backup_auth_credential_check_valid_contents(SignalBorrowedBuffer params_bytes);
@ -2133,6 +2150,8 @@ SignalFfiError *signal_key_transparency_check(SignalCPromisePairOfOwnedBufferOfc
SignalFfiError *signal_key_transparency_e164_search_key(SignalOwnedBuffer *out, const char *e164);
SignalFfiError *signal_key_transparency_reset_data_field(SignalOwnedBuffer *out, SignalBorrowedBuffer account_data, uint8_t field);
SignalFfiError *signal_key_transparency_username_hash_search_key(SignalOwnedBuffer *out, SignalBorrowedBuffer hash);
SignalFfiError *signal_kyber_key_pair_clone(SignalMutPointerKyberKeyPair *new_obj, SignalConstPointerKyberKeyPair obj);
@ -2793,6 +2812,8 @@ SignalFfiError *signal_unauthenticated_chat_connection_send_message(SignalCPromi
SignalFfiError *signal_unauthenticated_chat_connection_send_multi_recipient_message(SignalCPromiseOwnedBufferOfServiceIdFixedWidthBinaryBytes *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerUnauthenticatedChatConnection chat, SignalBorrowedBuffer payload, uint64_t timestamp, SignalBorrowedBuffer auth, bool online_only, bool is_urgent);
SignalFfiError *signal_unauthenticated_chat_connection_send_raw_grpc(SignalCPromiseOwnedBufferOfc_uchar *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerUnauthenticatedChatConnection chat, const char *service, const char *method, SignalBorrowedBuffer payload);
SignalFfiError *signal_unidentified_sender_message_content_deserialize(SignalMutPointerUnidentifiedSenderMessageContent *out, SignalBorrowedBuffer data);
SignalFfiError *signal_unidentified_sender_message_content_destroy(SignalMutPointerUnidentifiedSenderMessageContent p);

View File

@ -118,6 +118,11 @@ typedef struct {
const SignalFakeChatRemoteEnd *raw;
} SignalConstPointerFakeChatRemoteEnd;
typedef struct {
uint32_t first;
uint32_t second;
} SignalPairOfu32u32;
typedef struct {
bool present;
SignalMutPointerHttpRequest first;
@ -236,21 +241,6 @@ typedef struct {
SignalTestingSemaphore *raw;
} SignalMutPointerTestingSemaphore;
/**
* 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 SignalOwnedBuffer *result, const void *context);
const void *context;
SignalRawCancellationId cancellation_id;
} SignalCPromiseOwnedBufferOfc_uchar;
typedef struct {
SignalTestingValueHolder *raw;
} SignalMutPointerTestingValueHolder;
@ -327,7 +317,7 @@ SignalFfiError *signal_testing_error_on_return_io(SignalCPromiseRawPointer *prom
SignalFfiError *signal_testing_error_on_return_sync(const void **out, const void *_needs_cleanup);
SignalFfiError *signal_testing_fake_chat_connection_create(SignalMutPointerFakeChatConnection *out, SignalConstPointerTokioAsyncContext tokio, SignalConstPointerFfiChatListenerStruct listener, const char *alerts_joined_by_newlines);
SignalFfiError *signal_testing_fake_chat_connection_create(SignalMutPointerFakeChatConnection *out, SignalConstPointerTokioAsyncContext tokio, SignalConstPointerFfiChatListenerStruct listener, const char *grpc_overrides_joined_by_newlines, const char *alerts_joined_by_newlines);
SignalFfiError *signal_testing_fake_chat_connection_create_provisioning(SignalMutPointerFakeChatConnection *out, SignalConstPointerTokioAsyncContext tokio, SignalConstPointerFfiProvisioningListenerStruct listener);
@ -339,14 +329,26 @@ SignalFfiError *signal_testing_fake_chat_connection_take_remote(SignalMutPointer
SignalFfiError *signal_testing_fake_chat_connection_take_unauthenticated_chat(SignalMutPointerUnauthenticatedChatConnection *out, SignalConstPointerFakeChatConnection chat);
SignalFfiError *signal_testing_fake_chat_remote_end_binproto_to_json(const char **out, const char *name, SignalBorrowedBuffer input);
SignalFfiError *signal_testing_fake_chat_remote_end_grpc_frame_for_message_length(SignalOwnedBuffer *out, uint32_t len);
SignalFfiError *signal_testing_fake_chat_remote_end_inject_connection_interrupted(SignalConstPointerFakeChatRemoteEnd chat);
SignalFfiError *signal_testing_fake_chat_remote_end_json_to_binproto(SignalOwnedBuffer *out, const char *name, const char *input);
SignalFfiError *signal_testing_fake_chat_remote_end_next_grpc_message(SignalPairOfu32u32 *out, SignalBorrowedBuffer input, uint32_t offset);
SignalFfiError *signal_testing_fake_chat_remote_end_receive_incoming_grpc_request(SignalCPromiseOptionalPairOfMutPointerHttpRequestu64 *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerFakeChatRemoteEnd chat);
SignalFfiError *signal_testing_fake_chat_remote_end_receive_incoming_request(SignalCPromiseOptionalPairOfMutPointerHttpRequestu64 *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerFakeChatRemoteEnd chat);
SignalFfiError *signal_testing_fake_chat_remote_end_send_raw_server_request(SignalConstPointerFakeChatRemoteEnd chat, SignalBorrowedBuffer bytes);
SignalFfiError *signal_testing_fake_chat_remote_end_send_raw_server_response(SignalConstPointerFakeChatRemoteEnd chat, SignalBorrowedBuffer bytes);
SignalFfiError *signal_testing_fake_chat_remote_end_send_server_grpc_response(SignalCPromisebool *promise, SignalConstPointerTokioAsyncContext async_runtime, SignalConstPointerFakeChatRemoteEnd chat, SignalConstPointerFakeChatResponse response);
SignalFfiError *signal_testing_fake_chat_remote_end_send_server_response(SignalConstPointerFakeChatRemoteEnd chat, SignalConstPointerFakeChatResponse response);
SignalFfiError *signal_testing_fake_chat_response_create(SignalMutPointerFakeChatResponse *out, uint64_t id, uint16_t status, const char *message, SignalBorrowedBytestringArray headers, SignalOptionalBorrowedSliceOfc_uchar body);
@ -387,6 +389,8 @@ SignalFfiError *signal_testing_key_trans_fatal_verification_failure(void);
SignalFfiError *signal_testing_key_trans_non_fatal_verification_failure(void);
SignalFfiError *signal_testing_key_trans_stored_account_data(SignalOwnedBuffer *out);
SignalFfiError *signal_testing_other_testing_handle_type_get_value(const char **out, SignalConstPointerOtherTestingHandleType handle);
SignalFfiError *signal_testing_panic_in_body_async(const void *_input);

View File

@ -23,7 +23,7 @@ where Selector.Api: Sendable, Selector.Connection: ChatServiceTestSetup {
// 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: ChatServiceTestState<Selector.Connection> = .init()
private lazy var state: ChatServiceTestState<Selector.Connection> = .init(grpcOverrides: self.grpcOverrides)
internal var api: Selector.Api {
// swiftlint:disable:next force_cast
@ -32,6 +32,10 @@ where Selector.Api: Sendable, Selector.Connection: ChatServiceTestSetup {
internal var fakeRemote: FakeChatRemote {
state.fakeRemote
}
internal var grpcOverrides: [String] {
[]
}
}
class UnauthChatServiceTestBase<Service: Sendable>: ChatServiceTestBase<UnauthServiceSelectorHelper<Service>> {}
@ -43,8 +47,10 @@ private struct ChatServiceTestState<Connection: ChatServiceTestSetup> {
let connection: Connection.StaticSelf
let fakeRemote: FakeChatRemote
init() {
(connection, fakeRemote) = Connection.fakeConnectWithNoOpListener(tokioAsyncContext: tokioAsyncContext)
init(grpcOverrides: [String]) {
(connection, fakeRemote) =
Connection
.fakeConnectWithNoOpListener(tokioAsyncContext: tokioAsyncContext, grpcOverrides: grpcOverrides)
}
}
@ -54,24 +60,34 @@ protocol ChatServiceTestSetup {
///
/// This syntax permits implementers to override the type; we will just never do that.
associatedtype StaticSelf = Self
static func fakeConnectWithNoOpListener(tokioAsyncContext: TokioAsyncContext) -> (StaticSelf, FakeChatRemote)
static func fakeConnectWithNoOpListener(
tokioAsyncContext: TokioAsyncContext,
grpcOverrides: [String]
) -> (StaticSelf, FakeChatRemote)
}
extension UnauthenticatedChatConnection: ChatServiceTestSetup {
static func fakeConnectWithNoOpListener(tokioAsyncContext: TokioAsyncContext) -> (StaticSelf, FakeChatRemote) {
static func fakeConnectWithNoOpListener(
tokioAsyncContext: TokioAsyncContext,
grpcOverrides: [String]
) -> (StaticSelf, FakeChatRemote) {
class NoOpListener: ConnectionEventsListener {
func connectionWasInterrupted(_ service: UnauthenticatedChatConnection, error: Error?) {}
}
return fakeConnect(
tokioAsyncContext: tokioAsyncContext,
listener: NoOpListener()
listener: NoOpListener(),
grpcOverrides: grpcOverrides,
)
}
}
extension AuthenticatedChatConnection: ChatServiceTestSetup {
static func fakeConnectWithNoOpListener(tokioAsyncContext: TokioAsyncContext) -> (StaticSelf, FakeChatRemote) {
static func fakeConnectWithNoOpListener(
tokioAsyncContext: TokioAsyncContext,
grpcOverrides: [String]
) -> (StaticSelf, FakeChatRemote) {
class NoOpListener: ChatConnectionListener {
func connectionWasInterrupted(_ service: LibSignalClient.AuthenticatedChatConnection, error: (any Error)?) {
}
@ -88,7 +104,8 @@ extension AuthenticatedChatConnection: ChatServiceTestSetup {
return fakeConnect(
tokioAsyncContext: tokioAsyncContext,
listener: NoOpListener()
listener: NoOpListener(),
grpcOverrides: grpcOverrides,
)
}
}

View File

@ -193,4 +193,36 @@ class UnauthUsernamesServiceTests: UnauthChatServiceTestBase<any UnauthUsernames
}
}
class UnauthUsernamesServiceGrpcTests: UnauthChatServiceTestBase<any UnauthUsernamesService> {
override class var selector: SelectorCheck { .usernames }
override var grpcOverrides: [String] {
["AccountsAnonymousLookupUsernameLink"]
}
func testUsernameLinkLookup() async throws {
let api = self.api
async let responseFuture = api.lookUpUsernameLink(
UUID(uuid: nilUuid),
entropy: UnauthUsernamesServiceTests.ENCRYPTED_USERNAME_ENTROPY
)
let (request, id) = try await fakeRemote.getNextIncomingGrpcRequest()
XCTAssertEqual(
request.getSingleGrpcMessage("org.signal.chat.account.LookupUsernameLinkRequest"),
["usernameLinkHandle": "AAAAAAAAAAAAAAAAAAAAAA=="]
)
try await fakeRemote.sendGrpcResponse(
requestId: id,
name: "org.signal.chat.account.LookupUsernameLinkResponse",
json: ["usernameCiphertext": UnauthUsernamesServiceTests.ENCRYPTED_USERNAME]
)
let responseFromServer = try await responseFuture
XCTAssertNotNil(responseFromServer)
XCTAssertEqual(responseFromServer!.value, UnauthUsernamesServiceTests.EXPECTED_USERNAME)
}
}
#endif

View File

@ -113,6 +113,32 @@ final class KeyTransparencyTests: TestCaseBase {
XCTAssertEqual(1, store.distinguishedTreeHeads.count)
}
func testResetFieldThrowsOnCorruptData() async throws {
let store = TestStore()
await store.setAccountData(Data([1, 2, 3]), for: self.testAccount.aci)
do {
try await KeyTransparency.resetField(
.e164,
for: self.testAccount.aci,
store: store
)
XCTFail("should have failed")
} catch SignalError.invalidArgument(_) {
} catch {
XCTFail("unexpected exception thrown: \(error)")
}
}
func testResetFieldIsNoopWhenDataIsMissing() async throws {
let store = TestStore()
try await KeyTransparency.resetField(
.e164,
for: self.testAccount.aci,
store: store
)
XCTAssertNil(store.accountData[self.testAccount.aci])
}
// These testing endpoints aren't generated in device builds, to save on code size.
#if !os(iOS) || targetEnvironment(simulator)
func testNonFatalErrorBridging() throws {
@ -145,6 +171,23 @@ final class KeyTransparencyTests: TestCaseBase {
}
}
func testResetFieldUpdatesStoreOnSuccess() async throws {
let store = TestStore()
let storedAccountData = failOnError {
try invokeFnReturningData {
signal_testing_key_trans_stored_account_data($0)
}
}
await store.setAccountData(storedAccountData, for: self.testAccount.aci)
XCTAssertEqual(1, store.accountData[self.testAccount.aci]!.count)
try await KeyTransparency.resetField(
.e164,
for: self.testAccount.aci,
store: store
)
XCTAssertEqual(2, store.accountData[self.testAccount.aci]!.count)
}
func customNetworkErrorTestImpl(status: UInt16, headers: [String: String] = [:]) async throws {
let tokio = TokioAsyncContext()
let (chat, remote) = UnauthenticatedChatConnection.fakeConnect(