ic-backup 0.62.0

Host-side snapshot backup and same-release recovery for Internet Computer canisters
Documentation
//! Adapted Canic state/validation cases and exact local identity regressions.

use super::*;
use crate::tests::support::schema;

const HASH: &str = "abababababababababababababababababababababababababababababababab";
const CANISTER: &str = "aaaaa-aa";

fn request(canister: &str, snapshot: &str) -> DownloadArtifactRequest {
    DownloadArtifactRequest {
        canister_id: canister.to_owned(),
        snapshot_id: snapshot.to_owned(),
        snapshot_taken_at_timestamp: u64::MAX,
        snapshot_total_size_bytes: u64::MAX,
    }
}

fn journal() -> DownloadJournalRecord {
    DownloadJournalRecord::new(HASH, request(CANISTER, "snap-1")).expect("exact selection")
}

#[test]
fn canonical_identity_and_exact_metadata_survive_roundtrip() {
    let record =
        DownloadJournalRecord::new(&HASH.to_uppercase(), request("AAAAA-AA", "Snapshot-X"))
            .expect("normalize principal and digest");
    let entry = &record.artifacts()[0];
    assert_eq!(record.intent(), HASH);
    assert_eq!(entry.canister_id(), CANISTER);
    assert_eq!(entry.snapshot_id(), "Snapshot-X");
    assert_eq!(entry.snapshot_taken_at_timestamp(), u64::MAX);
    assert_eq!(entry.snapshot_total_size_bytes(), u64::MAX);
    assert_eq!(entry.staging_path(), "artifacts/aaaaa-aa.tmp");
    assert_eq!(entry.artifact_path(), "artifacts/aaaaa-aa");
    let decoded: DownloadJournalRecord =
        serde_json::from_slice(&serde_json::to_vec(&record).expect("serialize")).expect("decode");
    assert_eq!(decoded, record);
    assert!(matches!(
        record.artifact(CANISTER, "snapshot-x"),
        Err(DownloadJournalRecordError::SnapshotMismatch)
    ));
    assert!(matches!(
        record.artifact("2vxsx-fae", "Snapshot-X"),
        Err(DownloadJournalRecordError::UnknownArtifact)
    ));
}

#[test]
fn exact_transitions_preserve_identity_and_cannot_reset_spending_evidence() {
    let mut record = journal();
    let checksum = ArtifactChecksumRecord::from_bytes(b"actual staged bytes");
    let original = record.clone();
    assert!(
        record
            .advance(CANISTER, "snap-1", ArtifactTransition::Durable)
            .is_err()
    );
    assert_eq!(record, original);
    record
        .advance(
            CANISTER,
            "snap-1",
            ArtifactTransition::ChecksumVerified(checksum.clone()),
        )
        .unwrap();
    let verified = record.clone();
    assert!(
        record
            .advance(CANISTER, "wrong", ArtifactTransition::Durable)
            .is_err()
    );
    assert_eq!(record, verified);
    record
        .advance(CANISTER, "snap-1", ArtifactTransition::Durable)
        .unwrap();
    assert_eq!(record.artifacts()[0].checksum(), Some(&checksum));
    let durable = record.clone();
    for transition in [
        ArtifactTransition::Durable,
        ArtifactTransition::ChecksumVerified(checksum),
    ] {
        assert!(record.advance(CANISTER, "snap-1", transition).is_err());
        assert_eq!(record, durable);
    }
    let roundtrip: DownloadJournalRecord =
        serde_json::from_slice(&serde_json::to_vec(&record).unwrap()).unwrap();
    assert_eq!(record, roundtrip);
}

