Skip to content

Commit b6db49f

Browse files
TypeScript: Provide BackupsJsonExporter for takeout flow
1 parent b7453e7 commit b6db49f

File tree

19 files changed

+848
-46
lines changed

19 files changed

+848
-46
lines changed

RELEASE_NOTES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@ v0.84.0
33
- keytrans: Verify signatures from all auditors
44

55
- Java: Fixed `IdentityKeyPair(byte[])` to correctly declare that it throws InvalidKeyException.
6+
7+
- Node: Add BackupsJsonExporter, to convert Backup proto objects to human-readable JSON for export.

acknowledgments/acknowledgments-android.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5018,7 +5018,7 @@ DEALINGS IN THE SOFTWARE.
50185018
50195019
```
50205020

5021-
## protobuf-codegen 3.7.2, protobuf-support 3.7.2, protobuf 3.7.2
5021+
## protobuf-codegen 3.7.2, protobuf-json-mapping 3.7.2, protobuf-support 3.7.2, protobuf 3.7.2
50225022

50235023
```
50245024
Copyright (c) 2019 Stepan Koltsov

node/Native.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,10 @@ export function BackupAuthCredential_GetBackupId(credentialBytes: Uint8Array): U
229229
export function BackupAuthCredential_GetBackupLevel(credentialBytes: Uint8Array): number;
230230
export function BackupAuthCredential_GetType(credentialBytes: Uint8Array): number;
231231
export function BackupAuthCredential_PresentDeterministic(credentialBytes: Uint8Array, serverParamsBytes: Uint8Array, randomness: Uint8Array): Uint8Array;
232+
export function BackupJsonExporter_ExportFrames(exporter: Wrapper<BackupJsonExporter>, frames: Uint8Array): string;
233+
export function BackupJsonExporter_Finish(exporter: Wrapper<BackupJsonExporter>): string;
234+
export function BackupJsonExporter_GetInitialChunk(exporter: Wrapper<BackupJsonExporter>): string;
235+
export function BackupJsonExporter_New(backupInfo: Uint8Array, shouldValidate: boolean): BackupJsonExporter;
232236
export function BackupKey_DeriveBackupId(backupKey: Uint8Array, aci: Uint8Array): Uint8Array;
233237
export function BackupKey_DeriveEcKey(backupKey: Uint8Array, aci: Uint8Array): PrivateKey;
234238
export function BackupKey_DeriveLocalBackupMetadataKey(backupKey: Uint8Array): Uint8Array;
@@ -734,6 +738,7 @@ export function initLogger(maxLevel: LogLevel, callback: (level: LogLevel, targe
734738
export function test_only_fn_returns_123(): number;
735739
interface Aes256GcmSiv { readonly __type: unique symbol; }
736740
interface AuthenticatedChatConnection { readonly __type: unique symbol; }
741+
interface BackupJsonExporter { readonly __type: unique symbol; }
737742
interface BackupRestoreResponse { readonly __type: unique symbol; }
738743
interface BackupStoreResponse { readonly __type: unique symbol; }
739744
interface BridgedStringMap { readonly __type: unique symbol; }

node/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

node/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"mocha": "^11",
6565
"prettier": "^2.7.1",
6666
"prettier-plugin-packagejson": "^2.5.19",
67+
"protobufjs": "^7.5.3",
6768
"rimraf": "^6.0.1",
6869
"sinon": "^21.0.0",
6970
"sinon-chai": "^4.0.1",

node/ts/MessageBackup.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export class MessageBackupKey {
114114
export enum Purpose {
115115
DeviceTransfer = 0,
116116
RemoteBackup = 1,
117+
TakeoutExport = 2,
117118
}
118119

119120
/**
@@ -277,3 +278,52 @@ export class ComparableBackup {
277278
return Native.ComparableBackup_GetUnknownFields(this);
278279
}
279280
}
281+
282+
/**
283+
* Streaming exporter that produces a human-readable JSON representation of a backup.
284+
*/
285+
export class BackupJsonExporter {
286+
readonly _nativeHandle: Native.BackupJsonExporter;
287+
288+
private constructor(handle: Native.BackupJsonExporter) {
289+
this._nativeHandle = handle;
290+
}
291+
292+
/**
293+
* Initializes the streaming exporter and returns the first chunk of output.
294+
* @param backupInfo The serialized BackupInfo protobuf without a varint header.
295+
* @param [options] Additional configuration for the exporter.
296+
* @param [options.validate=true] Whether to run semantic validation on the backup.
297+
* @returns An object containing the exporter and the first chunk of output, containing the backup info.
298+
* @throws Error if the input is invalid.
299+
*/
300+
public static start(
301+
backupInfo: Uint8Array,
302+
options?: { validate?: boolean }
303+
): { exporter: BackupJsonExporter; chunk: string } {
304+
const shouldValidate = options?.validate ?? true;
305+
const handle = Native.BackupJsonExporter_New(backupInfo, shouldValidate);
306+
const exporter = new BackupJsonExporter(handle);
307+
const chunk = Native.BackupJsonExporter_GetInitialChunk(exporter);
308+
return { exporter, chunk };
309+
}
310+
311+
/**
312+
* Validates and exports a human-readable JSON representation of backup frames.
313+
* @param frames One or more varint delimited Frame serialized protobuf messages.
314+
* @returns A string containing the exported frames.
315+
* @throws Error if the input is invalid.
316+
*/
317+
public exportFrames(frames: Uint8Array): string {
318+
return Native.BackupJsonExporter_ExportFrames(this, frames);
319+
}
320+
321+
/**
322+
* Completes the validation and export of the previously exported frames.
323+
* @returns A string containing the final chunk of the output.
324+
* @throws Error if some previous input fails validation at the final stage.
325+
*/
326+
public finish(): string {
327+
return Native.BackupJsonExporter_Finish(this);
328+
}
329+
}

node/ts/test/MessageBackupTest.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { Buffer } from 'node:buffer';
88
import * as fs from 'node:fs';
99
import * as path from 'node:path';
1010
import { Readable } from 'node:stream';
11+
import protobuf from 'protobufjs/minimal.js';
12+
const { Reader } = protobuf;
1113

1214
import * as MessageBackup from '../MessageBackup.js';
1315
import * as util from './util.js';
@@ -190,6 +192,60 @@ const exampleBackup = fs.readFileSync(
190192
path.join(import.meta.dirname, '../../ts/test/canonical-backup.binproto')
191193
);
192194

195+
function chunkLengthDelimited(binproto: Uint8Array): Uint8Array[] {
196+
const r = Reader.create(binproto);
197+
const chunks: Uint8Array[] = [];
198+
199+
while (r.pos < r.len) {
200+
const headerStart = r.pos; // start of the varint length prefix
201+
const length = r.uint32(); // implicitly advances to the start of the body
202+
const bodyStart = r.pos; // now points to the start of the proto message
203+
const end = bodyStart + length;
204+
205+
if (end > r.len) {
206+
throw new Error('truncated length-delimited chunk');
207+
}
208+
209+
// Include the varint header + body
210+
chunks.push(binproto.subarray(headerStart, end));
211+
r.pos = end;
212+
}
213+
214+
return chunks;
215+
}
216+
217+
function stripLengthPrefix(chunk: Uint8Array): Uint8Array {
218+
const reader = Reader.create(chunk);
219+
const length = reader.uint32();
220+
const bodyStart = reader.pos;
221+
const bodyEnd = bodyStart + length;
222+
if (bodyEnd > reader.len) {
223+
throw new Error('truncated length-delimited chunk');
224+
}
225+
if (bodyEnd !== reader.len) {
226+
throw new Error('unexpected trailing data after chunk body');
227+
}
228+
return chunk.subarray(bodyStart, bodyEnd);
229+
}
230+
231+
const exampleBackupChunks = chunkLengthDelimited(exampleBackup);
232+
if (exampleBackupChunks.length === 0) {
233+
throw new Error('expected at least one length-delimited chunk');
234+
}
235+
const [exampleBackupInfoChunk, ...exampleFrameChunks] = exampleBackupChunks;
236+
const exampleBackupInfo = stripLengthPrefix(exampleBackupInfoChunk);
237+
const exampleFrames = exampleFrameChunks;
238+
239+
function concatFrames(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
240+
if (chunks.length === 0) {
241+
return new Uint8Array();
242+
}
243+
if (chunks.length === 1) {
244+
return new Uint8Array(chunks[0]);
245+
}
246+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
247+
}
248+
193249
describe('ComparableBackup', () => {
194250
describe('exampleBackup', () => {
195251
it('stringifies to the expected value', async () => {
@@ -211,6 +267,121 @@ describe('ComparableBackup', () => {
211267
});
212268
});
213269

270+
describe('BackupJsonExporter', () => {
271+
it('streams pretty JSON for a canonical backup', () => {
272+
const backupInfo = exampleBackupInfo;
273+
const frames = exampleFrames.slice();
274+
275+
const { exporter, chunk: initialChunk } =
276+
MessageBackup.BackupJsonExporter.start(backupInfo);
277+
278+
// Stream the frames across multiple chunks to mirror the real exporter usage.
279+
const chunkGroups = [frames.slice(0, 2), frames.slice(2)].filter(
280+
(group) => group.length > 0
281+
);
282+
const exportedFrameChunks = chunkGroups.map((group) =>
283+
exporter.exportFrames(concatFrames(group))
284+
);
285+
286+
const jsonText = [
287+
initialChunk,
288+
...exportedFrameChunks,
289+
exporter.finish(),
290+
].join('');
291+
assert.isTrue(jsonText.startsWith('[\n {'));
292+
assert.isTrue(jsonText.endsWith(']\n'));
293+
294+
const parsed = JSON.parse(jsonText) as unknown;
295+
assert.isArray(parsed);
296+
297+
const parsedArray = parsed as Array<unknown>;
298+
assert.lengthOf(parsedArray, frames.length + 1);
299+
300+
const [backupInfoJson, firstFrame] = parsedArray;
301+
assert.isObject(backupInfoJson);
302+
assert.containsAllKeys(backupInfoJson as Record<string, unknown>, [
303+
'version',
304+
'mediaRootBackupKey',
305+
]);
306+
assert.isObject(firstFrame);
307+
const firstFrameRecord = firstFrame as Record<string, unknown>;
308+
assert.property(firstFrameRecord, 'account');
309+
const accountValue = firstFrameRecord.account;
310+
assert.isObject(accountValue);
311+
const accountRecord = accountValue as Record<string, unknown>;
312+
assert.containsAllKeys(accountRecord, [
313+
'profileKey',
314+
'username',
315+
'accountSettings',
316+
]);
317+
});
318+
319+
it('returns an empty chunk when no frames are provided', () => {
320+
const backupInfo = exampleBackupInfo;
321+
const { exporter } = MessageBackup.BackupJsonExporter.start(backupInfo);
322+
assert.equal(exporter.exportFrames(new Uint8Array()), '');
323+
});
324+
325+
it('validates frames when requested', () => {
326+
const backupInfo = exampleBackupInfo;
327+
const frames = exampleFrames.slice();
328+
const { exporter } = MessageBackup.BackupJsonExporter.start(backupInfo, {
329+
validate: true,
330+
});
331+
332+
const groupedFrames = [frames.slice(0, 1), frames.slice(1)];
333+
for (const group of groupedFrames) {
334+
if (group.length === 0) {
335+
continue;
336+
}
337+
exporter.exportFrames(concatFrames(group));
338+
}
339+
340+
exporter.finish();
341+
});
342+
343+
it('throws when validation fails', () => {
344+
const backupInfo = exampleBackupInfo;
345+
const frames = exampleFrames.slice();
346+
const { exporter, chunk } = MessageBackup.BackupJsonExporter.start(
347+
backupInfo,
348+
{
349+
validate: true,
350+
}
351+
);
352+
353+
// baseline chunk should still be produced
354+
assert.isTrue(chunk.startsWith('[\n'));
355+
356+
const missingAccountChunk = concatFrames(frames.slice(1));
357+
exporter.exportFrames(missingAccountChunk);
358+
359+
assert.throws(() => exporter.finish());
360+
});
361+
362+
it('can skip validation when explicitly disabled', () => {
363+
const backupInfo = exampleBackupInfo;
364+
const frames = exampleFrames.slice();
365+
const { exporter } = MessageBackup.BackupJsonExporter.start(backupInfo, {
366+
validate: false,
367+
});
368+
369+
const missingAccountChunk = concatFrames(frames.slice(1));
370+
exporter.exportFrames(missingAccountChunk);
371+
372+
exporter.finish();
373+
});
374+
375+
it('still rejects malformed data even when validation is disabled', () => {
376+
const backupInfo = exampleBackupInfo;
377+
const { exporter } = MessageBackup.BackupJsonExporter.start(backupInfo, {
378+
validate: false,
379+
});
380+
381+
assert.throws(() => exporter.exportFrames(Uint8Array.of(0x02, 0x01)));
382+
});
383+
});
384+
214385
describe('OnlineBackupValidator', () => {
215386
it('can read frames from a valid file', () => {
216387
// `Readable.read` normally returns `any`, because it supports settable encodings.

rust/bridge/shared/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ libsignal-bridge-macros = { workspace = true }
2222
libsignal-bridge-types = { workspace = true }
2323
libsignal-core = { workspace = true }
2424
libsignal-keytrans = { workspace = true }
25-
libsignal-message-backup = { workspace = true }
25+
libsignal-message-backup = { workspace = true, features = ["json"] }
2626
libsignal-net = { workspace = true }
2727
libsignal-net-chat = { workspace = true }
2828
libsignal-protocol = { workspace = true }

rust/bridge/shared/src/message_backup.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ async fn MessageBackupValidator_Validate(
117117
}
118118

119119
bridge_handle_fns!(OnlineBackupValidator, clone = false);
120+
bridge_handle_fns!(BackupJsonExporter, clone = false, ffi = false, jni = false);
120121

121122
#[bridge_fn]
122123
fn OnlineBackupValidator_New(
@@ -155,3 +156,32 @@ fn OnlineBackupValidator_AddFrame(
155156
fn OnlineBackupValidator_Finalize(backup: &mut OnlineBackupValidator) -> Result<(), ReadError> {
156157
backup.finalize().map_err(ReadError::with_error_only)
157158
}
159+
160+
#[bridge_fn(ffi = false, jni = false)]
161+
fn BackupJsonExporter_New(
162+
backup_info: &[u8],
163+
should_validate: bool,
164+
) -> Result<BackupJsonExporter, ReadError> {
165+
let (exporter, initial_chunk) =
166+
libsignal_message_backup::json::exporter::JsonExporter::new(backup_info, should_validate)?;
167+
168+
Ok(BackupJsonExporter::new(exporter, initial_chunk))
169+
}
170+
171+
#[bridge_fn(ffi = false, jni = false)]
172+
fn BackupJsonExporter_GetInitialChunk(exporter: &BackupJsonExporter) -> String {
173+
exporter.initial_chunk()
174+
}
175+
176+
#[bridge_fn(ffi = false, jni = false)]
177+
fn BackupJsonExporter_ExportFrames(
178+
exporter: &mut BackupJsonExporter,
179+
frames: &[u8],
180+
) -> Result<String, ReadError> {
181+
exporter.inner_mut().export_frames(frames)
182+
}
183+
184+
#[bridge_fn(ffi = false, jni = false)]
185+
fn BackupJsonExporter_Finish(exporter: &mut BackupJsonExporter) -> Result<String, ReadError> {
186+
exporter.inner_mut().finish()
187+
}

rust/bridge/shared/testing/src/message_backup.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ use libsignal_bridge_types::io::{AsyncInput, InputStream};
88
use libsignal_bridge_types::support::*;
99
use libsignal_bridge_types::*;
1010
use libsignal_message_backup::backup::Purpose;
11-
use libsignal_message_backup::{BackupReader, ReadError, ReadResult};
11+
use libsignal_message_backup::{BackupReader, FoundUnknownField, ReadError, ReadResult};
1212

1313
pub struct ComparableBackup {
14-
pub backup: libsignal_message_backup::backup::serialize::Backup,
15-
pub found_unknown_fields: Vec<libsignal_message_backup::FoundUnknownField>,
14+
backup: libsignal_message_backup::backup::serialize::Backup,
15+
found_unknown_fields: Vec<FoundUnknownField>,
1616
}
1717

1818
bridge_as_handle!(ComparableBackup);

0 commit comments

Comments
 (0)