From 26c1d7d648ff42a83936a8f40e5b417bd3cb9dc6 Mon Sep 17 00:00:00 2001 From: Luke Gordon Date: Mon, 13 Apr 2026 12:30:42 -0500 Subject: [PATCH 1/3] Extract backup logic into new file --- crates/hashi/src/backup.rs | 358 +++++++++++++++++++++++ crates/hashi/src/cli/commands/backup.rs | 368 ++---------------------- crates/hashi/src/lib.rs | 1 + 3 files changed, 376 insertions(+), 351 deletions(-) create mode 100644 crates/hashi/src/backup.rs diff --git a/crates/hashi/src/backup.rs b/crates/hashi/src/backup.rs new file mode 100644 index 000000000..98160045c --- /dev/null +++ b/crates/hashi/src/backup.rs @@ -0,0 +1,358 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Core backup logic for creating and restoring encrypted backup archives. +//! +//! This module handles the mechanics of building backup manifests, encrypting +//! files into age-wrapped tar archives, and extracting them. CLI-specific +//! orchestration (config loading, recipient resolution, user output) lives in +//! [`crate::cli::commands::backup`]. + +use anyhow::Context; +use anyhow::Result; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io; +use std::io::ErrorKind; +use std::io::Read; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use tracing::info; + +use age::Encryptor; + +pub const BACKUP_MANIFEST_FILE_NAME: &str = "hashi-config-backup-manifest.toml"; + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct BackupManifest { + pub files: Vec, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct BackupManifestFile { + pub archive_name: PathBuf, + pub original_path: PathBuf, +} + +pub fn build_backup_manifest(files: &[PathBuf]) -> Result { + let mut archive_names = HashSet::new(); + let mut manifest_files = Vec::with_capacity(files.len()); + + for file in files { + let base_name = file + .file_name() + .ok_or_else(|| { + anyhow::anyhow!("Backup input does not have a file name: {}", file.display()) + })? + .to_string_lossy(); + + let archive_name = if archive_names.contains(base_name.as_ref()) { + let stem = Path::new(base_name.as_ref()) + .file_stem() + .unwrap_or_default() + .to_string_lossy(); + let ext = Path::new(base_name.as_ref()) + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + + let mut suffix = 2u32; + loop { + let candidate = format!("{stem}-{suffix}{ext}"); + if archive_names.insert(candidate.clone()) { + info!( + original = %file.display(), + renamed = %candidate, + "Archive name collision for {base_name}", + ); + break PathBuf::from(candidate); + } + suffix += 1; + } + } else { + archive_names.insert(base_name.to_string()); + PathBuf::from(base_name.as_ref()) + }; + + manifest_files.push(BackupManifestFile { + archive_name, + original_path: file.clone(), + }); + } + + Ok(BackupManifest { + files: manifest_files, + }) +} + +pub fn encrypt_files_to_age_archive( + manifest: &BackupManifest, + recipient: &dyn age::Recipient, + output_path: &Path, +) -> Result<()> { + let output = File::create(output_path) + .with_context(|| format!("Failed to create {}", output_path.display()))?; + let encryptor = Encryptor::with_recipients(std::iter::once(recipient))?; + let mut encrypted = encryptor.wrap_output(output)?; + { + let mut archive = tar::Builder::new(&mut encrypted); + append_backup_manifest(&mut archive, manifest)?; + + for file in &manifest.files { + archive.append_path_with_name(&file.original_path, &file.archive_name)?; + info!( + original = %file.original_path.display(), + archive_name = %file.archive_name.display(), + "Added file to backup archive", + ); + } + + archive.finish()?; + } + encrypted.finish()?; + + Ok(()) +} + +pub fn encrypted_backup_file_name() -> PathBuf { + // ISO 8601 basic format in UTC, e.g. 20260409T230419Z. Compact, sorts + // lexicographically, and contains no characters that need escaping on any + // common filesystem. + let timestamp = jiff::Timestamp::now() + .to_zoned(jiff::tz::TimeZone::UTC) + .strftime("%Y%m%dT%H%M%SZ") + .to_string(); + PathBuf::from(format!("hashi-config-backup-{timestamp}.tar.age")) +} + +fn append_backup_manifest( + archive: &mut tar::Builder, + manifest: &BackupManifest, +) -> Result<()> { + let manifest_bytes = toml::to_string_pretty(manifest)?.into_bytes(); + let mut header = tar::Header::new_gnu(); + header.set_size(manifest_bytes.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + archive.append_data( + &mut header, + BACKUP_MANIFEST_FILE_NAME, + manifest_bytes.as_slice(), + )?; + Ok(()) +} + +/// Determine the directory name to extract a backup tarball into. +/// +/// Uses the tarball's file name with the `.tar.age` suffix stripped, so +/// `hashi-config-backup-20260409T230419Z.tar.age` becomes +/// `hashi-config-backup-20260409T230419Z`. +pub fn extract_dir_name(backup_tarball: &Path) -> Result { + let file_name = backup_tarball + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + anyhow::anyhow!( + "Backup tarball path has no file name: {}", + backup_tarball.display() + ) + })?; + + let stem = file_name + .strip_suffix(".tar.age") + .or_else(|| file_name.strip_suffix(".age")) + .unwrap_or(file_name); + + if stem.is_empty() { + anyhow::bail!( + "Cannot derive extract directory name from {}", + backup_tarball.display() + ); + } + + Ok(PathBuf::from(stem)) +} + +pub fn read_backup_manifest( + mut entry: tar::Entry<'_, R>, +) -> Result<(BackupManifest, String)> { + let path = entry.path()?.into_owned(); + let file_name = path.file_name().ok_or_else(|| { + anyhow::anyhow!( + "First tar entry does not have a file name: {}", + path.display() + ) + })?; + if file_name != BACKUP_MANIFEST_FILE_NAME { + anyhow::bail!( + "Expected backup manifest {} as the first tar entry, found {}", + BACKUP_MANIFEST_FILE_NAME, + path.display() + ); + } + + let mut manifest_toml = String::new(); + entry.read_to_string(&mut manifest_toml)?; + let manifest: BackupManifest = toml::from_str(&manifest_toml)?; + + Ok((manifest, manifest_toml)) +} + +pub fn write_manifest_to_extract_dir(extract_dir: &Path, manifest_toml: &str) -> Result<()> { + let manifest_path = extract_dir.join(BACKUP_MANIFEST_FILE_NAME); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&manifest_path) + .map_err(|e| match e.kind() { + ErrorKind::AlreadyExists => anyhow::anyhow!( + "Refusing to overwrite existing file: {}", + manifest_path.display() + ), + _ => anyhow::Error::from(e) + .context(format!("Failed to create {}", manifest_path.display())), + })?; + io::Write::write_all(&mut file, manifest_toml.as_bytes()) + .with_context(|| format!("Failed to write manifest to {}", manifest_path.display()))?; + info!(path = %manifest_path.display(), "Restored manifest"); + Ok(()) +} + +pub fn restore_backup_entries( + entries: tar::Entries<'_, R>, + output_dir: &Path, + manifest: &BackupManifest, +) -> Result> { + let expected_files: HashMap<_, _> = manifest + .files + .iter() + .map(|file| (file.archive_name.clone(), file)) + .collect(); + let mut restored_files = HashMap::new(); + + for entry in entries { + let mut entry = entry?; + let archive_path = entry.path()?.into_owned(); + let archive_name = PathBuf::from(archive_path.file_name().ok_or_else(|| { + anyhow::anyhow!( + "Backup entry does not have a file name: {}", + archive_path.display() + ) + })?); + + let entry_type = entry.header().entry_type(); + if entry_type != tar::EntryType::Regular { + anyhow::bail!( + "Backup entry {} has unexpected type {:?}; only regular files are supported", + archive_name.display(), + entry_type + ); + } + + if archive_path != archive_name { + anyhow::bail!( + "Backup entry must be at the tar root: {}", + archive_path.display() + ); + } + + if !expected_files.contains_key(&archive_name) { + anyhow::bail!( + "Backup archive contains unexpected file: {}", + archive_name.display() + ); + } + + let output_path = output_dir.join(&archive_name); + let mut output_file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&output_path) + .map_err(|e| match e.kind() { + ErrorKind::AlreadyExists => anyhow::anyhow!( + "Refusing to overwrite existing file: {}", + output_path.display() + ), + _ => anyhow::Error::from(e) + .context(format!("Failed to create {}", output_path.display())), + })?; + io::copy(&mut entry, &mut output_file).with_context(|| { + format!( + "Failed to write restored file contents to {}", + output_path.display() + ) + })?; + info!( + archive_name = %archive_name.display(), + output = %output_path.display(), + "Restored file", + ); + restored_files.insert(archive_name, output_path); + } + + if restored_files.len() != manifest.files.len() { + anyhow::bail!( + "Backup archive is missing file entries: expected {}, restored {}", + manifest.files.len(), + restored_files.len() + ); + } + + Ok(restored_files) +} + +pub fn copy_restored_files_to_original_paths( + restored_files: &HashMap, + manifest: &BackupManifest, +) -> Result<()> { + for file in &manifest.files { + let restored_path = restored_files.get(&file.archive_name).ok_or_else(|| { + anyhow::anyhow!( + "Restored file missing for archive entry {}", + file.archive_name.display() + ) + })?; + + if let Some(parent) = file.original_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directory {}", parent.display()) + })?; + } + + let mut source = File::open(restored_path) + .with_context(|| format!("Failed to open {}", restored_path.display()))?; + let mut dest = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&file.original_path) + .map_err(|e| match e.kind() { + ErrorKind::AlreadyExists => anyhow::anyhow!( + "Refusing to overwrite existing original path: {}", + file.original_path.display() + ), + _ => anyhow::Error::from(e) + .context(format!("Failed to create {}", file.original_path.display())), + })?; + io::copy(&mut source, &mut dest).with_context(|| { + format!( + "Failed to copy {} to {}", + restored_path.display(), + file.original_path.display() + ) + })?; + info!( + from = %restored_path.display(), + to = %file.original_path.display(), + "Copied restored file to original path", + ); + } + + Ok(()) +} diff --git a/crates/hashi/src/cli/commands/backup.rs b/crates/hashi/src/cli/commands/backup.rs index 693c0edf2..a5470fb86 100644 --- a/crates/hashi/src/cli/commands/backup.rs +++ b/crates/hashi/src/cli/commands/backup.rs @@ -1,46 +1,28 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -//! Backup command implementations +//! CLI backup command implementations +//! +//! Orchestrates config loading, recipient/identity resolution, and user-facing +//! output. The core archive logic lives in [`crate::backup`]. use age::Decryptor; -use age::Encryptor; use age::IdentityFile; use age::cli_common::UiCallbacks; use age::plugin; use anyhow::Context; use anyhow::Result; -use std::collections::HashMap; -use std::collections::HashSet; use std::fs; use std::fs::File; -use std::fs::OpenOptions; -use std::io; -use std::io::ErrorKind; -use std::io::Read; -use std::os::unix::fs::OpenOptionsExt; use std::path::Path; -use std::path::PathBuf; use std::str::FromStr; +use crate::backup; use crate::cli::config::BackupRecipient; use crate::cli::config::CliConfig; use crate::cli::print_info; use crate::cli::print_success; -const BACKUP_MANIFEST_FILE_NAME: &str = "hashi-config-backup-manifest.toml"; - -#[derive(serde::Deserialize, serde::Serialize)] -struct BackupManifest { - files: Vec, -} - -#[derive(serde::Deserialize, serde::Serialize)] -struct BackupManifestFile { - archive_name: PathBuf, - original_path: PathBuf, -} - /// Save an encrypted backup of the current config and referenced files pub fn save( config: &CliConfig, @@ -74,7 +56,7 @@ pub fn save( fs::create_dir_all(output_dir) .with_context(|| format!("Failed to create output directory {}", output_dir.display()))?; - let manifest = build_backup_manifest(&files)?; + let manifest = backup::build_backup_manifest(&files)?; print_info(&format!( "Backing up {} file(s) using age recipient {}", @@ -84,8 +66,8 @@ pub fn save( let encryptor_recipient = build_encryptor_recipient(&recipient)?; - let output_path = output_dir.join(encrypted_backup_file_name()); - encrypt_files_to_age_archive(&manifest, encryptor_recipient.as_ref(), &output_path)?; + let output_path = output_dir.join(backup::encrypted_backup_file_name()); + backup::encrypt_files_to_age_archive(&manifest, encryptor_recipient.as_ref(), &output_path)?; print_success(&format!("Backup completed: {}", output_path.display())); @@ -122,7 +104,7 @@ pub fn restore( ) -> Result<()> { let identities = load_backup_identities(backup_age_identity)?; - let extract_dir = output_dir.join(extract_dir_name(backup_tarball)?); + let extract_dir = output_dir.join(backup::extract_dir_name(backup_tarball)?); fs::create_dir_all(&extract_dir).with_context(|| { format!( "Failed to create output directory {}", @@ -149,12 +131,12 @@ pub fn restore( .next() .transpose()? .ok_or_else(|| anyhow::anyhow!("Backup archive is empty"))?; - let (manifest, manifest_toml) = read_backup_manifest(manifest_entry)?; - write_manifest_to_extract_dir(&extract_dir, &manifest_toml)?; - let restored_files = restore_backup_entries(entries, &extract_dir, &manifest)?; + let (manifest, manifest_toml) = backup::read_backup_manifest(manifest_entry)?; + backup::write_manifest_to_extract_dir(&extract_dir, &manifest_toml)?; + let restored_files = backup::restore_backup_entries(entries, &extract_dir, &manifest)?; if copy_to_original_paths { - copy_restored_files_to_original_paths(&restored_files, &manifest)?; + backup::copy_restored_files_to_original_paths(&restored_files, &manifest)?; } print_success(&format!( @@ -166,167 +148,6 @@ pub fn restore( Ok(()) } -/// Determine the directory name to extract a backup tarball into. -/// -/// Uses the tarball's file name with the `.tar.age` suffix stripped, so -/// `hashi-config-backup-20260409T230419Z.tar.age` becomes -/// `hashi-config-backup-20260409T230419Z`. -fn extract_dir_name(backup_tarball: &Path) -> Result { - let file_name = backup_tarball - .file_name() - .and_then(|name| name.to_str()) - .ok_or_else(|| { - anyhow::anyhow!( - "Backup tarball path has no file name: {}", - backup_tarball.display() - ) - })?; - - let stem = file_name - .strip_suffix(".tar.age") - .or_else(|| file_name.strip_suffix(".age")) - .unwrap_or(file_name); - - if stem.is_empty() { - anyhow::bail!( - "Cannot derive extract directory name from {}", - backup_tarball.display() - ); - } - - Ok(PathBuf::from(stem)) -} - -/// Write the raw manifest TOML into the extract directory so the user can -/// inspect it alongside the restored files. -fn write_manifest_to_extract_dir(extract_dir: &Path, manifest_toml: &str) -> Result<()> { - let manifest_path = extract_dir.join(BACKUP_MANIFEST_FILE_NAME); - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&manifest_path) - .map_err(|e| match e.kind() { - ErrorKind::AlreadyExists => anyhow::anyhow!( - "Refusing to overwrite existing file: {}", - manifest_path.display() - ), - _ => anyhow::Error::from(e) - .context(format!("Failed to create {}", manifest_path.display())), - })?; - io::Write::write_all(&mut file, manifest_toml.as_bytes()) - .with_context(|| format!("Failed to write manifest to {}", manifest_path.display()))?; - print_info(&format!("Restored manifest to {}", manifest_path.display())); - Ok(()) -} - -fn encrypt_files_to_age_archive( - manifest: &BackupManifest, - recipient: &dyn age::Recipient, - output_path: &Path, -) -> Result<()> { - let output = File::create(output_path) - .with_context(|| format!("Failed to create {}", output_path.display()))?; - let encryptor = Encryptor::with_recipients(std::iter::once(recipient))?; - let mut encrypted = encryptor.wrap_output(output)?; - { - let mut archive = tar::Builder::new(&mut encrypted); - append_backup_manifest(&mut archive, manifest)?; - - for file in &manifest.files { - archive.append_path_with_name(&file.original_path, &file.archive_name)?; - print_info(&format!( - "Added {} to {}", - file.original_path.display(), - file.archive_name.display() - )); - } - - archive.finish()?; - } - encrypted.finish()?; - - Ok(()) -} - -fn encrypted_backup_file_name() -> PathBuf { - // ISO 8601 basic format in UTC, e.g. 20260409T230419Z. Compact, sorts - // lexicographically, and contains no characters that need escaping on any - // common filesystem. - let timestamp = jiff::Timestamp::now() - .to_zoned(jiff::tz::TimeZone::UTC) - .strftime("%Y%m%dT%H%M%SZ") - .to_string(); - PathBuf::from(format!("hashi-config-backup-{timestamp}.tar.age")) -} - -fn build_backup_manifest(files: &[PathBuf]) -> Result { - let mut archive_names = HashSet::new(); - let mut manifest_files = Vec::with_capacity(files.len()); - - for file in files { - let base_name = file - .file_name() - .ok_or_else(|| { - anyhow::anyhow!("Backup input does not have a file name: {}", file.display()) - })? - .to_string_lossy(); - - let archive_name = if archive_names.contains(base_name.as_ref()) { - let stem = Path::new(base_name.as_ref()) - .file_stem() - .unwrap_or_default() - .to_string_lossy(); - let ext = Path::new(base_name.as_ref()) - .extension() - .map(|e| format!(".{}", e.to_string_lossy())) - .unwrap_or_default(); - - let mut suffix = 2u32; - loop { - let candidate = format!("{stem}-{suffix}{ext}"); - if archive_names.insert(candidate.clone()) { - print_info(&format!( - "Archive name collision for {base_name}: renamed to {candidate} (original: {})", - file.display() - )); - break PathBuf::from(candidate); - } - suffix += 1; - } - } else { - archive_names.insert(base_name.to_string()); - PathBuf::from(base_name.as_ref()) - }; - - manifest_files.push(BackupManifestFile { - archive_name, - original_path: file.clone(), - }); - } - - Ok(BackupManifest { - files: manifest_files, - }) -} - -fn append_backup_manifest( - archive: &mut tar::Builder, - manifest: &BackupManifest, -) -> Result<()> { - let manifest_bytes = toml::to_string_pretty(manifest)?.into_bytes(); - let mut header = tar::Header::new_gnu(); - header.set_size(manifest_bytes.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - archive.append_data( - &mut header, - BACKUP_MANIFEST_FILE_NAME, - manifest_bytes.as_slice(), - )?; - Ok(()) -} - fn load_backup_identities( backup_age_identity: &Path, ) -> Result>> { @@ -342,169 +163,14 @@ fn load_backup_identities( .map_err(Into::into) } -fn read_backup_manifest(mut entry: tar::Entry<'_, R>) -> Result<(BackupManifest, String)> { - let path = entry.path()?.into_owned(); - let file_name = path.file_name().ok_or_else(|| { - anyhow::anyhow!( - "First tar entry does not have a file name: {}", - path.display() - ) - })?; - if file_name != BACKUP_MANIFEST_FILE_NAME { - anyhow::bail!( - "Expected backup manifest {} as the first tar entry, found {}", - BACKUP_MANIFEST_FILE_NAME, - path.display() - ); - } - - let mut manifest_toml = String::new(); - entry.read_to_string(&mut manifest_toml)?; - let manifest: BackupManifest = toml::from_str(&manifest_toml)?; - - Ok((manifest, manifest_toml)) -} - -fn restore_backup_entries( - entries: tar::Entries<'_, R>, - output_dir: &Path, - manifest: &BackupManifest, -) -> Result> { - let expected_files: HashMap<_, _> = manifest - .files - .iter() - .map(|file| (file.archive_name.clone(), file)) - .collect(); - let mut restored_files = HashMap::new(); - - for entry in entries { - let mut entry = entry?; - let archive_path = entry.path()?.into_owned(); - let archive_name = PathBuf::from(archive_path.file_name().ok_or_else(|| { - anyhow::anyhow!( - "Backup entry does not have a file name: {}", - archive_path.display() - ) - })?); - - let entry_type = entry.header().entry_type(); - if entry_type != tar::EntryType::Regular { - anyhow::bail!( - "Backup entry {} has unexpected type {:?}; only regular files are supported", - archive_name.display(), - entry_type - ); - } - - if archive_path != archive_name { - anyhow::bail!( - "Backup entry must be at the tar root: {}", - archive_path.display() - ); - } - - if !expected_files.contains_key(&archive_name) { - anyhow::bail!( - "Backup archive contains unexpected file: {}", - archive_name.display() - ); - } - - let output_path = output_dir.join(&archive_name); - let mut output_file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&output_path) - .map_err(|e| match e.kind() { - ErrorKind::AlreadyExists => anyhow::anyhow!( - "Refusing to overwrite existing file: {}", - output_path.display() - ), - _ => anyhow::Error::from(e) - .context(format!("Failed to create {}", output_path.display())), - })?; - io::copy(&mut entry, &mut output_file).with_context(|| { - format!( - "Failed to write restored file contents to {}", - output_path.display() - ) - })?; - print_info(&format!( - "Restored {} to {}", - archive_name.display(), - output_path.display() - )); - restored_files.insert(archive_name, output_path); - } - - if restored_files.len() != manifest.files.len() { - anyhow::bail!( - "Backup archive is missing file entries: expected {}, restored {}", - manifest.files.len(), - restored_files.len() - ); - } - - Ok(restored_files) -} - -fn copy_restored_files_to_original_paths( - restored_files: &HashMap, - manifest: &BackupManifest, -) -> Result<()> { - for file in &manifest.files { - let restored_path = restored_files.get(&file.archive_name).ok_or_else(|| { - anyhow::anyhow!( - "Restored file missing for archive entry {}", - file.archive_name.display() - ) - })?; - - if let Some(parent) = file.original_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directory {}", parent.display()) - })?; - } - - let mut source = File::open(restored_path) - .with_context(|| format!("Failed to open {}", restored_path.display()))?; - let mut dest = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&file.original_path) - .map_err(|e| match e.kind() { - ErrorKind::AlreadyExists => anyhow::anyhow!( - "Refusing to overwrite existing original path: {}", - file.original_path.display() - ), - _ => anyhow::Error::from(e) - .context(format!("Failed to create {}", file.original_path.display())), - })?; - io::copy(&mut source, &mut dest).with_context(|| { - format!( - "Failed to copy {} to {}", - restored_path.display(), - file.original_path.display() - ) - })?; - print_info(&format!( - "Copied {} to {}", - restored_path.display(), - file.original_path.display() - )); - } - - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use crate::cli::config::BitcoinConfig; use age::secrecy::ExposeSecret; use age::x25519; + use std::path::Path; + use std::path::PathBuf; use tempfile::TempDir; const CONFIG_CONTENTS: &[u8] = b"sui_rpc_url = \"https://fullnode.mainnet.sui.io:443\"\n"; @@ -618,7 +284,7 @@ mod tests { /// Compute the nested directory that `restore` will extract into, given the /// tarball path and the user-supplied output directory. fn expected_extract_dir(tarball: &Path, output_dir: &Path) -> PathBuf { - output_dir.join(super::extract_dir_name(tarball).unwrap()) + output_dir.join(backup::extract_dir_name(tarball).unwrap()) } #[test] @@ -640,7 +306,7 @@ mod tests { assert_mode_0600(&extract_dir.join("btc.wif")); // The manifest should also be extracted alongside the restored files. - let manifest_path = extract_dir.join(BACKUP_MANIFEST_FILE_NAME); + let manifest_path = extract_dir.join(backup::BACKUP_MANIFEST_FILE_NAME); assert!( manifest_path.exists(), "manifest not extracted to {}", diff --git a/crates/hashi/src/lib.rs b/crates/hashi/src/lib.rs index 79d53388d..75de6d1b7 100644 --- a/crates/hashi/src/lib.rs +++ b/crates/hashi/src/lib.rs @@ -9,6 +9,7 @@ use std::sync::RwLock; use anyhow::anyhow; use sui_futures::service::Service; +pub mod backup; pub mod btc_monitor; pub mod cli; pub mod communication; From 291414fb409846bc47445ebefeb9fe5ffe63fe0d Mon Sep 17 00:00:00 2001 From: Luke Gordon Date: Mon, 13 Apr 2026 12:34:48 -0500 Subject: [PATCH 2/3] Add snapshot_to_path_db_method --- crates/hashi/src/db.rs | 108 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/crates/hashi/src/db.rs b/crates/hashi/src/db.rs index 2af550913..190a60da3 100644 --- a/crates/hashi/src/db.rs +++ b/crates/hashi/src/db.rs @@ -9,6 +9,7 @@ use fastcrypto_tbls::threshold_schnorr::avss; use fastcrypto_tbls::threshold_schnorr::batch_avss; use fjall::Keyspace; use fjall::KeyspaceCreateOptions; +use fjall::Readable; use fjall::Result; use sui_sdk_types::Address; @@ -19,7 +20,6 @@ use serde::de::DeserializeOwned; use crate::mpc::types::RotationMessages; pub struct Database { - #[allow(unused)] db: fjall::Database, // keyspaces @@ -74,6 +74,47 @@ impl Database { }) } + /// Returns all keyspaces paired with their names. When adding a new + /// keyspace, add it here so that it is included in snapshots and backups. + fn all_keyspaces(&self) -> [(&str, &Keyspace); 4] { + [ + (ENCRYPTION_KEYS_CF_NAME, &self.encryption_keys), + (DEALER_MESSAGES_CF_NAME, &self.dealer_messages), + (ROTATION_MESSAGES_CF_NAME, &self.rotation_messages), + (NONCE_MESSAGES_CF_NAME, &self.nonce_messages), + ] + } + + /// Create a consistent snapshot of this database and write it as a new fjall + /// database at `dest`. + /// + /// Uses fjall's MVCC snapshot to get a point-in-time view of all keyspaces + /// while the source database remains open for writes. The destination is a + /// fully self-contained fjall database that can later be opened with + /// [`Database::open`]. + pub fn snapshot_to_path(&self, dest: &Path) -> anyhow::Result<()> { + let snapshot = self.db.snapshot(); + + let dest_db = fjall::Database::builder(dest).open().map_err(|e| { + anyhow::anyhow!( + "failed to open destination database at {}: {e}", + dest.display() + ) + })?; + + for (name, source_ks) in self.all_keyspaces() { + let dest_ks = dest_db.keyspace(name, KeyspaceCreateOptions::default)?; + for guard in snapshot.iter(source_ks) { + let (key, value) = guard.into_inner()?; + dest_ks.insert(&*key, &*value)?; + } + } + + dest_db.persist(fjall::PersistMode::SyncAll)?; + + Ok(()) + } + /// Store encryption key for the given epoch. /// /// No-op if a key already exists for this epoch (idempotent for restart safety). @@ -1000,4 +1041,69 @@ mod tests { "rotation messages must not leak to the store's self.epoch=87" ); } + + #[test] + fn test_snapshot_to_path() { + use std::collections::BTreeMap; + use std::num::NonZeroU16; + + let src_dir = tempfile::Builder::new().tempdir().unwrap(); + let db = Database::open(src_dir.path()).unwrap(); + + let dealer1 = Address::new([1u8; 32]); + let dealer2 = Address::new([2u8; 32]); + let enc_key = EncryptionPrivateKey::new(&mut rand::thread_rng()); + let dealer_msg = create_test_message(); + let nonce_msg = create_test_nonce_message(); + let mut rotation_msgs: BTreeMap = BTreeMap::new(); + rotation_msgs.insert(NonZeroU16::new(1).unwrap(), create_test_message()); + + db.store_encryption_key(10, &enc_key).unwrap(); + db.store_dealer_message(10, &dealer1, &dealer_msg).unwrap(); + db.store_rotation_messages(10, &dealer2, &rotation_msgs) + .unwrap(); + db.store_nonce_message(10, 0, &dealer1, &nonce_msg).unwrap(); + + let dest_dir = tempfile::Builder::new().tempdir().unwrap(); + db.snapshot_to_path(dest_dir.path()).unwrap(); + + // Drop the source so we know we're reading from the destination + drop(db); + + let restored = Database::open(dest_dir.path()).unwrap(); + + // Verify encryption key + assert_eq!(restored.get_encryption_key(10).unwrap().unwrap(), enc_key); + + // Verify dealer message + let restored_dealer = restored.get_dealer_message(10, &dealer1).unwrap().unwrap(); + assert_eq!( + bcs::to_bytes(&restored_dealer).unwrap(), + bcs::to_bytes(&dealer_msg).unwrap() + ); + + // Verify rotation messages + let restored_rotation = restored.list_all_rotation_messages(10).unwrap(); + assert_eq!(restored_rotation.len(), 1); + assert_eq!(restored_rotation[0].0, dealer2); + + // Verify nonce messages + let restored_nonces = restored.list_nonce_messages(10, 0).unwrap(); + assert_eq!(restored_nonces.len(), 1); + assert_eq!(restored_nonces[0].0, dealer1); + } + + #[test] + fn test_snapshot_to_path_empty_db() { + let src_dir = tempfile::Builder::new().tempdir().unwrap(); + let db = Database::open(src_dir.path()).unwrap(); + + let dest_dir = tempfile::Builder::new().tempdir().unwrap(); + db.snapshot_to_path(dest_dir.path()).unwrap(); + drop(db); + + let restored = Database::open(dest_dir.path()).unwrap(); + assert!(restored.latest_encryption_key_epoch().unwrap().is_none()); + assert!(restored.list_all_dealer_messages(0).unwrap().is_empty()); + } } From 2ee066043e4a375f5203c892f262eeccd4a60d3f Mon Sep 17 00:00:00 2001 From: Luke Gordon Date: Mon, 13 Apr 2026 12:48:23 -0500 Subject: [PATCH 3/3] Backup and restore db and node config --- Cargo.lock | 1 + crates/e2e-tests/src/main.rs | 1 + crates/hashi/Cargo.toml | 3 +- crates/hashi/src/backup.rs | 186 ++++++++++++++++++++++-- crates/hashi/src/cli/commands/backup.rs | 135 +++++++++++++++-- crates/hashi/src/cli/config.rs | 9 ++ crates/hashi/src/cli/mod.rs | 13 +- 7 files changed, 320 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f1a59a24..539c1e33a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2868,6 +2868,7 @@ dependencies = [ "tracing", "tracing-subscriber", "tracing-test", + "walkdir", "x509-parser", ] diff --git a/crates/e2e-tests/src/main.rs b/crates/e2e-tests/src/main.rs index 8f81443a4..20a92d58d 100644 --- a/crates/e2e-tests/src/main.rs +++ b/crates/e2e-tests/src/main.rs @@ -833,6 +833,7 @@ fn write_cli_config(data_dir: &Path, state: &LocalnetState) -> Result<()> { .map(std::path::PathBuf::from), backup_age_pubkey: None, gas_coin: None, + node_config_path: None, bitcoin: Some(hashi::cli::config::BitcoinConfig { rpc_url: Some(state.btc_rpc_url.clone()), rpc_user: Some(state.btc_rpc_user.clone()), diff --git a/crates/hashi/Cargo.toml b/crates/hashi/Cargo.toml index 5a8cb3e61..9d3b02625 100644 --- a/crates/hashi/Cargo.toml +++ b/crates/hashi/Cargo.toml @@ -72,10 +72,11 @@ tap.workspace = true backon.workspace = true jiff.workspace = true tar.workspace = true +tempfile.workspace = true +walkdir.workspace = true [dev-dependencies] ed25519-dalek.workspace = true test-strategy.workspace = true proptest.workspace = true tracing-test = "0.2" -tempfile.workspace = true diff --git a/crates/hashi/src/backup.rs b/crates/hashi/src/backup.rs index 98160045c..767f760a8 100644 --- a/crates/hashi/src/backup.rs +++ b/crates/hashi/src/backup.rs @@ -26,21 +26,22 @@ use tracing::info; use age::Encryptor; pub const BACKUP_MANIFEST_FILE_NAME: &str = "hashi-config-backup-manifest.toml"; +pub const DB_SNAPSHOT_TAR_PREFIX: &str = "db"; #[derive(serde::Deserialize, serde::Serialize)] pub struct BackupManifest { - pub files: Vec, + pub paths: Vec, } #[derive(serde::Deserialize, serde::Serialize)] -pub struct BackupManifestFile { +pub struct BackupManifestEntry { pub archive_name: PathBuf, pub original_path: PathBuf, } -pub fn build_backup_manifest(files: &[PathBuf]) -> Result { +pub fn build_backup_manifest(files: &[PathBuf], db_original_path: &Path) -> Result { let mut archive_names = HashSet::new(); - let mut manifest_files = Vec::with_capacity(files.len()); + let mut manifest_paths = Vec::new(); for file in files { let base_name = file @@ -78,19 +79,25 @@ pub fn build_backup_manifest(files: &[PathBuf]) -> Result { PathBuf::from(base_name.as_ref()) }; - manifest_files.push(BackupManifestFile { + manifest_paths.push(BackupManifestEntry { archive_name, original_path: file.clone(), }); } + manifest_paths.push(BackupManifestEntry { + archive_name: PathBuf::from(DB_SNAPSHOT_TAR_PREFIX), + original_path: db_original_path.to_path_buf(), + }); + Ok(BackupManifest { - files: manifest_files, + paths: manifest_paths, }) } pub fn encrypt_files_to_age_archive( manifest: &BackupManifest, + db_snapshot_dir: &Path, recipient: &dyn age::Recipient, output_path: &Path, ) -> Result<()> { @@ -102,15 +109,25 @@ pub fn encrypt_files_to_age_archive( let mut archive = tar::Builder::new(&mut encrypted); append_backup_manifest(&mut archive, manifest)?; - for file in &manifest.files { - archive.append_path_with_name(&file.original_path, &file.archive_name)?; + for entry in &manifest.paths { + if entry.archive_name == Path::new(DB_SNAPSHOT_TAR_PREFIX) { + continue; + } + archive.append_path_with_name(&entry.original_path, &entry.archive_name)?; info!( - original = %file.original_path.display(), - archive_name = %file.archive_name.display(), + original = %entry.original_path.display(), + archive_name = %entry.archive_name.display(), "Added file to backup archive", ); } + append_dir_recursive( + &mut archive, + db_snapshot_dir, + Path::new(DB_SNAPSHOT_TAR_PREFIX), + )?; + info!("Added database snapshot to backup archive"); + archive.finish()?; } encrypted.finish()?; @@ -118,6 +135,36 @@ pub fn encrypt_files_to_age_archive( Ok(()) } +/// Recursively append all files under `src_dir` into the tar archive under +/// `tar_prefix`. For example, if `src_dir` contains `file.sst` and +/// `tar_prefix` is `db`, the archive entry will be `db/file.sst`. +fn append_dir_recursive( + archive: &mut tar::Builder, + src_dir: &Path, + tar_prefix: &Path, +) -> Result<()> { + for entry in walkdir::WalkDir::new(src_dir).min_depth(1) { + let entry = entry.with_context(|| { + format!( + "Failed to walk database snapshot directory {}", + src_dir.display() + ) + })?; + let relative = entry + .path() + .strip_prefix(src_dir) + .expect("walkdir entry is always under src_dir"); + let archive_path = tar_prefix.join(relative); + + if entry.file_type().is_dir() { + archive.append_dir(&archive_path, entry.path())?; + } else { + archive.append_path_with_name(entry.path(), &archive_path)?; + } + } + Ok(()) +} + pub fn encrypted_backup_file_name() -> PathBuf { // ISO 8601 basic format in UTC, e.g. 20260409T230419Z. Compact, sorts // lexicographically, and contains no characters that need escaping on any @@ -228,16 +275,57 @@ pub fn restore_backup_entries( output_dir: &Path, manifest: &BackupManifest, ) -> Result> { + let db_prefix = Path::new(DB_SNAPSHOT_TAR_PREFIX); let expected_files: HashMap<_, _> = manifest - .files + .paths .iter() - .map(|file| (file.archive_name.clone(), file)) + .filter(|entry| entry.archive_name != db_prefix) + .map(|entry| (entry.archive_name.clone(), entry)) .collect(); let mut restored_files = HashMap::new(); for entry in entries { let mut entry = entry?; let archive_path = entry.path()?.into_owned(); + + // Database snapshot entries live under the db/ prefix and are extracted + // into the output directory preserving the directory structure. + if archive_path.starts_with(db_prefix) { + let output_path = output_dir.join(&archive_path); + let entry_type = entry.header().entry_type(); + if entry_type == tar::EntryType::Directory { + fs::create_dir_all(&output_path).with_context(|| { + format!("Failed to create directory {}", output_path.display()) + })?; + } else { + if let Some(parent) = output_path.parent() { + fs::create_dir_all(parent).with_context(|| { + format!("Failed to create directory {}", parent.display()) + })?; + } + let mut output_file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&output_path) + .map_err(|e| match e.kind() { + ErrorKind::AlreadyExists => anyhow::anyhow!( + "Refusing to overwrite existing file: {}", + output_path.display() + ), + _ => anyhow::Error::from(e) + .context(format!("Failed to create {}", output_path.display())), + })?; + io::copy(&mut entry, &mut output_file).with_context(|| { + format!( + "Failed to write restored file contents to {}", + output_path.display() + ) + })?; + } + continue; + } + + // Config file entries must be at the tar root. let archive_name = PathBuf::from(archive_path.file_name().ok_or_else(|| { anyhow::anyhow!( "Backup entry does not have a file name: {}", @@ -296,10 +384,10 @@ pub fn restore_backup_entries( restored_files.insert(archive_name, output_path); } - if restored_files.len() != manifest.files.len() { + if restored_files.len() != expected_files.len() { anyhow::bail!( "Backup archive is missing file entries: expected {}, restored {}", - manifest.files.len(), + expected_files.len(), restored_files.len() ); } @@ -311,7 +399,13 @@ pub fn copy_restored_files_to_original_paths( restored_files: &HashMap, manifest: &BackupManifest, ) -> Result<()> { - for file in &manifest.files { + let db_prefix = Path::new(DB_SNAPSHOT_TAR_PREFIX); + + for file in manifest + .paths + .iter() + .filter(|entry| entry.archive_name != db_prefix) + { let restored_path = restored_files.get(&file.archive_name).ok_or_else(|| { anyhow::anyhow!( "Restored file missing for archive entry {}", @@ -356,3 +450,65 @@ pub fn copy_restored_files_to_original_paths( Ok(()) } + +/// Copy the extracted database snapshot directory to its original path. +/// +/// Looks up the DB entry in the manifest to determine the original path, +/// then recursively copies the extracted `db/` directory to that location. +/// Fails if the destination already exists. +pub fn copy_db_snapshot_to_original_path( + extract_dir: &Path, + manifest: &BackupManifest, +) -> Result<()> { + let db_prefix = Path::new(DB_SNAPSHOT_TAR_PREFIX); + let db_entry = manifest + .paths + .iter() + .find(|entry| entry.archive_name == db_prefix) + .ok_or_else(|| anyhow::anyhow!("Backup manifest does not contain a database entry"))?; + + let source = extract_dir.join(DB_SNAPSHOT_TAR_PREFIX); + let dest = &db_entry.original_path; + + if dest.exists() { + anyhow::bail!( + "Refusing to overwrite existing database directory: {}", + dest.display() + ); + } + + copy_dir_recursive(&source, dest).with_context(|| { + format!( + "Failed to copy database snapshot from {} to {}", + source.display(), + dest.display() + ) + })?; + + info!( + from = %source.display(), + to = %dest.display(), + "Copied database snapshot to original path", + ); + + Ok(()) +} + +/// Recursively copy a directory tree from `src` to `dest`. +fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { + for entry in walkdir::WalkDir::new(src) { + let entry = entry?; + let relative = entry.path().strip_prefix(src).expect("walkdir under src"); + let target = dest.join(relative); + + if entry.file_type().is_dir() { + fs::create_dir_all(&target)?; + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} diff --git a/crates/hashi/src/cli/commands/backup.rs b/crates/hashi/src/cli/commands/backup.rs index a5470fb86..497a5914d 100644 --- a/crates/hashi/src/cli/commands/backup.rs +++ b/crates/hashi/src/cli/commands/backup.rs @@ -23,9 +23,10 @@ use crate::cli::config::CliConfig; use crate::cli::print_info; use crate::cli::print_success; -/// Save an encrypted backup of the current config and referenced files +/// Save an encrypted backup of the current config, referenced files, and database pub fn save( config: &CliConfig, + node_config_override: Option<&Path>, backup_age_pubkey_override: Option, output_dir: &Path, ) -> Result<()> { @@ -45,6 +46,16 @@ pub fn save( ); } + // Resolve the node config path: CLI override > config file field + let node_config_path = node_config_override + .map(|p| p.to_path_buf()) + .or_else(|| config.node_config_path.clone()) + .ok_or_else(|| { + anyhow::anyhow!( + "No node config path specified. Pass --node-config or set node_config_path in the CLI config file." + ) + })?; + let files = config.backup_file_paths(); for file in &files { @@ -53,13 +64,56 @@ pub fn save( } } + // Load node config to find the database path + let node_config = crate::config::Config::load(&node_config_path).with_context(|| { + format!( + "Failed to load node config from {}", + node_config_path.display() + ) + })?; + let db_path = node_config.db.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "Node config at {} does not specify a database path", + node_config_path.display() + ) + })?; + + // Open the database — fails with a clear message if the node is running + let db = crate::db::Database::open(db_path).map_err(|e| { + if e.downcast_ref::() + .is_some_and(|fe| matches!(fe, fjall::Error::Locked)) + { + anyhow::anyhow!( + "Cannot open database at {}: it is locked by a running hashi node. \ + Stop the node before running backup save.", + db_path.display() + ) + } else { + e.context(format!("Failed to open database at {}", db_path.display())) + } + })?; + + // Snapshot the database to a temp directory + let tmp_dir = tempfile::Builder::new() + .prefix("hashi-db-snapshot-") + .tempdir() + .context("Failed to create temp directory for database snapshot")?; + db.snapshot_to_path(tmp_dir.path()) + .context("Failed to snapshot database")?; + drop(db); + + print_info(&format!( + "Database snapshot created from {}", + db_path.display() + )); + fs::create_dir_all(output_dir) .with_context(|| format!("Failed to create output directory {}", output_dir.display()))?; - let manifest = backup::build_backup_manifest(&files)?; + let manifest = backup::build_backup_manifest(&files, db_path)?; print_info(&format!( - "Backing up {} file(s) using age recipient {}", + "Backing up {} file(s) + database using age recipient {}", files.len(), recipient )); @@ -67,7 +121,12 @@ pub fn save( let encryptor_recipient = build_encryptor_recipient(&recipient)?; let output_path = output_dir.join(backup::encrypted_backup_file_name()); - backup::encrypt_files_to_age_archive(&manifest, encryptor_recipient.as_ref(), &output_path)?; + backup::encrypt_files_to_age_archive( + &manifest, + tmp_dir.path(), + encryptor_recipient.as_ref(), + &output_path, + )?; print_success(&format!("Backup completed: {}", output_path.display())); @@ -137,6 +196,7 @@ pub fn restore( if copy_to_original_paths { backup::copy_restored_files_to_original_paths(&restored_files, &manifest)?; + backup::copy_db_snapshot_to_original_path(&extract_dir, &manifest)?; } print_success(&format!( @@ -187,20 +247,39 @@ mod tests { } impl TestFixture { - /// Create a new fixture with a config file and two referenced key files on disk. + fn db_path(&self) -> PathBuf { + let node_config = + crate::config::Config::load(self.config.node_config_path.as_ref().unwrap()) + .unwrap(); + node_config.db.unwrap() + } + + /// Create a new fixture with a config file, two referenced key files, + /// a node config pointing to a database, and an empty database on disk. fn new() -> Self { let src = tempfile::Builder::new().tempdir().unwrap(); let config_path = src.path().join("hashi-cli.toml"); let keypair_path = src.path().join("keypair.pem"); let btc_key_path = src.path().join("btc.wif"); + let db_path = src.path().join("db"); fs::write(&config_path, CONFIG_CONTENTS).unwrap(); fs::write(&keypair_path, KEYPAIR_CONTENTS).unwrap(); fs::write(&btc_key_path, BTC_KEY_CONTENTS).unwrap(); + // Create a node config file with a db path and initialise the database + let node_config_path = src.path().join("config.toml"); + let node_config = crate::config::Config { + db: Some(db_path.clone()), + ..Default::default() + }; + node_config.save(&node_config_path).unwrap(); + let _db = crate::db::Database::open(&db_path).unwrap(); + let config = CliConfig { loaded_from_path: Some(config_path.clone()), keypair_path: Some(keypair_path.clone()), + node_config_path: Some(node_config_path), bitcoin: Some(BitcoinConfig { private_key_path: Some(btc_key_path.clone()), ..BitcoinConfig::default() @@ -226,13 +305,19 @@ mod tests { } /// Run `save` with a freshly generated age identity and return everything `restore` needs. - fn save_with_fresh_identity(config: &CliConfig) -> SavedBackup { + fn save_with_fresh_identity(fixture: &TestFixture) -> SavedBackup { let dir = tempfile::Builder::new().tempdir().unwrap(); let identity = x25519::Identity::generate(); let recipient = identity.to_public(); - save(config, Some(recipient.to_string()), dir.path()).unwrap(); + save( + &fixture.config, + None, + Some(recipient.to_string()), + dir.path(), + ) + .unwrap(); let tarball = fs::read_dir(dir.path()) .unwrap() @@ -290,7 +375,7 @@ mod tests { #[test] fn round_trip_restores_files_to_output_dir() { let fixture = TestFixture::new(); - let backup = save_with_fresh_identity(&fixture.config); + let backup = save_with_fresh_identity(&fixture); let out = tempfile::Builder::new().tempdir().unwrap(); restore(&backup.tarball, &backup.identity_file, out.path(), false).unwrap(); @@ -323,12 +408,16 @@ mod tests { #[test] fn round_trip_copy_to_original_paths_rewrites_originals() { let fixture = TestFixture::new(); - let backup = save_with_fresh_identity(&fixture.config); + let backup = save_with_fresh_identity(&fixture); + + let db_path = fixture.db_path(); // Delete the originals so copy_to_original_paths can recreate them. fs::remove_file(&fixture.config_path).unwrap(); + fs::remove_file(fixture.config.node_config_path.as_ref().unwrap()).unwrap(); fs::remove_file(&fixture.keypair_path).unwrap(); fs::remove_file(&fixture.btc_key_path).unwrap(); + fs::remove_dir_all(&db_path).unwrap(); let out = tempfile::Builder::new().tempdir().unwrap(); restore(&backup.tarball, &backup.identity_file, out.path(), true).unwrap(); @@ -346,12 +435,15 @@ mod tests { assert_mode_0600(&fixture.config_path); assert_mode_0600(&fixture.keypair_path); assert_mode_0600(&fixture.btc_key_path); + + // The restored database should be openable. + let _db = crate::db::Database::open(&db_path).unwrap(); } #[test] fn restore_refuses_to_overwrite_existing_original_paths() { let fixture = TestFixture::new(); - let backup = save_with_fresh_identity(&fixture.config); + let backup = save_with_fresh_identity(&fixture); // Delete keypair and btc key but leave the config in place. The config file is // the first entry in `backup_file_paths()`, so the copy-back loop will bail on @@ -390,14 +482,24 @@ mod tests { let keypair_path = sui_dir.join("key.pem"); let btc_key_path = btc_dir.join("key.pem"); + let db_path = src.path().join("db"); fs::write(&config_path, CONFIG_CONTENTS).unwrap(); fs::write(&keypair_path, KEYPAIR_CONTENTS).unwrap(); fs::write(&btc_key_path, BTC_KEY_CONTENTS).unwrap(); + let node_config_path = src.path().join("config.toml"); + let node_config = crate::config::Config { + db: Some(db_path.clone()), + ..Default::default() + }; + node_config.save(&node_config_path).unwrap(); + drop(crate::db::Database::open(&db_path).unwrap()); + let config = CliConfig { loaded_from_path: Some(config_path.clone()), keypair_path: Some(keypair_path.clone()), + node_config_path: Some(node_config_path), bitcoin: Some(BitcoinConfig { private_key_path: Some(btc_key_path.clone()), ..BitcoinConfig::default() @@ -405,7 +507,15 @@ mod tests { ..CliConfig::default() }; - let backup = save_with_fresh_identity(&config); + let fixture = TestFixture { + _src: src, + config, + config_path: config_path.clone(), + keypair_path: keypair_path.clone(), + btc_key_path: btc_key_path.clone(), + }; + + let backup = save_with_fresh_identity(&fixture); // Verify the archive contains both key.pem and key-2.pem. let out = tempfile::Builder::new().tempdir().unwrap(); @@ -417,9 +527,12 @@ mod tests { // Now test that --copy-to-original-paths uses the real paths, not the // disambiguated archive names. + let db_path = fixture.db_path(); fs::remove_file(&config_path).unwrap(); + fs::remove_file(fixture.config.node_config_path.as_ref().unwrap()).unwrap(); fs::remove_file(&keypair_path).unwrap(); fs::remove_file(&btc_key_path).unwrap(); + fs::remove_dir_all(&db_path).unwrap(); let out2 = tempfile::Builder::new().tempdir().unwrap(); restore(&backup.tarball, &backup.identity_file, out2.path(), true).unwrap(); diff --git a/crates/hashi/src/cli/config.rs b/crates/hashi/src/cli/config.rs index 588208850..d1eb94ba3 100644 --- a/crates/hashi/src/cli/config.rs +++ b/crates/hashi/src/cli/config.rs @@ -98,6 +98,10 @@ pub struct CliConfig { /// Optional: Gas coin object ID to use for transactions pub gas_coin: Option
, + /// Path to the validator node config file (same as used by `hashi server`). + /// Required for backup commands that need access to the database. + pub node_config_path: Option, + /// Optional Bitcoin configuration for deposit/withdrawal commands #[serde(default)] pub bitcoin: Option, @@ -120,6 +124,7 @@ impl Default for CliConfig { keypair_path: None, backup_age_pubkey: None, gas_coin: None, + node_config_path: None, bitcoin: None, } } @@ -313,6 +318,10 @@ sui_rpc_url = "https://fullnode.mainnet.sui.io:443" paths.push(path.clone()); } + if let Some(path) = &self.node_config_path { + paths.push(path.clone()); + } + if let Some(path) = &self.keypair_path { paths.push(path.clone()); } diff --git a/crates/hashi/src/cli/mod.rs b/crates/hashi/src/cli/mod.rs index 877b2a6a7..5983c1520 100644 --- a/crates/hashi/src/cli/mod.rs +++ b/crates/hashi/src/cli/mod.rs @@ -230,6 +230,11 @@ pub enum ConfigCommands { pub enum BackupCommands { /// Save an encrypted backup of the current config and referenced files Save { + /// Path to the validator node config file (same as used by `hashi server`). + /// Overrides `node_config_path` from the CLI config file. + #[clap(long)] + node_config: Option, + /// Age recipient public key used to encrypt the backup #[clap(long)] backup_age_pubkey: Option, @@ -597,10 +602,16 @@ pub async fn run(opts: CliGlobalOpts, command: CliCommand) -> anyhow::Result<()> }, CliCommand::Backup { action } => match action { BackupCommands::Save { + node_config, backup_age_pubkey, output_dir, } => { - commands::backup::save(&config, backup_age_pubkey, &output_dir)?; + commands::backup::save( + &config, + node_config.as_deref(), + backup_age_pubkey, + &output_dir, + )?; } BackupCommands::Restore { backup_tarball,