config_store.rs (13935B)
1 #[cfg(unix)] 2 use std::os::unix::fs::OpenOptionsExt; 3 use std::{ 4 collections::BTreeMap, 5 fs::{self, File, OpenOptions}, 6 io::Write, 7 path::Path, 8 }; 9 10 use anyhow::{Context, Result, bail}; 11 use serde::{Deserialize, Serialize}; 12 13 use crate::domain::{Amount, MICRO_IUNA}; 14 15 const CONFIG_FILE_VERSION: u32 = 1; 16 const AMOUNT_UNIT_MICROIUNA: &str = "microiuna"; 17 const LEGACY_AMOUNT_UNIT_PRE_RENAME: &str = concat!("micro", "l", "uun"); 18 pub const DEFAULT_BURN_AMOUNT: Amount = MICRO_IUNA / 10_000; 19 pub const DEFAULT_BURN_FEE: Amount = DEFAULT_BURN_AMOUNT; 20 pub const DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT: u8 = 50; 21 pub const DEFAULT_POW_MINING_WORKERS: u8 = 1; 22 pub const MAX_POW_MINING_WORKERS: u8 = 32; 23 pub const DEFAULT_P2P_BIND_PORT: u16 = 9444; 24 25 #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] 26 pub struct UiConfig { 27 pub setup_complete: bool, 28 #[serde(skip_serializing, default)] 29 pub auth_password_hash: Option<String>, 30 pub mining_enabled: bool, 31 pub pow_mining_enabled: bool, 32 pub pow_mining_workers: u8, 33 pub burn_per_block: Amount, 34 pub burn_fee: Amount, 35 pub recovery_vdf_top_rank_percent: u8, 36 pub keep_track_of_metrics: bool, 37 pub p2p_accept_inbound: bool, 38 pub p2p_bind_port: u16, 39 pub p2p_announce_addr: Option<String>, 40 pub peers: Vec<String>, 41 pub address_book: BTreeMap<String, String>, 42 } 43 44 impl Default for UiConfig { 45 fn default() -> Self { 46 Self { 47 setup_complete: false, 48 auth_password_hash: None, 49 mining_enabled: false, 50 pow_mining_enabled: false, 51 pow_mining_workers: DEFAULT_POW_MINING_WORKERS, 52 burn_per_block: DEFAULT_BURN_AMOUNT, 53 burn_fee: DEFAULT_BURN_FEE, 54 recovery_vdf_top_rank_percent: DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT, 55 keep_track_of_metrics: false, 56 p2p_accept_inbound: false, 57 p2p_bind_port: DEFAULT_P2P_BIND_PORT, 58 p2p_announce_addr: None, 59 peers: Vec::new(), 60 address_book: BTreeMap::new(), 61 } 62 } 63 } 64 65 #[derive(Debug, Deserialize, Serialize)] 66 struct ConfigFile { 67 version: u32, 68 #[serde(default)] 69 amount_unit: Option<String>, 70 setup_complete: bool, 71 #[serde(default)] 72 auth_password_hash: Option<String>, 73 #[serde(default)] 74 mining_enabled: Option<bool>, 75 #[serde(default)] 76 pow_mining_enabled: bool, 77 #[serde(default = "default_pow_mining_workers")] 78 pow_mining_workers: u8, 79 #[serde(default)] 80 burn_per_block: Amount, 81 #[serde(default)] 82 burn_fee: Option<Amount>, 83 #[serde(default)] 84 recovery_vdf_top_rank_percent: Option<u8>, 85 #[serde(default)] 86 keep_track_of_metrics: bool, 87 #[serde(default)] 88 p2p_accept_inbound: Option<bool>, 89 #[serde(default = "default_p2p_bind_port")] 90 p2p_bind_port: u16, 91 #[serde(default)] 92 p2p_announce_addr: Option<String>, 93 #[serde(default)] 94 peers: Vec<String>, 95 #[serde(default)] 96 address_book: BTreeMap<String, String>, 97 } 98 99 pub fn load_or_create(path: &Path) -> Result<UiConfig> { 100 if path.exists() { 101 return load(path); 102 } 103 104 let config = UiConfig::default(); 105 save(path, &config)?; 106 Ok(config) 107 } 108 109 pub fn save(path: &Path, config: &UiConfig) -> Result<()> { 110 if let Some(parent) = path.parent() { 111 fs::create_dir_all(parent) 112 .with_context(|| format!("failed to create config directory {}", parent.display()))?; 113 } 114 115 let stored = ConfigFile { 116 version: CONFIG_FILE_VERSION, 117 amount_unit: Some(AMOUNT_UNIT_MICROIUNA.to_string()), 118 setup_complete: config.setup_complete, 119 auth_password_hash: config.auth_password_hash.clone(), 120 mining_enabled: Some(config.mining_enabled), 121 pow_mining_enabled: config.pow_mining_enabled, 122 pow_mining_workers: clamp_pow_mining_workers(config.pow_mining_workers), 123 burn_per_block: config.burn_per_block, 124 burn_fee: Some(config.burn_fee), 125 recovery_vdf_top_rank_percent: Some(config.recovery_vdf_top_rank_percent), 126 keep_track_of_metrics: config.keep_track_of_metrics, 127 p2p_accept_inbound: Some(config.p2p_accept_inbound), 128 p2p_bind_port: config.p2p_bind_port, 129 p2p_announce_addr: config.p2p_announce_addr.clone(), 130 peers: config.peers.clone(), 131 address_book: config.address_book.clone(), 132 }; 133 let bytes = serde_json::to_vec_pretty(&stored).context("failed to serialize config file")?; 134 let mut file = create_config_file(path)?; 135 file.write_all(&bytes) 136 .with_context(|| format!("failed to write config file {}", path.display()))?; 137 file.write_all(b"\n") 138 .with_context(|| format!("failed to write config file {}", path.display()))?; 139 Ok(()) 140 } 141 142 fn load(path: &Path) -> Result<UiConfig> { 143 let bytes = 144 fs::read(path).with_context(|| format!("failed to read config file {}", path.display()))?; 145 let stored: ConfigFile = serde_json::from_slice(&bytes) 146 .with_context(|| format!("failed to parse config file {}", path.display()))?; 147 148 if stored.version != CONFIG_FILE_VERSION { 149 bail!( 150 "unsupported config file version {} in {}", 151 stored.version, 152 path.display() 153 ); 154 } 155 156 let scale = if matches!( 157 stored.amount_unit.as_deref(), 158 Some(AMOUNT_UNIT_MICROIUNA) | Some(LEGACY_AMOUNT_UNIT_PRE_RENAME) 159 ) { 160 1 161 } else { 162 MICRO_IUNA 163 }; 164 165 let p2p_accept_inbound = stored 166 .p2p_accept_inbound 167 .unwrap_or_else(|| stored.p2p_announce_addr.is_some()); 168 169 Ok(UiConfig { 170 setup_complete: stored.setup_complete, 171 auth_password_hash: stored.auth_password_hash, 172 mining_enabled: stored.mining_enabled.unwrap_or(stored.burn_per_block > 0), 173 pow_mining_enabled: stored.pow_mining_enabled, 174 pow_mining_workers: clamp_pow_mining_workers(stored.pow_mining_workers), 175 burn_per_block: stored.burn_per_block.saturating_mul(scale), 176 burn_fee: stored 177 .burn_fee 178 .map(|fee| fee.saturating_mul(scale)) 179 .unwrap_or(DEFAULT_BURN_FEE), 180 recovery_vdf_top_rank_percent: stored 181 .recovery_vdf_top_rank_percent 182 .unwrap_or(DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT) 183 .min(100), 184 keep_track_of_metrics: stored.keep_track_of_metrics, 185 p2p_accept_inbound, 186 p2p_bind_port: stored.p2p_bind_port, 187 p2p_announce_addr: stored.p2p_announce_addr, 188 peers: stored.peers, 189 address_book: stored.address_book, 190 }) 191 } 192 193 pub fn clamp_pow_mining_workers(workers: u8) -> u8 { 194 workers.clamp(1, MAX_POW_MINING_WORKERS) 195 } 196 197 fn default_pow_mining_workers() -> u8 { 198 DEFAULT_POW_MINING_WORKERS 199 } 200 201 fn default_p2p_bind_port() -> u16 { 202 DEFAULT_P2P_BIND_PORT 203 } 204 205 fn create_config_file(path: &Path) -> Result<File> { 206 let mut options = OpenOptions::new(); 207 options.write(true).create(true).truncate(true); 208 #[cfg(unix)] 209 options.mode(0o600); 210 options 211 .open(path) 212 .with_context(|| format!("failed to create config file {}", path.display())) 213 } 214 215 #[cfg(test)] 216 mod tests { 217 use std::fs; 218 219 use tempfile::tempdir; 220 221 use crate::domain::MICRO_IUNA; 222 223 use super::{DEFAULT_BURN_AMOUNT, DEFAULT_BURN_FEE, UiConfig, load_or_create, save}; 224 225 #[test] 226 fn creates_default_config_file() { 227 let dir = tempdir().unwrap(); 228 let path = dir.path().join("config.json"); 229 230 let config = load_or_create(&path).unwrap(); 231 232 assert!(!config.setup_complete); 233 let stored = fs::read_to_string(path).unwrap(); 234 assert!(stored.contains("\"version\": 1")); 235 assert!(stored.contains("\"amount_unit\": \"microiuna\"")); 236 assert!(stored.contains("\"setup_complete\": false")); 237 assert!(stored.contains("\"auth_password_hash\": null")); 238 assert!(stored.contains("\"mining_enabled\": false")); 239 assert!(stored.contains("\"pow_mining_enabled\": false")); 240 assert!(stored.contains("\"pow_mining_workers\": 1")); 241 assert!(stored.contains("\"burn_per_block\": 100")); 242 assert!(stored.contains("\"burn_fee\": 100")); 243 assert!(!stored.contains("\"pow_mine_fee\"")); 244 assert!(!stored.contains("required_burn")); 245 assert!(stored.contains("\"keep_track_of_metrics\": false")); 246 assert!(stored.contains("\"p2p_accept_inbound\": false")); 247 assert!(stored.contains("\"p2p_bind_port\": 9444")); 248 assert!(stored.contains("\"p2p_announce_addr\": null")); 249 assert!(stored.contains("\"peers\": []")); 250 assert!(stored.contains("\"address_book\": {}")); 251 } 252 253 #[test] 254 fn saves_and_loads_setup_completion() { 255 let dir = tempdir().unwrap(); 256 let path = dir.path().join("config.json"); 257 258 save( 259 &path, 260 &UiConfig { 261 setup_complete: true, 262 auth_password_hash: Some("auth-hash".to_string()), 263 mining_enabled: true, 264 pow_mining_enabled: true, 265 pow_mining_workers: 4, 266 burn_per_block: 50 * MICRO_IUNA, 267 burn_fee: 3 * MICRO_IUNA, 268 keep_track_of_metrics: true, 269 p2p_accept_inbound: true, 270 p2p_bind_port: 9555, 271 p2p_announce_addr: Some("203.0.113.10:9444".to_string()), 272 peers: vec!["127.0.0.1:9444".to_string()], 273 address_book: [("iuna-address".to_string(), "Alice".to_string())].into(), 274 ..UiConfig::default() 275 }, 276 ) 277 .unwrap(); 278 let config = load_or_create(&path).unwrap(); 279 280 assert!(config.setup_complete); 281 assert_eq!(config.auth_password_hash.as_deref(), Some("auth-hash")); 282 assert!(config.mining_enabled); 283 assert!(config.pow_mining_enabled); 284 assert_eq!(config.pow_mining_workers, 4); 285 assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); 286 assert_eq!(config.burn_fee, 3 * MICRO_IUNA); 287 assert!(config.keep_track_of_metrics); 288 assert!(config.p2p_accept_inbound); 289 assert_eq!(config.p2p_bind_port, 9555); 290 assert_eq!( 291 config.p2p_announce_addr.as_deref(), 292 Some("203.0.113.10:9444") 293 ); 294 assert_eq!(config.peers, vec!["127.0.0.1:9444"]); 295 assert_eq!( 296 config.address_book.get("iuna-address"), 297 Some(&"Alice".to_string()) 298 ); 299 } 300 301 #[test] 302 fn ui_config_json_does_not_expose_password_hash() { 303 let json = serde_json::to_string(&UiConfig { 304 auth_password_hash: Some("secret-password-hash".to_string()), 305 ..UiConfig::default() 306 }) 307 .unwrap(); 308 309 assert!(!json.contains("secret-password-hash")); 310 assert!(!json.contains("auth_password_hash")); 311 } 312 313 #[test] 314 fn loads_old_config_without_burn_rate_as_zero() { 315 let dir = tempdir().unwrap(); 316 let path = dir.path().join("config.json"); 317 fs::write( 318 &path, 319 r#"{ 320 "version": 1, 321 "setup_complete": true, 322 "peers": ["127.0.0.1:9444"] 323 } 324 "#, 325 ) 326 .unwrap(); 327 328 let config = load_or_create(&path).unwrap(); 329 330 assert!(config.setup_complete); 331 assert!(!config.mining_enabled); 332 assert!(!config.pow_mining_enabled); 333 assert_eq!(config.pow_mining_workers, 1); 334 assert_eq!(config.burn_per_block, 0); 335 assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); 336 assert!(!config.keep_track_of_metrics); 337 assert!(!config.p2p_accept_inbound); 338 assert_eq!(config.p2p_bind_port, 9444); 339 assert_eq!(config.peers, vec!["127.0.0.1:9444"]); 340 assert!(config.address_book.is_empty()); 341 } 342 343 #[test] 344 fn default_burn_settings_are_prefilled_while_disabled() { 345 let config = UiConfig::default(); 346 347 assert!(!config.mining_enabled); 348 assert_eq!(config.burn_per_block, DEFAULT_BURN_AMOUNT); 349 assert_eq!(config.burn_fee, DEFAULT_BURN_FEE); 350 } 351 352 #[test] 353 fn legacy_config_with_announce_address_keeps_public_node_enabled() { 354 let dir = tempdir().unwrap(); 355 let path = dir.path().join("config.json"); 356 fs::write( 357 &path, 358 r#"{ 359 "version": 1, 360 "setup_complete": true, 361 "p2p_announce_addr": "203.0.113.10:9444", 362 "peers": [] 363 } 364 "#, 365 ) 366 .unwrap(); 367 368 let config = load_or_create(&path).unwrap(); 369 370 assert!(config.p2p_accept_inbound); 371 assert_eq!( 372 config.p2p_announce_addr.as_deref(), 373 Some("203.0.113.10:9444") 374 ); 375 } 376 377 #[test] 378 fn loads_old_config_with_burn_rate_as_mining_enabled() { 379 let dir = tempdir().unwrap(); 380 let path = dir.path().join("config.json"); 381 fs::write( 382 &path, 383 r#"{ 384 "version": 1, 385 "setup_complete": true, 386 "burn_per_block": 2, 387 "burn_fee": 1, 388 "peers": [] 389 } 390 "#, 391 ) 392 .unwrap(); 393 394 let config = load_or_create(&path).unwrap(); 395 396 assert!(config.mining_enabled); 397 assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); 398 assert_eq!(config.burn_fee, MICRO_IUNA); 399 } 400 401 #[test] 402 fn loads_renamed_legacy_micro_unit_config_without_rescaling_amounts() { 403 let dir = tempdir().unwrap(); 404 let path = dir.path().join("config.json"); 405 let legacy_unit = format!("micro{}{}", "l", "uun"); 406 fs::write( 407 &path, 408 format!( 409 r#"{{ 410 "version": 1, 411 "amount_unit": "{legacy_unit}", 412 "setup_complete": true, 413 "mining_enabled": true, 414 "burn_per_block": 2000000, 415 "burn_fee": 3, 416 "pow_mine_fee": 5, 417 "peers": [] 418 }} 419 "# 420 ), 421 ) 422 .unwrap(); 423 424 let config = load_or_create(&path).unwrap(); 425 426 assert!(config.mining_enabled); 427 assert_eq!(config.burn_per_block, 2 * MICRO_IUNA); 428 assert_eq!(config.burn_fee, 3); 429 } 430 }