casper_devnet/
assets.rs

1use anyhow::{Result, anyhow};
2use bip32::{DerivationPath, XPrv};
3use blake2::Blake2bVar;
4use blake2::digest::{Update, VariableOutput};
5use casper_types::account::AccountHash;
6use casper_types::{AsymmetricType, PublicKey, SecretKey};
7use directories::ProjectDirs;
8use flate2::read::GzDecoder;
9use futures::StreamExt;
10use indicatif::{ProgressBar, ProgressStyle};
11use semver::Version;
12use serde::Deserialize;
13use sha2::{Digest, Sha512};
14use std::ffi::OsStr;
15use std::fs::File;
16use std::io::{Cursor, Read};
17use std::path::{Path, PathBuf};
18use std::str::FromStr;
19use std::sync::Arc;
20use std::time::Duration as StdDuration;
21use tar::Archive;
22use time::format_description::well_known::Rfc3339;
23use time::{Duration, OffsetDateTime};
24use tokio::fs as tokio_fs;
25use tokio::process::Command;
26use tokio::task;
27
28pub const BOOTSTRAP_NODES: u32 = 3;
29
30const DEVNET_BASE_PORT_RPC: u32 = 11000;
31const DEVNET_BASE_PORT_REST: u32 = 14000;
32const DEVNET_BASE_PORT_SSE: u32 = 18000;
33const DEVNET_BASE_PORT_NETWORK: u32 = 22000;
34const DEVNET_BASE_PORT_BINARY: u32 = 28000;
35const DEVNET_NET_PORT_OFFSET: u32 = 100;
36
37const DEVNET_INITIAL_BALANCE_USER: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
38const DEVNET_INITIAL_BALANCE_VALIDATOR: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
39const DEVNET_INITIAL_DELEGATION_AMOUNT: u128 = 1_000_000_000_000_000_000;
40const DEVNET_VALIDATOR_BASE_WEIGHT: u128 = 1_000_000_000_000_000_000;
41const DEVNET_SEED_DOMAIN: &[u8] = b"casper-unsafe-devnet-v1";
42const DERIVATION_PATH_PREFIX: &str = "m/44'/506'/0'/0";
43const USER_DERIVATION_START: u32 = 100;
44const DERIVED_ACCOUNTS_FILE: &str = "derived-accounts.txt";
45const SECRET_KEY_PEM: &str = "secret_key.pem";
46const PUBLIC_KEY_PEM: &str = "public_key.pem";
47const PUBLIC_KEY_HEX: &str = "public_key_hex";
48const MOTE_PER_CSPR: u128 = 1_000_000_000;
49const PROGRESS_TICK_MS: u64 = 120;
50
51#[derive(Debug)]
52struct DerivedAccountMaterial {
53    path: DerivationPath,
54    public_key_hex: String,
55    public_key_pem: String,
56    account_hash: String,
57    secret_key_pem: Option<String>,
58}
59
60#[derive(Debug)]
61struct DerivedAccountInfo {
62    role: &'static str,
63    label: String,
64    path: DerivationPath,
65    account_hash: String,
66    balance_motes: u128,
67}
68
69impl DerivedAccountInfo {
70    fn line(&self) -> String {
71        let balance_cspr = format_cspr(self.balance_motes);
72        if self.label.is_empty() {
73            return format!(
74                "{} bip32_path={} account_hash={} balance_cspr={}",
75                self.role, self.path, self.account_hash, balance_cspr
76            );
77        }
78        format!(
79            "{} {} bip32_path={} account_hash={} balance_cspr={}",
80            self.role, self.label, self.path, self.account_hash, balance_cspr
81        )
82    }
83}
84
85/// Layout of generated assets for a given network.
86#[derive(Clone, Debug)]
87pub struct AssetsLayout {
88    assets_root: PathBuf,
89    network_name: String,
90}
91
92impl AssetsLayout {
93    /// Create a new layout rooted at `assets_root/<network_name>`.
94    pub fn new(assets_root: PathBuf, network_name: String) -> Self {
95        Self {
96            assets_root,
97            network_name,
98        }
99    }
100
101    /// Root folder for assets (contains all networks).
102    pub fn assets_root(&self) -> &Path {
103        &self.assets_root
104    }
105
106    /// Network name used in paths and configs.
107    pub fn network_name(&self) -> &str {
108        &self.network_name
109    }
110
111    /// Base directory for this network's assets.
112    pub fn net_dir(&self) -> PathBuf {
113        self.assets_root.join(&self.network_name)
114    }
115
116    /// Directory that contains all node folders.
117    pub fn nodes_dir(&self) -> PathBuf {
118        self.net_dir().join("nodes")
119    }
120
121    /// Directory for a single node.
122    pub fn node_dir(&self, node_id: u32) -> PathBuf {
123        self.nodes_dir().join(format!("node-{}", node_id))
124    }
125
126    /// Directory for a node's binaries.
127    pub fn node_bin_dir(&self, node_id: u32) -> PathBuf {
128        self.node_dir(node_id).join("bin")
129    }
130
131    /// Directory for a node's configs.
132    pub fn node_config_root(&self, node_id: u32) -> PathBuf {
133        self.node_dir(node_id).join("config")
134    }
135
136    /// Directory for a node's logs.
137    pub fn node_logs_dir(&self, node_id: u32) -> PathBuf {
138        self.node_dir(node_id).join("logs")
139    }
140
141    /// Directory for daemon-related artifacts.
142    pub fn daemon_dir(&self) -> PathBuf {
143        self.net_dir().join("daemon")
144    }
145
146    /// Returns true if the network's nodes directory exists.
147    pub async fn exists(&self) -> bool {
148        tokio_fs::metadata(self.nodes_dir())
149            .await
150            .map(|meta| meta.is_dir())
151            .unwrap_or(false)
152    }
153
154    /// Count node directories under `nodes/`.
155    pub async fn count_nodes(&self) -> Result<u32> {
156        let nodes_dir = self.nodes_dir();
157        let mut count = 0u32;
158        if !is_dir(&nodes_dir).await {
159            return Ok(0);
160        }
161        let mut entries = tokio_fs::read_dir(&nodes_dir).await?;
162        while let Some(entry) = entries.next_entry().await? {
163            if !entry.file_type().await?.is_dir() {
164                continue;
165            }
166            let name = entry.file_name();
167            let name = name.to_string_lossy();
168            if name.starts_with("node-") {
169                count += 1;
170            }
171        }
172        Ok(count)
173    }
174
175    /// Find the newest protocol version directory for a node.
176    pub async fn latest_protocol_version_dir(&self, node_id: u32) -> Result<String> {
177        let bin_dir = self.node_bin_dir(node_id);
178        let mut versions: Vec<(Version, String)> = Vec::new();
179        let mut entries = tokio_fs::read_dir(&bin_dir).await?;
180        while let Some(entry) = entries.next_entry().await? {
181            if !entry.file_type().await?.is_dir() {
182                continue;
183            }
184            let name = entry.file_name().to_string_lossy().to_string();
185            let version_str = name.replace('_', ".");
186            if let Ok(version) = Version::parse(&version_str) {
187                versions.push((version, name));
188            }
189        }
190        versions.sort_by(|a, b| a.0.cmp(&b.0));
191        versions
192            .last()
193            .map(|(_, name)| name.clone())
194            .ok_or_else(|| {
195                anyhow!(
196                    "no protocol version directories found in {}",
197                    bin_dir.display()
198                )
199            })
200    }
201
202    /// Return all config.toml paths for a node.
203    pub async fn node_config_paths(&self, node_id: u32) -> Result<Vec<PathBuf>> {
204        let config_root = self.node_config_root(node_id);
205        let mut paths = Vec::new();
206        if !is_dir(&config_root).await {
207            return Ok(paths);
208        }
209        let mut entries = tokio_fs::read_dir(&config_root).await?;
210        while let Some(entry) = entries.next_entry().await? {
211            if !entry.file_type().await?.is_dir() {
212                continue;
213            }
214            let path = entry.path().join("config.toml");
215            if is_file(&path).await {
216                paths.push(path);
217            }
218        }
219        Ok(paths)
220    }
221}
222
223pub fn data_dir() -> Result<PathBuf> {
224    let project_dirs = ProjectDirs::from("xyz", "veleslabs", "casper-devnet")
225        .ok_or_else(|| anyhow!("unable to resolve data directory"))?;
226    Ok(project_dirs.data_dir().to_path_buf())
227}
228
229pub fn default_assets_root() -> Result<PathBuf> {
230    Ok(data_dir()?.join("networks"))
231}
232
233pub fn assets_bundle_root() -> Result<PathBuf> {
234    Ok(data_dir()?.join("assets"))
235}
236
237pub fn file_name(path: &Path) -> Option<&OsStr> {
238    path.file_name()
239}
240
241pub fn sse_endpoint(node_id: u32) -> String {
242    format!(
243        "http://127.0.0.1:{}/events",
244        node_port(DEVNET_BASE_PORT_SSE, node_id)
245    )
246}
247
248pub fn rest_endpoint(node_id: u32) -> String {
249    format!(
250        "http://127.0.0.1:{}",
251        node_port(DEVNET_BASE_PORT_REST, node_id)
252    )
253}
254
255pub fn rpc_endpoint(node_id: u32) -> String {
256    format!(
257        "http://127.0.0.1:{}/rpc",
258        node_port(DEVNET_BASE_PORT_RPC, node_id)
259    )
260}
261
262pub fn binary_address(node_id: u32) -> String {
263    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id))
264}
265
266pub fn network_address(node_id: u32) -> String {
267    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
268}
269
270/// Parameters for building a local devnet asset tree.
271pub struct SetupOptions {
272    pub nodes: u32,
273    pub users: Option<u32>,
274    pub delay_seconds: u64,
275    pub network_name: String,
276    pub protocol_version: String,
277    pub node_log_format: String,
278    pub seed: Arc<str>,
279}
280
281/// Create or refresh local assets for a devnet.
282pub async fn setup_local(layout: &AssetsLayout, opts: &SetupOptions) -> Result<()> {
283    let genesis_nodes = opts.nodes;
284    if genesis_nodes == 0 {
285        return Err(anyhow!("nodes must be greater than 0"));
286    }
287    let total_nodes = genesis_nodes;
288    let users = opts.users.unwrap_or(total_nodes);
289    let bundle_root = assets_bundle_root()?;
290    let protocol_version = parse_protocol_version(&opts.protocol_version)?;
291    let protocol_version_chain = protocol_version.to_string();
292    let protocol_version_fs = protocol_version_chain.replace('.', "_");
293    let bundle_dir = bundle_dir_for_version(&bundle_root, &protocol_version).await?;
294
295    let chainspec_path = bundle_dir.join("chainspec.toml");
296    let config_path = bundle_dir.join("node-config.toml");
297    let sidecar_config_path = bundle_dir.join("sidecar-config.toml");
298
299    let net_dir = layout.net_dir();
300    tokio_fs::create_dir_all(&net_dir).await?;
301
302    setup_directories(layout, total_nodes, users, &protocol_version_fs).await?;
303    preflight_bundle(&bundle_dir, &chainspec_path, &config_path).await?;
304    setup_binaries(layout, total_nodes, &bundle_dir, &protocol_version_fs).await?;
305
306    setup_seeded_keys(layout, total_nodes, users, Arc::clone(&opts.seed)).await?;
307
308    setup_chainspec(
309        layout,
310        total_nodes,
311        &chainspec_path,
312        opts.delay_seconds,
313        &protocol_version_chain,
314        &opts.network_name,
315    )
316    .await?;
317
318    setup_accounts(layout, total_nodes, genesis_nodes, users).await?;
319
320    setup_node_configs(
321        layout,
322        total_nodes,
323        &protocol_version_fs,
324        &config_path,
325        &sidecar_config_path,
326        &opts.node_log_format,
327    )
328    .await?;
329
330    Ok(())
331}
332
333/// Remove assets for the given network (and local dumps).
334pub async fn teardown(layout: &AssetsLayout) -> Result<()> {
335    let net_dir = layout.net_dir();
336    if is_dir(&net_dir).await {
337        tokio_fs::remove_dir_all(&net_dir).await?;
338    }
339    let dumps = layout.net_dir().join("dumps");
340    if is_dir(&dumps).await {
341        tokio_fs::remove_dir_all(dumps).await?;
342    }
343    Ok(())
344}
345
346pub fn parse_protocol_version(raw: &str) -> Result<Version> {
347    let trimmed = raw.trim();
348    let normalized = trimmed.strip_prefix('v').unwrap_or(trimmed);
349    Version::parse(normalized).map_err(|err| anyhow!("invalid protocol version {}: {}", raw, err))
350}
351
352fn bundle_version_dir(bundle_root: &Path, protocol_version: &Version) -> PathBuf {
353    bundle_root.join(format!("v{}", protocol_version))
354}
355
356async fn bundle_dir_for_version(bundle_root: &Path, protocol_version: &Version) -> Result<PathBuf> {
357    let version_dir = bundle_version_dir(bundle_root, protocol_version);
358    if is_dir(&version_dir).await {
359        return Ok(version_dir);
360    }
361    Err(anyhow!("assets bundle missing {}", version_dir.display()))
362}
363
364pub async fn has_bundle_version(protocol_version: &Version) -> Result<bool> {
365    let bundle_root = assets_bundle_root()?;
366    Ok(is_dir(&bundle_version_dir(&bundle_root, protocol_version)).await)
367}
368
369async fn extract_assets_bundle(bundle_path: &Path, bundle_root: &Path) -> Result<()> {
370    if !is_file(bundle_path).await {
371        return Err(anyhow!("missing assets bundle {}", bundle_path.display()));
372    }
373
374    let bundle_path = bundle_path.to_path_buf();
375    let bundle_root = bundle_root.to_path_buf();
376    spawn_blocking_result(move || {
377        std::fs::create_dir_all(&bundle_root)?;
378        let file = File::open(&bundle_path)?;
379        let decoder = GzDecoder::new(file);
380        let mut archive = Archive::new(decoder);
381        println!("unpacking assets into {}", bundle_root.display());
382        archive.unpack(&bundle_root)?;
383        Ok(())
384    })
385    .await
386}
387
388pub async fn install_assets_bundle(bundle_path: &Path) -> Result<()> {
389    let bundle_root = assets_bundle_root()?;
390    println!(
391        "unpacking local assets bundle {} into {}",
392        bundle_path.display(),
393        bundle_root.display()
394    );
395    extract_assets_bundle(bundle_path, &bundle_root).await
396}
397
398pub async fn pull_assets_bundles(target_override: Option<&str>, force: bool) -> Result<()> {
399    let bundle_root = assets_bundle_root()?;
400    let target = target_override
401        .map(str::to_string)
402        .unwrap_or_else(default_target);
403    println!("assets pull target: {}", target);
404    let release = fetch_latest_release().await?;
405    println!("release tag: {}", release.tag_name);
406
407    let mut assets = Vec::new();
408    for asset in release.assets {
409        if let Some(version) = parse_release_asset_version(&asset.name, &target) {
410            assets.push(ReleaseAsset {
411                url: asset.browser_download_url,
412                version,
413            });
414        }
415    }
416
417    if assets.is_empty() {
418        return Err(anyhow!(
419            "no assets found for target {} in release {}",
420            target,
421            release.tag_name
422        ));
423    }
424
425    for asset in assets {
426        let bytes = download_asset(&asset.url, &asset.version).await?;
427        let expected_hash = download_asset_sha512(&asset.url).await?;
428        let actual_hash = sha512_hex(&bytes);
429        if expected_hash != actual_hash {
430            return Err(anyhow!(
431                "sha512 mismatch for {} (expected {}, got {})",
432                asset.url,
433                expected_hash,
434                actual_hash
435            ));
436        }
437        let remote_manifest = extract_manifest_from_bytes(&bytes).await?;
438        let version_dir = bundle_version_dir(&bundle_root, &asset.version);
439        let local_manifest = read_local_manifest(&version_dir).await?;
440
441        if !force
442            && let (Some(remote), Some(local)) = (&remote_manifest, &local_manifest)
443            && remote == local
444        {
445            println!("already have this file v{}", asset.version);
446            continue;
447        }
448
449        if is_dir(&version_dir).await {
450            tokio_fs::remove_dir_all(&version_dir).await?;
451        }
452
453        println!("saving assets bundle v{}", asset.version);
454        unpack_assets_with_progress(&bytes, &bundle_root, &asset.version).await?;
455    }
456
457    tokio_fs::write(bundle_root.join("latest"), release.tag_name).await?;
458    Ok(())
459}
460
461pub async fn most_recent_bundle_version() -> Result<Version> {
462    let mut versions = list_bundle_versions().await?;
463    versions.sort();
464    versions
465        .pop()
466        .ok_or_else(|| anyhow!("no protocol versions found in assets store"))
467}
468
469pub async fn list_bundle_versions() -> Result<Vec<Version>> {
470    let bundle_root = assets_bundle_root()?;
471    if !is_dir(&bundle_root).await {
472        return Ok(Vec::new());
473    }
474    let mut versions: Vec<Version> = Vec::new();
475    let mut entries = tokio_fs::read_dir(&bundle_root).await?;
476    while let Some(entry) = entries.next_entry().await? {
477        if !entry.file_type().await?.is_dir() {
478            continue;
479        }
480        let name = entry.file_name().to_string_lossy().to_string();
481        if !name.starts_with('v') {
482            continue;
483        }
484        let dir_path = entry.path();
485        let chainspec_path = dir_path.join("chainspec.toml");
486        if !is_file(&chainspec_path).await {
487            continue;
488        }
489        let contents = tokio_fs::read_to_string(&chainspec_path).await?;
490        let version = spawn_blocking_result(move || parse_chainspec_version(&contents)).await?;
491        let expected_dir = format!("v{}", version);
492        if name != expected_dir {
493            return Err(anyhow!(
494                "bundle directory {} does not match chainspec protocol version {}",
495                name,
496                version
497            ));
498        }
499        versions.push(version);
500    }
501    Ok(versions)
502}
503
504fn parse_chainspec_version(contents: &str) -> Result<Version> {
505    let value: toml::Value = toml::from_str(contents)?;
506    let protocol = value
507        .get("protocol")
508        .and_then(|v| v.as_table())
509        .ok_or_else(|| anyhow!("chainspec missing [protocol] section"))?;
510    let version = protocol
511        .get("version")
512        .and_then(|v| v.as_str())
513        .ok_or_else(|| anyhow!("chainspec missing protocol.version"))?;
514    parse_protocol_version(version)
515}
516
517#[derive(Deserialize)]
518struct GithubRelease {
519    tag_name: String,
520    assets: Vec<GithubAsset>,
521}
522
523#[derive(Deserialize)]
524struct GithubAsset {
525    name: String,
526    browser_download_url: String,
527}
528
529struct ReleaseAsset {
530    url: String,
531    version: Version,
532}
533
534async fn fetch_latest_release() -> Result<GithubRelease> {
535    let client = reqwest::Client::builder()
536        .user_agent("casper-devnet")
537        .build()?;
538    let url = "https://api.github.com/repos/veles-labs/devnet-launcher-assets/releases/latest";
539    println!("GET {}", url);
540    let response = client.get(url).send().await?.error_for_status()?;
541    Ok(response.json::<GithubRelease>().await?)
542}
543
544fn parse_release_asset_version(name: &str, target: &str) -> Option<Version> {
545    let trimmed = name.strip_prefix("casper-v")?;
546    let trimmed = trimmed.strip_suffix(".tar.gz")?;
547    let (version, asset_target) = trimmed.split_once('-')?;
548    if asset_target != target {
549        return None;
550    }
551    parse_protocol_version(version).ok()
552}
553
554fn download_progress_style() -> ProgressStyle {
555    ProgressStyle::with_template("{msg} {bar:40.cyan/blue} {bytes:>7}/{total_bytes:7} ({eta})")
556        .expect("valid download progress template")
557        .progress_chars("█▉▊▋▌▍▎▏ ")
558}
559
560fn download_spinner_style() -> ProgressStyle {
561    ProgressStyle::with_template("{msg} {spinner:.cyan} {bytes:>7}")
562        .expect("valid download spinner template")
563        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
564}
565
566fn unpack_spinner_style() -> ProgressStyle {
567    ProgressStyle::with_template("{msg} {spinner:.magenta} {elapsed_precise}")
568        .expect("valid unpack spinner template")
569        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
570}
571
572async fn download_asset(url: &str, version: &Version) -> Result<Vec<u8>> {
573    let client = reqwest::Client::builder()
574        .user_agent("casper-devnet")
575        .build()?;
576    println!("GET {}", url);
577    let response = client.get(url).send().await?.error_for_status()?;
578    let total = response.content_length();
579    let pb = match total {
580        Some(total) if total > 0 => {
581            let pb = ProgressBar::new(total);
582            pb.set_style(download_progress_style());
583            pb
584        }
585        _ => {
586            let pb = ProgressBar::new_spinner();
587            pb.set_style(download_spinner_style());
588            pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
589            pb
590        }
591    };
592    pb.set_message(format!("⬇️  v{} download", version));
593
594    let mut bytes = Vec::new();
595    if let Some(total) = total && total <= usize::MAX as u64 {
596        bytes.reserve(total as usize);
597    }
598
599    let mut stream = response.bytes_stream();
600    while let Some(chunk) = stream.next().await {
601        match chunk {
602            Ok(chunk) => {
603                pb.inc(chunk.len() as u64);
604                bytes.extend_from_slice(&chunk);
605            }
606            Err(err) => {
607                pb.finish_with_message(format!("❌  v{} download failed", version));
608                return Err(err.into());
609            }
610        }
611    }
612
613    pb.finish_with_message(format!("✅  v{} downloaded", version));
614    Ok(bytes)
615}
616
617async fn download_asset_sha512(url: &str) -> Result<String> {
618    let sha_url = format!("{url}.sha512");
619    let client = reqwest::Client::builder()
620        .user_agent("casper-devnet")
621        .build()?;
622    println!("GET {}", sha_url);
623    let response = client.get(sha_url).send().await?.error_for_status()?;
624    let text = response.text().await?;
625    parse_sha512(&text)
626}
627
628fn parse_sha512(text: &str) -> Result<String> {
629    let token = text
630        .split_whitespace()
631        .next()
632        .ok_or_else(|| anyhow!("invalid sha512 file contents"))?;
633    let token = token.trim();
634    if token.len() != 128 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
635        return Err(anyhow!("invalid sha512 hash {}", token));
636    }
637    Ok(token.to_lowercase())
638}
639
640fn sha512_hex(bytes: &[u8]) -> String {
641    let digest = Sha512::digest(bytes);
642    let mut out = String::with_capacity(digest.len() * 2);
643    for byte in digest {
644        use std::fmt::Write;
645        let _ = write!(&mut out, "{:02x}", byte);
646    }
647    out
648}
649
650async fn extract_manifest_from_bytes(bytes: &[u8]) -> Result<Option<serde_json::Value>> {
651    let bytes = bytes.to_vec();
652    spawn_blocking_result(move || {
653        let cursor = Cursor::new(bytes);
654        let decoder = GzDecoder::new(cursor);
655        let mut archive = Archive::new(decoder);
656        let entries = archive.entries()?;
657        for entry in entries {
658            let mut entry = entry?;
659            let path = entry.path()?;
660            if path.file_name() == Some(OsStr::new("manifest.json")) {
661                let mut contents = String::new();
662                entry.read_to_string(&mut contents)?;
663                let value = serde_json::from_str(&contents)?;
664                return Ok(Some(value));
665            }
666        }
667        Ok(None)
668    })
669    .await
670}
671
672async fn read_local_manifest(version_dir: &Path) -> Result<Option<serde_json::Value>> {
673    let path = version_dir.join("manifest.json");
674    if !is_file(&path).await {
675        return Ok(None);
676    }
677    let contents = tokio_fs::read_to_string(&path).await?;
678    let value = serde_json::from_str(&contents)?;
679    Ok(Some(value))
680}
681
682async fn unpack_assets_with_progress(
683    bytes: &[u8],
684    bundle_root: &Path,
685    version: &Version,
686) -> Result<()> {
687    let pb = ProgressBar::new_spinner();
688    pb.set_style(unpack_spinner_style());
689    pb.set_message(format!("📦  v{} unpack", version));
690    pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
691
692    let result = extract_assets_from_bytes(bytes, bundle_root).await;
693    match result {
694        Ok(()) => pb.finish_with_message(format!("✅  v{} unpacked", version)),
695        Err(_) => pb.finish_with_message(format!("❌  v{} unpack failed", version)),
696    }
697    result
698}
699
700async fn extract_assets_from_bytes(bytes: &[u8], bundle_root: &Path) -> Result<()> {
701    let bytes = bytes.to_vec();
702    let bundle_root = bundle_root.to_path_buf();
703    spawn_blocking_result(move || {
704        std::fs::create_dir_all(&bundle_root)?;
705        let cursor = Cursor::new(bytes);
706        let decoder = GzDecoder::new(cursor);
707        let mut archive = Archive::new(decoder);
708        archive.unpack(&bundle_root)?;
709        Ok(())
710    })
711    .await
712}
713
714fn default_target() -> String {
715    env!("BUILD_TARGET").to_string()
716}
717
718async fn setup_directories(
719    layout: &AssetsLayout,
720    total_nodes: u32,
721    users: u32,
722    protocol_version_fs: &str,
723) -> Result<()> {
724    let net_dir = layout.net_dir();
725    let bin_dir = net_dir.join("bin");
726    let chainspec_dir = net_dir.join("chainspec");
727    let daemon_dir = net_dir.join("daemon");
728    let nodes_dir = net_dir.join("nodes");
729    let users_dir = net_dir.join("users");
730
731    tokio_fs::create_dir_all(bin_dir).await?;
732    tokio_fs::create_dir_all(chainspec_dir).await?;
733    tokio_fs::create_dir_all(daemon_dir.join("config")).await?;
734    tokio_fs::create_dir_all(daemon_dir.join("logs")).await?;
735    tokio_fs::create_dir_all(daemon_dir.join("socket")).await?;
736    tokio_fs::create_dir_all(&nodes_dir).await?;
737    tokio_fs::create_dir_all(&users_dir).await?;
738
739    for node_id in 1..=total_nodes {
740        let node_dir = layout.node_dir(node_id);
741        tokio_fs::create_dir_all(node_dir.join("bin").join(protocol_version_fs)).await?;
742        tokio_fs::create_dir_all(node_dir.join("config").join(protocol_version_fs)).await?;
743        tokio_fs::create_dir_all(node_dir.join("keys")).await?;
744        tokio_fs::create_dir_all(node_dir.join("logs")).await?;
745        tokio_fs::create_dir_all(node_dir.join("storage")).await?;
746    }
747
748    for user_id in 1..=users {
749        tokio_fs::create_dir_all(users_dir.join(format!("user-{}", user_id))).await?;
750    }
751
752    Ok(())
753}
754
755async fn setup_binaries(
756    layout: &AssetsLayout,
757    total_nodes: u32,
758    bundle_dir: &Path,
759    protocol_version_fs: &str,
760) -> Result<()> {
761    let node_bin_src = bundle_dir.join("bin").join("casper-node");
762    let sidecar_src = bundle_dir.join("bin").join("casper-sidecar");
763
764    for node_id in 1..=total_nodes {
765        let node_bin_dir = layout.node_bin_dir(node_id);
766        let version_dir = node_bin_dir.join(protocol_version_fs);
767
768        let node_dest = version_dir.join("casper-node");
769        hardlink_file(&node_bin_src, &node_dest).await?;
770
771        let sidecar_dest = version_dir.join("casper-sidecar");
772        hardlink_file(&sidecar_src, &sidecar_dest).await?;
773    }
774
775    Ok(())
776}
777
778async fn preflight_bundle(
779    bundle_dir: &Path,
780    chainspec_path: &Path,
781    config_path: &Path,
782) -> Result<()> {
783    let mut missing = Vec::new();
784
785    let node_bin = bundle_dir.join("bin").join("casper-node");
786    let sidecar_bin = bundle_dir.join("bin").join("casper-sidecar");
787    if !is_file(&node_bin).await {
788        missing.push(node_bin.clone());
789    }
790    if !is_file(&sidecar_bin).await {
791        missing.push(sidecar_bin.clone());
792    }
793    if !is_file(chainspec_path).await {
794        missing.push(chainspec_path.to_path_buf());
795    }
796    if !is_file(config_path).await {
797        missing.push(config_path.to_path_buf());
798    }
799
800    if !missing.is_empty() {
801        let message = missing
802            .into_iter()
803            .map(|path| format!("missing source file {}", path.display()))
804            .collect::<Vec<_>>()
805            .join("\n");
806        return Err(anyhow!(message));
807    }
808
809    verify_binary_version(&node_bin, "casper-node").await?;
810    verify_binary_version(&sidecar_bin, "casper-sidecar").await?;
811    Ok(())
812}
813
814async fn verify_binary_version(path: &Path, label: &str) -> Result<()> {
815    let output = Command::new(path).arg("--version").output().await?;
816    if output.status.success() {
817        return Ok(());
818    }
819    let stderr = String::from_utf8_lossy(&output.stderr);
820    let stdout = String::from_utf8_lossy(&output.stdout);
821    Err(anyhow!(
822        "{} --version failed (status={}): {}{}",
823        label,
824        output.status,
825        stdout,
826        stderr
827    ))
828}
829
830///
831/// Derive an unsafe deterministic root key from an arbitrary seed string.
832///
833/// DEVNET ONLY, NOT BIP-39, NOT WALLET-COMPATIBLE.
834///
835fn unsafe_root_from_seed(seed: &str) -> Result<XPrv> {
836    if seed.is_empty() {
837        return Err(anyhow!("seed must not be empty"));
838    }
839    let mut hasher = Blake2bVar::new(32).map_err(|_| anyhow!("invalid blake2b output size"))?;
840    hasher.update(DEVNET_SEED_DOMAIN);
841    hasher.update(seed.as_bytes());
842
843    let mut entropy = [0u8; 32];
844    hasher
845        .finalize_variable(&mut entropy)
846        .map_err(|_| anyhow!("failed to finalize blake2b"))?;
847
848    Ok(XPrv::new(entropy)?)
849}
850
851fn derive_xprv_from_path(root: &XPrv, path: &DerivationPath) -> Result<XPrv> {
852    let mut key = root.clone();
853    for child in path.iter() {
854        key = key.derive_child(child)?;
855    }
856    Ok(key)
857}
858
859///
860/// Derive a single Casper account from a given root and derivation path.
861///
862fn derive_account_material(
863    root: &XPrv,
864    path: &DerivationPath,
865    write_secret: bool,
866) -> Result<DerivedAccountMaterial> {
867    let child = derive_xprv_from_path(root, path)?;
868    let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
869    let public_key = PublicKey::from(&secret_key);
870    let public_key_hex = public_key.to_hex();
871    let public_key_pem = public_key.to_pem()?;
872    let account_hash = AccountHash::from(&public_key).to_hex_string();
873    let secret_key_pem = if write_secret {
874        Some(secret_key.to_pem()?)
875    } else {
876        None
877    };
878
879    Ok(DerivedAccountMaterial {
880        path: path.clone(),
881        public_key_hex,
882        public_key_pem,
883        account_hash,
884        secret_key_pem,
885    })
886}
887
888async fn write_account_keys(dir: &Path, account: &DerivedAccountMaterial) -> Result<()> {
889    tokio_fs::create_dir_all(dir).await?;
890    tokio_fs::write(dir.join(PUBLIC_KEY_HEX), &account.public_key_hex).await?;
891    if let Some(secret_key_pem) = &account.secret_key_pem {
892        tokio_fs::write(dir.join(SECRET_KEY_PEM), secret_key_pem).await?;
893    }
894    tokio_fs::write(dir.join(PUBLIC_KEY_PEM), &account.public_key_pem).await?;
895    Ok(())
896}
897
898async fn setup_seeded_keys(
899    layout: &AssetsLayout,
900    total_nodes: u32,
901    users: u32,
902    seed: Arc<str>,
903) -> Result<()> {
904    let seed = seed.to_string();
905    let seed_for_root = seed.clone();
906    let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
907    let mut summary = Vec::new();
908
909    for node_id in 1..=total_nodes {
910        let path =
911            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
912        let account = spawn_blocking_result({
913            let root = root.clone();
914            let path = path.clone();
915            move || derive_account_material(&root, &path, true)
916        })
917        .await?;
918        write_account_keys(&layout.node_dir(node_id).join("keys"), &account).await?;
919
920        summary.push(DerivedAccountInfo {
921            role: "validator",
922            label: format!("node-{}", node_id),
923            path: account.path.clone(),
924            account_hash: account.account_hash.clone(),
925            balance_motes: DEVNET_INITIAL_BALANCE_VALIDATOR,
926        });
927    }
928
929    for user_id in 1..=users {
930        let path = DerivationPath::from_str(&format!(
931            "{}/{}",
932            DERIVATION_PATH_PREFIX,
933            USER_DERIVATION_START + user_id - 1
934        ))?;
935        let account = spawn_blocking_result({
936            let root = root.clone();
937            let path = path.clone();
938            move || derive_account_material(&root, &path, false)
939        })
940        .await?;
941        write_account_keys(
942            &layout
943                .net_dir()
944                .join("users")
945                .join(format!("user-{}", user_id)),
946            &account,
947        )
948        .await?;
949        summary.push(DerivedAccountInfo {
950            role: "user",
951            label: format!("user-{}", user_id),
952            path: account.path.clone(),
953            account_hash: account.account_hash.clone(),
954            balance_motes: DEVNET_INITIAL_BALANCE_USER,
955        });
956    }
957
958    write_derived_accounts_summary(layout, &seed, &summary).await?;
959
960    Ok(())
961}
962
963async fn setup_chainspec(
964    layout: &AssetsLayout,
965    total_nodes: u32,
966    chainspec_template: &Path,
967    delay_seconds: u64,
968    protocol_version_chain: &str,
969    network_name: &str,
970) -> Result<()> {
971    let chainspec_dest = layout.net_dir().join("chainspec/chainspec.toml");
972    copy_file(chainspec_template, &chainspec_dest).await?;
973
974    let activation_point = genesis_timestamp(delay_seconds)?;
975    let chainspec_contents = tokio_fs::read_to_string(&chainspec_dest).await?;
976    let protocol_version_chain = protocol_version_chain.to_string();
977    let network_name = network_name.to_string();
978    let updated = spawn_blocking_result(move || {
979        let mut value: toml::Value = toml::from_str(&chainspec_contents)?;
980        set_string(
981            &mut value,
982            &["protocol", "activation_point"],
983            activation_point,
984        )?;
985        set_string(&mut value, &["protocol", "version"], protocol_version_chain)?;
986        set_string(&mut value, &["network", "name"], network_name)?;
987
988        set_integer(&mut value, &["core", "validator_slots"], total_nodes as i64)?;
989
990        Ok(toml::to_string(&value)?)
991    })
992    .await?;
993
994    tokio_fs::write(&chainspec_dest, updated).await?;
995
996    Ok(())
997}
998
999async fn setup_accounts(
1000    layout: &AssetsLayout,
1001    total_nodes: u32,
1002    genesis_nodes: u32,
1003    users: u32,
1004) -> Result<()> {
1005    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
1006    struct NodeAccount {
1007        node_id: u32,
1008        public_key: String,
1009        is_genesis: bool,
1010    }
1011
1012    struct UserAccount {
1013        user_id: u32,
1014        public_key: String,
1015        validator_key: Option<String>,
1016    }
1017
1018    let mut node_accounts = Vec::new();
1019    let mut user_accounts = Vec::new();
1020
1021    for node_id in 1..=total_nodes {
1022        let public_key =
1023            read_key(&layout.node_dir(node_id).join("keys").join(PUBLIC_KEY_HEX)).await?;
1024        node_accounts.push(NodeAccount {
1025            node_id,
1026            public_key,
1027            is_genesis: node_id <= genesis_nodes,
1028        });
1029    }
1030
1031    for user_id in 1..=users {
1032        let public_key = read_key(
1033            &layout
1034                .net_dir()
1035                .join("users")
1036                .join(format!("user-{}", user_id))
1037                .join(PUBLIC_KEY_HEX),
1038        )
1039        .await?;
1040        let validator_key = if user_id <= genesis_nodes {
1041            Some(read_key(&layout.node_dir(user_id).join("keys").join(PUBLIC_KEY_HEX)).await?)
1042        } else {
1043            None
1044        };
1045        user_accounts.push(UserAccount {
1046            user_id,
1047            public_key,
1048            validator_key,
1049        });
1050    }
1051
1052    let contents = spawn_blocking_result(move || {
1053        let mut lines = Vec::new();
1054        for node in node_accounts {
1055            if node.node_id > 1 {
1056                lines.push(String::new());
1057            }
1058            lines.push(format!("# VALIDATOR {}.", node.node_id));
1059            lines.push("[[accounts]]".to_string());
1060            lines.push(format!("public_key = \"{}\"", node.public_key));
1061            lines.push(format!(
1062                "balance = \"{}\"",
1063                DEVNET_INITIAL_BALANCE_VALIDATOR
1064            ));
1065            if node.is_genesis {
1066                lines.push(String::new());
1067                lines.push("[accounts.validator]".to_string());
1068                lines.push(format!(
1069                    "bonded_amount = \"{}\"",
1070                    validator_weight(node.node_id)
1071                ));
1072                lines.push(format!("delegation_rate = {}", node.node_id));
1073            }
1074        }
1075
1076        for user in user_accounts {
1077            lines.push(String::new());
1078            lines.push(format!("# USER {}.", user.user_id));
1079            if let Some(validator_key) = user.validator_key {
1080                lines.push("[[delegators]]".to_string());
1081                lines.push(format!("validator_public_key = \"{}\"", validator_key));
1082                lines.push(format!("delegator_public_key = \"{}\"", user.public_key));
1083                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
1084                lines.push(format!(
1085                    "delegated_amount = \"{}\"",
1086                    DEVNET_INITIAL_DELEGATION_AMOUNT + user.user_id as u128
1087                ));
1088            } else {
1089                lines.push("[[accounts]]".to_string());
1090                lines.push(format!("public_key = \"{}\"", user.public_key));
1091                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
1092            }
1093        }
1094
1095        Ok(format!("{}\n", lines.join("\n")))
1096    })
1097    .await?;
1098    tokio_fs::write(&accounts_path, contents).await?;
1099
1100    Ok(())
1101}
1102
1103async fn setup_node_configs(
1104    layout: &AssetsLayout,
1105    total_nodes: u32,
1106    protocol_version_fs: &str,
1107    config_template: &Path,
1108    sidecar_template: &Path,
1109    log_format: &str,
1110) -> Result<()> {
1111    let chainspec_path = layout.net_dir().join("chainspec/chainspec.toml");
1112    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
1113    let log_format = log_format.to_string();
1114
1115    for node_id in 1..=total_nodes {
1116        let config_root = layout.node_config_root(node_id).join(protocol_version_fs);
1117        tokio_fs::create_dir_all(&config_root).await?;
1118
1119        copy_file(&chainspec_path, &config_root.join("chainspec.toml")).await?;
1120        copy_file(&accounts_path, &config_root.join("accounts.toml")).await?;
1121        copy_file(config_template, &config_root.join("config.toml")).await?;
1122
1123        let config_contents = tokio_fs::read_to_string(config_root.join("config.toml")).await?;
1124        let log_format = log_format.clone();
1125        let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
1126        let known = known_addresses(node_id, total_nodes);
1127        let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
1128        let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
1129        let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
1130
1131        #[cfg(not(target_os = "macos"))]
1132        let diagnostics_socket = Some(
1133            layout
1134                .node_dir(node_id)
1135                .join("diagnostics_port.socket")
1136                .to_string_lossy()
1137                .to_string(),
1138        );
1139        #[cfg(target_os = "macos")]
1140        let diagnostics_socket: Option<String> = None;
1141
1142        let updated_config = spawn_blocking_result(move || {
1143            let mut config_value: toml::Value = toml::from_str(&config_contents)?;
1144
1145            set_string(
1146                &mut config_value,
1147                &["consensus", "secret_key_path"],
1148                "../../keys/secret_key.pem".to_string(),
1149            )?;
1150            set_string(&mut config_value, &["logging", "format"], log_format)?;
1151            set_string(
1152                &mut config_value,
1153                &["network", "bind_address"],
1154                bind_address,
1155            )?;
1156            set_array(&mut config_value, &["network", "known_addresses"], known)?;
1157            set_string(
1158                &mut config_value,
1159                &["storage", "path"],
1160                "../../storage".to_string(),
1161            )?;
1162            set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
1163            set_string(
1164                &mut config_value,
1165                &["event_stream_server", "address"],
1166                sse_address,
1167            )?;
1168
1169            if let Some(diagnostics_socket) = diagnostics_socket {
1170                set_string(
1171                    &mut config_value,
1172                    &["diagnostics_port", "socket_path"],
1173                    diagnostics_socket,
1174                )?;
1175            } else {
1176                // Even though macOS supports unix sockets, the paths we're using based tend to be too long
1177                // and that causes network startup issues.
1178                // Let's just disable the diagnostics port on macOS for now, it's mostly useful for core protocol development debugging anyway.
1179                // We may want to revisit this in the future if we find a better way to handle it (i.e. with cmd line argument).
1180                set_bool(&mut config_value, &["diagnostics_port", "enabled"], false)?;
1181            }
1182
1183            set_string(
1184                &mut config_value,
1185                &["binary_port_server", "address"],
1186                binary_address,
1187            )?;
1188
1189            // Enable requests that are disabled by default for security reasons.
1190            set_bool(
1191                &mut config_value,
1192                &["binary_port_server", "allow_request_get_trie"],
1193                true,
1194            )?;
1195
1196            // Enable speculative execution requests.
1197            set_bool(
1198                &mut config_value,
1199                &["binary_port_server", "allow_request_speculative_exec"],
1200                true,
1201            )?;
1202
1203            Ok(toml::to_string(&config_value)?)
1204        })
1205        .await?;
1206
1207        tokio_fs::write(config_root.join("config.toml"), updated_config).await?;
1208
1209        if is_file(sidecar_template).await {
1210            let sidecar_path = config_root.join("sidecar.toml");
1211            copy_file(sidecar_template, &sidecar_path).await?;
1212
1213            let sidecar_contents = tokio_fs::read_to_string(&sidecar_path).await?;
1214            let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
1215            let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
1216
1217            let updated_sidecar = spawn_blocking_result(move || {
1218                let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
1219                set_string(
1220                    &mut sidecar_value,
1221                    &["rpc_server", "main_server", "ip_address"],
1222                    "0.0.0.0".to_string(),
1223                )?;
1224                set_integer(
1225                    &mut sidecar_value,
1226                    &["rpc_server", "main_server", "port"],
1227                    rpc_port,
1228                )?;
1229                set_string(
1230                    &mut sidecar_value,
1231                    &["rpc_server", "node_client", "ip_address"],
1232                    "0.0.0.0".to_string(),
1233                )?;
1234                set_integer(
1235                    &mut sidecar_value,
1236                    &["rpc_server", "node_client", "port"],
1237                    binary_port,
1238                )?;
1239
1240                Ok(toml::to_string(&sidecar_value)?)
1241            })
1242            .await?;
1243
1244            tokio_fs::write(&sidecar_path, updated_sidecar).await?;
1245        }
1246    }
1247
1248    Ok(())
1249}
1250
1251fn node_port(base: u32, node_id: u32) -> u32 {
1252    base + DEVNET_NET_PORT_OFFSET + node_id
1253}
1254
1255fn bootstrap_address(node_id: u32) -> String {
1256    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
1257}
1258
1259fn known_addresses(node_id: u32, total_nodes: u32) -> Vec<String> {
1260    let bootstrap_nodes = BOOTSTRAP_NODES.min(total_nodes);
1261    let mut addresses = Vec::new();
1262    addresses.push(bootstrap_address(1));
1263
1264    if node_id < bootstrap_nodes {
1265        for id in 2..=bootstrap_nodes {
1266            addresses.push(bootstrap_address(id));
1267        }
1268    } else {
1269        let limit = node_id.min(total_nodes);
1270        for id in 2..=limit {
1271            addresses.push(bootstrap_address(id));
1272        }
1273    }
1274
1275    addresses
1276}
1277
1278fn validator_weight(node_id: u32) -> u128 {
1279    DEVNET_VALIDATOR_BASE_WEIGHT + node_id as u128
1280}
1281
1282fn genesis_timestamp(delay_seconds: u64) -> Result<String> {
1283    let ts = OffsetDateTime::now_utc() + Duration::seconds(delay_seconds as i64);
1284    Ok(ts.format(&Rfc3339)?)
1285}
1286
1287async fn read_key(path: &Path) -> Result<String> {
1288    Ok(tokio_fs::read_to_string(path).await?.trim().to_string())
1289}
1290
1291fn format_cspr(motes: u128) -> String {
1292    let whole = motes / MOTE_PER_CSPR;
1293    let rem = motes % MOTE_PER_CSPR;
1294    if rem == 0 {
1295        return whole.to_string();
1296    }
1297    let frac = format!("{:09}", rem);
1298    let frac = frac.trim_end_matches('0');
1299    format!("{}.{}", whole, frac)
1300}
1301
1302fn derived_accounts_path(layout: &AssetsLayout) -> PathBuf {
1303    layout.net_dir().join(DERIVED_ACCOUNTS_FILE)
1304}
1305
1306async fn write_derived_accounts_summary(
1307    layout: &AssetsLayout,
1308    seed: &str,
1309    accounts: &[DerivedAccountInfo],
1310) -> Result<()> {
1311    let mut lines = Vec::new();
1312    lines.push(format!("seed: {}", seed));
1313    for account in accounts {
1314        lines.push(account.line());
1315    }
1316    tokio_fs::write(derived_accounts_path(layout), lines.join("\n")).await?;
1317    Ok(())
1318}
1319
1320pub async fn derived_accounts_summary(layout: &AssetsLayout) -> Option<String> {
1321    tokio_fs::read_to_string(derived_accounts_path(layout))
1322        .await
1323        .ok()
1324}
1325
1326async fn copy_file(src: &Path, dest: &Path) -> Result<()> {
1327    if !is_file(src).await {
1328        return Err(anyhow!("missing source file {}", src.display()));
1329    }
1330    if let Some(parent) = dest.parent() {
1331        tokio_fs::create_dir_all(parent).await?;
1332    }
1333    tokio_fs::copy(src, dest).await?;
1334    Ok(())
1335}
1336
1337async fn hardlink_file(src: &Path, dest: &Path) -> Result<()> {
1338    if !is_file(src).await {
1339        return Err(anyhow!("missing source file {}", src.display()));
1340    }
1341    if let Some(parent) = dest.parent() {
1342        tokio_fs::create_dir_all(parent).await?;
1343    }
1344    if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
1345        if metadata.is_dir() {
1346            return Err(anyhow!("destination {} is a directory", dest.display()));
1347        }
1348        tokio_fs::remove_file(dest).await?;
1349    }
1350    tokio_fs::hard_link(src, dest).await?;
1351    Ok(())
1352}
1353
1354async fn is_dir(path: &Path) -> bool {
1355    tokio_fs::metadata(path)
1356        .await
1357        .map(|meta| meta.is_dir())
1358        .unwrap_or(false)
1359}
1360
1361async fn is_file(path: &Path) -> bool {
1362    tokio_fs::metadata(path)
1363        .await
1364        .map(|meta| meta.is_file())
1365        .unwrap_or(false)
1366}
1367
1368async fn spawn_blocking_result<F, T>(f: F) -> Result<T>
1369where
1370    F: FnOnce() -> Result<T> + Send + 'static,
1371    T: Send + 'static,
1372{
1373    match task::spawn_blocking(f).await {
1374        Ok(result) => result,
1375        Err(err) => Err(anyhow!("blocking task failed: {}", err)),
1376    }
1377}
1378
1379fn set_string(root: &mut toml::Value, path: &[&str], value: String) -> Result<()> {
1380    set_value(root, path, toml::Value::String(value))
1381}
1382
1383fn set_integer(root: &mut toml::Value, path: &[&str], value: i64) -> Result<()> {
1384    set_value(root, path, toml::Value::Integer(value))
1385}
1386
1387fn set_array(root: &mut toml::Value, path: &[&str], values: Vec<String>) -> Result<()> {
1388    let array = values.into_iter().map(toml::Value::String).collect();
1389    set_value(root, path, toml::Value::Array(array))
1390}
1391
1392fn set_bool(root: &mut toml::Value, path: &[&str], value: bool) -> Result<()> {
1393    set_value(root, path, toml::Value::Boolean(value))
1394}
1395
1396fn set_value(root: &mut toml::Value, path: &[&str], value: toml::Value) -> Result<()> {
1397    let table = root
1398        .as_table_mut()
1399        .ok_or_else(|| anyhow!("TOML root is not a table"))?;
1400
1401    let mut current = table;
1402    for key in &path[..path.len() - 1] {
1403        current = ensure_table(current, key);
1404    }
1405    current.insert(path[path.len() - 1].to_string(), value);
1406    Ok(())
1407}
1408
1409fn ensure_table<'a>(table: &'a mut toml::value::Table, key: &str) -> &'a mut toml::value::Table {
1410    if !table.contains_key(key) {
1411        table.insert(
1412            key.to_string(),
1413            toml::Value::Table(toml::value::Table::new()),
1414        );
1415    }
1416    table
1417        .get_mut(key)
1418        .and_then(|v| v.as_table_mut())
1419        .expect("table entry is not a table")
1420}
1421
1422#[cfg(test)]
1423mod tests {
1424    use super::format_cspr;
1425
1426    #[test]
1427    fn format_cspr_handles_whole_and_fractional() {
1428        assert_eq!(format_cspr(0), "0");
1429        assert_eq!(format_cspr(1), "0.000000001");
1430        assert_eq!(format_cspr(1_000_000_000), "1");
1431        assert_eq!(format_cspr(1_000_000_001), "1.000000001");
1432        assert_eq!(format_cspr(123_000_000_000), "123");
1433        assert_eq!(format_cspr(123_000_000_456), "123.000000456");
1434    }
1435}