ic-backup 0.4.2

Host-side snapshot backup and same-release recovery for Internet Computer canisters
Documentation
use super::*;
use crate::{
    model::attempt_journal::ObservationReceiptRequest,
    test_support::ic_snapshot_upload_data_observation::{input, with_original},
};
use ic_management_canister_types::SnapshotDataKind;

fn settlement(
    request: &IcSnapshotUploadDataObservationRequest<'_, '_, '_>,
    response: &IcSnapshotUploadDataObservationResponse,
    challenge: &ArtifactChecksumRecord,
    attribution: IcSnapshotUploadDataAttribution,
) -> IcSnapshotUploadDataSettlement {
    IcSnapshotUploadDataSettlement {
        authority: request.authority().digest(),
        mutation_attempt: request.mutation_attempt(),
        observation_attempt: request.observation_attempt(),
        challenge: challenge.clone(),
        readback: crate::model::ic_snapshot_data::IcSnapshotDataReply::decode(
            request.payload(),
            &response.input().reply,
        )
        .unwrap()
        .digest(),
        observation_evidence: response.input().evidence.clone(),
        attribution,
        evidence: ArtifactChecksumRecord::from_bytes(
            b"retained integration settlement declaration",
        ),
    }
}

fn attributions() -> [(IcSnapshotUploadDataAttribution, ObservationOutcomeRecord); 3] {
    let proof = ArtifactChecksumRecord::from_bytes(b"separately qualified evidence declaration");
    [
        (
            IcSnapshotUploadDataAttribution::Applied {
                attribution: proof.clone(),
            },
            ObservationOutcomeRecord::Applied,
        ),
        (
            IcSnapshotUploadDataAttribution::NotApplied {
                exclusion: proof.clone(),
            },
            ObservationOutcomeRecord::NotApplied,
        ),
        (
            IcSnapshotUploadDataAttribution::Unresolved { uncertainty: proof },
            ObservationOutcomeRecord::Uncertain,
        ),
    ]
}

#[test]
fn equal_zero_bytes_need_independent_claim_and_pure_views_leave_all_spending_pending() {
    for kind in [
        SnapshotDataKind::WasmModule { offset: 0, size: 2 },
        SnapshotDataKind::WasmMemory { offset: 0, size: 2 },
        SnapshotDataKind::StableMemory { offset: 0, size: 2 },
    ] {
        with_original(kind, &[0; 2], |plan, upload, read, journal| {
            let before = journal.digest();
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[0; 2])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            for (attribution, outcome) in attributions() {
                let actual = settlement(&request, &response, &challenge, attribution);
                let view =
                    validate_settlement(&request, journal, &response, &challenge, &actual).unwrap();
                assert_eq!(view.outcome(), outcome);
                assert!(view.observation().matches_original_chunk());
                assert!(std::ptr::eq(view.settlement(), &raw const actual));
                assert!(std::ptr::eq(
                    view.observation().response(),
                    &raw const response
                ));
                assert_eq!(journal.digest(), before);
                assert_eq!(journal.view().pending_mutation, Some(1));
                assert_eq!(journal.view().pending_observation, Some(2));
                assert_eq!(journal.view().mutations_remaining, 0);
                assert_eq!(journal.view().observations_remaining, 0);
                assert!(!journal.view().applied);
                let mut retained = journal.clone();
                retained
                    .record_observation(ObservationReceiptRequest {
                        attempt: actual.observation_attempt,
                        request: read.digest().hash().into(),
                        outcome: view.outcome(),
                        evidence: actual.evidence.hash().into(),
                    })
                    .unwrap();
                assert_eq!(retained.view().pending_observation, None);
                assert_eq!(
                    retained.view().pending_mutation,
                    if outcome == ObservationOutcomeRecord::Uncertain {
                        Some(1)
                    } else {
                        None
                    }
                );
                assert_eq!(
                    retained.view().applied,
                    outcome == ObservationOutcomeRecord::Applied
                );
                assert_eq!(retained.view().mutations_used, 1);
                assert_eq!(retained.view().observations_used, 1);
                assert_eq!(retained.view().mutations_remaining, 0);
                assert_eq!(retained.view().observations_remaining, 0);
                assert!(retained.reserve_mutation().is_err());
                assert!(
                    retained
                        .reserve_observation(1, read.digest().hash())
                        .is_err()
                );
            }
        });
    }
}

