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: + * + *

    + *
  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()`. + *
+ * + * 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