Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8cb0c5fc | ||
|
|
c41e917d4e | ||
|
|
4d43a6270a | ||
|
|
7543c3d35b | ||
|
|
bd383e51f0 | ||
|
|
fb9407cbcb | ||
|
|
9903175a51 | ||
|
|
af55da7bbd | ||
|
|
875f93019b | ||
|
|
73bcc78e12 | ||
|
|
d0b3edc0f1 | ||
|
|
ec67c55017 | ||
|
|
ee47959258 | ||
|
|
7b399f26d8 | ||
|
|
f70d1faaa0 | ||
|
|
b8f2aaf5dc | ||
|
|
2af375875b |
2
.github/workflows/build_and_test.yml
vendored
2
.github/workflows/build_and_test.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/npm.yml
vendored
2
.github/workflows/npm.yml
vendored
@ -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
|
||||
|
||||
@ -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
46
Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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 "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.
|
||||
</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
|
||||
|
||||
@ -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 "New" or "Revised" 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>
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -23,7 +23,7 @@ repositories {
|
||||
}
|
||||
|
||||
allprojects {
|
||||
version = "0.94.0"
|
||||
version = "0.94.1"
|
||||
group = "org.signal"
|
||||
|
||||
tasks.withType(JavaCompile) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<*>,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -902,5 +902,9 @@
|
||||
{
|
||||
"version": "v0.93.2",
|
||||
"size": 7515184
|
||||
},
|
||||
{
|
||||
"version": "v0.94.0",
|
||||
"size": 7522920
|
||||
}
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
2
justfile
2
justfile
@ -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
|
||||
|
||||
4
node/package-lock.json
generated
4
node/package-lock.json
generated
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
3411
node/ts/Native.ts
3411
node/ts/Native.ts
File diff suppressed because it is too large
Load Diff
@ -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') ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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[] = []) {
|
||||
|
||||
@ -529,6 +529,7 @@ describe('chat service api', () => {
|
||||
const [_chat, fakeRemote] = AuthenticatedChatConnection.fakeConnect(
|
||||
tokio,
|
||||
listener,
|
||||
[],
|
||||
['UPPERcase', 'lowercase']
|
||||
);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -43,6 +43,7 @@ exclude = [
|
||||
"CPromisebool",
|
||||
"CPromiseFfiCdsiLookupResponse",
|
||||
"CPromiseMutPointerRegistrationService",
|
||||
"CPromiseOwnedBufferOfc_uchar",
|
||||
"FfiCdsiLookupResponse",
|
||||
"FfiCdsiLookupResponseEntry",
|
||||
"FfiChatListenerStruct",
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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()
|
||||
19
rust/bridge/node/native_ts/Cargo.toml
Normal file
19
rust/bridge/node/native_ts/Cargo.toml
Normal 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"] }
|
||||
@ -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 -%}
|
||||
51
rust/bridge/node/native_ts/src/main.rs
Normal file
51
rust/bridge/node/native_ts/src/main.rs
Normal 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(())
|
||||
}
|
||||
@ -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)]
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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:
|
||||
//!
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
108
rust/bridge/shared/types/src/metadata.rs
Normal file
108
rust/bridge/shared/types/src/metadata.rs
Normal 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),
|
||||
}
|
||||
@ -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!(
|
||||
|
||||
@ -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
@ -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.
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
476
rust/net/infra/src/tcp_ssl/proxy/reflector.rs
Normal file
476
rust/net/infra/src/tcp_ssl/proxy/reflector.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
///
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user