#[test]
fn changed_destination_metadata_cannot_reuse_identical_read_arguments_and_raw_data() {
    with_original(
        SnapshotDataKind::WasmMemory { offset: 0, size: 2 },
        &[42; 2],
        |plan, upload, read, journal| {
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[42; 2])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            let actual = settlement(&request, &response, &challenge, attributions()[0].0.clone());
            let mut values = read.metadata().metadata().clone();
            values.taken_at_timestamp ^= 1;
            let bytes = candid::encode_one(values).unwrap();
            let metadata = crate::model::ic_snapshot_metadata::IcSnapshotMetadataReply::decode(
                read.metadata().request(),
                &bytes,
            )
            .unwrap();
            let other_read = crate::model::ic_snapshot_data::IcSnapshotDataRequest::new(
                &metadata,
                read.kind().clone(),
            )
            .unwrap();
            assert_eq!(other_read.digest(), read.digest());
            let other =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, &other_read)
                    .unwrap();
            assert!(matches!(
                validate_settlement(&other, journal, &response, &challenge, &actual),
                Err(IcSnapshotUploadDataSettlementError::ReadbackMismatch)
            ));
        },
    );
}

#[test]
fn known_empty_chunk_uses_existing_hash_decoder_and_no_new_allowance() {
    with_original(
        SnapshotDataKind::WasmChunk {
            hash: crate::test_support::ic_snapshot_upload::unhex(
                ArtifactChecksumRecord::from_bytes(&[]).hash(),
            ),
        },
        &[],
        |plan, upload, read, journal| {
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            let actual = settlement(&request, &response, &challenge, attributions()[0].0.clone());
            let view =
                validate_settlement(&request, journal, &response, &challenge, &actual).unwrap();
            assert_eq!(view.outcome(), ObservationOutcomeRecord::Applied);
            assert_eq!(view.observation().reply().chunk(), &[] as &[u8]);
            assert_eq!(journal.view().pending_observation, Some(2));
            assert_eq!(journal.view().observations_remaining, 0);
        },
    );
}

#[test]
fn different_bytes_reject_applied_but_never_infer_nonapplication_or_uncertainty() {
    with_original(
        SnapshotDataKind::WasmModule { offset: 1, size: 2 },
        &[42; 2],
        |plan, upload, read, journal| {
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[0; 2])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            for (attribution, outcome) in attributions() {
                let actual = settlement(&request, &response, &challenge, attribution);
                let result = validate_settlement(&request, journal, &response, &challenge, &actual);
                if outcome == ObservationOutcomeRecord::Applied {
                    assert!(matches!(
                        result,
                        Err(IcSnapshotUploadDataSettlementError::AppliedBytesMismatch)
                    ));
                } else {
                    let view = result.unwrap();
                    assert_eq!(view.outcome(), outcome);
                    assert!(!view.observation().matches_original_chunk());
                }
            }
        },
    );
}

