iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

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 }