Covers bad magic, unsupported version, wrong passphrase, truncation, and tampered ciphertext. The wrong-passphrase / tampered-tag pair both collapse to RelicarioError::Decrypt — same opaque-failure contract as the live vault.
189 lines
6.6 KiB
Rust
189 lines
6.6 KiB
Rust
//! Backup container round-trip + error-path coverage.
|
|
|
|
use relicario_core::backup::{pack_backup, unpack_backup, BackupInput};
|
|
|
|
fn empty_input() -> BackupInput<'static> {
|
|
BackupInput {
|
|
salt: &[0u8; 32],
|
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
|
devices_json: "[]",
|
|
manifest_enc: &[],
|
|
settings_enc: &[],
|
|
items: vec![],
|
|
attachments: vec![],
|
|
reference_jpg: None,
|
|
git_archive: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_vault_round_trip() {
|
|
let out = pack_backup(empty_input(), "test-passphrase-1234").unwrap();
|
|
assert_eq!(&out[..4], b"RBAK", "magic header");
|
|
assert_eq!(out[4], 0x01, "format version");
|
|
|
|
let unpacked = unpack_backup(&out, "test-passphrase-1234").unwrap();
|
|
assert_eq!(unpacked.salt, [0u8; 32]);
|
|
assert!(unpacked.devices_json.contains("[]"));
|
|
assert!(unpacked.items.is_empty());
|
|
assert!(unpacked.attachments.is_empty());
|
|
assert!(unpacked.reference_jpg.is_none());
|
|
assert!(unpacked.git_archive.is_none());
|
|
}
|
|
|
|
use relicario_core::backup::{BackupAttachment, BackupItem};
|
|
|
|
#[test]
|
|
fn populated_vault_round_trip() {
|
|
let manifest_enc = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42];
|
|
let settings_enc = vec![0x01, 0x02, 0x03];
|
|
let item_a_ct = vec![0xAA; 100];
|
|
let item_b_ct = vec![0xBB; 200];
|
|
let attach_x_ct = vec![0xCC; 4096];
|
|
let attach_y_ct = vec![0xDD; 8192];
|
|
|
|
let input = BackupInput {
|
|
salt: &[0x77u8; 32],
|
|
params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#,
|
|
devices_json: r#"[{"name":"laptop","public_key":"deadbeef"}]"#,
|
|
manifest_enc: &manifest_enc,
|
|
settings_enc: &settings_enc,
|
|
items: vec![
|
|
BackupItem { id: "1111111111111111".to_string(), ciphertext: &item_a_ct },
|
|
BackupItem { id: "2222222222222222".to_string(), ciphertext: &item_b_ct },
|
|
],
|
|
attachments: vec![
|
|
BackupAttachment {
|
|
item_id: "1111111111111111".to_string(),
|
|
attachment_id: "aaaa1111".to_string(),
|
|
ciphertext: &attach_x_ct,
|
|
},
|
|
BackupAttachment {
|
|
item_id: "2222222222222222".to_string(),
|
|
attachment_id: "bbbb2222".to_string(),
|
|
ciphertext: &attach_y_ct,
|
|
},
|
|
],
|
|
reference_jpg: None,
|
|
git_archive: None,
|
|
};
|
|
|
|
let out = pack_backup(input, "another-strong-passphrase").unwrap();
|
|
let unpacked = unpack_backup(&out, "another-strong-passphrase").unwrap();
|
|
|
|
assert_eq!(unpacked.salt, [0x77u8; 32]);
|
|
assert!(unpacked.devices_json.contains("laptop"));
|
|
assert_eq!(unpacked.manifest_enc, manifest_enc);
|
|
assert_eq!(unpacked.settings_enc, settings_enc);
|
|
|
|
assert_eq!(unpacked.items.len(), 2);
|
|
let by_id: std::collections::HashMap<_, _> =
|
|
unpacked.items.iter().map(|i| (i.id.as_str(), &i.ciphertext)).collect();
|
|
assert_eq!(by_id.get("1111111111111111").unwrap(), &&item_a_ct);
|
|
assert_eq!(by_id.get("2222222222222222").unwrap(), &&item_b_ct);
|
|
|
|
assert_eq!(unpacked.attachments.len(), 2);
|
|
let by_aid: std::collections::HashMap<_, _> = unpacked
|
|
.attachments
|
|
.iter()
|
|
.map(|a| ((a.item_id.as_str(), a.attachment_id.as_str()), &a.ciphertext))
|
|
.collect();
|
|
assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct);
|
|
assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_with_reference_image() {
|
|
let jpg_bytes: Vec<u8> = (0u8..=255).cycle().take(1024 * 64).collect(); // 64 KiB
|
|
let mut input = empty_input();
|
|
input.reference_jpg = Some(&jpg_bytes);
|
|
|
|
let out = pack_backup(input, "p").unwrap();
|
|
let unpacked = unpack_backup(&out, "p").unwrap();
|
|
|
|
assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice()));
|
|
assert!(unpacked.git_archive.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_with_git_archive() {
|
|
let tar_bytes: Vec<u8> = b"FAKE TAR BYTES; core treats opaquely".repeat(50);
|
|
let mut input = empty_input();
|
|
input.git_archive = Some(&tar_bytes);
|
|
|
|
let out = pack_backup(input, "p").unwrap();
|
|
let unpacked = unpack_backup(&out, "p").unwrap();
|
|
|
|
assert_eq!(unpacked.git_archive.as_deref(), Some(tar_bytes.as_slice()));
|
|
}
|
|
|
|
#[test]
|
|
fn no_history_produces_strict_subset() {
|
|
let mut a = empty_input();
|
|
a.git_archive = Some(b"some-tar-bytes");
|
|
let with = pack_backup(a, "p").unwrap();
|
|
|
|
let without = pack_backup(empty_input(), "p").unwrap();
|
|
|
|
// The "without" file is strictly smaller (one fewer base64-encoded blob in JSON).
|
|
assert!(without.len() < with.len(),
|
|
"no-history backup should be smaller: with={}, without={}",
|
|
with.len(), without.len()
|
|
);
|
|
}
|
|
|
|
use relicario_core::RelicarioError;
|
|
|
|
#[test]
|
|
fn bad_magic_rejected() {
|
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
|
bytes[0] = b'X';
|
|
match unpack_backup(&bytes, "p") {
|
|
Err(RelicarioError::BackupBadMagic) => {}
|
|
other => panic!("expected BackupBadMagic, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_version_rejected() {
|
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
|
bytes[4] = 0xFF;
|
|
match unpack_backup(&bytes, "p") {
|
|
Err(RelicarioError::BackupUnsupportedVersion { found, expected }) => {
|
|
assert_eq!(found, 0xFF);
|
|
assert_eq!(expected, 0x01);
|
|
}
|
|
other => panic!("expected BackupUnsupportedVersion, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_passphrase_rejected_as_decrypt_error() {
|
|
let bytes = pack_backup(empty_input(), "right-passphrase").unwrap();
|
|
match unpack_backup(&bytes, "wrong-passphrase") {
|
|
Err(RelicarioError::Decrypt) => {}
|
|
other => panic!("expected Decrypt (opaque), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_file_rejected() {
|
|
let bytes = pack_backup(empty_input(), "p").unwrap();
|
|
let truncated = &bytes[..bytes.len().min(60)]; // shorter than HEADER_LEN + TAG_LEN
|
|
match unpack_backup(truncated, "p") {
|
|
Err(RelicarioError::Format(_)) => {}
|
|
other => panic!("expected Format(truncated), got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tampered_ciphertext_rejected_as_decrypt_error() {
|
|
let mut bytes = pack_backup(empty_input(), "p").unwrap();
|
|
let last = bytes.len() - 1;
|
|
bytes[last] ^= 0xFF; // flip a byte in the auth-tag region
|
|
match unpack_backup(&bytes, "p") {
|
|
Err(RelicarioError::Decrypt) => {}
|
|
other => panic!("expected Decrypt for tampered tag, got {other:?}"),
|
|
}
|
|
}
|