mirror of
https://github.com/signalapp/libsignal.git
synced 2024-09-19 11:32:17 +02:00
backup: bridge canonical serialization as ComparableBackup
This commit is contained in:
parent
854343294d
commit
66cd3f0133
3
.gitattributes
vendored
3
.gitattributes
vendored
@ -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
|
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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]]
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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")
|
||||
|
@ -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()));
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
../../../../../../../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto
|
@ -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": []
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
build/**
|
||||
dist/**
|
||||
Native.d.ts
|
||||
ts/test/canonical-backup.expected.json
|
5
node/Native.d.ts
vendored
5
node/Native.d.ts
vendored
@ -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_Serialize(obj: Wrapper<CiphertextMessage>): Buffer;
|
||||
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_new(environment: number, userAgent: string): ConnectionManager;
|
||||
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 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; }
|
||||
|
@ -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<string>;
|
||||
};
|
||||
|
||||
export type CancellationError = LibSignalErrorCommon & {
|
||||
code: ErrorCode.Cancelled;
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
1
node/ts/test/canonical-backup.binproto
Symbolic link
1
node/ts/test/canonical-backup.binproto
Symbolic link
@ -0,0 +1 @@
|
||||
../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto
|
1
node/ts/test/canonical-backup.expected.json
Symbolic link
1
node/ts/test/canonical-backup.expected.json
Symbolic link
@ -0,0 +1 @@
|
||||
../../../java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json
|
@ -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"]
|
||||
|
@ -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() {
|
||||
|
@ -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"]
|
||||
|
@ -17,3 +17,5 @@ pub use libsignal_bridge_types::node;
|
||||
pub fn test_only_fn_returns_123() -> u32 {
|
||||
123
|
||||
}
|
||||
|
||||
pub mod message_backup;
|
||||
|
58
rust/bridge/shared/testing/src/message_backup.rs
Normal file
58
rust/bridge/shared/testing/src/message_backup.rs
Normal 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()
|
||||
}
|
@ -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<u32, 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.
|
||||
@ -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 {
|
||||
fn describe(&self) -> String {
|
||||
"null pointer".to_owned()
|
||||
|
@ -1456,6 +1456,9 @@ macro_rules! jni_result_type {
|
||||
(MessageBackupValidationOutcome) => {
|
||||
::jni::objects::JObject<'local>
|
||||
};
|
||||
(MessageBackupReadOutcome) => {
|
||||
::jni::objects::JObject<'local>
|
||||
};
|
||||
(LookupResponse) => {
|
||||
::jni::objects::JObject<'local>
|
||||
};
|
||||
|
@ -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 } => {
|
||||
|
@ -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(_)) => {
|
||||
(ClassName("java.lang.NullPointerException"), error)
|
||||
}
|
||||
|
@ -58,5 +58,11 @@ pub struct MessageBackupValidationOutcome {
|
||||
pub error_message: Option<String>,
|
||||
pub found_unknown_fields: Vec<FoundUnknownField>,
|
||||
}
|
||||
#[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<FoundUnknownField>,
|
||||
}
|
||||
|
||||
bridge_as_handle!(ComparableBackup);
|
||||
|
@ -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<Handle<'a, Self::ResultType>> {
|
||||
@ -1454,7 +1462,7 @@ pub fn clone_from_array_of_wrappers<'a, T: BridgeHandle<Strategy = Mutable<T>> +
|
||||
#[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 {
|
||||
|
@ -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 {
|
||||
|
@ -57,6 +57,30 @@ pub struct ReadResult<B> {
|
||||
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)]
|
||||
pub struct FoundUnknownField {
|
||||
pub frame_index: usize,
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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);
|
||||
|
@ -16,6 +16,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
#include <stdlib.h>
|
||||
#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_ */
|
||||
|
@ -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 {
|
||||
try validateMessageBackup(key: MessageBackupKey.testKey(), purpose: .remoteBackup, length: UInt64(bytes.count), makeStream: { SignalInputStreamAdapter(bytes) })
|
||||
}
|
||||
|
@ -0,0 +1 @@
|
||||
../../../../rust/message-backup/tests/res/test-cases/valid/account-data.binproto
|
@ -0,0 +1 @@
|
||||
../../../../java/client/src/test/resources/org/signal/libsignal/messagebackup/canonical-backup.expected.json
|
Loading…
Reference in New Issue
Block a user