ic-backup 0.3.1

Host-side snapshot backup and same-release recovery for Internet Computer canisters
Documentation
//! Fresh local publication, immutable record replay and acknowledged crash recovery.

use super::*;
use crate::{
    model::{artifacts::ChecksumError, download_journal::DownloadArtifactRequest},
    ops::persistence::{create_operation_plan, json::create_json_durable_at_barriers},
    test_support::{
        hold_at_acknowledged_barrier, kill_child_at_acknowledged_barrier,
        membership::{hash, plan},
        temp_dir,
    },
};
use serde_json::json;
use std::{
    fs, io,
    os::unix::fs::{PermissionsExt, symlink},
    path::PathBuf,
    process::Command,
};

fn prepare() -> (PathBuf, BackupLayoutGuard, OperationPlanRecord) {
    let root = temp_dir("ic-backup-download-manifest");
    let mut value = serde_json::to_value(plan()).unwrap();
    value["selected_targets"] = json!(["aaaaa-aa", crate::test_support::membership::APP]);
    value["operations"][0]["target"] = json!("aaaaa-aa");
    let plan: OperationPlanRecord = serde_json::from_value(value).unwrap();
    for id in plan.selected_targets() {
        let staging = root.join(format!("artifacts/{id}.tmp"));
        fs::create_dir_all(&staging).unwrap();
        fs::write(staging.join("heap.bin"), id.as_bytes()).unwrap();
    }
    let layout = BackupLayoutGuard::acquire(&root).unwrap();
    create_operation_plan(&layout, &plan).unwrap();
    let mut journal = DownloadJournalGuard::create(
        &layout,
        plan.digest().hash(),
        plan.selected_targets()
            .iter()
            .map(|id| DownloadArtifactRequest {
                canister_id: id.clone(),
                snapshot_id: format!("Snap-{id}"),
                snapshot_taken_at_timestamp: u64::MAX,
                snapshot_total_size_bytes: u64::MAX,
            })
            .collect(),
    )
    .unwrap();
    for id in plan.selected_targets() {
        let snapshot = format!("Snap-{id}");
        journal.record_downloaded(id, &snapshot).unwrap();
        journal.verify_artifact(id, &snapshot).unwrap();
        journal.finalize_artifact(id, &snapshot).unwrap();
    }
    drop(journal);
    (root, layout, plan)
}

