iuna-chain-audit.rs (6903B)
1 use std::{ 2 env, fs, 3 io::Read, 4 path::{Path, PathBuf}, 5 process, 6 time::{SystemTime, UNIX_EPOCH}, 7 }; 8 9 use anyhow::{Context, Result, bail}; 10 use iuna::{ 11 adapters::chain_store::SqliteChainStore, 12 domain::{Block, FinalizerMode}, 13 }; 14 use serde::Serialize; 15 use sha2::{Digest, Sha256}; 16 17 #[derive(Serialize)] 18 struct TicketResume { 19 height: u64, 20 hash: String, 21 rank: u32, 22 delay_blocks: u64, 23 delay_ms: u64, 24 } 25 26 #[derive(Serialize)] 27 struct RecoveryEvidence { 28 height: u64, 29 hash: String, 30 miner: String, 31 timestamp_ms: u64, 32 gap_from_parent_ms: u64, 33 next_ticket: Option<TicketResume>, 34 immediate_ticket_resume: bool, 35 } 36 37 #[derive(Serialize)] 38 struct AuditReport { 39 format: u8, 40 source: PathBuf, 41 captured_at_ms: u64, 42 database_copy_sha256: String, 43 verified_under_current_consensus_ruleset: bool, 44 profile_id: String, 45 height: u64, 46 tip_hash: String, 47 genesis_hash: String, 48 recovery_count: usize, 49 recoveries: Vec<RecoveryEvidence>, 50 } 51 52 fn main() -> Result<()> { 53 let mut args = env::args_os().skip(1); 54 let source = PathBuf::from( 55 args.next() 56 .context("usage: iuna-chain-audit CHAIN_DB [--output REPORT.json]")?, 57 ); 58 let mut output = None; 59 while let Some(argument) = args.next() { 60 if argument == "--output" { 61 output = Some(PathBuf::from(args.next().context("missing --output path")?)); 62 } else { 63 bail!("unknown argument: {}", argument.to_string_lossy()); 64 } 65 } 66 67 let report = audit_snapshot(&source)?; 68 let json = serde_json::to_string_pretty(&report)? + "\n"; 69 if let Some(output) = output { 70 fs::write(&output, json) 71 .with_context(|| format!("failed to write audit report {}", output.display()))?; 72 println!( 73 "wrote {} recovery events to {}", 74 report.recovery_count, 75 output.display() 76 ); 77 } else { 78 print!("{json}"); 79 } 80 Ok(()) 81 } 82 83 fn audit_snapshot(source: &Path) -> Result<AuditReport> { 84 if !source.is_file() { 85 bail!("chain database does not exist: {}", source.display()); 86 } 87 let companion = ["-wal", "-journal"] 88 .into_iter() 89 .map(|suffix| path_with_suffix(source, suffix)) 90 .find(|path| path.exists()); 91 if let Some(companion) = companion { 92 bail!( 93 "refusing a database with an active SQLite companion; stop the node or create a SQLite backup first: {}", 94 companion.display() 95 ); 96 } 97 98 let before = fs::metadata(source).context("failed to inspect source database")?; 99 let temporary = env::temp_dir().join(format!( 100 "iuna-chain-audit-{}-{}.sqlite3", 101 process::id(), 102 now_ms()? 103 )); 104 fs::copy(source, &temporary).with_context(|| { 105 format!( 106 "failed to copy source database {} to {}", 107 source.display(), 108 temporary.display() 109 ) 110 })?; 111 let after = fs::metadata(source).context("failed to re-inspect source database")?; 112 if before.len() != after.len() || before.modified().ok() != after.modified().ok() { 113 cleanup_temporary_database(&temporary); 114 bail!("source database changed while it was copied; retry on a stable snapshot"); 115 } 116 117 let result = audit_copy(source, &temporary); 118 cleanup_temporary_database(&temporary); 119 result 120 } 121 122 fn audit_copy(source: &Path, copy: &Path) -> Result<AuditReport> { 123 let database_copy_sha256 = file_sha256(copy)?; 124 let loaded = SqliteChainStore::open(copy)? 125 .load_with_verification_status()? 126 .context("chain database contains no snapshot")?; 127 let verified_under_current_consensus_ruleset = loaded.revalidation_from_height.is_none(); 128 let snapshot = loaded.snapshot; 129 let tip = snapshot.blocks.last().context("chain snapshot is empty")?; 130 let genesis = snapshot.blocks.first().context("chain snapshot is empty")?; 131 let recoveries = snapshot 132 .blocks 133 .iter() 134 .enumerate() 135 .filter(|(_, block)| block.finalizer_mode == FinalizerMode::Recovery) 136 .map(|(index, block)| recovery_evidence(&snapshot.blocks, index, block)) 137 .collect::<Vec<_>>(); 138 139 Ok(AuditReport { 140 format: 1, 141 source: source.to_path_buf(), 142 captured_at_ms: now_ms()?, 143 database_copy_sha256, 144 verified_under_current_consensus_ruleset, 145 profile_id: snapshot.launch_profile.profile_id.clone(), 146 height: tip.height, 147 tip_hash: tip.hash.clone(), 148 genesis_hash: genesis.hash.clone(), 149 recovery_count: recoveries.len(), 150 recoveries, 151 }) 152 } 153 154 fn recovery_evidence(blocks: &[Block], index: usize, recovery: &Block) -> RecoveryEvidence { 155 let parent_timestamp = index 156 .checked_sub(1) 157 .and_then(|parent| blocks.get(parent)) 158 .map(|block| block.timestamp_ms) 159 .unwrap_or(recovery.timestamp_ms); 160 let next_ticket_block = blocks[index.saturating_add(1)..] 161 .iter() 162 .find(|block| block.finalizer_mode == FinalizerMode::Ticket); 163 let next_ticket = next_ticket_block.map(|block| TicketResume { 164 height: block.height, 165 hash: block.hash.clone(), 166 rank: block.finalizer_rank, 167 delay_blocks: block.height.saturating_sub(recovery.height), 168 delay_ms: block.timestamp_ms.saturating_sub(recovery.timestamp_ms), 169 }); 170 let immediate_ticket_resume = blocks.get(index.saturating_add(1)).is_some_and(|block| { 171 block.finalizer_mode == FinalizerMode::Ticket && block.prev_hash == recovery.hash 172 }); 173 174 RecoveryEvidence { 175 height: recovery.height, 176 hash: recovery.hash.clone(), 177 miner: recovery.miner.clone(), 178 timestamp_ms: recovery.timestamp_ms, 179 gap_from_parent_ms: recovery.timestamp_ms.saturating_sub(parent_timestamp), 180 next_ticket, 181 immediate_ticket_resume, 182 } 183 } 184 185 fn cleanup_temporary_database(path: &Path) { 186 let _ = fs::remove_file(path); 187 for suffix in ["-wal", "-shm"] { 188 let companion = path_with_suffix(path, suffix); 189 let _ = fs::remove_file(companion); 190 } 191 } 192 193 fn path_with_suffix(path: &Path, suffix: &str) -> PathBuf { 194 let mut value = path.as_os_str().to_os_string(); 195 value.push(suffix); 196 PathBuf::from(value) 197 } 198 199 fn file_sha256(path: &Path) -> Result<String> { 200 let mut file = fs::File::open(path)?; 201 let mut digest = Sha256::new(); 202 let mut buffer = [0_u8; 64 * 1024]; 203 loop { 204 let read = file.read(&mut buffer)?; 205 if read == 0 { 206 break; 207 } 208 digest.update(&buffer[..read]); 209 } 210 Ok(format!("{:x}", digest.finalize())) 211 } 212 213 fn now_ms() -> Result<u64> { 214 SystemTime::now() 215 .duration_since(UNIX_EPOCH) 216 .context("system clock is before Unix epoch")? 217 .as_millis() 218 .try_into() 219 .context("timestamp does not fit in u64") 220 }