Skip to main content

thruput_node_supervisor/
local_state.rs

1//! Private state and lock ownership for one local chain.
2
3use 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
24/// Private faucet credential included in a local-chain backup.
25/// Keep this storage identity stable across tool branding changes.
26pub const LOCAL_FAUCET_KEY_FILE: &str = "groundhog-faucet.key";
27/// Retained signed funding requests included in a local-chain backup.
28pub 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/// hog-devnode writes a parameter wider than 32 bits as a decimal string, as
59/// genesis JSON does, and the rest as JSON integers.
60#[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
188/// Check old versions before orphan cleanup, then check again under the chain lock.
189pub(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)] // Error boundary accepts both borrowed reasons and owned I/O errors.
335pub(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        // SAFETY: geteuid has no arguments or pointer contracts.
363        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/// How long to retry a contended chain lock before reporting another holder.
399/// As with the PID-file lock, a child spawned concurrently by another thread
400/// briefly co-owns this process's descriptors (and their `flock`s) between
401/// fork and exec, so an instantly contended lock does not yet prove another
402/// process holds the chain.
403#[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    // SAFETY: file owns a valid descriptor; flock retains no pointers.
439    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); // No runtime metadata yet, but the journal already binds the identity.
542        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    /// A child spawned from another thread and held between fork and exec,
560    /// where it co-owns every descriptor this process had open at the fork.
561    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                // SAFETY: the hook only calls write(2), close(2), and read(2),
580                // which are async-signal-safe, on descriptors this thread keeps
581                // open. Closing the inherited writer lets the child exec and
582                // exit if this test process dies before releasing it.
583                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        /// Let the child exec after `delay`, from another thread.
601        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    // A child spawned by another thread keeps the released lock's descriptor
611    // until it execs, so the next owner must wait out that window rather than
612    // report another process. The child's exec time on a loaded machine is
613    // unbounded, so the retry window here only guards against a hang.
614    // Serial because the child is found on PATH, which binary-resolution tests
615    // replace.
616    #[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        // SAFETY: probe owns a valid descriptor; flock retains no pointers.
627        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        // hog-devnode writes uint64 parameters as decimal strings and the
771        // rest as JSON integers; which values a parameter admits is its call.
772        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}