#[test]
fn download_manifest_publishes_private_exact_records_and_replays_without_artifact_reads() {
    let (root, layout, plan) = prepare();
    let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
    let record = journal.record().unwrap().clone();
    let original = fs::read(journal.path()).unwrap();
    let digest = journal.publish_download_manifest(&plan).unwrap();
    let path = root.join(MANIFEST_FILE);
    let bytes = fs::read(&path).unwrap();
    assert_eq!(
        fs::metadata(&path).unwrap().permissions().mode() & 0o777,
        0o600
    );
    assert!(
        matches!(journal.publish_download_manifest(&plan),Err(DownloadManifestError::Persistence(PersistenceError::Io(error))) if error.kind()==io::ErrorKind::AlreadyExists)
    );
    assert!(matches!(
        read_download_manifest(&layout, &plan, &digest),
        Err(DownloadManifestError::Journal(DownloadJournalError::Lock(
            _
        )))
    ));
    drop(journal);
    fs::rename(root.join("artifacts"), root.join("retained-artifacts")).unwrap();
    drop(layout);
    let layout = BackupLayoutGuard::acquire(&root).unwrap();
    for _ in 0..2 {
        assert_eq!(
            read_download_manifest(&layout, &plan, &digest).unwrap(),
            record
        );
        assert_eq!(fs::read(&path).unwrap(), bytes);
        assert_eq!(
            fs::read(root.join("download-journal.json")).unwrap(),
            original
        );
    }
    assert!(matches!(
        read_download_manifest(&layout, &plan, &hash("12")),
        Err(DownloadManifestError::DigestMismatch)
    ));
    drop(layout);
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn download_manifest_rejects_changed_last_tree_and_original_journal_without_publication() {
    let (root, layout, plan) = prepare();
    let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
    let id = plan.selected_targets().last().unwrap();
    fs::write(root.join(format!("artifacts/{id}/heap.bin")), b"changed").unwrap();
    assert!(matches!(
        journal.publish_download_manifest(&plan),
        Err(DownloadManifestError::Integrity(
            DownloadIntegrityError::Checksum(ChecksumError::ChecksumMismatch { .. })
        ))
    ));
    assert!(!root.join(MANIFEST_FILE).exists());
    fs::write(root.join(format!("artifacts/{id}/heap.bin")), id.as_bytes()).unwrap();
    let mut changed = serde_json::to_value(journal.record().unwrap()).unwrap();
    changed["artifacts"][0]["snapshot_id"] = json!("another-token");
    fs::write(journal.path(), serde_json::to_vec(&changed).unwrap()).unwrap();
    assert!(matches!(
        journal.publish_download_manifest(&plan),
        Err(DownloadManifestError::Integrity(
            DownloadIntegrityError::JournalChanged
        ))
    ));
    assert!(!root.join(MANIFEST_FILE).exists());
    drop(journal);
    drop(layout);
    fs::remove_dir_all(root).unwrap();
}

#[test]
fn download_manifest_lost_reply_preserves_originals_and_requires_exact_local_replay() {
    for published in [false, true] {
        let (root, layout, plan) = prepare();
        let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
        let original = fs::read(journal.path()).unwrap();
        let record = journal.record().unwrap().clone();
        assert!(
            journal
                .publish_manifest_with(&plan, |path, record| {
                    if published {
                        create_json_durable(path, record)?;
                    }
                    Err(io::Error::other("lost local publication response").into())
                })
                .is_err()
        );
        assert_eq!(fs::read(journal.path()).unwrap(), original);
        assert_eq!(root.join(MANIFEST_FILE).exists(), published);
        if !published {
            journal.publish_download_manifest(&plan).unwrap();
        }
        drop(journal);
        assert_eq!(
            read_download_manifest(&layout, &plan, &record.digest()).unwrap(),
            record
        );
        drop(layout);
        fs::remove_dir_all(root).unwrap();
    }
}

#[test]
fn download_manifest_replay_rejects_missing_changed_unsafe_and_excessive_records_without_repair() {
    for kind in [
        "missing-journal",
        "changed-journal",
        "changed-plan",
        "unsafe",
        "oversized",
        "conflict",
    ] {
        let (root, layout, plan) = prepare();
        let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
        let record = journal.record().unwrap().clone();
        let digest = journal.publish_download_manifest(&plan).unwrap();
        drop(journal);
        let path = root.join(MANIFEST_FILE);
        let bytes = fs::read(&path).unwrap();
        match kind {
            "missing-journal" => fs::rename(
                root.join("download-journal.json"),
                root.join("retained-journal"),
            )
            .unwrap(),
            "changed-journal" => {
                let mut value = serde_json::to_value(&record).unwrap();
                value["artifacts"][0]["snapshot_taken_at_timestamp"] = json!(0);
                fs::write(
                    root.join("download-journal.json"),
                    serde_json::to_vec(&value).unwrap(),
                )
                .unwrap();
            }
            "changed-plan" => {
                fs::rename(root.join("operation-plan.json"), root.join("retained-plan")).unwrap();
            }
            "unsafe" => {
                fs::rename(&path, root.join("retained-manifest")).unwrap();
                symlink(root.join("retained-manifest"), &path).unwrap();
            }
            "oversized" => {
                fs::write(
                    &path,
                    vec![b' '; usize::try_from(MAX_DOWNLOAD_JOURNAL_BYTES).unwrap() + 1],
                )
                .unwrap();
            }
            "conflict" => {
                let mut value = serde_json::to_value(&record).unwrap();
                value["artifacts"][0]["snapshot_id"] = json!("other");
                fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
            }
            _ => unreachable!(),
        }
        let result = read_download_manifest(&layout, &plan, &digest);
        match kind {
            "changed-journal" => {
                assert!(matches!(result, Err(DownloadManifestError::JournalChanged)));
            }
            "oversized" => assert!(matches!(
                result,
                Err(DownloadManifestError::Persistence(
                    PersistenceError::RecordTooLarge { .. }
                ))
            )),
            "conflict" => assert!(matches!(result, Err(DownloadManifestError::DigestMismatch))),
            _ => assert!(result.is_err()),
        }
        if !matches!(kind, "oversized" | "conflict") {
            assert_eq!(fs::read(&path).unwrap(), bytes);
        }
        drop(layout);
        fs::remove_dir_all(root).unwrap();
    }
}

#[test]
fn download_manifest_acknowledged_death_on_both_publication_sides_recovers_originals() {
    const ROOT: &str = "IC_BACKUP_MANIFEST_CHILD_ROOT";
    const PLAN: &str = "IC_BACKUP_MANIFEST_CHILD_PLAN";
    const PUBLISHED: &str = "IC_BACKUP_MANIFEST_CHILD_PUBLISHED";
    const HANDSHAKE: &str = "IC_BACKUP_MANIFEST_CHILD_HANDSHAKE";
    if let Some(root) = std::env::var_os(ROOT) {
        let root = PathBuf::from(root);
        let plan: OperationPlanRecord =
            serde_json::from_str(&std::env::var(PLAN).unwrap()).unwrap();
        let layout = BackupLayoutGuard::acquire(&root).unwrap();
        let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
        let handshake = PathBuf::from(std::env::var_os(HANDSHAKE).unwrap());
        let published = std::env::var(PUBLISHED).unwrap() == "yes";
        journal
            .publish_manifest_with(&plan, |path, record| {
                create_json_durable_at_barriers(
                    path,
                    record,
                    || {
                        if !published {
                            hold_at_acknowledged_barrier(&handshake);
                        }
                    },
                    || {
                        if published {
                            hold_at_acknowledged_barrier(&handshake);
                        }
                    },
                )
            })
            .unwrap();
        panic!("manifest crash child passed acknowledged barrier");
    }
    for published in [false, true] {
        let (root, layout, plan) = prepare();
        let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
        let record = journal.record().unwrap().clone();
        let original = fs::read(journal.path()).unwrap();
        drop(journal);
        drop(layout);
        let handshake = temp_dir("ic-backup-download-manifest-handshake");
        fs::create_dir(&handshake).unwrap();
        let mut child=Command::new(std::env::current_exe().unwrap())
            .args(["--exact","ops::persistence::download_journal::manifest::tests::download_manifest_acknowledged_death_on_both_publication_sides_recovers_originals","--nocapture"])
            .env(ROOT,&root).env(PLAN,serde_json::to_string(&plan).unwrap())
            .env(PUBLISHED,if published {"yes"} else {"no"}).env(HANDSHAKE,&handshake).spawn().unwrap();
        kill_child_at_acknowledged_barrier(&mut child, &handshake);
        let layout = BackupLayoutGuard::acquire(&root).unwrap();
        assert_eq!(root.join(MANIFEST_FILE).exists(), published);
        assert_eq!(
            fs::read(root.join("download-journal.json")).unwrap(),
            original
        );
        if !published {
            let journal = DownloadJournalGuard::open(&layout, plan.digest().hash()).unwrap();
            journal.publish_download_manifest(&plan).unwrap();
        }
        assert_eq!(
            read_download_manifest(&layout, &plan, &record.digest()).unwrap(),
            record
        );
        drop(layout);
        fs::remove_dir_all(root).unwrap();
        fs::remove_dir_all(handshake).unwrap();
    }
}