ic-backup 0.4.1

Host-side snapshot backup and same-release recovery for Internet Computer canisters
Documentation
use super::*;

fn reference(name: &str, hash: &str) -> RestoreReferenceRecord {
    RestoreReferenceRecord::new(PathBuf::from(format!("/operator/{name}.json")), hash)
        .expect("valid reference")
}

#[test]
fn canonical_hashes_and_exact_locations_own_equality() {
    let hash = "ab".repeat(32);
    assert_eq!(
        reference("journal", &hash),
        reference("journal", &hash.to_uppercase())
    );
    for path in [
        "relative.json",
        "/",
        "/operator/../journal.json",
        "/operator/./journal.json",
        "/operator//journal.json",
        "/operator/journal\0.json",
    ] {
        assert!(matches!(
            RestoreReferenceRecord::new(path.into(), &hash),
            Err(RestoreReferenceError::InvalidJournal { .. })
        ));
    }
    assert!(matches!(
        RestoreReferenceRecord::new("/operator/journal".into(), "not a hash"),
        Err(RestoreReferenceError::Checksum(ChecksumError::InvalidHash(
            _
        )))
    ));
    let oversized = format!("/{}", "x".repeat(MAX_JOURNAL_PATH_BYTES));
    assert!(matches!(
        RestoreReferenceRecord::new(oversized.into(), &hash),
        Err(RestoreReferenceError::InvalidJournal { .. })
    ));
}

#[test]
fn retention_is_ordered_idempotent_and_cannot_rebind_intent() {
    let mut record = RestoreReferencesRecord::empty();
    let first = reference("a", &"aa".repeat(32));
    let last = reference("z", &"bb".repeat(32));
    assert!(record.retain(last.clone()).expect("retain last"));
    assert!(record.retain(first.clone()).expect("retain first"));
    assert!(!record.retain(first.clone()).expect("idempotent retention"));
    assert_eq!(record.entries(), &[first, last]);
    let before = record.clone();
    assert!(
        matches!(record.retain(reference("a", &"cc".repeat(32))), Err(RestoreReferenceError::AuthorityConflict { journal }) if journal == Path::new("/operator/a.json"))
    );
    assert_eq!(record, before);
}

#[test]
fn persisted_generation_fields_and_duplicate_identities_reject() {
    let hash = "ab".repeat(32);
    let entry =
        serde_json::json!({"journal":"/operator/journal.json", "authority":hash.to_uppercase()});
    let valid = serde_json::json!({"version":1,"restores":[entry.clone()]});
    let record: RestoreReferencesRecord =
        serde_json::from_value(valid).expect("decode current record");
    assert_eq!(record.entries()[0].authority(), hash);
    let serialized = serde_json::to_value(record).expect("encode record");
    assert_eq!(
        serialized,
        serde_json::json!({"version":1,"restores":[{"journal":"/operator/journal.json","authority":hash}]})
    );
    for invalid in [
        serde_json::json!({"version":2,"restores":[]}),
        serde_json::json!({"version":1}),
        serde_json::json!({"restores":[]}),
        serde_json::json!({"version":1,"restores":[],"unknown":true}),
        serde_json::json!({"version":1,"restores":[entry.clone(),entry]}),
        serde_json::json!({"version":1,"restores":[{"journal":"relative","authority":hash}]}),
        serde_json::json!({"version":1,"restores":[{"journal":"/operator/journal.json","authority":"invalid"}]}),
        serde_json::json!({"version":1,"restores":[{"journal":"/operator/journal.json","authority":hash,"unknown":true}]}),
        serde_json::json!({"version":1,"restores":[{"journal":"/operator/journal.json"}]}),
    ] {
        assert!(serde_json::from_value::<RestoreReferencesRecord>(invalid).is_err());
    }
}

#[test]
fn count_bound_rejects_new_entries_and_excess_decoding() {
    let hash = "12".repeat(32);
    let mut record = RestoreReferencesRecord::empty();
    for index in 0..MAX_RESTORE_REFERENCES {
        record
            .retain(reference(&index.to_string(), &hash))
            .expect("within count bound");
    }
    assert!(
        !record
            .retain(reference("0", &hash))
            .expect("exact replay at full capacity")
    );
    let before = record.clone();
    assert!(matches!(
        record.retain(reference("extra", &hash)),
        Err(RestoreReferenceError::TooManyReferences {
            limit: MAX_RESTORE_REFERENCES
        })
    ));
    assert_eq!(record, before);
    let bytes = serde_json::to_vec(&record).expect("encode full admitted list");
    let decoded: RestoreReferencesRecord =
        serde_json::from_slice(&bytes).expect("exact bound accepts");
    assert_eq!(decoded, record);
    let mut value = serde_json::to_value(&record).expect("record as JSON");
    value["restores"]
        .as_array_mut()
        .expect("entries")
        .push(serde_json::to_value(reference("extra", &hash)).expect("new entry"));
    assert!(serde_json::from_value::<RestoreReferencesRecord>(value).is_err());
}

#[cfg(unix)]
#[test]
fn non_utf8_journal_identity_rejects() {
    use std::os::unix::ffi::OsStringExt;
    let path = PathBuf::from(std::ffi::OsString::from_vec(
        b"/operator/\xff.json".to_vec(),
    ));
    assert!(
        matches!(RestoreReferenceRecord::new(path.clone(), &"ab".repeat(32)), Err(RestoreReferenceError::InvalidJournal { journal }) if journal == path)
    );
}