0
0
mirror of https://github.com/signalapp/libsignal.git synced 2024-09-19 19:42:19 +02:00

backup: bridge canonical serialization as ComparableBackup

This commit is contained in:
Alex Konradi 2024-07-16 14:20:31 -04:00 committed by GitHub
parent 854343294d
commit 66cd3f0133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 647 additions and 20 deletions

3
.gitattributes vendored
View File

@ -5,3 +5,6 @@ acknowledgments/acknowledgments.*.hbs merge text=auto
# Treat encrypted and unencrypted message backup files as binary # Treat encrypted and unencrypted message backup files as binary
**/*.binproto binary **/*.binproto binary
**/*.binproto.encrypted binary **/*.binproto.encrypted binary
# Avoid Windows line-endings for files compared literally.
**/*.expected.json text eol=lf

2
Cargo.lock generated
View File

@ -2011,6 +2011,7 @@ dependencies = [
name = "libsignal-bridge-testing" name = "libsignal-bridge-testing"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"displaydoc",
"futures-util", "futures-util",
"jni 0.21.1", "jni 0.21.1",
"libsignal-bridge-macros", "libsignal-bridge-macros",
@ -2023,6 +2024,7 @@ dependencies = [
"signal-neon-futures", "signal-neon-futures",
"strum", "strum",
"thiserror", "thiserror",
"tokio",
] ]
[[package]] [[package]]

View File

@ -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.
*
* <p>When comparing the contents of two backups:
*
* <ol>
* <li>Create a `ComparableBackup` instance for each of the inputs.
* <li>Check the `unknownFields()` value; if it's not empty, some parts of the backup weren't
* parsed and won't be compared.
* <li>Produce a canonical string for each backup with `comparableString()`.
* </ol>
*
* 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.
*
* <p>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 <code>InputStream</code> that produces the input
* @param streamLength the number of bytes each <code>InputStream</code> 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.
*
* <p>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.
*
* <p>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;
}
}

View File

@ -33,14 +33,8 @@ public class MessageBackup {
public static enum Purpose { public static enum Purpose {
// This needs to be kept in sync with the corresponding Rust enum. // This needs to be kept in sync with the corresponding Rust enum.
DEVICE_TRANSFER(0), DEVICE_TRANSFER,
REMOTE_BACKUP(1); REMOTE_BACKUP,
private final int value;
private Purpose(int value) {
this.value = value;
}
} }
/** /**
@ -71,7 +65,7 @@ public class MessageBackup {
ValidationError.class, ValidationError.class,
() -> () ->
Native.MessageBackupValidator_Validate( 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. // Rust conversion code is generating an instance of this class.
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")

View File

@ -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()));
}
}

View File

@ -0,0 +1 @@
../../../../../../../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto

View File

@ -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": []
}

View File

@ -44,5 +44,10 @@ public final class NativeTesting {
private 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(); public static native int test_only_fn_returns_123();
} }

View File

@ -1,3 +1,4 @@
build/** build/**
dist/** dist/**
Native.d.ts Native.d.ts
ts/test/canonical-backup.expected.json

5
node/Native.d.ts vendored
View File

@ -192,6 +192,9 @@ export function ChatService_unauth_send_and_debug(asyncRuntime: Wrapper<TokioAsy
export function CiphertextMessage_FromPlaintextContent(m: Wrapper<PlaintextContent>): CiphertextMessage; export function CiphertextMessage_FromPlaintextContent(m: Wrapper<PlaintextContent>): CiphertextMessage;
export function CiphertextMessage_Serialize(obj: Wrapper<CiphertextMessage>): Buffer; export function CiphertextMessage_Serialize(obj: Wrapper<CiphertextMessage>): Buffer;
export function CiphertextMessage_Type(msg: Wrapper<CiphertextMessage>): number; export function CiphertextMessage_Type(msg: Wrapper<CiphertextMessage>): number;
export function ComparableBackup_GetComparableString(backup: Wrapper<ComparableBackup>): string;
export function ComparableBackup_GetUnknownFields(backup: Wrapper<ComparableBackup>): string[];
export function ComparableBackup_ReadUnencrypted(stream: InputStream, len: bigint, purpose: number): Promise<ComparableBackup>;
export function ConnectionManager_clear_proxy(connectionManager: Wrapper<ConnectionManager>): void; export function ConnectionManager_clear_proxy(connectionManager: Wrapper<ConnectionManager>): void;
export function ConnectionManager_new(environment: number, userAgent: string): ConnectionManager; export function ConnectionManager_new(environment: number, userAgent: string): ConnectionManager;
export function ConnectionManager_set_ipv6_enabled(connectionManager: Wrapper<ConnectionManager>, ipv6Enabled: boolean): void; export function ConnectionManager_set_ipv6_enabled(connectionManager: Wrapper<ConnectionManager>, ipv6Enabled: boolean): void;
@ -553,6 +556,8 @@ interface Aes256GcmSiv { readonly __type: unique symbol; }
interface CdsiLookup { readonly __type: unique symbol; } interface CdsiLookup { readonly __type: unique symbol; }
interface Chat { readonly __type: unique symbol; } interface Chat { readonly __type: unique symbol; }
interface CiphertextMessage { 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 ConnectionManager { readonly __type: unique symbol; }
interface DecryptionErrorMessage { readonly __type: unique symbol; } interface DecryptionErrorMessage { readonly __type: unique symbol; }
interface ExpiringProfileKeyCredential { readonly __type: unique symbol; } interface ExpiringProfileKeyCredential { readonly __type: unique symbol; }

View File

@ -51,6 +51,8 @@ export enum ErrorCode {
AppExpired, AppExpired,
DeviceDelinked, DeviceDelinked,
BackupValidation,
Cancelled, Cancelled,
} }
@ -240,6 +242,11 @@ export type SvrRestoreFailedError = LibSignalErrorCommon & {
readonly triesRemaining: number; readonly triesRemaining: number;
}; };
export type BackupValidationError = LibSignalErrorCommon & {
code: ErrorCode.BackupValidation;
readonly unknownFields: ReadonlyArray<string>;
};
export type CancellationError = LibSignalErrorCommon & { export type CancellationError = LibSignalErrorCommon & {
code: ErrorCode.Cancelled; code: ErrorCode.Cancelled;
}; };

View File

@ -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<ComparableBackup> {
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<string> {
return Native.ComparableBackup_GetUnknownFields(this);
}
}

View File

@ -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));
});
});
});

View File

@ -0,0 +1 @@
../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto

View File

@ -0,0 +1 @@
../../../java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json

View File

@ -38,6 +38,8 @@ prefix = "Signal"
renaming_overrides_prefixing = true renaming_overrides_prefixing = true
[export.rename] [export.rename]
"FfiInputStreamStruct" = "SignalInputStream"
# Avoid double-prefixing these # Avoid double-prefixing these
"SignalFfiError" = "SignalFfiError" "SignalFfiError" = "SignalFfiError"
"SignalErrorCode" = "SignalErrorCode" "SignalErrorCode" = "SignalErrorCode"
@ -52,7 +54,7 @@ args = "horizontal"
[parse] [parse]
parse_deps = true parse_deps = true
include = [] include = []
extra_bindings = ["libsignal-bridge-testing"] extra_bindings = ["libsignal-bridge-testing", "libsignal-bridge-types"]
[parse.expand] [parse.expand]
crates = ["libsignal-bridge-testing"] crates = ["libsignal-bridge-testing"]

View File

@ -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] #[no_mangle]
pub unsafe extern "C" fn signal_error_free(err: *mut SignalFfiError) { pub unsafe extern "C" fn signal_error_free(err: *mut SignalFfiError) {
if !err.is_null() { if !err.is_null() {

View File

@ -13,12 +13,14 @@ license = "AGPL-3.0-only"
[dependencies] [dependencies]
libsignal-bridge-macros = { path = "../macros" } libsignal-bridge-macros = { path = "../macros" }
libsignal-bridge-types = { path = "../types" } 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" futures-util = "0.3.7"
paste = "1.0" paste = "1.0"
scopeguard = "1.0" scopeguard = "1.0"
thiserror = "1.0.50" thiserror = "1.0.50"
tokio = "1"
jni = { version = "0.21", package = "jni", optional = true } jni = { version = "0.21", package = "jni", optional = true }
linkme = { version = "0.3.9", optional = true } linkme = { version = "0.3.9", optional = true }
@ -29,5 +31,5 @@ strum = { version = "0.26", features = ["derive"] }
[features] [features]
ffi = ["libsignal-bridge-types/ffi"] ffi = ["libsignal-bridge-types/ffi"]
jni = ["dep:jni", "libsignal-bridge-types/jni"] 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"] signal-media = ["libsignal-bridge-types/signal-media"]

View File

@ -17,3 +17,5 @@ pub use libsignal_bridge_types::node;
pub fn test_only_fn_returns_123() -> u32 { pub fn test_only_fn_returns_123() -> u32 {
123 123
} }
pub mod message_backup;

View File

@ -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<libsignal_message_backup::FoundUnknownField>,
}
bridge_as_handle!(ComparableBackup);
bridge_handle_fns!(ComparableBackup, clone = false);
#[bridge_fn]
async fn ComparableBackup_ReadUnencrypted(
stream: &mut dyn InputStream,
len: u64,
purpose: AsType<Purpose, u8>,
) -> Result<ComparableBackup, ReadError> {
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()
}

View File

@ -101,6 +101,8 @@ pub enum SignalErrorCode {
AppExpired = 160, AppExpired = 160,
DeviceDeregistered = 161, DeviceDeregistered = 161,
BackupValidation = 170,
} }
pub trait UpcastAsAny { pub trait UpcastAsAny {
@ -131,6 +133,9 @@ pub trait FfiError: UpcastAsAny + fmt::Debug + Send + 'static {
fn provide_tries_remaining(&self) -> Result<u32, WrongErrorKind> { fn provide_tries_remaining(&self) -> Result<u32, WrongErrorKind> {
Err(WrongErrorKind) Err(WrongErrorKind)
} }
fn provide_unknown_fields(&self) -> Result<Vec<String>, WrongErrorKind> {
Err(WrongErrorKind)
}
} }
/// The top-level error type (opaquely) returned to C clients when something goes wrong. /// 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<Vec<String>, WrongErrorKind> {
Ok(self
.found_unknown_fields
.iter()
.map(ToString::to_string)
.collect())
}
}
impl FfiError for NullPointerError { impl FfiError for NullPointerError {
fn describe(&self) -> String { fn describe(&self) -> String {
"null pointer".to_owned() "null pointer".to_owned()

View File

@ -1456,6 +1456,9 @@ macro_rules! jni_result_type {
(MessageBackupValidationOutcome) => { (MessageBackupValidationOutcome) => {
::jni::objects::JObject<'local> ::jni::objects::JObject<'local>
}; };
(MessageBackupReadOutcome) => {
::jni::objects::JObject<'local>
};
(LookupResponse) => { (LookupResponse) => {
::jni::objects::JObject<'local> ::jni::objects::JObject<'local>
}; };

View File

@ -50,6 +50,7 @@ pub enum SignalJniError {
ChatService(ChatServiceError), ChatService(ChatServiceError),
InvalidUri(InvalidUri), InvalidUri(InvalidUri),
ConnectTimedOut, ConnectTimedOut,
BackupValidation(#[from] libsignal_message_backup::ReadError),
Bridge(BridgeLayerError), Bridge(BridgeLayerError),
TestingError { TestingError {
exception_class: ClassName<'static>, exception_class: ClassName<'static>,
@ -97,6 +98,7 @@ impl fmt::Display for SignalJniError {
SignalJniError::InvalidUri(e) => write!(f, "{}", e), SignalJniError::InvalidUri(e) => write!(f, "{}", e),
SignalJniError::WebSocket(e) => write!(f, "{e}"), SignalJniError::WebSocket(e) => write!(f, "{e}"),
SignalJniError::ConnectTimedOut => write!(f, "connect timed out"), SignalJniError::ConnectTimedOut => write!(f, "connect timed out"),
SignalJniError::BackupValidation(e) => write!(f, "{}", e),
SignalJniError::Svr3(e) => write!(f, "{}", e), SignalJniError::Svr3(e) => write!(f, "{}", e),
SignalJniError::Bridge(e) => write!(f, "{}", e), SignalJniError::Bridge(e) => write!(f, "{}", e),
SignalJniError::TestingError { exception_class } => { SignalJniError::TestingError { exception_class } => {

View File

@ -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::<Vec<_>>()
.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(_)) => { SignalJniError::Bridge(BridgeLayerError::NullPointer(_)) => {
(ClassName("java.lang.NullPointerException"), error) (ClassName("java.lang.NullPointerException"), error)
} }

View File

@ -58,5 +58,11 @@ pub struct MessageBackupValidationOutcome {
pub error_message: Option<String>, pub error_message: Option<String>,
pub found_unknown_fields: Vec<FoundUnknownField>, pub found_unknown_fields: Vec<FoundUnknownField>,
} }
#[cfg(feature = "ffi")] bridge_as_handle!(MessageBackupValidationOutcome, jni = false, node = false);
ffi_bridge_as_handle!(MessageBackupValidationOutcome);
pub struct ComparableBackup {
pub backup: libsignal_message_backup::backup::serialize::Backup,
pub found_unknown_fields: Vec<FoundUnknownField>,
}
bridge_as_handle!(ComparableBackup);

View File

@ -16,6 +16,7 @@ use std::ops::{Deref, DerefMut, RangeInclusive};
use std::slice; use std::slice;
use crate::io::{InputStream, SyncInputStream}; use crate::io::{InputStream, SyncInputStream};
use crate::message_backup::MessageBackupValidationOutcome;
use crate::net::chat::{MakeChatListener, ResponseAndDebugInfo}; use crate::net::chat::{MakeChatListener, ResponseAndDebugInfo};
use crate::node::chat::NodeMakeChatListener; use crate::node::chat::NodeMakeChatListener;
use crate::support::{extend_lifetime, Array, AsType, FixedLengthBincodeSerializable, Serialized}; 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; type ResultType = JsObject;
fn convert_into(self, cx: &mut impl Context<'a>) -> JsResult<'a, Self::ResultType> { 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, found_unknown_fields,
} = self; } = self;
let error_message = error_message.convert_into(cx)?; let error_message = error_message.convert_into(cx)?;
let unknown_field_messages = let unknown_field_messages = found_unknown_fields.as_slice().convert_into(cx)?;
make_array(cx, found_unknown_fields.into_iter().map(|s| s.to_string()))?;
let obj = JsObject::new(cx); let obj = JsObject::new(cx);
obj.set(cx, "errorMessage", error_message)?; 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> { impl<'a, T: Value> ResultTypeInfo<'a> for Handle<'a, T> {
type ResultType = T; type ResultType = T;
fn convert_into(self, _cx: &mut impl Context<'a>) -> NeonResult<Handle<'a, Self::ResultType>> { fn convert_into(self, _cx: &mut impl Context<'a>) -> NeonResult<Handle<'a, Self::ResultType>> {
@ -1454,7 +1462,7 @@ pub fn clone_from_array_of_wrappers<'a, T: BridgeHandle<Strategy = Mutable<T>> +
#[macro_export] #[macro_export]
macro_rules! node_bridge_as_handle { macro_rules! node_bridge_as_handle {
( $typ:ty as false $(, $($_:tt)*)? ) => {}; ( $typ:ty as false $(, $($_:tt)*)? ) => {};
( $typ:ty as $node_name:ident ) => { ( $typ:ty as $node_name:ident $(, mut = false)? ) => {
::paste::paste! { ::paste::paste! {
#[doc = "ts: interface " $typ " { readonly __type: unique symbol; }"] #[doc = "ts: interface " $typ " { readonly __type: unique symbol; }"]
impl node::BridgeHandle for $typ { impl node::BridgeHandle for $typ {

View File

@ -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. /// Represents an error returned by a callback.
#[derive(Debug)] #[derive(Debug)]
struct CallbackError { struct CallbackError {

View File

@ -57,6 +57,30 @@ pub struct ReadResult<B> {
pub found_unknown_fields: Vec<FoundUnknownField>, pub found_unknown_fields: Vec<FoundUnknownField>,
} }
#[derive(Debug, thiserror::Error)]
#[must_use]
pub struct ReadError {
pub error: Error,
pub found_unknown_fields: Vec<FoundUnknownField>,
}
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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct FoundUnknownField { pub struct FoundUnknownField {
pub frame_index: usize, pub frame_index: usize,

View File

@ -61,6 +61,7 @@ public enum SignalError: Error {
case chatServiceInactive(String) case chatServiceInactive(String)
case appExpired(String) case appExpired(String)
case deviceDeregistered(String) case deviceDeregistered(String)
case backupValidation(unknownFields: [String], message: String)
case unknown(UInt32, String) case unknown(UInt32, String)
} }
@ -201,6 +202,11 @@ internal func checkError(_ error: SignalFfiErrorRef?) throws {
throw SignalError.appExpired(errStr) throw SignalError.appExpired(errStr)
case SignalErrorCodeDeviceDeregistered: case SignalErrorCodeDeviceDeregistered:
throw SignalError.deviceDeregistered(errStr) 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: default:
throw SignalError.unknown(errType, errStr) throw SignalError.unknown(errType, errStr)
} }

View File

@ -65,6 +65,75 @@ public func validateMessageBackup(
return outcome.unknownFields 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. /// The outcome of a failed validation attempt.
public struct MessageBackupValidationError: Error { public struct MessageBackupValidationError: Error {
/// The human-readable error that caused validation to fail. /// The human-readable error that caused validation to fail.

View File

@ -196,6 +196,7 @@ typedef enum {
SignalErrorCodeSvrRestoreFailed = 151, SignalErrorCodeSvrRestoreFailed = 151,
SignalErrorCodeAppExpired = 160, SignalErrorCodeAppExpired = 160,
SignalErrorCodeDeviceDeregistered = 161, SignalErrorCodeDeviceDeregistered = 161,
SignalErrorCodeBackupValidation = 170,
} SignalErrorCode; } SignalErrorCode;
/** /**
@ -376,6 +377,8 @@ typedef struct {
SignalOwnedBufferOfusize lengths; SignalOwnedBufferOfusize lengths;
} SignalBytestringArray; } SignalBytestringArray;
typedef SignalBytestringArray SignalStringArray;
typedef struct { typedef struct {
const unsigned char *base; const unsigned char *base;
size_t length; size_t length;
@ -660,8 +663,6 @@ typedef struct {
typedef SignalFfiChatListenerStruct SignalFfiMakeChatListenerStruct; typedef SignalFfiChatListenerStruct SignalFfiMakeChatListenerStruct;
typedef SignalBytestringArray SignalStringArray;
typedef int (*SignalRead)(void *ctx, uint8_t *buf, size_t buf_len, size_t *amount_read); typedef int (*SignalRead)(void *ctx, uint8_t *buf, size_t buf_len, size_t *amount_read);
typedef int (*SignalSkip)(void *ctx, uint64_t amount); 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_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); void signal_error_free(SignalFfiError *err);
SignalFfiError *signal_identitykeypair_deserialize(SignalPrivateKey **private_key, SignalPublicKey **public_key, SignalBorrowedBuffer input); SignalFfiError *signal_identitykeypair_deserialize(SignalPrivateKey **private_key, SignalPublicKey **public_key, SignalBorrowedBuffer input);

View File

@ -16,6 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
#include <stdlib.h> #include <stdlib.h>
#include "signal_ffi.h" #include "signal_ffi.h"
typedef struct SignalComparableBackup SignalComparableBackup;
SignalFfiError *signal_test_only_fn_returns_123(uint32_t *out); 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_ */ #endif /* SIGNAL_FFI_TESTING_H_ */

View File

@ -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<UInt8>) throws -> MessageBackupUnknownFields { static func validateBackup(bytes: some Collection<UInt8>) throws -> MessageBackupUnknownFields {
try validateMessageBackup(key: MessageBackupKey.testKey(), purpose: .remoteBackup, length: UInt64(bytes.count), makeStream: { SignalInputStreamAdapter(bytes) }) try validateMessageBackup(key: MessageBackupKey.testKey(), purpose: .remoteBackup, length: UInt64(bytes.count), makeStream: { SignalInputStreamAdapter(bytes) })
} }

View File

@ -0,0 +1 @@
../../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto

View File

@ -0,0 +1 @@
../../../../java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json