1use std::{
4 collections::BTreeMap,
5 fs::{self, File, OpenOptions},
6 io::{self, Read, Write},
7 path::Path,
8 sync::{Arc, Mutex},
9};
10
11use serde::Deserialize;
12use thruput_json_rpc_types::{
13 NetworkHashText, base58::Base58, bootstrap_manifest::BootstrapManifestAllocation,
14};
15use thruput_signing::LocalSigner;
16use thruput_tx_validation::ConfigParamName;
17use zeroize::Zeroizing;
18
19use crate::{
20 HogdDevConfig, NodeSupervisorError, config::LOCAL_FAUCET_STORAGE_TOKENS,
21 funding_journal::FundingJournal,
22};
23
24pub const LOCAL_FAUCET_KEY_FILE: &str = "groundhog-faucet.key";
27pub const LOCAL_FUNDING_ATTEMPTS_FILE: &str = "groundhog-funding.json";
29const STATE_METADATA_FILE: &str = "hog-devnode-data.json";
30const MAX_METADATA_BYTES: usize = 256 * 1024;
31
32#[derive(Debug)]
33pub(crate) struct LocalChainState {
34 pub(crate) signer: Arc<LocalSigner>,
35 pub(crate) config: HogdDevConfig,
36 pub(crate) network_hash: NetworkHashText,
37 pub(crate) lock: Option<Arc<File>>,
38 pub(crate) journal: Arc<Mutex<FundingJournal>>,
39}
40
41#[derive(Deserialize)]
42#[serde(rename_all = "camelCase", deny_unknown_fields)]
43struct ChainMetadata {
44 schema_version: u32,
45 kind: String,
46 bootstrap_config: PersistedBootstrap,
47}
48
49#[derive(Deserialize)]
50#[serde(rename_all = "camelCase", deny_unknown_fields)]
51struct PersistedBootstrap {
52 bootstrap_faucet: BootstrapManifestAllocation,
53 bootstrap_accounts: Vec<BootstrapManifestAllocation>,
54 #[serde(default, deserialize_with = "read_overrides")]
55 network_config_overrides: BTreeMap<ConfigParamName, u64>,
56}
57
58#[derive(Deserialize)]
61#[serde(untagged)]
62enum OverrideValue {
63 Integer(u64),
64 Decimal(String),
65}
66
67fn read_overrides<'de, D: serde::Deserializer<'de>>(
68 deserializer: D,
69) -> Result<BTreeMap<ConfigParamName, u64>, D::Error> {
70 struct Overrides;
71 impl<'de> serde::de::Visitor<'de> for Overrides {
72 type Value = BTreeMap<ConfigParamName, u64>;
73 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 formatter.write_str("named uint64 network overrides")
75 }
76 fn visit_map<M: serde::de::MapAccess<'de>>(
77 self,
78 mut map: M,
79 ) -> Result<Self::Value, M::Error> {
80 let mut overrides = BTreeMap::new();
81 while let Some((name, value)) = map.next_entry::<String, OverrideValue>()? {
82 let parameter = name.parse().map_err(serde::de::Error::custom)?;
83 let value = match value {
84 OverrideValue::Integer(value) => value,
85 OverrideValue::Decimal(text) => text.parse().map_err(|_| {
86 serde::de::Error::custom(format!(
87 "network override {parameter} is not a uint64 decimal: {text:?}"
88 ))
89 })?,
90 };
91 if overrides.insert(parameter, value).is_some() {
92 return Err(serde::de::Error::custom("duplicate network override"));
93 }
94 }
95 Ok(overrides)
96 }
97 }
98 deserializer.deserialize_map(Overrides)
99}
100
101impl LocalChainState {
102 pub(crate) fn prepare(config: &HogdDevConfig) -> Result<Self, NodeSupervisorError> {
103 let Some(dir) = config.data_dir.as_deref() else {
104 let network_hash = config
105 .init_network_hash
106 .clone()
107 .unwrap_or_else(|| [0; 32].into());
108 let signer = Arc::new(random_signer(Path::new("ephemeral faucet"))?);
109 let faucet = Base58(
110 thruput_signing::PubkeyAccount::from_public_key_bytes(
111 signer.verifying_key_bytes(),
112 0,
113 )
114 .address(),
115 );
116 return Ok(Self {
117 signer,
118 config: config.clone(),
119 network_hash: network_hash.clone(),
120 lock: None,
121 journal: Arc::new(Mutex::new(FundingJournal::memory(network_hash, faucet))),
122 });
123 };
124 let lock = acquire_chain_lock(dir)?;
125 let metadata = read_chain_metadata(dir)?;
126 reject_reinitialization(config, metadata.is_some())?;
127 if metadata.is_none() {
128 reject_unidentified_state(dir)?;
129 }
130 let key_path = dir.join(LOCAL_FAUCET_KEY_FILE);
131 let signer = load_or_create_faucet(&key_path, metadata.is_none())?;
132 let faucet = Base58(
133 thruput_signing::PubkeyAccount::from_public_key_bytes(signer.verifying_key_bytes(), 0)
134 .address(),
135 );
136 let signer = Arc::new(signer);
137 let journal = FundingJournal::open(
138 dir,
139 config.init_network_hash.clone(),
140 faucet,
141 metadata.is_none(),
142 &signer,
143 )?;
144 let mut effective = config.clone();
145 if let Some(metadata) = metadata {
146 if metadata
147 .bootstrap_config
148 .bootstrap_faucet
149 .public_key
150 .as_ref()
151 != signer.verifying_key_bytes()
152 || metadata
153 .bootstrap_config
154 .bootstrap_faucet
155 .initial_storage_tokens
156 != LOCAL_FAUCET_STORAGE_TOKENS.to_string()
157 {
158 return Err(state_error(
159 &key_path,
160 "faucet credential does not match the chain's public bootstrap intent",
161 ));
162 }
163 if metadata.bootstrap_config.network_config_overrides != config.network_config_overrides
164 {
165 return Err(state_error(
166 dir,
167 "network overrides differ from the chain's persisted intent",
168 ));
169 }
170 effective.bootstrap_accounts = metadata.bootstrap_config.bootstrap_accounts;
171 }
172 Ok(Self {
173 signer,
174 config: effective,
175 network_hash: journal.network_hash().clone(),
176 lock: Some(Arc::new(lock)),
177 journal: Arc::new(Mutex::new(journal)),
178 })
179 }
180
181 pub(crate) fn public_key(&self) -> thruput_json_rpc_types::Ed25519PublicKey {
182 Base58(thruput_xdr::types::Ed25519PublicKey::from(
183 self.signer.verifying_key_bytes(),
184 ))
185 }
186}
187
188pub(crate) fn preflight_state_version(config: &HogdDevConfig) -> Result<(), NodeSupervisorError> {
190 if let Some(dir) = config.data_dir.as_deref() {
191 let metadata = read_chain_metadata(dir)?;
192 reject_reinitialization(config, metadata.is_some())?;
193 }
194 Ok(())
195}
196
197fn reject_reinitialization(
198 config: &HogdDevConfig,
199 has_metadata: bool,
200) -> Result<(), NodeSupervisorError> {
201 if let Some(dir) = config.data_dir.as_deref()
202 && config.init_network_hash.is_some()
203 && (has_metadata || entry_exists(&dir.join(LOCAL_FUNDING_ATTEMPTS_FILE))?)
204 {
205 return Err(already_initialized(dir));
206 }
207 Ok(())
208}
209
210pub(crate) fn already_initialized(path: &Path) -> NodeSupervisorError {
211 state_error(
212 path,
213 "directory is already initialized; --init-network-hash is only for a new chain; omit it to restart with the persisted network identity",
214 )
215}
216
217#[derive(Deserialize)]
218#[serde(rename_all = "camelCase")]
219struct Envelope {
220 schema_version: u32,
221}
222
223fn read_chain_metadata(dir: &Path) -> Result<Option<ChainMetadata>, NodeSupervisorError> {
224 let path = dir.join(STATE_METADATA_FILE);
225 let file = match regular_file(&path, false) {
226 Ok(file) => file,
227 Err(_error) if !entry_exists(&path)? => return Ok(None),
228 Err(error) => return Err(error),
229 };
230 let mut bytes = Vec::new();
231 file.take((MAX_METADATA_BYTES + 1) as u64)
232 .read_to_end(&mut bytes)
233 .map_err(|e| state_error(&path, e))?;
234 if bytes.len() > MAX_METADATA_BYTES {
235 return Err(state_error(&path, "bootstrap metadata exceeds size limit"));
236 }
237 let envelope: Envelope = serde_json::from_slice(&bytes).map_err(|e| state_error(&path, e))?;
238 if envelope.schema_version != 5 {
239 return Err(state_error(
240 &path,
241 "unsupported local state schema; choose a new data directory for this version",
242 ));
243 }
244 let metadata: ChainMetadata =
245 serde_json::from_slice(&bytes).map_err(|e| state_error(&path, e))?;
246 if metadata.schema_version != 5 || metadata.kind != "hog-devnode-data" {
247 return Err(state_error(&path, "unexpected local state metadata family"));
248 }
249 Ok(Some(metadata))
250}
251
252fn reject_unidentified_state(dir: &Path) -> Result<(), NodeSupervisorError> {
253 fn contains_files(path: &Path) -> io::Result<bool> {
254 for entry in fs::read_dir(path)? {
255 let entry = entry?;
256 if !entry.file_type()?.is_dir() || contains_files(&entry.path())? {
257 return Ok(true);
258 }
259 }
260 Ok(false)
261 }
262 for name in ["ramdb", "events", "indexer"] {
263 let path = dir.join(name);
264 match fs::symlink_metadata(&path) {
265 Ok(metadata)
266 if !metadata.is_dir()
267 || contains_files(&path).map_err(|e| state_error(&path, e))? =>
268 {
269 return Err(state_error(
270 dir,
271 "persisted state has no public bootstrap metadata; do not generate a replacement faucet",
272 ));
273 }
274 Ok(_) => {}
275 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
276 Err(e) => return Err(state_error(&path, e)),
277 }
278 }
279 Ok(())
280}
281
282fn entry_exists(path: &Path) -> Result<bool, NodeSupervisorError> {
283 match fs::symlink_metadata(path) {
284 Ok(_) => Ok(true),
285 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
286 Err(e) => Err(state_error(path, e)),
287 }
288}
289
290fn load_or_create_faucet(key_path: &Path, fresh: bool) -> Result<LocalSigner, NodeSupervisorError> {
291 let signer = match private_file(key_path) {
292 Ok(file) => {
293 let mut bytes = Zeroizing::new([0u8; 64]);
294 let mut reader = file.take(65);
295 reader.read_exact(bytes.as_mut()).map_err(|_| {
296 state_error(
297 key_path,
298 "faucet credential is corrupt; do not replace it for an existing chain",
299 )
300 })?;
301 let mut extra = [0];
302 if reader
303 .read(&mut extra)
304 .map_err(|e| state_error(key_path, e))?
305 != 0
306 {
307 return Err(state_error(
308 key_path,
309 "faucet credential must contain exactly 64 bytes",
310 ));
311 }
312 LocalSigner::from_keypair_bytes(*bytes).map_err(|_| {
313 state_error(key_path, "faucet credential has inconsistent keypair bytes")
314 })?
315 }
316 Err(_error) if !entry_exists(key_path)? && fresh => {
317 let signer = random_signer(key_path)?;
318 let bytes = Zeroizing::new(signer.to_keypair_bytes());
319 atomic_private_write(key_path, bytes.as_ref(), false)
320 .map_err(|e| state_error(key_path, e))?;
321 signer
322 }
323 Err(error) => return Err(error),
324 };
325 Ok(signer)
326}
327
328fn random_signer(path: &Path) -> Result<LocalSigner, NodeSupervisorError> {
329 let mut secret = Zeroizing::new([0u8; 32]);
330 getrandom::fill(secret.as_mut()).map_err(|e| state_error(path, e))?;
331 Ok(LocalSigner::from_secret_key(*secret))
332}
333
334#[allow(clippy::needless_pass_by_value)] pub(crate) fn state_error(path: &Path, reason: impl ToString) -> NodeSupervisorError {
336 NodeSupervisorError::LocalFundingState {
337 path: path.to_owned(),
338 reason: reason.to_string(),
339 }
340}
341
342pub(crate) fn private_file(path: &Path) -> Result<File, NodeSupervisorError> {
343 regular_file(path, true)
344}
345
346fn regular_file(path: &Path, private: bool) -> Result<File, NodeSupervisorError> {
347 let mut options = OpenOptions::new();
348 options.read(true);
349 #[cfg(unix)]
350 {
351 use std::os::unix::fs::OpenOptionsExt;
352 options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
353 }
354 let file = options.open(path).map_err(|e| state_error(path, e))?;
355 let metadata = file.metadata().map_err(|e| state_error(path, e))?;
356 if !metadata.is_file() {
357 return Err(state_error(path, "expected a regular file"));
358 }
359 #[cfg(unix)]
360 {
361 use std::os::unix::fs::MetadataExt;
362 if private && (metadata.mode() & 0o077 != 0 || metadata.uid() != unsafe { libc::geteuid() })
364 {
365 return Err(state_error(
366 path,
367 "private local state must be owned by the current user with mode 0600",
368 ));
369 }
370 }
371 #[cfg(not(unix))]
372 let _ = private;
373 Ok(file)
374}
375
376pub(crate) fn atomic_private_write(path: &Path, bytes: &[u8], replace: bool) -> io::Result<()> {
377 let parent = path
378 .parent()
379 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "missing state parent"))?;
380 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
381 #[cfg(unix)]
382 {
383 use std::os::unix::fs::PermissionsExt;
384 temporary
385 .as_file()
386 .set_permissions(fs::Permissions::from_mode(0o600))?;
387 }
388 temporary.write_all(bytes)?;
389 temporary.as_file().sync_all()?;
390 if replace {
391 temporary.persist(path).map_err(|e| e.error)?;
392 } else {
393 temporary.persist_noclobber(path).map_err(|e| e.error)?;
394 }
395 File::open(parent)?.sync_all()
396}
397
398#[cfg(unix)]
404const CHAIN_LOCK_RETRY_WINDOW: std::time::Duration = std::time::Duration::from_millis(500);
405#[cfg(unix)]
406const CHAIN_LOCK_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(10);
407
408#[cfg(unix)]
409fn acquire_chain_lock(dir: &Path) -> Result<File, NodeSupervisorError> {
410 acquire_chain_lock_within(dir, CHAIN_LOCK_RETRY_WINDOW)
411}
412
413#[cfg(unix)]
414fn acquire_chain_lock_within(
415 dir: &Path,
416 retry_window: std::time::Duration,
417) -> Result<File, NodeSupervisorError> {
418 use std::os::{fd::AsRawFd, unix::fs::OpenOptionsExt};
419 use std::{thread, time::Instant};
420 let path = dir.join(".hog-devnode.lock");
421 let file = OpenOptions::new()
422 .read(true)
423 .write(true)
424 .create(true)
425 .truncate(false)
426 .mode(0o600)
427 .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
428 .open(&path)
429 .map_err(|e| state_error(&path, e))?;
430 if !file
431 .metadata()
432 .map_err(|e| state_error(&path, e))?
433 .is_file()
434 {
435 return Err(state_error(&path, "chain lock must be a regular file"));
436 }
437 let deadline = Instant::now() + retry_window;
438 while unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
440 let contended = io::Error::last_os_error().kind() == io::ErrorKind::WouldBlock;
441 if !contended || Instant::now() >= deadline {
442 return Err(state_error(dir, "local chain is locked by another process"));
443 }
444 thread::sleep(CHAIN_LOCK_RETRY_INTERVAL);
445 }
446 Ok(file)
447}
448
449#[cfg(not(unix))]
450fn acquire_chain_lock(_dir: &Path) -> Result<File, NodeSupervisorError> {
451 Err(NodeSupervisorError::PersistentStateUnsupported)
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457 use std::os::unix::fs::{PermissionsExt, symlink};
458
459 fn config(dir: &Path) -> HogdDevConfig {
460 fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).unwrap();
461 HogdDevConfig::default().with_data_dir(dir)
462 }
463
464 fn persist_metadata(dir: &Path, state: &LocalChainState) {
465 let intent = state
466 .config
467 .to_bootstrap_manifest([0; 32].into(), state.public_key());
468 fs::write(
469 dir.join(STATE_METADATA_FILE),
470 serde_json::to_vec(&serde_json::json!({
471 "schemaVersion": 5, "kind": "hog-devnode-data", "bootstrapConfig": {
472 "bootstrapFaucet": intent.bootstrap_faucet,
473 "bootstrapAccounts": intent.bootstrap_accounts,
474 }
475 }))
476 .unwrap(),
477 )
478 .unwrap();
479 }
480
481 #[test]
482 fn initialized_identity_restarts_without_flag_and_rejects_any_initialization_without_writes() {
483 for original_hash in [[0; 32], [0xab; 32]] {
484 let dir = tempfile::tempdir().unwrap();
485 let initial = config(dir.path()).with_init_network_hash(original_hash.into());
486 let state = LocalChainState::prepare(&initial).unwrap();
487 assert_eq!(state.network_hash, original_hash.into());
488 persist_metadata(dir.path(), &state);
489 drop(state);
490 let files = [
491 LOCAL_FUNDING_ATTEMPTS_FILE,
492 LOCAL_FAUCET_KEY_FILE,
493 STATE_METADATA_FILE,
494 ];
495 let original: Vec<_> = files
496 .iter()
497 .map(|name| fs::read(dir.path().join(name)).unwrap())
498 .collect();
499 for requested in [[0; 32], [0xab; 32], [0xcd; 32]] {
500 let attempt = config(dir.path()).with_init_network_hash(requested.into());
501 let preflight = preflight_state_version(&attempt).unwrap_err();
502 assert!(preflight.to_string().contains("already initialized"));
503 let error = LocalChainState::prepare(&attempt).unwrap_err();
504 assert!(error.to_string().contains("omit it to restart"));
505 for (name, bytes) in files.iter().zip(&original) {
506 assert_eq!(&fs::read(dir.path().join(name)).unwrap(), bytes);
507 }
508 }
509 let restarted = LocalChainState::prepare(&config(dir.path())).unwrap();
510 assert_eq!(restarted.network_hash, original_hash.into());
511 }
512 }
513
514 #[test]
515 fn fresh_ephemeral_and_persistent_identity_defaults_to_zero() {
516 for init_network_hash in [None, Some([0; 32].into()), Some([0xab; 32].into())] {
517 let expected = init_network_hash.clone().unwrap_or_else(|| [0; 32].into());
518 let ephemeral = HogdDevConfig {
519 init_network_hash: init_network_hash.clone(),
520 ..HogdDevConfig::default()
521 };
522 let state = LocalChainState::prepare(&ephemeral).unwrap();
523 assert_eq!(state.network_hash, expected);
524 let dir = tempfile::tempdir().unwrap();
525 let persistent = HogdDevConfig {
526 data_dir: Some(dir.path().into()),
527 ..ephemeral
528 };
529 let state = LocalChainState::prepare(&persistent).unwrap();
530 assert_eq!(state.network_hash, expected);
531 assert_eq!(state.journal.lock().unwrap().network_hash(), &expected);
532 }
533 }
534
535 #[test]
536 fn prepared_identity_survives_an_interrupted_first_start() {
537 let dir = tempfile::tempdir().unwrap();
538 let initial = config(dir.path()).with_init_network_hash([0xab; 32].into());
539 let state = LocalChainState::prepare(&initial).unwrap();
540 let key = state.public_key();
541 drop(state); assert!(
543 preflight_state_version(&initial)
544 .unwrap_err()
545 .to_string()
546 .contains("already initialized")
547 );
548 assert!(
549 LocalChainState::prepare(&initial)
550 .unwrap_err()
551 .to_string()
552 .contains("already initialized")
553 );
554 let retry = LocalChainState::prepare(&config(dir.path())).unwrap();
555 assert_eq!(retry.network_hash, [0xab; 32].into());
556 assert_eq!(retry.public_key(), key);
557 }
558
559 struct PausedChild {
562 release: io::PipeWriter,
563 spawner: std::thread::JoinHandle<std::process::ExitStatus>,
564 }
565
566 impl PausedChild {
567 fn spawn() -> Self {
568 use std::os::{fd::AsRawFd, unix::process::CommandExt};
569
570 let (mut forked, forked_writer) = io::pipe().unwrap();
571 let (release_reader, release) = io::pipe().unwrap();
572 let (forked_fd, release_fd, release_writer_fd) = (
573 forked_writer.as_raw_fd(),
574 release_reader.as_raw_fd(),
575 release.as_raw_fd(),
576 );
577 let spawner = std::thread::spawn(move || {
578 let mut command = std::process::Command::new("true");
579 unsafe {
584 command.pre_exec(move || {
585 let mut byte = 0u8;
586 libc::write(forked_fd, (&raw const byte).cast(), 1);
587 libc::close(release_writer_fd);
588 libc::read(release_fd, (&raw mut byte).cast(), 1);
589 Ok(())
590 });
591 }
592 let status = command.status().unwrap();
593 drop((forked_writer, release_reader));
594 status
595 });
596 forked.read_exact(&mut [0]).unwrap();
597 Self { release, spawner }
598 }
599
600 fn release_after(mut self, delay: std::time::Duration) -> std::thread::JoinHandle<()> {
602 std::thread::spawn(move || {
603 std::thread::sleep(delay);
604 self.release.write_all(&[0]).unwrap();
605 assert!(self.spawner.join().unwrap().success());
606 })
607 }
608 }
609
610 #[test]
617 #[serial_test::serial]
618 fn chain_lock_handoff_waits_out_a_concurrent_spawn() {
619 use std::os::fd::AsRawFd;
620 let dir = tempfile::tempdir().unwrap();
621 let config = config(dir.path());
622 let first = LocalChainState::prepare(&config).unwrap();
623 let child = PausedChild::spawn();
624 drop(first);
625 let probe = File::open(dir.path().join(".hog-devnode.lock")).unwrap();
626 let locked = unsafe { libc::flock(probe.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
628 assert_ne!(locked, 0, "the child holds the chain lock");
629 let released = child.release_after(std::time::Duration::from_millis(50));
630 acquire_chain_lock_within(dir.path(), std::time::Duration::from_secs(30)).unwrap();
631 released.join().unwrap();
632 }
633
634 #[test]
635 fn credential_and_journal_survive_pending_start_and_persisted_restart() {
636 let dir = tempfile::tempdir().unwrap();
637 let config = config(dir.path());
638 let first = LocalChainState::prepare(&config).unwrap();
639 let key = first.public_key();
640 assert!(
641 LocalChainState::prepare(&config).is_err(),
642 "chain lock must stay held"
643 );
644 for file in [LOCAL_FAUCET_KEY_FILE, LOCAL_FUNDING_ATTEMPTS_FILE] {
645 assert_eq!(
646 fs::metadata(dir.path().join(file))
647 .unwrap()
648 .permissions()
649 .mode()
650 & 0o777,
651 0o600
652 );
653 }
654 drop(first);
655 let pending = LocalChainState::prepare(&config).unwrap();
656 assert_eq!(pending.public_key(), key);
657 persist_metadata(dir.path(), &pending);
658 drop(pending);
659 let restarted = LocalChainState::prepare(&config).unwrap();
660 assert_eq!(restarted.public_key(), key);
661 }
662
663 #[test]
664 fn restart_keeps_original_public_allocations_when_user_selection_changes() {
665 let dir = tempfile::tempdir().unwrap();
666 let original =
667 config(dir.path()).with_bootstrap_accounts(vec![BootstrapManifestAllocation::new(
668 Base58(thruput_xdr::types::Ed25519PublicKey::from([0x11; 32])),
669 "1234",
670 )]);
671 let state = LocalChainState::prepare(&original).unwrap();
672 persist_metadata(dir.path(), &state);
673 drop(state);
674 let restart = LocalChainState::prepare(&config(dir.path())).unwrap();
675 assert_eq!(
676 restart.config.bootstrap_accounts,
677 original.bootstrap_accounts
678 );
679 drop(restart);
680 let changed = original
681 .with_network_config_overrides([(ConfigParamName::MaxBytestringLen, 42)].into());
682 assert!(LocalChainState::prepare(&changed).is_err());
683 }
684
685 #[test]
686 fn existing_chain_never_replaces_missing_corrupt_or_mismatched_private_state() {
687 for damaged in [
688 "missing-key",
689 "corrupt-key",
690 "wrong-key",
691 "missing-journal",
692 "wrong-journal",
693 "permissions",
694 "symlink",
695 ] {
696 let dir = tempfile::tempdir().unwrap();
697 let config = config(dir.path());
698 let state = LocalChainState::prepare(&config).unwrap();
699 persist_metadata(dir.path(), &state);
700 drop(state);
701 let key = dir.path().join(LOCAL_FAUCET_KEY_FILE);
702 let journal = dir.path().join(LOCAL_FUNDING_ATTEMPTS_FILE);
703 match damaged {
704 "missing-key" => fs::remove_file(&key).unwrap(),
705 "corrupt-key" => fs::write(&key, [0; 10]).unwrap(),
706 "wrong-key" => fs::write(
707 &key,
708 LocalSigner::from_secret_key([0x33; 32]).to_keypair_bytes(),
709 )
710 .unwrap(),
711 "missing-journal" => fs::remove_file(&journal).unwrap(),
712 "wrong-journal" => {
713 let mut data: serde_json::Value =
714 serde_json::from_slice(&fs::read(&journal).unwrap()).unwrap();
715 data["faucet"] = serde_json::json!(format!("0x{}", "11".repeat(32)));
716 fs::write(&journal, serde_json::to_vec(&data).unwrap()).unwrap();
717 }
718 "permissions" => {
719 fs::set_permissions(&key, fs::Permissions::from_mode(0o644)).unwrap();
720 }
721 _ => {
722 fs::remove_file(&key).unwrap();
723 symlink(dir.path().join("absent"), &key).unwrap();
724 }
725 }
726 let before = fs::read(&key).ok();
727 assert!(LocalChainState::prepare(&config).is_err(), "{damaged}");
728 assert_eq!(fs::read(&key).ok(), before, "must not replace {damaged}");
729 }
730 }
731
732 #[test]
733 fn unsupported_state_fails_before_credential_creation() {
734 for version in [1, 2, 3] {
735 let dir = tempfile::tempdir().unwrap();
736 let config = config(dir.path());
737 fs::write(
738 dir.path().join(STATE_METADATA_FILE),
739 format!(r#"{{"schemaVersion":{version}}}"#),
740 )
741 .unwrap();
742 let error = preflight_state_version(&config).unwrap_err();
743 assert!(error.to_string().contains("unsupported local state schema"));
744 assert!(!dir.path().join(LOCAL_FAUCET_KEY_FILE).exists());
745 }
746 let dir = tempfile::tempdir().unwrap();
747 let config = config(dir.path());
748 fs::create_dir(dir.path().join("ramdb")).unwrap();
749 fs::write(dir.path().join("ramdb/data"), "original").unwrap();
750 assert!(LocalChainState::prepare(&config).is_err());
751 assert!(!dir.path().join(LOCAL_FAUCET_KEY_FILE).exists());
752 assert_eq!(
753 fs::read(dir.path().join("ramdb/data")).unwrap(),
754 b"original"
755 );
756 }
757
758 fn persisted_with_overrides(text: &str) -> serde_json::Result<PersistedBootstrap> {
759 let faucet = serde_json::to_string(&Base58(thruput_xdr::types::Ed25519PublicKey::from(
760 [0x11; 32],
761 )))
762 .unwrap();
763 serde_json::from_str(&format!(
764 r#"{{"bootstrapFaucet":{{"publicKey":{faucet},"initialStorageTokens":"1"}},"bootstrapAccounts":[],"networkConfigOverrides":{text}}}"#
765 ))
766 }
767
768 #[test]
769 fn metadata_overrides_read_integers_and_uint64_decimals() {
770 let persisted = persisted_with_overrides(
773 r#"{"max_bytestring_len":42,"max_tx_gas":"18446744073709551615"}"#,
774 )
775 .unwrap();
776
777 assert_eq!(
778 persisted.network_config_overrides,
779 [
780 (ConfigParamName::MaxBytestringLen, 42),
781 (ConfigParamName::MaxTxGas, u64::MAX),
782 ]
783 .into()
784 );
785 }
786
787 #[test]
788 fn metadata_overrides_reject_unknown_duplicate_and_malformed_values() {
789 for text in [
790 r#"{"unknown":1}"#,
791 r#"{"max_bytestring_len":1,"max_bytestring_len":2}"#,
792 r#"{"max_tx_gas":-1}"#,
793 r#"{"max_tx_gas":"18446744073709551616"}"#,
794 r#"{"max_tx_gas":"1e9"}"#,
795 r#"{"max_tx_gas":true}"#,
796 ] {
797 assert!(persisted_with_overrides(text).is_err(), "{text}");
798 }
799 }
800}