mod apply;
mod options;
mod plan;
mod run;
use super::*;
use crate::test_support::temp_dir;
use canic_backup::{
artifacts::ArtifactChecksum,
journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
manifest::{
BackupUnit, BackupUnitKind, ConsistencySection, FleetBackupManifest, FleetMember,
FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
VerificationCheck, VerificationPlan,
},
persistence::BackupLayout,
restore::{RestoreApplyDryRun, RestoreApplyJournal, RestorePlanner},
};
use std::{
ffi::OsString,
fs,
path::{Path, PathBuf},
};
const ROOT: &str = "aaaaa-aa";
const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
struct RestoreCliFixture {
root: PathBuf,
journal_path: PathBuf,
out_path: PathBuf,
}
impl RestoreCliFixture {
fn new(prefix: &str, out_file: &str) -> Self {
let root = temp_dir(prefix);
fs::create_dir_all(&root).expect("create temp root");
Self {
journal_path: root.join("restore-apply-journal.json"),
out_path: root.join(out_file),
root,
}
}
fn write_journal(&self, journal: &RestoreApplyJournal) {
fs::write(
&self.journal_path,
serde_json::to_vec(journal).expect("serialize journal"),
)
.expect("write journal");
}
fn run_restore_run(&self, extra: &[&str]) -> Result<(), RestoreCommandError> {
self.run_journal_command("run", extra)
}
fn read_out<T>(&self, label: &str) -> T
where
T: serde::de::DeserializeOwned,
{
serde_json::from_slice(&fs::read(&self.out_path).expect(label)).expect(label)
}
fn run_journal_command(
&self,
command: &str,
extra: &[&str],
) -> Result<(), RestoreCommandError> {
let mut args = vec![
OsString::from(command),
OsString::from("--journal"),
OsString::from(self.journal_path.as_os_str()),
OsString::from("--out"),
OsString::from(self.out_path.as_os_str()),
];
args.extend(extra.iter().map(OsString::from));
run(args)
}
}
impl Drop for RestoreCliFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
#[cfg(unix)]
fn write_fake_icp_upload(root: &Path, uploaded_snapshot_id: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = root.join("icp-upload-ok");
fs::write(
&path,
format!("#!/bin/sh\nprintf 'Uploaded snapshot: {uploaded_snapshot_id}\\n'\n"),
)
.expect("write fake icp");
let mut permissions = fs::metadata(&path)
.expect("fake icp metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions).expect("make fake icp executable");
path
}
fn ready_apply_journal() -> RestoreApplyJournal {
let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
let dry_run = RestoreApplyDryRun::from_plan(&plan);
let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
journal.ready = true;
journal.blocked_reasons = Vec::new();
journal.backup_root = Some("/tmp/canic-cli-restore-artifacts".to_string());
for operation in &mut journal.operations {
operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
operation.blocking_reasons = Vec::new();
}
journal.blocked_operations = 0;
journal.ready_operations = journal.operation_count;
journal.validate().expect("journal should validate");
journal
}
fn valid_manifest() -> FleetBackupManifest {
FleetBackupManifest {
manifest_version: 1,
backup_id: "backup-test".to_string(),
created_at: "2026-05-03T00:00:00Z".to_string(),
tool: ToolMetadata {
name: "canic".to_string(),
version: "0.30.1".to_string(),
},
source: SourceMetadata {
environment: "local".to_string(),
root_canister: ROOT.to_string(),
},
consistency: ConsistencySection {
backup_units: vec![BackupUnit {
unit_id: "fleet".to_string(),
kind: BackupUnitKind::Subtree,
roles: vec!["root".to_string(), "app".to_string()],
}],
},
fleet: FleetSection {
topology_hash_algorithm: "sha256".to_string(),
topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
discovery_topology_hash: HASH.to_string(),
pre_snapshot_topology_hash: HASH.to_string(),
topology_hash: HASH.to_string(),
members: vec![
fleet_member("root", ROOT, None, IdentityMode::Fixed),
fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
],
},
verification: VerificationPlan::default(),
}
}
fn restore_ready_manifest() -> FleetBackupManifest {
let mut manifest = valid_manifest();
for member in &mut manifest.fleet.members {
member.source_snapshot.module_hash = Some(HASH.to_string());
member.source_snapshot.checksum = Some(HASH.to_string());
}
manifest
}
fn fleet_member(
role: &str,
canister_id: &str,
parent_canister_id: Option<&str>,
identity_mode: IdentityMode,
) -> FleetMember {
FleetMember {
role: role.to_string(),
canister_id: canister_id.to_string(),
parent_canister_id: parent_canister_id.map(str::to_string),
subnet_canister_id: Some(ROOT.to_string()),
controller_hint: None,
identity_mode,
verification_checks: vec![VerificationCheck {
kind: "status".to_string(),
roles: vec![role.to_string()],
}],
source_snapshot: SourceSnapshot {
snapshot_id: format!("{role}-snapshot"),
module_hash: None,
code_version: Some("v0.30.1".to_string()),
artifact_path: format!("artifacts/{role}"),
checksum_algorithm: "sha256".to_string(),
checksum: None,
},
}
}
fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
layout.write_manifest(manifest).expect("write manifest");
let artifacts = manifest
.fleet
.members
.iter()
.map(|member| {
let bytes = format!("{} artifact", member.role);
let artifact_path = root.join(&member.source_snapshot.artifact_path);
if let Some(parent) = artifact_path.parent() {
fs::create_dir_all(parent).expect("create artifact parent");
}
fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
ArtifactJournalEntry {
canister_id: member.canister_id.clone(),
snapshot_id: member.source_snapshot.snapshot_id.clone(),
state: ArtifactState::Durable,
temp_path: None,
artifact_path: member.source_snapshot.artifact_path.clone(),
checksum_algorithm: checksum.algorithm,
checksum: Some(checksum.hash),
updated_at: "2026-05-03T00:00:00Z".to_string(),
}
})
.collect();
layout
.write_journal(&DownloadJournal {
journal_version: 1,
backup_id: manifest.backup_id.clone(),
discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts,
})
.expect("write journal");
}
fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
for member in &mut manifest.fleet.members {
let bytes = format!("{} apply artifact", member.role);
let artifact_path = root.join(&member.source_snapshot.artifact_path);
if let Some(parent) = artifact_path.parent() {
fs::create_dir_all(parent).expect("create artifact parent");
}
fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
member.source_snapshot.checksum = Some(checksum.hash);
}
}
fn journal_lock_path(path: &Path) -> PathBuf {
let mut lock_path = path.as_os_str().to_os_string();
lock_path.push(".lock");
PathBuf::from(lock_path)
}