iuna

iuna

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

commit 1d21300512bdd7ad9dffa9f3572cb302efc1ac79
parent 330bdfe7f263cc7931931e3bc99a2fec55a3ce4b
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 20:52:33 +0200

Harden persistence crash consistency

Diffstat:
MPLAN.md | 4++++
Msrc/adapters/chain_store.rs | 37+++++++++++++++++++++++++++++++++++++
Msrc/adapters/config_store.rs | 115++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/adapters/ui_data_store.rs | 42++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/wallet_store.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
5 files changed, 333 insertions(+), 57 deletions(-)

diff --git a/PLAN.md b/PLAN.md @@ -127,6 +127,10 @@ Acceptance: ## 6. Crash Consistency +Status: started with atomic JSON writes for config and wallet files, stale +temp-file regression coverage, and rollback tests that keep the last committed +chain snapshot/UI projection after failed persistence work. + Goal: prove local persistence survives process death at bad moments. Scenarios: diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -170,3 +170,40 @@ fn unix_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use tempfile::tempdir; + + use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet}; + + use super::SqliteChainStore; + + fn test_snapshot(seed: &str) -> ChainSnapshot { + let wallet = Wallet::from_seed(seed); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1); + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap() + .snapshot() + } + + #[test] + fn failed_snapshot_save_keeps_last_committed_chain() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let snapshot = test_snapshot("chain-store-rollback"); + let tip = snapshot.blocks.last().unwrap().hash.clone(); + store.save(&snapshot).unwrap(); + + let mut invalid = snapshot.clone(); + invalid.blocks.clear(); + let error = store.save(&invalid).unwrap_err(); + + assert!(error.to_string().contains("cannot persist empty chain")); + let restored = store.load().unwrap().unwrap(); + assert_eq!(restored.blocks.last().unwrap().hash, tip); + } +} diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs @@ -4,7 +4,8 @@ use std::{ collections::BTreeMap, fs::{self, File, OpenOptions}, io::Write, - path::Path, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, bail}; @@ -141,13 +142,10 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> { peers: config.peers.clone(), address_book: config.address_book.clone(), }; - let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; - let mut file = create_config_file(path)?; - file.write_all(&bytes) - .with_context(|| format!("failed to write config file {}", path.display()))?; - file.write_all(b"\n") - .with_context(|| format!("failed to write config file {}", path.display()))?; - Ok(()) + let mut bytes = + serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; + bytes.push(b'\n'); + atomic_write_config_file(path, &bytes) } fn load(path: &Path) -> Result<UiConfig> { @@ -219,9 +217,52 @@ fn default_stratum_bind_port() -> u16 { DEFAULT_STRATUM_BIND_PORT } +fn atomic_write_config_file(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + for attempt in 0..16 { + let temp_path = temp_file_path(path, attempt); + match create_config_file(&temp_path) { + Ok(mut file) => { + if let Err(error) = write_and_sync(&mut file, bytes) { + let _ = fs::remove_file(&temp_path); + return Err(error).with_context(|| { + format!("failed to write config file {}", path.display()) + }); + } + drop(file); + if let Err(error) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(error).with_context(|| { + format!("failed to replace config file {}", path.display()) + }); + } + sync_parent_dir(path); + return Ok(()); + } + Err(error) + if error + .downcast_ref::<std::io::Error>() + .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) => + { + continue; + } + Err(error) => return Err(error), + } + } + + bail!( + "failed to create temporary config file for {}", + path.display() + ) +} + fn create_config_file(path: &Path) -> Result<File> { let mut options = OpenOptions::new(); - options.write(true).create(true).truncate(true); + options.write(true).create_new(true); #[cfg(unix)] options.mode(0o600); options @@ -229,6 +270,36 @@ fn create_config_file(path: &Path) -> Result<File> { .with_context(|| format!("failed to create config file {}", path.display())) } +fn write_and_sync(file: &mut File, bytes: &[u8]) -> Result<()> { + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) +} + +fn temp_file_path(path: &Path, attempt: u64) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("config"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + path.with_file_name(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + nanos.saturating_add(u128::from(attempt)) + )) +} + +fn sync_parent_dir(path: &Path) { + if let Some(parent) = path.parent() { + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + } +} + #[cfg(test)] mod tests { use std::fs; @@ -322,6 +393,32 @@ mod tests { } #[test] + fn stale_atomic_temp_file_does_not_replace_saved_config() { + let dir = tempdir().unwrap(); + let path = dir.path().join("config.json"); + let stale_temp = dir.path().join(".config.json.crash.tmp"); + + save( + &path, + &UiConfig { + setup_complete: true, + mining_enabled: true, + peers: vec!["127.0.0.1:9444".to_string()], + ..UiConfig::default() + }, + ) + .unwrap(); + fs::write(&stale_temp, b"{\"version\": 1,").unwrap(); + + let config = load_or_create(&path).unwrap(); + + assert!(config.setup_complete); + assert!(config.mining_enabled); + assert_eq!(config.peers, vec!["127.0.0.1:9444"]); + assert!(stale_temp.exists()); + } + + #[test] fn ui_config_json_does_not_expose_password_hash() { let json = serde_json::to_string(&UiConfig { auth_password_hash: Some("secret-password-hash".to_string()), diff --git a/src/adapters/ui_data_store.rs b/src/adapters/ui_data_store.rs @@ -1341,3 +1341,45 @@ fn unix_ms() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use tempfile::tempdir; + + use crate::domain::{ChainSnapshot, GenesisBurn, Ledger, Wallet}; + + use super::SqliteUiDataStore; + + fn test_snapshot(seed: &str) -> ChainSnapshot { + let wallet = Wallet::from_seed(seed); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1); + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap() + .snapshot() + } + + #[test] + fn failed_ui_projection_keeps_last_committed_projection() { + let dir = tempdir().unwrap(); + let store = SqliteUiDataStore::open(dir.path().join("ui_data.sqlite3")).unwrap(); + let snapshot = test_snapshot("ui-data-rollback"); + let tip = snapshot.blocks.last().unwrap().hash.clone(); + store.project_snapshot(&snapshot, true).unwrap(); + assert!(store.is_projected_to(&tip).unwrap()); + assert!(!store.load_metrics().unwrap().is_empty()); + + let mut invalid = snapshot.clone(); + invalid.blocks.clear(); + let error = store.project_snapshot(&invalid, true).unwrap_err(); + + assert!( + format!("{error:#}").contains("chain snapshot is empty"), + "{error:#}" + ); + assert!(store.is_projected_to(&tip).unwrap()); + assert!(!store.load_metrics().unwrap().is_empty()); + } +} diff --git a/src/adapters/wallet_store.rs b/src/adapters/wallet_store.rs @@ -1,7 +1,8 @@ use std::{ fs::{self, File, OpenOptions}, io::Write, - path::Path, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, }; use anyhow::{Context, Result, anyhow, bail}; @@ -62,9 +63,7 @@ pub fn load_or_create(path: &Path) -> Result<Wallet> { let seed = generate_seed_phrase()?; let wallet = Wallet::from_seed(&seed); - let mut file = create_wallet_file(path)?; - write_wallet_file(&mut file, seed, wallet.address()) - .with_context(|| format!("failed to write wallet file {}", path.display()))?; + write_wallet_file(path, seed, wallet.address(), WalletFileMode::CreateNew)?; Ok(wallet) } @@ -160,9 +159,14 @@ pub fn encrypt_existing_with_password(path: &Path, password: &str) -> Result<()> wallet.address() ); } - let mut file = open_wallet_file(path, WalletFileMode::Replace)?; - write_encrypted_wallet_data_file(&mut file, WalletData { seed }, wallet.address(), password) - .with_context(|| format!("failed to encrypt wallet file {}", path.display())) + write_encrypted_wallet_data_file( + path, + WalletData { seed }, + wallet.address(), + password, + WalletFileMode::Replace, + ) + .with_context(|| format!("failed to encrypt wallet file {}", path.display())) } pub fn reencrypt_with_password( @@ -182,12 +186,12 @@ pub fn reencrypt_with_password( wallet.address() ); } - let mut file = open_wallet_file(path, WalletFileMode::Replace)?; write_encrypted_wallet_data_file( - &mut file, + path, WalletData { seed }, wallet.address(), new_password, + WalletFileMode::Replace, ) .with_context(|| format!("failed to re-encrypt wallet file {}", path.display()))?; Ok(wallet) @@ -201,15 +205,10 @@ fn load_encrypted_or_plaintext(path: &Path, password: Option<&str>) -> Result<Wa let stored = read_wallet_file(path)?; let wallet = wallet_from_stored(&stored, password)?; if stored.version == 1 { - let mut file = OpenOptions::new() - .write(true) - .truncate(true) - .open(path) - .with_context(|| format!("failed to migrate wallet file {}", path.display()))?; let seed = stored .seed .context("legacy wallet file does not contain a seed")?; - write_wallet_file(&mut file, seed, wallet.address()) + write_wallet_file(path, seed, wallet.address(), WalletFileMode::Replace) .with_context(|| format!("failed to migrate wallet file {}", path.display()))?; return Ok(wallet); } @@ -262,9 +261,7 @@ enum WalletFileMode { fn write_wallet(path: &Path, seed: String, mode: WalletFileMode) -> Result<Wallet> { let wallet = Wallet::from_seed(&seed); - let mut file = open_wallet_file(path, mode)?; - write_wallet_file(&mut file, seed, wallet.address()) - .with_context(|| format!("failed to write wallet file {}", path.display()))?; + write_wallet_file(path, seed, wallet.address(), mode)?; Ok(wallet) } @@ -275,43 +272,48 @@ fn write_wallet_encrypted( mode: WalletFileMode, ) -> Result<Wallet> { let wallet = Wallet::from_seed(&seed); - let mut file = open_wallet_file(path, mode)?; - write_encrypted_wallet_file(&mut file, seed, wallet.address(), password) - .with_context(|| format!("failed to write wallet file {}", path.display()))?; + write_encrypted_wallet_file(path, seed, wallet.address(), password, mode)?; Ok(wallet) } -fn write_wallet_file(file: &mut File, seed: String, address: &str) -> Result<()> { - write_wallet_data_file(file, WalletData { seed }, address) +fn write_wallet_file(path: &Path, seed: String, address: &str, mode: WalletFileMode) -> Result<()> { + write_wallet_data_file(path, WalletData { seed }, address, mode) } -fn write_wallet_data_file(file: &mut File, data: WalletData, address: &str) -> Result<()> { +fn write_wallet_data_file( + path: &Path, + data: WalletData, + address: &str, + mode: WalletFileMode, +) -> Result<()> { let stored = WalletFile { version: PLAINTEXT_WALLET_FILE_VERSION, seed: Some(data.seed), address: address.to_string(), encryption: None, }; - let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; - file.write_all(&bytes)?; - file.write_all(b"\n")?; - Ok(()) + let mut bytes = + serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; + bytes.push(b'\n'); + atomic_write_wallet_file(path, &bytes, mode) } fn write_encrypted_wallet_file( - file: &mut File, + path: &Path, seed: String, address: &str, password: &str, + mode: WalletFileMode, ) -> Result<()> { - write_encrypted_wallet_data_file(file, WalletData { seed }, address, password) + write_encrypted_wallet_data_file(path, WalletData { seed }, address, password, mode) } fn write_encrypted_wallet_data_file( - file: &mut File, + path: &Path, data: WalletData, address: &str, password: &str, + mode: WalletFileMode, ) -> Result<()> { let encryption = encrypt_wallet_data(&data, address, password)?; let stored = WalletFile { @@ -320,10 +322,10 @@ fn write_encrypted_wallet_data_file( address: address.to_string(), encryption: Some(encryption), }; - let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; - file.write_all(&bytes)?; - file.write_all(b"\n")?; - Ok(()) + let mut bytes = + serde_json::to_vec_pretty(&stored).context("failed to serialize wallet file")?; + bytes.push(b'\n'); + atomic_write_wallet_file(path, &bytes, mode) } fn wallet_data(stored: &WalletFile, password: Option<&str>) -> Result<WalletData> { @@ -482,27 +484,66 @@ fn normalize_seed_phrase(seed_phrase: &str) -> Result<String> { Ok(mnemonic.to_string()) } -fn create_wallet_file(path: &Path) -> Result<File> { - open_wallet_file(path, WalletFileMode::CreateNew) -} - -fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> { +fn atomic_write_wallet_file(path: &Path, bytes: &[u8], mode: WalletFileMode) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create wallet directory {}", parent.display()))?; } - let mut options = OpenOptions::new(); - options.write(true); - match mode { - WalletFileMode::CreateNew => { - options.create_new(true); - } - WalletFileMode::Replace => { - options.create(true).truncate(true); + for attempt in 0..16 { + let temp_path = temp_file_path(path, attempt); + match open_wallet_temp_file(&temp_path) { + Ok(mut file) => { + if let Err(error) = write_and_sync(&mut file, bytes) { + let _ = fs::remove_file(&temp_path); + return Err(error).with_context(|| { + format!("failed to write wallet file {}", path.display()) + }); + } + drop(file); + match mode { + WalletFileMode::CreateNew => { + if let Err(error) = fs::hard_link(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(error).with_context(|| { + format!("failed to create wallet file {}", path.display()) + }); + } + let _ = fs::remove_file(&temp_path); + } + WalletFileMode::Replace => { + if let Err(error) = fs::rename(&temp_path, path) { + let _ = fs::remove_file(&temp_path); + return Err(error).with_context(|| { + format!("failed to replace wallet file {}", path.display()) + }); + } + } + } + sync_parent_dir(path); + return Ok(()); + } + Err(error) + if error + .downcast_ref::<std::io::Error>() + .is_some_and(|error| error.kind() == std::io::ErrorKind::AlreadyExists) => + { + continue; + } + Err(error) => return Err(error), } } + bail!( + "failed to create temporary wallet file for {}", + path.display() + ) +} + +fn open_wallet_temp_file(path: &Path) -> Result<File> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; @@ -513,3 +554,58 @@ fn open_wallet_file(path: &Path, mode: WalletFileMode) -> Result<File> { .open(path) .with_context(|| format!("failed to create wallet file {}", path.display())) } + +fn write_and_sync(file: &mut File, bytes: &[u8]) -> Result<()> { + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) +} + +fn temp_file_path(path: &Path, attempt: u64) -> PathBuf { + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("wallet"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + path.with_file_name(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + nanos.saturating_add(u128::from(attempt)) + )) +} + +fn sync_parent_dir(path: &Path) { + if let Some(parent) = path.parent() { + if let Ok(dir) = File::open(parent) { + let _ = dir.sync_all(); + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::tempdir; + + use super::{load_or_create, replace_with_imported_seed_phrase}; + + const TEST_SEED: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art"; + + #[test] + fn stale_atomic_temp_file_does_not_replace_saved_wallet() { + let dir = tempdir().unwrap(); + let path = dir.path().join("wallet.json"); + let stale_temp = dir.path().join(".wallet.json.crash.tmp"); + let wallet = replace_with_imported_seed_phrase(&path, TEST_SEED).unwrap(); + fs::write(&stale_temp, b"{\"version\": 2,").unwrap(); + + let loaded = load_or_create(&path).unwrap(); + + assert_eq!(loaded.address(), wallet.address()); + assert!(stale_temp.exists()); + } +}