#[test]
fn exact_authority_both_attempts_challenge_readback_and_observation_evidence_are_required() {
    with_original(
        SnapshotDataKind::WasmModule { offset: 1, size: 2 },
        &[42; 2],
        |plan, upload, read, journal| {
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[42; 2])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            let base = settlement(&request, &response, &challenge, attributions()[0].0.clone());
            let other = ArtifactChecksumRecord::from_bytes(b"changed original");
            for change in 0..6 {
                let mut actual = base.clone();
                match change {
                    0 => actual.authority = other.clone(),
                    1 => actual.mutation_attempt = 0,
                    2 => actual.observation_attempt = 1025,
                    3 => actual.challenge = other.clone(),
                    4 => actual.readback = other.clone(),
                    _ => actual.observation_evidence = other.clone(),
                }
                let error = validate_settlement(&request, journal, &response, &challenge, &actual)
                    .unwrap_err();
                assert!(match change {
                    0 => matches!(
                        error,
                        IcSnapshotUploadDataSettlementError::AuthorityMismatch
                    ),
                    1 | 2 => matches!(error, IcSnapshotUploadDataSettlementError::AttemptMismatch),
                    3 => matches!(
                        error,
                        IcSnapshotUploadDataSettlementError::ChallengeMismatch
                    ),
                    4 => matches!(error, IcSnapshotUploadDataSettlementError::ReadbackMismatch),
                    _ => matches!(
                        error,
                        IcSnapshotUploadDataSettlementError::ObservationEvidenceMismatch
                    ),
                });
            }
        },
    );
}

#[test]
fn changed_raw_bytes_and_equal_bytes_with_different_provenance_cannot_rebind_settlement() {
    with_original(
        SnapshotDataKind::StableMemory { offset: 1, size: 2 },
        &[42; 2],
        |plan, upload, read, journal| {
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[42; 2])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            let actual = settlement(&request, &response, &challenge, attributions()[2].0.clone());
            let other =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[0; 2])).unwrap();
            assert!(matches!(
                validate_settlement(&request, journal, &other, &challenge, &actual),
                Err(IcSnapshotUploadDataSettlementError::ReadbackMismatch)
            ));
            let mut fields = response.input().clone();
            fields.evidence = ArtifactChecksumRecord::from_bytes(b"different provider evidence");
            let other = IcSnapshotUploadDataObservationResponse::new(fields).unwrap();
            assert!(matches!(
                validate_settlement(&request, journal, &other, &challenge, &actual),
                Err(IcSnapshotUploadDataSettlementError::ObservationEvidenceMismatch)
            ));
        },
    );
}

#[test]
fn stale_or_resolved_reservations_and_lost_or_malformed_reads_stay_outside_settlement() {
    with_original(
        SnapshotDataKind::WasmModule { offset: 1, size: 2 },
        &[42; 2],
        |plan, upload, read, journal| {
            let request =
                IcSnapshotUploadDataObservationRequest::new(plan, 7, journal, upload, read)
                    .unwrap();
            let response =
                IcSnapshotUploadDataObservationResponse::new(input(&request, &[42; 2])).unwrap();
            let challenge = ArtifactChecksumRecord::from_bytes(b"current qualification");
            let actual = settlement(&request, &response, &challenge, attributions()[2].0.clone());
            let mut settled = journal.clone();
            settled
                .record_observation(ObservationReceiptRequest {
                    attempt: 2,
                    request: read.digest().hash().into(),
                    outcome: ObservationOutcomeRecord::Uncertain,
                    evidence: actual.evidence.hash().into(),
                })
                .unwrap();
            for current in [
                &settled,
                &AttemptJournalRecord::new(journal.authority().clone()),
            ] {
                assert!(matches!(
                    validate_settlement(&request, current, &response, &challenge, &actual),
                    Err(IcSnapshotUploadDataSettlementError::Observation(_))
                ));
            }
            for reply in [vec![], b"lost or invalid reply".to_vec()] {
                let mut fields = response.input().clone();
                fields.reply = reply;
                let absent = IcSnapshotUploadDataObservationResponse::new(fields).unwrap();
                assert!(matches!(
                    validate_settlement(&request, journal, &absent, &challenge, &actual),
                    Err(IcSnapshotUploadDataSettlementError::Observation(_))
                ));
            }
            assert_eq!(journal.view().pending_observation, Some(2));
            assert_eq!(settled.view().pending_mutation, Some(1));
            assert_eq!(settled.view().observations_remaining, 0);
        },
    );
}