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