#[test]
fn current_schema_requires_every_field_and_closed_state_evidence() {
    let value = serde_json::to_value(journal()).expect("serialize");
    for (state, checksum) in [
        (
            ArtifactStateRecord::Created,
            Some(ArtifactChecksumRecord::from_bytes(b"bytes")),
        ),
        (ArtifactStateRecord::ChecksumVerified, None),
        (ArtifactStateRecord::Durable, None),
    ] {
        let mut invalid = value.clone();
        invalid["artifacts"][0]["state"] = serde_json::to_value(state).unwrap();
        invalid["artifacts"][0]["checksum"] = serde_json::to_value(checksum).unwrap();
        assert!(
            serde_json::from_value::<DownloadJournalRecord>(invalid).is_err(),
            "invalid checksum state {state:?}"
        );
    }
    schema::required_fields::<DownloadJournalRecord>(
        &value,
        "",
        &["version", "intent", "artifacts"],
    );
    schema::required_fields::<DownloadJournalRecord>(
        &value,
        "/artifacts/0",
        &[
            "canister_id",
            "snapshot_id",
            "snapshot_taken_at_timestamp",
            "snapshot_total_size_bytes",
            "staging_path",
            "artifact_path",
            "state",
            "checksum",
        ],
    );
    for (field, invalid_value) in [
        ("state", serde_json::json!("ChecksumVerified")),
        ("state", serde_json::json!("Durable")),
        ("state", serde_json::json!("Unknown")),
        ("state", serde_json::json!("Downloaded")),
        (
            "checksum",
            serde_json::json!({"algorithm":"sha256","hash":HASH}),
        ),
        ("artifact_path", serde_json::json!("../outside")),
        ("artifact_path", serde_json::json!("artifacts/AAAAA-AA")),
        ("staging_path", serde_json::json!("/tmp/arbitrary")),
        ("unexpected", serde_json::json!(true)),
    ] {
        let mut invalid = value.clone();
        invalid["artifacts"][0][field] = invalid_value;
        assert!(
            serde_json::from_value::<DownloadJournalRecord>(invalid).is_err(),
            "rejected {field}"
        );
    }
    for field in ["version", "intent", "unexpected"] {
        let mut invalid = value.clone();
        invalid[field] = serde_json::json!(2);
        assert!(serde_json::from_value::<DownloadJournalRecord>(invalid).is_err());
    }
    schema::duplicate_versions::<DownloadJournalRecord>(&value);
}

#[test]
fn only_one_artifact_is_admitted_without_reinterpreting_old_sets() {
    let original = serde_json::to_value(journal()).unwrap();
    let entry = original["artifacts"][0].clone();
    for entries in [serde_json::json!([]), serde_json::json!([entry, entry])] {
        let mut invalid = original.clone();
        invalid["artifacts"] = entries;
        assert!(serde_json::from_value::<DownloadJournalRecord>(invalid).is_err());
    }
}

#[test]
fn snapshot_tokens_are_exact_bounded_ascii_and_principals_are_validated() {
    for value in [
        "",
        "white space",
        "line\nfeed",
        "🦀",
        "\0",
        &"x".repeat(MAX_SNAPSHOT_ID_BYTES + 1),
    ] {
        assert!(matches!(
            DownloadJournalRecord::new(HASH, request(CANISTER, value)),
            Err(DownloadJournalRecordError::InvalidSnapshotId)
        ));
    }
    assert!(
        DownloadJournalRecord::new(HASH, request(CANISTER, &"x".repeat(MAX_SNAPSHOT_ID_BYTES)))
            .is_ok()
    );
    for value in ["bad", "aaaaaaa", &"a".repeat(64)] {
        assert!(matches!(
            DownloadJournalRecord::new(HASH, request(value, "s")),
            Err(DownloadJournalRecordError::InvalidPrincipal)
        ));
    }
}

#[test]
fn download_manifest_independent_goldens_cover_states_metadata_and_exact_tokens() {
    let contract: serde_json::Value = serde_json::from_str(include_str!(
        "../../../../../../docs/contracts/download-manifest.json"
    ))
    .unwrap();
    for golden in contract["goldens"].as_array().unwrap() {
        let record: DownloadJournalRecord =
            serde_json::from_value(golden["record"].clone()).unwrap();
        assert_eq!(record.digest().hash(), golden["sha256"].as_str().unwrap());
        let mut changed = golden["record"].clone();
        changed["intent"] = serde_json::json!(HASH.to_uppercase());
        changed["artifacts"][0]["canister_id"] = serde_json::json!("AAAAA-AA");
        let normalized: DownloadJournalRecord = serde_json::from_value(changed).unwrap();
        assert_eq!(record.digest(), normalized.digest());
        for (field, value) in [
            ("snapshot_id", serde_json::json!("s".repeat(256))),
            ("snapshot_taken_at_timestamp", serde_json::json!(0)),
            ("snapshot_total_size_bytes", serde_json::json!(0)),
        ] {
            let mut changed = golden["record"].clone();
            changed["artifacts"][0][field] = value;
            let changed: DownloadJournalRecord = serde_json::from_value(changed).unwrap();
            assert_ne!(record.digest(), changed.digest());
        }
    }
    for rejected in contract["rejected_records"].as_array().unwrap() {
        assert!(
            serde_json::from_value::<DownloadJournalRecord>(rejected["record"].clone()).is_err()
        );
    }
}