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