diff --git a/.gitattributes b/.gitattributes
index 378f5bd1..01c842b6 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,3 +5,6 @@ acknowledgments/acknowledgments.*.hbs merge text=auto
# Treat encrypted and unencrypted message backup files as binary
**/*.binproto binary
**/*.binproto.encrypted binary
+
+# Avoid Windows line-endings for files compared literally.
+**/*.expected.json text eol=lf
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index bdaaf3a7..52ffbdc0 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2011,6 +2011,7 @@ dependencies = [
name = "libsignal-bridge-testing"
version = "0.1.0"
dependencies = [
+ "displaydoc",
"futures-util",
"jni 0.21.1",
"libsignal-bridge-macros",
@@ -2023,6 +2024,7 @@ dependencies = [
"signal-neon-futures",
"strum",
"thiserror",
+ "tokio",
]
[[package]]
diff --git a/java/client/src/main/java/org/signal/libsignal/messagebackup/ComparableBackup.java b/java/client/src/main/java/org/signal/libsignal/messagebackup/ComparableBackup.java
new file mode 100644
index 00000000..5049d58b
--- /dev/null
+++ b/java/client/src/main/java/org/signal/libsignal/messagebackup/ComparableBackup.java
@@ -0,0 +1,106 @@
+//
+// Copyright 2024 Signal Messenger, LLC.
+// SPDX-License-Identifier: AGPL-3.0-only
+//
+
+package org.signal.libsignal.messagebackup;
+
+import static org.signal.libsignal.internal.FilterExceptions.filterExceptions;
+
+import java.io.IOException;
+import java.io.InputStream;
+import org.signal.libsignal.internal.CalledFromNative;
+import org.signal.libsignal.internal.NativeHandleGuard;
+import org.signal.libsignal.internal.NativeTesting;
+
+/**
+ * An in-memory representation of a backup file used to compare contents.
+ *
+ *
When comparing the contents of two backups:
+ *
+ *
+ * - Create a `ComparableBackup` instance for each of the inputs.
+ *
- Check the `unknownFields()` value; if it's not empty, some parts of the backup weren't
+ * parsed and won't be compared.
+ *
- Produce a canonical string for each backup with `comparableString()`.
+ *
+ *
+ * Compare the canonical string representations. The diff of the canonical strings (which may be
+ * rather large) will show the differences between the logical content of the input backup files.
+ */
+public class ComparableBackup implements NativeHandleGuard.Owner {
+ /**
+ * Reads an unencrypted message backup bundle into memory for comparison.
+ *
+ * Returns an error if the input cannot be read or if validation fails.
+ *
+ * @param purpose whether the input was created for device-to-device transfer or remote backup
+ * @param input an InputStream
that produces the input
+ * @param streamLength the number of bytes each InputStream
will produce
+ * @throws ValidationError with an error message if the input is invalid
+ * @throws IOException if the input could not be read
+ */
+ public static ComparableBackup readUnencrypted(
+ MessageBackup.Purpose purpose, InputStream input, long streamLength)
+ throws ValidationError, IOException {
+
+ long handle =
+ filterExceptions(
+ IOException.class,
+ ValidationError.class,
+ () ->
+ NativeTesting.ComparableBackup_ReadUnencrypted(
+ input, streamLength, purpose.ordinal()));
+
+ return new ComparableBackup(handle);
+ }
+
+ /**
+ * Produces a string representation of the contents.
+ *
+ *
The returned strings for two backups will be equal if the backups contain the same logical
+ * content. If two backups' strings are not equal, the diff will show what is different between
+ * them.
+ *
+ * @return a canonical string representation of the backup
+ */
+ public String getComparableString() {
+ try (NativeHandleGuard guard = new NativeHandleGuard(this)) {
+ return filterExceptions(
+ () -> NativeTesting.ComparableBackup_GetComparableString(guard.nativeHandle()));
+ }
+ }
+
+ /**
+ * Returns the unrecognized protobuf fields present in the backup.
+ *
+ *
If the returned array is not empty, some parts of the backup were not recognized and won't
+ * be present in the string representation.
+ *
+ * @return information about each unknown field found in the backup
+ */
+ public String[] getUnknownFieldMessages() {
+ try (NativeHandleGuard guard = new NativeHandleGuard(this)) {
+ return (String[])
+ filterExceptions(
+ () -> NativeTesting.ComparableBackup_GetUnknownFields(guard.nativeHandle()));
+ }
+ }
+
+ @CalledFromNative
+ private ComparableBackup(long unsafeHandle) {
+ this.unsafeHandle = unsafeHandle;
+ }
+
+ private final long unsafeHandle;
+
+ @Override
+ @SuppressWarnings("deprecation")
+ protected void finalize() {
+ NativeTesting.ComparableBackup_Destroy(this.unsafeHandle);
+ }
+
+ public long unsafeNativeHandleWithoutGuard() {
+ return this.unsafeHandle;
+ }
+}
diff --git a/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackup.java b/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackup.java
index 9df3d35e..122b9ff6 100644
--- a/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackup.java
+++ b/java/client/src/main/java/org/signal/libsignal/messagebackup/MessageBackup.java
@@ -33,14 +33,8 @@ public class MessageBackup {
public static enum Purpose {
// This needs to be kept in sync with the corresponding Rust enum.
- DEVICE_TRANSFER(0),
- REMOTE_BACKUP(1);
-
- private final int value;
-
- private Purpose(int value) {
- this.value = value;
- }
+ DEVICE_TRANSFER,
+ REMOTE_BACKUP,
}
/**
@@ -71,7 +65,7 @@ public class MessageBackup {
ValidationError.class,
() ->
Native.MessageBackupValidator_Validate(
- keyGuard.nativeHandle(), first, second, streamLength, purpose.value));
+ keyGuard.nativeHandle(), first, second, streamLength, purpose.ordinal()));
// Rust conversion code is generating an instance of this class.
@SuppressWarnings("unchecked")
diff --git a/java/client/src/test/java/org/signal/libsignal/messagebackup/ComparableBackupTest.java b/java/client/src/test/java/org/signal/libsignal/messagebackup/ComparableBackupTest.java
new file mode 100644
index 00000000..8a812cc9
--- /dev/null
+++ b/java/client/src/test/java/org/signal/libsignal/messagebackup/ComparableBackupTest.java
@@ -0,0 +1,44 @@
+//
+// Copyright 2023 Signal Messenger, LLC.
+// SPDX-License-Identifier: AGPL-3.0-only
+//
+
+package org.signal.libsignal.messagebackup;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import java.io.IOException;
+import java.io.InputStream;
+import org.junit.Test;
+import org.signal.libsignal.util.ResourceReader;
+
+public class ComparableBackupTest {
+
+ static final MessageBackup.Purpose BACKUP_PURPOSE = MessageBackup.Purpose.REMOTE_BACKUP;
+ static final String CANONICAL_BACKUP_PROTO_NAME = "canonical-backup.binproto";
+ static final String CANONICAL_BACKUP_STRING_NAME = "canonical-backup.expected.json";
+
+ private static InputStream getCanonicalBackupInputStream() {
+ return ComparableBackupTest.class.getResourceAsStream(CANONICAL_BACKUP_PROTO_NAME);
+ }
+
+ @Test
+ public void canonicalBackupString() throws IOException, ValidationError {
+ final long length;
+ try (InputStream input = getCanonicalBackupInputStream()) {
+ length = ResourceReader.readAll(input).length;
+ }
+
+ ComparableBackup backup =
+ ComparableBackup.readUnencrypted(BACKUP_PURPOSE, getCanonicalBackupInputStream(), length);
+ assertArrayEquals(backup.getUnknownFieldMessages(), new String[] {});
+
+ assertEquals(
+ backup.getComparableString(),
+ new String(
+ ComparableBackupTest.class
+ .getResourceAsStream(CANONICAL_BACKUP_STRING_NAME)
+ .readAllBytes()));
+ }
+}
diff --git a/java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.binproto b/java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.binproto
new file mode 120000
index 00000000..63c06c9a
--- /dev/null
+++ b/java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.binproto
@@ -0,0 +1 @@
+../../../../../../../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto
\ No newline at end of file
diff --git a/java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json b/java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json
new file mode 100644
index 00000000..be2d7b7b
--- /dev/null
+++ b/java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json
@@ -0,0 +1,61 @@
+{
+ "meta": {
+ "version": 1,
+ "backup_time": {
+ "secs_since_epoch": 1715636551,
+ "nanos_since_epoch": 0
+ },
+ "purpose": "RemoteBackup"
+ },
+ "account_data": {
+ "profile_key": "610291abedc34249489da39a31c9a5cd99cdd26ff58732e268e357ee0075d9d8",
+ "username": {
+ "username": "boba_fett.66",
+ "link": {
+ "color": "OLIVE",
+ "entropy": "65675c73d00eb01005e3bb7c4a47f296cb6554f78981238815e915d824fd2e93",
+ "server_id": "61c101a2-00d5-4217-89c2-0518d8497af0"
+ }
+ },
+ "given_name": "Boba",
+ "family_name": "Fett",
+ "account_settings": {
+ "phone_number_sharing": "WithNobody",
+ "read_receipts": true,
+ "sealed_sender_indicators": true,
+ "typing_indicators": true,
+ "link_previews": false,
+ "not_discoverable_by_phone_number": true,
+ "prefer_contact_avatars": true,
+ "display_badges_on_profile": true,
+ "keep_muted_chats_archived": true,
+ "has_set_my_stories_privacy": true,
+ "has_viewed_onboarding_story": true,
+ "stories_disabled": true,
+ "story_view_receipts_enabled": true,
+ "has_seen_group_story_education_sheet": true,
+ "has_completed_username_onboarding": true,
+ "universal_expire_timer": {
+ "secs": 3,
+ "nanos": 600000000
+ },
+ "preferred_reaction_emoji": [
+ "🏎️"
+ ],
+ "default_chat_style": null,
+ "custom_chat_colors": []
+ },
+ "avatar_url_path": "",
+ "donation_subscription": {
+ "subscriber_id": "ecbb68c734331a2ea333cda747c98c4553652261582b4fce5ae0dea84dce6519",
+ "currency_code": "USD",
+ "manually_canceled": true
+ },
+ "backup_subscription": null
+ },
+ "recipients": [],
+ "chats": [],
+ "ad_hoc_calls": [],
+ "pinned_chats": [],
+ "sticker_packs": []
+}
\ No newline at end of file
diff --git a/java/shared/java/org/signal/libsignal/internal/NativeTesting.java b/java/shared/java/org/signal/libsignal/internal/NativeTesting.java
index ad6d6461..6663f012 100644
--- a/java/shared/java/org/signal/libsignal/internal/NativeTesting.java
+++ b/java/shared/java/org/signal/libsignal/internal/NativeTesting.java
@@ -44,5 +44,10 @@ public final class NativeTesting {
private NativeTesting() {}
+ public static native void ComparableBackup_Destroy(long handle);
+ public static native String ComparableBackup_GetComparableString(long backup);
+ public static native Object[] ComparableBackup_GetUnknownFields(long backup);
+ public static native long ComparableBackup_ReadUnencrypted(InputStream stream, long len, int purpose) throws Exception;
+
public static native int test_only_fn_returns_123();
}
diff --git a/node/.prettierignore b/node/.prettierignore
index 6e120731..bf6d4bc4 100644
--- a/node/.prettierignore
+++ b/node/.prettierignore
@@ -1,3 +1,4 @@
build/**
dist/**
Native.d.ts
+ts/test/canonical-backup.expected.json
\ No newline at end of file
diff --git a/node/Native.d.ts b/node/Native.d.ts
index cd8ddff3..68b97713 100644
--- a/node/Native.d.ts
+++ b/node/Native.d.ts
@@ -192,6 +192,9 @@ export function ChatService_unauth_send_and_debug(asyncRuntime: Wrapper): CiphertextMessage;
export function CiphertextMessage_Serialize(obj: Wrapper): Buffer;
export function CiphertextMessage_Type(msg: Wrapper): number;
+export function ComparableBackup_GetComparableString(backup: Wrapper): string;
+export function ComparableBackup_GetUnknownFields(backup: Wrapper): string[];
+export function ComparableBackup_ReadUnencrypted(stream: InputStream, len: bigint, purpose: number): Promise;
export function ConnectionManager_clear_proxy(connectionManager: Wrapper): void;
export function ConnectionManager_new(environment: number, userAgent: string): ConnectionManager;
export function ConnectionManager_set_ipv6_enabled(connectionManager: Wrapper, ipv6Enabled: boolean): void;
@@ -553,6 +556,8 @@ interface Aes256GcmSiv { readonly __type: unique symbol; }
interface CdsiLookup { readonly __type: unique symbol; }
interface Chat { readonly __type: unique symbol; }
interface CiphertextMessage { readonly __type: unique symbol; }
+interface ComparableBackup { readonly __type: unique symbol; }
+interface ComparableBackup { readonly __type: unique symbol; }
interface ConnectionManager { readonly __type: unique symbol; }
interface DecryptionErrorMessage { readonly __type: unique symbol; }
interface ExpiringProfileKeyCredential { readonly __type: unique symbol; }
diff --git a/node/ts/Errors.ts b/node/ts/Errors.ts
index 7b88d08e..60156300 100644
--- a/node/ts/Errors.ts
+++ b/node/ts/Errors.ts
@@ -51,6 +51,8 @@ export enum ErrorCode {
AppExpired,
DeviceDelinked,
+ BackupValidation,
+
Cancelled,
}
@@ -240,6 +242,11 @@ export type SvrRestoreFailedError = LibSignalErrorCommon & {
readonly triesRemaining: number;
};
+export type BackupValidationError = LibSignalErrorCommon & {
+ code: ErrorCode.BackupValidation;
+ readonly unknownFields: ReadonlyArray;
+};
+
export type CancellationError = LibSignalErrorCommon & {
code: ErrorCode.Cancelled;
};
diff --git a/node/ts/MessageBackup.ts b/node/ts/MessageBackup.ts
index 201e0703..cf81c4ee 100644
--- a/node/ts/MessageBackup.ts
+++ b/node/ts/MessageBackup.ts
@@ -99,3 +99,68 @@ export async function validate(
)
);
}
+
+/**
+ * An in-memory representation of a backup file used to compare contents.
+ *
+ * When comparing the contents of two backups:
+ * 1. Create a `ComparableBackup` instance for each of the inputs.
+ * 2. Check the `unknownFields()` value; if it's not empty, some parts of the
+ * backup weren't parsed and won't be compared.
+ * 3. Produce a canonical string for each backup with `comparableString()`.
+ * 4. Compare the canonical string representations.
+ *
+ * The diff of the canonical strings (which may be rather large) will show the
+ * differences between the logical content of the input backup files.
+ */
+export class ComparableBackup {
+ readonly _nativeHandle: Native.ComparableBackup;
+ constructor(handle: Native.ComparableBackup) {
+ this._nativeHandle = handle;
+ }
+
+ /**
+ * Read an unencrypted backup file into memory for comparison.
+ *
+ * @param purpose Whether the backup is intended for device-to-device transfer or remote storage.
+ * @param input An input stream that reads the backup contents.
+ * @param length The exact length of the input stream.
+ * @returns The in-memory representation.
+ * @throws BackupValidationError If an IO error occurs or the input is invalid.
+ */
+ public static async fromUnencrypted(
+ purpose: Purpose,
+ input: InputStream,
+ length: bigint
+ ): Promise {
+ const handle = await Native.ComparableBackup_ReadUnencrypted(
+ input,
+ length,
+ purpose
+ );
+ return new ComparableBackup(handle);
+ }
+
+ /**
+ * Produces a string representation of the contents.
+ *
+ * The returned strings for two backups will be equal if the backups contain
+ * the same logical content. If two backups' strings are not equal, the diff
+ * will show what is different between them.
+ *
+ * @returns a canonical string representation of the backup
+ */
+ public comparableString(): string {
+ return Native.ComparableBackup_GetComparableString(this);
+ }
+
+ /**
+ * Unrecognized protobuf fields present in the backup.
+ *
+ * If this is not empty, some parts of the backup were not recognized and
+ * won't be present in the string representation.
+ */
+ public get unknownFields(): Array {
+ return Native.ComparableBackup_GetUnknownFields(this);
+ }
+}
diff --git a/node/ts/test/MessageBackupTest.ts b/node/ts/test/MessageBackupTest.ts
index a46cdf2e..27af3d57 100644
--- a/node/ts/test/MessageBackupTest.ts
+++ b/node/ts/test/MessageBackupTest.ts
@@ -60,3 +60,25 @@ describe('MessageBackup', () => {
});
});
});
+
+describe('ComparableBackup', () => {
+ describe('exampleBackup', () => {
+ const input = fs.readFileSync(
+ path.join(__dirname, '../../ts/test/canonical-backup.binproto')
+ );
+
+ it('stringifies to the expected value', async () => {
+ const comparable = await MessageBackup.ComparableBackup.fromUnencrypted(
+ MessageBackup.Purpose.RemoteBackup,
+ new Uint8ArrayInputStream(input),
+ BigInt(input.length)
+ );
+
+ const expectedOutput = fs.readFileSync(
+ path.join(__dirname, '../../ts/test/canonical-backup.expected.json')
+ );
+ const output = comparable.comparableString();
+ assert.equal(output, new String(expectedOutput));
+ });
+ });
+});
diff --git a/node/ts/test/canonical-backup.binproto b/node/ts/test/canonical-backup.binproto
new file mode 120000
index 00000000..04e66eca
--- /dev/null
+++ b/node/ts/test/canonical-backup.binproto
@@ -0,0 +1 @@
+../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto
\ No newline at end of file
diff --git a/node/ts/test/canonical-backup.expected.json b/node/ts/test/canonical-backup.expected.json
new file mode 120000
index 00000000..f718b759
--- /dev/null
+++ b/node/ts/test/canonical-backup.expected.json
@@ -0,0 +1 @@
+../../../java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json
\ No newline at end of file
diff --git a/rust/bridge/ffi/cbindgen-testing.toml b/rust/bridge/ffi/cbindgen-testing.toml
index 49cb3e81..fecfedaa 100644
--- a/rust/bridge/ffi/cbindgen-testing.toml
+++ b/rust/bridge/ffi/cbindgen-testing.toml
@@ -38,6 +38,8 @@ prefix = "Signal"
renaming_overrides_prefixing = true
[export.rename]
+"FfiInputStreamStruct" = "SignalInputStream"
+
# Avoid double-prefixing these
"SignalFfiError" = "SignalFfiError"
"SignalErrorCode" = "SignalErrorCode"
@@ -52,7 +54,7 @@ args = "horizontal"
[parse]
parse_deps = true
include = []
-extra_bindings = ["libsignal-bridge-testing"]
+extra_bindings = ["libsignal-bridge-testing", "libsignal-bridge-types"]
[parse.expand]
crates = ["libsignal-bridge-testing"]
diff --git a/rust/bridge/ffi/src/lib.rs b/rust/bridge/ffi/src/lib.rs
index dfd7f5cd..3047d0cf 100644
--- a/rust/bridge/ffi/src/lib.rs
+++ b/rust/bridge/ffi/src/lib.rs
@@ -153,6 +153,27 @@ pub unsafe extern "C" fn signal_error_get_tries_remaining(
})
}
+#[no_mangle]
+pub unsafe extern "C" fn signal_error_get_unknown_fields(
+ err: *const SignalFfiError,
+ out: *mut StringArray,
+) -> *mut SignalFfiError {
+ let err = AssertUnwindSafe(err);
+ run_ffi_safe(|| {
+ let err = err.as_ref().ok_or(NullPointerError)?;
+ let value = err
+ .provide_unknown_fields()
+ .map_err(|_| {
+ SignalProtocolError::InvalidArgument(format!(
+ "cannot get unknown_fields from error ({})",
+ err
+ ))
+ })?
+ .into_boxed_slice();
+ write_result_to(out, value)
+ })
+}
+
#[no_mangle]
pub unsafe extern "C" fn signal_error_free(err: *mut SignalFfiError) {
if !err.is_null() {
diff --git a/rust/bridge/shared/testing/Cargo.toml b/rust/bridge/shared/testing/Cargo.toml
index 17b94fed..b786546e 100644
--- a/rust/bridge/shared/testing/Cargo.toml
+++ b/rust/bridge/shared/testing/Cargo.toml
@@ -13,12 +13,14 @@ license = "AGPL-3.0-only"
[dependencies]
libsignal-bridge-macros = { path = "../macros" }
libsignal-bridge-types = { path = "../types" }
-libsignal-message-backup = { path = "../../../message-backup" }
+libsignal-message-backup = { path = "../../../message-backup", features = ["json"] }
+displaydoc = "0.2"
futures-util = "0.3.7"
paste = "1.0"
scopeguard = "1.0"
thiserror = "1.0.50"
+tokio = "1"
jni = { version = "0.21", package = "jni", optional = true }
linkme = { version = "0.3.9", optional = true }
@@ -29,5 +31,5 @@ strum = { version = "0.26", features = ["derive"] }
[features]
ffi = ["libsignal-bridge-types/ffi"]
jni = ["dep:jni", "libsignal-bridge-types/jni"]
-node = ["dep:linkme", "libsignal-bridge-types/node"]
+node = ["dep:linkme", "dep:neon", "libsignal-bridge-types/node"]
signal-media = ["libsignal-bridge-types/signal-media"]
diff --git a/rust/bridge/shared/testing/src/lib.rs b/rust/bridge/shared/testing/src/lib.rs
index f2903b26..6ffc45b0 100644
--- a/rust/bridge/shared/testing/src/lib.rs
+++ b/rust/bridge/shared/testing/src/lib.rs
@@ -17,3 +17,5 @@ pub use libsignal_bridge_types::node;
pub fn test_only_fn_returns_123() -> u32 {
123
}
+
+pub mod message_backup;
diff --git a/rust/bridge/shared/testing/src/message_backup.rs b/rust/bridge/shared/testing/src/message_backup.rs
new file mode 100644
index 00000000..4bfc23d7
--- /dev/null
+++ b/rust/bridge/shared/testing/src/message_backup.rs
@@ -0,0 +1,58 @@
+//
+// Copyright 2024 Signal Messenger, LLC.
+// SPDX-License-Identifier: AGPL-3.0-only
+//
+
+use libsignal_bridge_macros::*;
+use libsignal_bridge_types::io::{AsyncInput, InputStream};
+use libsignal_bridge_types::support::*;
+use libsignal_bridge_types::*;
+use libsignal_message_backup::backup::Purpose;
+use libsignal_message_backup::{BackupReader, ReadError, ReadResult};
+
+pub struct ComparableBackup {
+ pub backup: libsignal_message_backup::backup::serialize::Backup,
+ pub found_unknown_fields: Vec,
+}
+
+bridge_as_handle!(ComparableBackup);
+bridge_handle_fns!(ComparableBackup, clone = false);
+
+#[bridge_fn]
+async fn ComparableBackup_ReadUnencrypted(
+ stream: &mut dyn InputStream,
+ len: u64,
+ purpose: AsType,
+) -> Result {
+ let reader = BackupReader::new_unencrypted(AsyncInput::new(stream, len), purpose.into_inner());
+
+ let ReadResult {
+ result,
+ found_unknown_fields,
+ } = reader.read_all().await;
+
+ match result {
+ Ok(backup) => Ok(ComparableBackup {
+ backup: backup.into(),
+ found_unknown_fields,
+ }),
+ Err(error) => Err(ReadError {
+ error,
+ found_unknown_fields,
+ }),
+ }
+}
+
+#[bridge_fn]
+fn ComparableBackup_GetComparableString(backup: &ComparableBackup) -> String {
+ backup.backup.to_string_pretty()
+}
+
+#[bridge_fn]
+fn ComparableBackup_GetUnknownFields(backup: &ComparableBackup) -> Box<[String]> {
+ backup
+ .found_unknown_fields
+ .iter()
+ .map(ToString::to_string)
+ .collect()
+}
diff --git a/rust/bridge/shared/types/src/ffi/error.rs b/rust/bridge/shared/types/src/ffi/error.rs
index 12a96ac7..8e0281a6 100644
--- a/rust/bridge/shared/types/src/ffi/error.rs
+++ b/rust/bridge/shared/types/src/ffi/error.rs
@@ -101,6 +101,8 @@ pub enum SignalErrorCode {
AppExpired = 160,
DeviceDeregistered = 161,
+
+ BackupValidation = 170,
}
pub trait UpcastAsAny {
@@ -131,6 +133,9 @@ pub trait FfiError: UpcastAsAny + fmt::Debug + Send + 'static {
fn provide_tries_remaining(&self) -> Result {
Err(WrongErrorKind)
}
+ fn provide_unknown_fields(&self) -> Result, WrongErrorKind> {
+ Err(WrongErrorKind)
+ }
}
/// The top-level error type (opaquely) returned to C clients when something goes wrong.
@@ -627,6 +632,24 @@ impl FfiError for signal_media::sanitize::webp::Error {
}
}
+impl FfiError for libsignal_message_backup::ReadError {
+ fn describe(&self) -> String {
+ self.to_string()
+ }
+
+ fn code(&self) -> SignalErrorCode {
+ SignalErrorCode::BackupValidation
+ }
+
+ fn provide_unknown_fields(&self) -> Result, WrongErrorKind> {
+ Ok(self
+ .found_unknown_fields
+ .iter()
+ .map(ToString::to_string)
+ .collect())
+ }
+}
+
impl FfiError for NullPointerError {
fn describe(&self) -> String {
"null pointer".to_owned()
diff --git a/rust/bridge/shared/types/src/jni/convert.rs b/rust/bridge/shared/types/src/jni/convert.rs
index ff05e17c..4ff9081c 100644
--- a/rust/bridge/shared/types/src/jni/convert.rs
+++ b/rust/bridge/shared/types/src/jni/convert.rs
@@ -1456,6 +1456,9 @@ macro_rules! jni_result_type {
(MessageBackupValidationOutcome) => {
::jni::objects::JObject<'local>
};
+ (MessageBackupReadOutcome) => {
+ ::jni::objects::JObject<'local>
+ };
(LookupResponse) => {
::jni::objects::JObject<'local>
};
diff --git a/rust/bridge/shared/types/src/jni/error.rs b/rust/bridge/shared/types/src/jni/error.rs
index 56ad9f6e..29288cb1 100644
--- a/rust/bridge/shared/types/src/jni/error.rs
+++ b/rust/bridge/shared/types/src/jni/error.rs
@@ -50,6 +50,7 @@ pub enum SignalJniError {
ChatService(ChatServiceError),
InvalidUri(InvalidUri),
ConnectTimedOut,
+ BackupValidation(#[from] libsignal_message_backup::ReadError),
Bridge(BridgeLayerError),
TestingError {
exception_class: ClassName<'static>,
@@ -97,6 +98,7 @@ impl fmt::Display for SignalJniError {
SignalJniError::InvalidUri(e) => write!(f, "{}", e),
SignalJniError::WebSocket(e) => write!(f, "{e}"),
SignalJniError::ConnectTimedOut => write!(f, "connect timed out"),
+ SignalJniError::BackupValidation(e) => write!(f, "{}", e),
SignalJniError::Svr3(e) => write!(f, "{}", e),
SignalJniError::Bridge(e) => write!(f, "{}", e),
SignalJniError::TestingError { exception_class } => {
diff --git a/rust/bridge/shared/types/src/jni/mod.rs b/rust/bridge/shared/types/src/jni/mod.rs
index 872d8954..2132afa5 100644
--- a/rust/bridge/shared/types/src/jni/mod.rs
+++ b/rust/bridge/shared/types/src/jni/mod.rs
@@ -321,6 +321,33 @@ impl<'env> ConsumableException<'env> {
};
}
+ SignalJniError::BackupValidation(ref err) => {
+ // TODO replace with try block once that is stabilized.
+ let throwable = (|| {
+ let libsignal_message_backup::ReadError {
+ error,
+ found_unknown_fields,
+ } = err;
+
+ let message = error.to_string().convert_into(env)?;
+ let found_unknown_fields = found_unknown_fields
+ .iter()
+ .map(|field| field.to_string())
+ .collect::>()
+ .into_boxed_slice()
+ .convert_into(env)?;
+ new_instance(
+ env,
+ ClassName("org.signal.libsignal.messagebackup.ValidationError"),
+ jni_args!((message => java.lang.String, found_unknown_fields => [java.lang.String]) -> void),
+ )
+ })();
+ return ConsumableException {
+ throwable: throwable.map(Into::into),
+ error: error.into(),
+ };
+ }
+
SignalJniError::Bridge(BridgeLayerError::NullPointer(_)) => {
(ClassName("java.lang.NullPointerException"), error)
}
diff --git a/rust/bridge/shared/types/src/message_backup.rs b/rust/bridge/shared/types/src/message_backup.rs
index 15d37e09..3c5ddc48 100644
--- a/rust/bridge/shared/types/src/message_backup.rs
+++ b/rust/bridge/shared/types/src/message_backup.rs
@@ -58,5 +58,11 @@ pub struct MessageBackupValidationOutcome {
pub error_message: Option,
pub found_unknown_fields: Vec,
}
-#[cfg(feature = "ffi")]
-ffi_bridge_as_handle!(MessageBackupValidationOutcome);
+bridge_as_handle!(MessageBackupValidationOutcome, jni = false, node = false);
+
+pub struct ComparableBackup {
+ pub backup: libsignal_message_backup::backup::serialize::Backup,
+ pub found_unknown_fields: Vec,
+}
+
+bridge_as_handle!(ComparableBackup);
diff --git a/rust/bridge/shared/types/src/node/convert.rs b/rust/bridge/shared/types/src/node/convert.rs
index 7decbe32..98af6a88 100644
--- a/rust/bridge/shared/types/src/node/convert.rs
+++ b/rust/bridge/shared/types/src/node/convert.rs
@@ -16,6 +16,7 @@ use std::ops::{Deref, DerefMut, RangeInclusive};
use std::slice;
use crate::io::{InputStream, SyncInputStream};
+use crate::message_backup::MessageBackupValidationOutcome;
use crate::net::chat::{MakeChatListener, ResponseAndDebugInfo};
use crate::node::chat::NodeMakeChatListener;
use crate::support::{extend_lifetime, Array, AsType, FixedLengthBincodeSerializable, Serialized};
@@ -889,7 +890,7 @@ impl<'a> ResultTypeInfo<'a> for () {
}
}
-impl<'a> ResultTypeInfo<'a> for crate::message_backup::MessageBackupValidationOutcome {
+impl<'a> ResultTypeInfo<'a> for MessageBackupValidationOutcome {
type ResultType = JsObject;
fn convert_into(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::ResultType> {
@@ -898,8 +899,7 @@ impl<'a> ResultTypeInfo<'a> for crate::message_backup::MessageBackupValidationOu
found_unknown_fields,
} = self;
let error_message = error_message.convert_into(cx)?;
- let unknown_field_messages =
- make_array(cx, found_unknown_fields.into_iter().map(|s| s.to_string()))?;
+ let unknown_field_messages = found_unknown_fields.as_slice().convert_into(cx)?;
let obj = JsObject::new(cx);
obj.set(cx, "errorMessage", error_message)?;
@@ -909,6 +909,14 @@ impl<'a> ResultTypeInfo<'a> for crate::message_backup::MessageBackupValidationOu
}
}
+impl<'a> ResultTypeInfo<'a> for &[libsignal_message_backup::FoundUnknownField] {
+ type ResultType = JsArray;
+
+ fn convert_into(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::ResultType> {
+ make_array(cx, self.iter().map(ToString::to_string))
+ }
+}
+
impl<'a, T: Value> ResultTypeInfo<'a> for Handle<'a, T> {
type ResultType = T;
fn convert_into(self, _cx: &mut impl Context<'a>) -> NeonResult> {
@@ -1454,7 +1462,7 @@ pub fn clone_from_array_of_wrappers<'a, T: BridgeHandle> +
#[macro_export]
macro_rules! node_bridge_as_handle {
( $typ:ty as false $(, $($_:tt)*)? ) => {};
- ( $typ:ty as $node_name:ident ) => {
+ ( $typ:ty as $node_name:ident $(, mut = false)? ) => {
::paste::paste! {
#[doc = "ts: interface " $typ " { readonly __type: unique symbol; }"]
impl node::BridgeHandle for $typ {
diff --git a/rust/bridge/shared/types/src/node/error.rs b/rust/bridge/shared/types/src/node/error.rs
index d2234293..389019da 100644
--- a/rust/bridge/shared/types/src/node/error.rs
+++ b/rust/bridge/shared/types/src/node/error.rs
@@ -542,6 +542,38 @@ impl SignalNodeError for CancellationError {
}
}
+impl SignalNodeError for libsignal_message_backup::ReadError {
+ fn throw<'a>(
+ self,
+ cx: &mut impl Context<'a>,
+ module: Handle<'a, JsObject>,
+ operation_name: &str,
+ ) -> JsResult<'a, JsValue> {
+ let libsignal_message_backup::ReadError {
+ error,
+ found_unknown_fields,
+ } = self;
+ let message = error.to_string();
+ let props = cx.empty_object();
+ let unknown_field_messages = found_unknown_fields.convert_into(cx)?;
+ props.set(cx, "unknownFields", unknown_field_messages)?;
+ match new_js_error(
+ cx,
+ module,
+ Some("BackupValidation"),
+ &message,
+ operation_name,
+ Some(props),
+ ) {
+ Some(error) => cx.throw(error),
+ None => {
+ // Make sure we still throw something.
+ cx.throw_error(&message)
+ }
+ }
+ }
+}
+
/// Represents an error returned by a callback.
#[derive(Debug)]
struct CallbackError {
diff --git a/rust/message-backup/src/lib.rs b/rust/message-backup/src/lib.rs
index 95c2c7e3..19ac0aff 100644
--- a/rust/message-backup/src/lib.rs
+++ b/rust/message-backup/src/lib.rs
@@ -57,6 +57,30 @@ pub struct ReadResult {
pub found_unknown_fields: Vec,
}
+#[derive(Debug, thiserror::Error)]
+#[must_use]
+pub struct ReadError {
+ pub error: Error,
+ pub found_unknown_fields: Vec,
+}
+
+impl std::fmt::Display for ReadError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ let Self {
+ error,
+ found_unknown_fields,
+ } = self;
+ write!(f, "{error} (with ")?;
+ if found_unknown_fields.is_empty() {
+ write!(f, "no unknown fields")?;
+ } else {
+ write!(f, "unknown fields: ")?;
+ f.debug_list().entries(found_unknown_fields).finish()?;
+ }
+ write!(f, ")")
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FoundUnknownField {
pub frame_index: usize,
diff --git a/swift/Sources/LibSignalClient/Error.swift b/swift/Sources/LibSignalClient/Error.swift
index 2a23abeb..bf199d26 100644
--- a/swift/Sources/LibSignalClient/Error.swift
+++ b/swift/Sources/LibSignalClient/Error.swift
@@ -61,6 +61,7 @@ public enum SignalError: Error {
case chatServiceInactive(String)
case appExpired(String)
case deviceDeregistered(String)
+ case backupValidation(unknownFields: [String], message: String)
case unknown(UInt32, String)
}
@@ -201,6 +202,11 @@ internal func checkError(_ error: SignalFfiErrorRef?) throws {
throw SignalError.appExpired(errStr)
case SignalErrorCodeDeviceDeregistered:
throw SignalError.deviceDeregistered(errStr)
+ case SignalErrorCodeBackupValidation:
+ let unknownFields = try invokeFnReturningStringArray {
+ signal_error_get_unknown_fields(error, $0)
+ }
+ throw SignalError.backupValidation(unknownFields: unknownFields, message: errStr)
default:
throw SignalError.unknown(errType, errStr)
}
diff --git a/swift/Sources/LibSignalClient/MessageBackup.swift b/swift/Sources/LibSignalClient/MessageBackup.swift
index 0551f1ea..32cf14b8 100644
--- a/swift/Sources/LibSignalClient/MessageBackup.swift
+++ b/swift/Sources/LibSignalClient/MessageBackup.swift
@@ -65,6 +65,75 @@ public func validateMessageBackup(
return outcome.unknownFields
}
+/// An in-memory representation of a backup file used to compare contents.
+///
+/// When comparing the contents of two backups:
+/// 1. Create a `ComparableBackup` instance for each of the inputs.
+/// 2. Check the `unknownFields()` value; if it's not empty, some parts of the
+/// backup weren't parsed and won't be compared.
+/// 3. Produce a canonical string for each backup with `comparableString()`.
+/// 4. Compare the canonical string representations.
+///
+/// The diff of the canonical strings (which may be rather large) will show the
+/// differences between the logical content of the input backup files.
+public class ComparableBackup: NativeHandleOwner {
+ /// Reads an unencrypted backup file into memory for comparison.
+ ///
+ /// - Parameters:
+ /// - purpose: Whether the backup is intended for transfer or remote storage.
+ /// - length: The exact length of the backup file, in bytes.
+ /// - stream: An InputStream that produces the backup contents.
+ ///
+ /// - Throws:
+ /// - `SignalError.ioError`: If an IO error on the input occurs.
+ /// - `SignalError.backupValidation`: If validation of the input fails.
+ public convenience init(purpose: MessageBackupPurpose, length: UInt64, stream: SignalInputStream) throws {
+ var handle: OpaquePointer?
+ try checkError(
+ try withInputStream(stream) { stream in
+ signal_comparable_backup_read_unencrypted(&handle, stream, length, purpose.rawValue)
+ }
+ )
+ self.init(owned: handle!)
+ }
+
+ /// Unrecognized protobuf fields present in the backup.
+ ///
+ /// If this is not empty, some parts of the backup were not recognized and
+ /// won't be present in the string representation.
+ public var unknownFields: MessageBackupUnknownFields {
+ let fields = failOnError {
+ try self.withNativeHandle { result in
+ try invokeFnReturningStringArray {
+ signal_comparable_backup_get_unknown_fields($0, result)
+ }
+ }
+ }
+ return MessageBackupUnknownFields(fields: fields)
+ }
+
+ /// Produces a string representation of the contents.
+ ///
+ /// The returned strings for two backups will be equal if the backups
+ /// contain the same logical content. If two backups' strings are not equal,
+ /// the diff will show what is different between them.
+ ///
+ /// - Returns: a canonical string representation of the backup.
+ public func comparableString() -> String {
+ return failOnError {
+ try self.withNativeHandle { result in
+ try invokeFnReturningString {
+ signal_comparable_backup_get_comparable_string($0, result)
+ }
+ }
+ }
+ }
+
+ override internal class func destroyNativeHandle(_ handle: OpaquePointer) -> SignalFfiErrorRef? {
+ signal_comparable_backup_destroy(handle)
+ }
+}
+
/// The outcome of a failed validation attempt.
public struct MessageBackupValidationError: Error {
/// The human-readable error that caused validation to fail.
diff --git a/swift/Sources/SignalFfi/signal_ffi.h b/swift/Sources/SignalFfi/signal_ffi.h
index 0e722a9c..be1ab186 100644
--- a/swift/Sources/SignalFfi/signal_ffi.h
+++ b/swift/Sources/SignalFfi/signal_ffi.h
@@ -196,6 +196,7 @@ typedef enum {
SignalErrorCodeSvrRestoreFailed = 151,
SignalErrorCodeAppExpired = 160,
SignalErrorCodeDeviceDeregistered = 161,
+ SignalErrorCodeBackupValidation = 170,
} SignalErrorCode;
/**
@@ -376,6 +377,8 @@ typedef struct {
SignalOwnedBufferOfusize lengths;
} SignalBytestringArray;
+typedef SignalBytestringArray SignalStringArray;
+
typedef struct {
const unsigned char *base;
size_t length;
@@ -660,8 +663,6 @@ typedef struct {
typedef SignalFfiChatListenerStruct SignalFfiMakeChatListenerStruct;
-typedef SignalBytestringArray SignalStringArray;
-
typedef int (*SignalRead)(void *ctx, uint8_t *buf, size_t buf_len, size_t *amount_read);
typedef int (*SignalSkip)(void *ctx, uint64_t amount);
@@ -760,6 +761,8 @@ SignalFfiError *signal_error_get_retry_after_seconds(const SignalFfiError *err,
SignalFfiError *signal_error_get_tries_remaining(const SignalFfiError *err, uint32_t *out);
+SignalFfiError *signal_error_get_unknown_fields(const SignalFfiError *err, SignalStringArray *out);
+
void signal_error_free(SignalFfiError *err);
SignalFfiError *signal_identitykeypair_deserialize(SignalPrivateKey **private_key, SignalPublicKey **public_key, SignalBorrowedBuffer input);
diff --git a/swift/Sources/SignalFfi/signal_ffi_testing.h b/swift/Sources/SignalFfi/signal_ffi_testing.h
index 8956ec6e..7f8470b4 100644
--- a/swift/Sources/SignalFfi/signal_ffi_testing.h
+++ b/swift/Sources/SignalFfi/signal_ffi_testing.h
@@ -16,6 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
#include
#include "signal_ffi.h"
+typedef struct SignalComparableBackup SignalComparableBackup;
+
SignalFfiError *signal_test_only_fn_returns_123(uint32_t *out);
+SignalFfiError *signal_comparable_backup_destroy(SignalComparableBackup *p);
+
+SignalFfiError *signal_comparable_backup_read_unencrypted(SignalComparableBackup **out, const SignalInputStream *stream, uint64_t len, uint8_t purpose);
+
+SignalFfiError *signal_comparable_backup_get_comparable_string(const char **out, const SignalComparableBackup *backup);
+
+SignalFfiError *signal_comparable_backup_get_unknown_fields(SignalStringArray *out, const SignalComparableBackup *backup);
+
#endif /* SIGNAL_FFI_TESTING_H_ */
diff --git a/swift/Tests/LibSignalClientTests/MessageBackupTests.swift b/swift/Tests/LibSignalClientTests/MessageBackupTests.swift
index 092039de..21829c39 100644
--- a/swift/Tests/LibSignalClientTests/MessageBackupTests.swift
+++ b/swift/Tests/LibSignalClientTests/MessageBackupTests.swift
@@ -63,6 +63,15 @@ class MessageBackupTests: TestCaseBase {
}
}
+ func testComparableBackup() throws {
+ let bytes = readResource(forName: "canonical-backup.binproto")
+ let backup = try ComparableBackup(purpose: .remoteBackup, length: UInt64(bytes.count), stream: SignalInputStreamAdapter(bytes))
+ let comparableString = backup.comparableString()
+
+ let expected = String(decoding: readResource(forName: "canonical-backup.expected.json"), as: UTF8.self)
+ XCTAssertEqual(comparableString, expected)
+ }
+
static func validateBackup(bytes: some Collection) throws -> MessageBackupUnknownFields {
try validateMessageBackup(key: MessageBackupKey.testKey(), purpose: .remoteBackup, length: UInt64(bytes.count), makeStream: { SignalInputStreamAdapter(bytes) })
}
diff --git a/swift/Tests/LibSignalClientTests/Resources/canonical-backup.binproto b/swift/Tests/LibSignalClientTests/Resources/canonical-backup.binproto
new file mode 120000
index 00000000..e7732383
--- /dev/null
+++ b/swift/Tests/LibSignalClientTests/Resources/canonical-backup.binproto
@@ -0,0 +1 @@
+../../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto
\ No newline at end of file
diff --git a/swift/Tests/LibSignalClientTests/Resources/canonical-backup.expected.json b/swift/Tests/LibSignalClientTests/Resources/canonical-backup.expected.json
new file mode 120000
index 00000000..11aeceff
--- /dev/null
+++ b/swift/Tests/LibSignalClientTests/Resources/canonical-backup.expected.json
@@ -0,0 +1 @@
+../../../../java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json
\ No newline at end of file