Skip to main content

casper_devnet/
assets.rs

1mod chainspec_override;
2
3use anyhow::{Result, anyhow};
4use bip32::{DerivationPath, XPrv};
5use blake2::Blake2bVar;
6use blake2::digest::{Update, VariableOutput};
7use casper_types::account::AccountHash;
8use casper_types::{AsymmetricType, PublicKey, SecretKey};
9use directories::ProjectDirs;
10use flate2::read::GzDecoder;
11use futures::StreamExt;
12use indicatif::{ProgressBar, ProgressStyle};
13use semver::Version;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha512};
16use std::ffi::OsStr;
17use std::fs::File;
18use std::io::{Cursor, Read};
19use std::os::unix::fs::PermissionsExt;
20use std::path::{Path, PathBuf};
21use std::process::Stdio;
22use std::str::FromStr;
23use std::sync::Arc;
24use std::time::Duration as StdDuration;
25use tar::Archive;
26use time::format_description::well_known::Rfc3339;
27use time::{Duration, OffsetDateTime};
28use tokio::fs as tokio_fs;
29use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
30use tokio::process::Command;
31use tokio::task;
32
33use crate::node_launcher::NODE_LAUNCHER_STATE_FILE;
34
35pub const BOOTSTRAP_NODES: u32 = 3;
36
37const DEVNET_BASE_PORT_RPC: u32 = 11000;
38const DEVNET_BASE_PORT_REST: u32 = 14000;
39const DEVNET_BASE_PORT_SSE: u32 = 18000;
40const DEVNET_BASE_PORT_NETWORK: u32 = 22000;
41const DEVNET_BASE_PORT_BINARY: u32 = 28000;
42const DEVNET_DIAGNOSTICS_PROXY_PORT: u32 = 32000;
43const DEVNET_NET_PORT_OFFSET: u32 = 100;
44
45const DEVNET_INITIAL_BALANCE_USER: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
46const DEVNET_INITIAL_BALANCE_VALIDATOR: u128 = 1_000_000_000_000_000_000_000_000_000_000_000_000;
47const DEVNET_INITIAL_DELEGATION_AMOUNT: u128 = 1_000_000_000_000_000_000;
48const DEVNET_VALIDATOR_BASE_WEIGHT: u128 = 1_000_000_000_000_000_000;
49const DEVNET_SEED_DOMAIN: &[u8] = b"casper-unsafe-devnet-v1";
50const DERIVATION_PATH_PREFIX: &str = "m/44'/506'/0'/0";
51const USER_DERIVATION_START: u32 = 100;
52const DERIVED_ACCOUNTS_FILE: &str = "derived-accounts.csv";
53const SECRET_KEY_PEM: &str = "secret_key.pem";
54const MOTE_PER_CSPR: u128 = 1_000_000_000;
55const PROGRESS_TICK_MS: u64 = 120;
56const CONTROL_SOCKET_NAME_MAX: usize = 80;
57const HOOKS_DIR_NAME: &str = "hooks";
58const HOOKS_PENDING_DIR_NAME: &str = "pending";
59const HOOKS_STATUS_DIR_NAME: &str = "status";
60const HOOKS_LOGS_DIR_NAME: &str = "logs";
61const HOOKS_WORK_DIR_NAME: &str = "work";
62const PRE_GENESIS_HOOK: &str = "pre-genesis";
63const POST_GENESIS_HOOK: &str = "post-genesis";
64const BLOCK_ADDED_HOOK: &str = "block-added";
65const PRE_STAGE_PROTOCOL_HOOK: &str = "pre-stage-protocol";
66const POST_STAGE_PROTOCOL_HOOK: &str = "post-stage-protocol";
67const PRE_GENESIS_SAMPLE: &str = "pre-genesis.sample";
68const POST_GENESIS_SAMPLE: &str = "post-genesis.sample";
69const BLOCK_ADDED_SAMPLE: &str = "block-added.sample";
70const PRE_STAGE_PROTOCOL_SAMPLE: &str = "pre-stage-protocol.sample";
71const POST_STAGE_PROTOCOL_SAMPLE: &str = "post-stage-protocol.sample";
72const HOOK_FILE_MODE: u32 = 0o755;
73const PRE_GENESIS_SAMPLE_SCRIPT: &[u8] = include_bytes!("../examples/hooks/pre-genesis.sample");
74const POST_GENESIS_SAMPLE_SCRIPT: &[u8] = include_bytes!("../examples/hooks/post-genesis.sample");
75const BLOCK_ADDED_SAMPLE_SCRIPT: &[u8] = include_bytes!("../examples/hooks/block-added.sample");
76const PRE_STAGE_PROTOCOL_SAMPLE_SCRIPT: &[u8] =
77    include_bytes!("../examples/hooks/pre-stage-protocol.sample");
78const POST_STAGE_PROTOCOL_SAMPLE_SCRIPT: &[u8] =
79    include_bytes!("../examples/hooks/post-stage-protocol.sample");
80
81#[derive(Debug)]
82struct DerivedAccountMaterial {
83    path: DerivationPath,
84    public_key_hex: String,
85    account_hash: String,
86    secret_key_pem: Option<String>,
87}
88
89#[derive(Debug, Clone)]
90pub(crate) struct DerivedPathMaterial {
91    pub public_key_hex: String,
92    pub account_hash: String,
93    pub secret_key_pem: String,
94}
95
96#[derive(Debug, Clone)]
97struct DerivedAccountInfo {
98    kind: &'static str,
99    name: String,
100    id: u32,
101    path: DerivationPath,
102    public_key_hex: String,
103    account_hash: String,
104    balance_motes: u128,
105}
106
107#[derive(Debug)]
108struct DerivedAccounts {
109    nodes: Vec<DerivedAccountInfo>,
110    users: Vec<DerivedAccountInfo>,
111}
112
113impl DerivedAccountInfo {
114    fn line(&self) -> String {
115        format!(
116            "{},{},{},{},{},{},{}",
117            self.kind,
118            self.name,
119            "secp256k1",
120            "bip32",
121            self.path,
122            self.account_hash,
123            format_cspr(self.balance_motes)
124        )
125    }
126}
127
128/// Layout of generated assets for a given network.
129#[derive(Clone, Debug)]
130pub struct AssetsLayout {
131    assets_root: PathBuf,
132    network_name: String,
133}
134
135impl AssetsLayout {
136    /// Create a new layout rooted at `assets_root/<network_name>`.
137    pub fn new(assets_root: PathBuf, network_name: String) -> Self {
138        Self {
139            assets_root,
140            network_name,
141        }
142    }
143
144    /// Root folder for assets (contains all networks).
145    pub fn assets_root(&self) -> &Path {
146        &self.assets_root
147    }
148
149    /// Network name used in paths and configs.
150    pub fn network_name(&self) -> &str {
151        &self.network_name
152    }
153
154    /// Base directory for this network's assets.
155    pub fn net_dir(&self) -> PathBuf {
156        self.assets_root.join(&self.network_name)
157    }
158
159    /// Directory that contains all node folders.
160    pub fn nodes_dir(&self) -> PathBuf {
161        self.net_dir().join("nodes")
162    }
163
164    /// Directory for a single node.
165    pub fn node_dir(&self, node_id: u32) -> PathBuf {
166        self.nodes_dir().join(format!("node-{}", node_id))
167    }
168
169    /// Directory for a node's binaries.
170    pub fn node_bin_dir(&self, node_id: u32) -> PathBuf {
171        self.node_dir(node_id).join("bin")
172    }
173
174    /// Directory for a node's configs.
175    pub fn node_config_root(&self, node_id: u32) -> PathBuf {
176        self.node_dir(node_id).join("config")
177    }
178
179    /// Directory for a node's logs.
180    pub fn node_logs_dir(&self, node_id: u32) -> PathBuf {
181        self.node_dir(node_id).join("logs")
182    }
183
184    /// Directory for network-scoped hooks.
185    pub fn hooks_dir(&self) -> PathBuf {
186        network_hooks_dir(&self.net_dir())
187    }
188
189    /// Directory for pending hook metadata.
190    pub fn hooks_pending_dir(&self) -> PathBuf {
191        network_hooks_pending_dir(&self.net_dir())
192    }
193
194    /// Directory for hook completion state.
195    pub fn hooks_status_dir(&self) -> PathBuf {
196        network_hooks_status_dir(&self.net_dir())
197    }
198
199    /// Directory for hook stdout/stderr logs.
200    pub fn hook_logs_dir(&self) -> PathBuf {
201        network_hook_logs_dir(&self.net_dir())
202    }
203
204    /// Root directory for per-hook working directories.
205    pub fn hook_work_root(&self) -> PathBuf {
206        network_hook_work_root(&self.net_dir())
207    }
208
209    /// Dedicated working directory for a specific hook.
210    pub fn hook_work_dir(&self, hook_name: &str) -> PathBuf {
211        network_hook_work_dir(&self.net_dir(), hook_name)
212    }
213
214    /// Path to the control socket for runtime operations.
215    pub fn control_socket_path(&self) -> PathBuf {
216        control_socket_path_for_network(&self.network_name)
217    }
218
219    /// Returns true if the network's nodes directory exists.
220    pub async fn exists(&self) -> bool {
221        tokio_fs::metadata(self.nodes_dir())
222            .await
223            .map(|meta| meta.is_dir())
224            .unwrap_or(false)
225    }
226
227    /// List node IDs under `nodes/`.
228    pub async fn node_ids(&self) -> Result<Vec<u32>> {
229        let nodes_dir = self.nodes_dir();
230        if !is_dir(&nodes_dir).await {
231            return Ok(Vec::new());
232        }
233        let mut node_ids = Vec::new();
234        let mut entries = tokio_fs::read_dir(&nodes_dir).await?;
235        while let Some(entry) = entries.next_entry().await? {
236            if !entry.file_type().await?.is_dir() {
237                continue;
238            }
239            let name = entry.file_name();
240            let name = name.to_string_lossy();
241            if let Some(node_id) = name
242                .strip_prefix("node-")
243                .and_then(|suffix| suffix.parse::<u32>().ok())
244            {
245                node_ids.push(node_id);
246            }
247        }
248        node_ids.sort_unstable();
249        Ok(node_ids)
250    }
251
252    /// Count node directories under `nodes/`.
253    pub async fn count_nodes(&self) -> Result<u32> {
254        Ok(self.node_ids().await?.len() as u32)
255    }
256
257    /// Find the newest protocol version directory for a node.
258    pub async fn latest_protocol_version_dir(&self, node_id: u32) -> Result<String> {
259        let bin_dir = self.node_bin_dir(node_id);
260        let mut versions: Vec<(Version, String)> = Vec::new();
261        let mut entries = tokio_fs::read_dir(&bin_dir).await?;
262        while let Some(entry) = entries.next_entry().await? {
263            if !entry.file_type().await?.is_dir() {
264                continue;
265            }
266            let name = entry.file_name().to_string_lossy().to_string();
267            let version_str = name.replace('_', ".");
268            if let Ok(version) = Version::parse(&version_str) {
269                versions.push((version, name));
270            }
271        }
272        versions.sort_by(|a, b| a.0.cmp(&b.0));
273        versions
274            .last()
275            .map(|(_, name)| name.clone())
276            .ok_or_else(|| {
277                anyhow!(
278                    "no protocol version directories found in {}",
279                    bin_dir.display()
280                )
281            })
282    }
283
284    /// Return all config.toml paths for a node.
285    pub async fn node_config_paths(&self, node_id: u32) -> Result<Vec<PathBuf>> {
286        let config_root = self.node_config_root(node_id);
287        let mut paths = Vec::new();
288        if !is_dir(&config_root).await {
289            return Ok(paths);
290        }
291        let mut entries = tokio_fs::read_dir(&config_root).await?;
292        while let Some(entry) = entries.next_entry().await? {
293            if !entry.file_type().await?.is_dir() {
294                continue;
295            }
296            let path = entry.path().join("config.toml");
297            if is_file(&path).await {
298                paths.push(path);
299            }
300        }
301        Ok(paths)
302    }
303}
304
305fn control_socket_path_for_network(network_name: &str) -> PathBuf {
306    let socket_name = format!("{}.socket", normalize_control_socket_name(network_name));
307    std::env::temp_dir().join(socket_name)
308}
309
310fn normalize_control_socket_name(network_name: &str) -> String {
311    let mut normalized = network_name
312        .chars()
313        .map(|ch| {
314            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
315                ch
316            } else {
317                '_'
318            }
319        })
320        .collect::<String>();
321    if normalized.is_empty() {
322        normalized.push_str("casper-dev");
323    }
324    if normalized.len() > CONTROL_SOCKET_NAME_MAX {
325        normalized.truncate(CONTROL_SOCKET_NAME_MAX);
326    }
327    normalized
328}
329
330pub fn data_dir() -> Result<PathBuf> {
331    let project_dirs = ProjectDirs::from("xyz", "veleslabs", "casper-devnet")
332        .ok_or_else(|| anyhow!("unable to resolve data directory"))?;
333    Ok(project_dirs.data_dir().to_path_buf())
334}
335
336pub fn default_assets_root() -> Result<PathBuf> {
337    Ok(data_dir()?.join("networks"))
338}
339
340pub fn assets_bundle_root() -> Result<PathBuf> {
341    Ok(data_dir()?.join("assets"))
342}
343
344pub fn custom_assets_root() -> Result<PathBuf> {
345    Ok(assets_bundle_root()?.join("custom"))
346}
347
348pub async fn custom_asset_path(name: &str) -> Result<PathBuf> {
349    validate_custom_asset_name(name)?;
350    let asset_dir = custom_assets_root()?.join(name);
351    if !is_dir(&asset_dir).await {
352        return Err(anyhow!(
353            "custom asset '{}' not found at {}",
354            name,
355            asset_dir.display()
356        ));
357    }
358    Ok(asset_dir)
359}
360
361pub fn optional_asset_selector(
362    asset: Option<&str>,
363    custom_asset: Option<&str>,
364) -> Result<AssetSelector> {
365    match (asset, custom_asset) {
366        (Some(_), Some(_)) => Err(anyhow!("--asset and --custom-asset are mutually exclusive")),
367        (Some(asset), None) => Ok(AssetSelector::Versioned(asset.to_string())),
368        (None, Some(custom_asset)) => Ok(AssetSelector::Custom(custom_asset.to_string())),
369        (None, None) => Ok(AssetSelector::LatestVersioned),
370    }
371}
372
373pub fn required_asset_selector(
374    asset: Option<&str>,
375    custom_asset: Option<&str>,
376) -> Result<AssetSelector> {
377    match optional_asset_selector(asset, custom_asset)? {
378        AssetSelector::LatestVersioned => {
379            Err(anyhow!("one of --asset or --custom-asset is required"))
380        }
381        selector => Ok(selector),
382    }
383}
384
385pub fn file_name(path: &Path) -> Option<&OsStr> {
386    path.file_name()
387}
388
389pub fn sse_endpoint(node_id: u32) -> String {
390    format!(
391        "http://127.0.0.1:{}/events",
392        node_port(DEVNET_BASE_PORT_SSE, node_id)
393    )
394}
395
396pub fn rest_endpoint(node_id: u32) -> String {
397    format!(
398        "http://127.0.0.1:{}",
399        node_port(DEVNET_BASE_PORT_REST, node_id)
400    )
401}
402
403pub fn rpc_endpoint(node_id: u32) -> String {
404    format!(
405        "http://127.0.0.1:{}/rpc",
406        node_port(DEVNET_BASE_PORT_RPC, node_id)
407    )
408}
409
410pub fn binary_address(node_id: u32) -> String {
411    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id))
412}
413
414pub fn network_address(node_id: u32) -> String {
415    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
416}
417
418pub fn diagnostics_proxy_port() -> u32 {
419    DEVNET_DIAGNOSTICS_PROXY_PORT
420}
421
422pub fn diagnostics_ws_endpoint(node_id: u32) -> String {
423    format!(
424        "ws://127.0.0.1:{}/diagnostics/node-{}/",
425        DEVNET_DIAGNOSTICS_PROXY_PORT, node_id
426    )
427}
428
429pub fn diagnostics_socket_path(network_name: &str, node_id: u32) -> String {
430    let socket_name = format!("{}-{}.sock", network_name, node_id);
431    tempfile::env::temp_dir()
432        .join(socket_name)
433        .to_string_lossy()
434        .to_string()
435}
436
437pub(crate) fn network_hooks_dir(network_dir: &Path) -> PathBuf {
438    network_dir.join(HOOKS_DIR_NAME)
439}
440
441pub(crate) fn network_hooks_pending_dir(network_dir: &Path) -> PathBuf {
442    network_hooks_dir(network_dir).join(format!(".{HOOKS_PENDING_DIR_NAME}"))
443}
444
445pub(crate) fn network_hooks_status_dir(network_dir: &Path) -> PathBuf {
446    network_hooks_dir(network_dir).join(format!(".{HOOKS_STATUS_DIR_NAME}"))
447}
448
449pub(crate) fn network_hook_logs_dir(network_dir: &Path) -> PathBuf {
450    network_hooks_dir(network_dir).join(HOOKS_LOGS_DIR_NAME)
451}
452
453pub(crate) fn network_hook_work_root(network_dir: &Path) -> PathBuf {
454    network_hooks_dir(network_dir).join(HOOKS_WORK_DIR_NAME)
455}
456
457pub(crate) fn network_hook_work_dir(network_dir: &Path, hook_name: &str) -> PathBuf {
458    network_hook_work_root(network_dir).join(hook_name)
459}
460
461#[derive(Clone, Debug, PartialEq, Eq)]
462pub enum AssetSelector {
463    LatestVersioned,
464    Versioned(String),
465    Custom(String),
466}
467
468#[derive(Clone, Copy, Debug, PartialEq, Eq)]
469enum AssetSourceKind {
470    Versioned,
471    Custom,
472}
473
474#[derive(Clone, Debug)]
475struct ResolvedAsset {
476    display_name: String,
477    source_kind: AssetSourceKind,
478    casper_node: PathBuf,
479    casper_sidecar: PathBuf,
480    chainspec: PathBuf,
481    node_config: PathBuf,
482    sidecar_config: PathBuf,
483    chainspec_protocol_version: Version,
484}
485
486#[derive(Debug)]
487pub struct SetupLocalResult {
488    pub protocol_version: String,
489}
490
491/// Parameters for building a local devnet asset tree.
492pub struct SetupOptions {
493    pub nodes: u32,
494    pub users: Option<u32>,
495    pub delay_seconds: u64,
496    pub network_name: String,
497    pub asset: AssetSelector,
498    pub protocol_version: Option<String>,
499    pub chainspec_overrides: Vec<String>,
500    pub node_log_format: String,
501    pub seed: Arc<str>,
502}
503
504pub struct CustomAssetInstallOptions {
505    pub name: String,
506    pub casper_node: PathBuf,
507    pub casper_sidecar: PathBuf,
508    pub chainspec: PathBuf,
509    pub node_config: PathBuf,
510    pub sidecar_config: PathBuf,
511}
512
513pub struct StageProtocolOptions {
514    pub asset: AssetSelector,
515    pub protocol_version: String,
516    pub activation_point: u64,
517    pub chainspec_overrides: Vec<String>,
518}
519
520#[derive(Debug)]
521pub struct StageProtocolResult {
522    pub staged_nodes: u32,
523}
524
525pub struct AddNodesOptions {
526    pub count: u32,
527    pub seed: Arc<str>,
528}
529
530#[derive(Debug)]
531pub struct AddNodesResult {
532    pub added_node_ids: Vec<u32>,
533    pub total_nodes: u32,
534}
535
536#[derive(Clone, Debug)]
537struct HookLogPaths {
538    stdout: PathBuf,
539    stderr: PathBuf,
540}
541
542#[derive(Clone, Debug, Serialize, Deserialize)]
543struct PendingPostStageProtocolHook {
544    asset_name: String,
545    network_name: String,
546    protocol_version: String,
547    activation_point: u64,
548    command_path: PathBuf,
549    stdout_path: PathBuf,
550    stderr_path: PathBuf,
551}
552
553#[derive(Clone, Debug, Serialize, Deserialize)]
554struct PendingPostGenesisHook {
555    network_name: String,
556    protocol_version: String,
557    command_path: PathBuf,
558    stdout_path: PathBuf,
559    stderr_path: PathBuf,
560}
561
562#[derive(Clone, Debug, Serialize, Deserialize)]
563struct CompletedPostStageProtocolHook {
564    asset_name: String,
565    network_name: String,
566    protocol_version: String,
567    activation_point: u64,
568    command_path: PathBuf,
569    stdout_path: PathBuf,
570    stderr_path: PathBuf,
571    status: HookRunStatus,
572    exit_code: Option<i32>,
573    error: Option<String>,
574    #[serde(with = "time::serde::rfc3339")]
575    completed_at: OffsetDateTime,
576}
577
578#[derive(Clone, Debug, Serialize, Deserialize)]
579struct CompletedPostGenesisHook {
580    network_name: String,
581    protocol_version: String,
582    command_path: PathBuf,
583    stdout_path: PathBuf,
584    stderr_path: PathBuf,
585    status: HookRunStatus,
586    exit_code: Option<i32>,
587    error: Option<String>,
588    #[serde(with = "time::serde::rfc3339")]
589    completed_at: OffsetDateTime,
590}
591
592#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
593#[serde(rename_all = "snake_case")]
594enum HookRunStatus {
595    Success,
596    Failure,
597}
598
599pub(crate) enum PendingPostStageProtocolHookResult {
600    NotRun,
601    Succeeded,
602    Failed(String),
603}
604
605pub(crate) enum PendingPostGenesisHookResult {
606    NotRun,
607    Succeeded,
608    Failed(String),
609}
610
611/// Create or refresh local assets for a devnet.
612pub async fn setup_local(layout: &AssetsLayout, opts: &SetupOptions) -> Result<SetupLocalResult> {
613    let genesis_nodes = opts.nodes;
614    if genesis_nodes == 0 {
615        return Err(anyhow!("nodes must be greater than 0"));
616    }
617    let total_nodes = genesis_nodes;
618    let users = opts.users.unwrap_or(total_nodes);
619    let asset = resolve_asset(&opts.asset).await?;
620    preflight_resolved_asset(&asset).await?;
621    let protocol_version = match &opts.protocol_version {
622        Some(protocol_version) => parse_protocol_version(protocol_version)?,
623        None => asset.chainspec_protocol_version.clone(),
624    };
625    let protocol_version_chain = protocol_version.to_string();
626    let protocol_version_fs = protocol_version_chain.replace('.', "_");
627
628    let net_dir = layout.net_dir();
629    tokio_fs::create_dir_all(&net_dir).await?;
630
631    setup_directories(layout, total_nodes, &protocol_version_fs).await?;
632    setup_binaries(layout, total_nodes, &asset, &protocol_version_fs).await?;
633
634    let derived_accounts =
635        setup_seeded_keys(layout, total_nodes, users, Arc::clone(&opts.seed)).await?;
636
637    setup_chainspec(
638        layout,
639        total_nodes,
640        &asset.chainspec,
641        opts.delay_seconds,
642        &protocol_version_chain,
643        &opts.network_name,
644        &opts.chainspec_overrides,
645    )
646    .await?;
647
648    setup_accounts(layout, total_nodes, genesis_nodes, users, &derived_accounts).await?;
649
650    setup_node_configs(
651        layout,
652        total_nodes,
653        &protocol_version_fs,
654        &asset.node_config,
655        &asset.sidecar_config,
656        &opts.node_log_format,
657    )
658    .await?;
659    ensure_network_hook_samples(layout).await?;
660
661    Ok(SetupLocalResult {
662        protocol_version: protocol_version_chain,
663    })
664}
665
666pub async fn install_custom_asset(opts: &CustomAssetInstallOptions) -> Result<()> {
667    validate_custom_asset_name(&opts.name)?;
668
669    let node_src = canonicalize_required_file(&opts.casper_node, "casper-node").await?;
670    let sidecar_src = canonicalize_required_file(&opts.casper_sidecar, "casper-sidecar").await?;
671    let chainspec_src = canonicalize_required_file(&opts.chainspec, "chainspec").await?;
672    let node_config_src = canonicalize_required_file(&opts.node_config, "node-config").await?;
673    let sidecar_config_src =
674        canonicalize_required_file(&opts.sidecar_config, "sidecar-config").await?;
675
676    verify_binary_version(&node_src, "casper-node").await?;
677    verify_binary_version(&sidecar_src, "casper-sidecar").await?;
678
679    let root = custom_assets_root()?;
680    let asset_dir = root.join(&opts.name);
681    if tokio_fs::symlink_metadata(&asset_dir).await.is_ok() {
682        return Err(anyhow!(
683            "custom asset '{}' already exists at {}",
684            opts.name,
685            asset_dir.display()
686        ));
687    }
688
689    tokio_fs::create_dir_all(asset_dir.join("bin")).await?;
690    symlink_file(&node_src, &asset_dir.join("bin").join("casper-node")).await?;
691    symlink_file(&sidecar_src, &asset_dir.join("bin").join("casper-sidecar")).await?;
692    symlink_file(&chainspec_src, &asset_dir.join("chainspec.toml")).await?;
693    symlink_file(&node_config_src, &asset_dir.join("node-config.toml")).await?;
694    symlink_file(&sidecar_config_src, &asset_dir.join("sidecar-config.toml")).await?;
695
696    Ok(())
697}
698
699pub async fn stage_protocol(
700    layout: &AssetsLayout,
701    opts: &StageProtocolOptions,
702) -> Result<StageProtocolResult> {
703    let asset = resolve_asset(&opts.asset).await?;
704    preflight_resolved_asset(&asset).await?;
705    let protocol_version = parse_protocol_version(&opts.protocol_version)?;
706    let protocol_version_chain = protocol_version.to_string();
707    let protocol_version_fs = protocol_version_chain.replace('.', "_");
708
709    let total_nodes = layout.count_nodes().await?;
710    if total_nodes == 0 {
711        return Err(anyhow!(
712            "no nodes found under {}; run start --setup-only first",
713            layout.nodes_dir().display()
714        ));
715    }
716    ensure_network_hook_samples(layout).await?;
717
718    let node_log_format = detect_current_node_log_format(layout).await?;
719    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
720    let mut overwritten_builtin_paths = Vec::new();
721
722    for node_id in 1..=total_nodes {
723        let node_bin_version_dir = layout.node_bin_dir(node_id).join(&protocol_version_fs);
724        let node_config_version_dir = layout.node_config_root(node_id).join(&protocol_version_fs);
725
726        if is_dir(&node_bin_version_dir).await {
727            tokio_fs::remove_dir_all(&node_bin_version_dir).await?;
728        }
729        if is_dir(&node_config_version_dir).await {
730            tokio_fs::remove_dir_all(&node_config_version_dir).await?;
731        }
732        tokio_fs::create_dir_all(&node_bin_version_dir).await?;
733        tokio_fs::create_dir_all(&node_config_version_dir).await?;
734
735        install_binary_file(
736            &asset,
737            &asset.casper_node,
738            &node_bin_version_dir.join("casper-node"),
739        )
740        .await?;
741        install_binary_file(
742            &asset,
743            &asset.casper_sidecar,
744            &node_bin_version_dir.join("casper-sidecar"),
745        )
746        .await?;
747
748        let chainspec_dest = node_config_version_dir.join("chainspec.toml");
749        copy_file(&asset.chainspec, &chainspec_dest).await?;
750        let staged_chainspec = tokio_fs::read_to_string(&chainspec_dest).await?;
751        let network_name = layout.network_name().to_string();
752        let activation_point = opts.activation_point as i64;
753        let protocol_version_chain = protocol_version_chain.clone();
754        let chainspec_overrides = opts.chainspec_overrides.clone();
755        let (updated_chainspec, node_overwritten_builtin_paths) =
756            spawn_blocking_result(move || {
757                let applied_overrides =
758                    chainspec_override::apply(&staged_chainspec, &chainspec_overrides)?;
759                update_chainspec_contents(
760                    &applied_overrides.contents,
761                    &protocol_version_chain,
762                    &activation_point.to_string(),
763                    false,
764                    &network_name,
765                    total_nodes,
766                )
767                .map(|updated| (updated, applied_overrides.overwritten_builtin_paths))
768            })
769            .await?;
770        for path in node_overwritten_builtin_paths {
771            if !overwritten_builtin_paths.contains(&path) {
772                overwritten_builtin_paths.push(path);
773            }
774        }
775        tokio_fs::write(&chainspec_dest, updated_chainspec).await?;
776
777        if is_file(&accounts_path).await {
778            copy_file(
779                &accounts_path,
780                &node_config_version_dir.join("accounts.toml"),
781            )
782            .await?;
783        }
784
785        let node_config_dest = node_config_version_dir.join("config.toml");
786        copy_file(&asset.node_config, &node_config_dest).await?;
787        let config_contents = tokio_fs::read_to_string(&node_config_dest).await?;
788        let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
789        let known = known_addresses(node_id, total_nodes);
790        let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
791        let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
792        let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
793        let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
794        let node_log_format = node_log_format.clone();
795        let updated_config = spawn_blocking_result(move || {
796            let mut config_value: toml::Value = toml::from_str(&config_contents)?;
797            set_string(
798                &mut config_value,
799                &["consensus", "secret_key_path"],
800                "../../keys/secret_key.pem".to_string(),
801            )?;
802            set_string(&mut config_value, &["logging", "format"], node_log_format)?;
803            set_string(
804                &mut config_value,
805                &["network", "bind_address"],
806                bind_address,
807            )?;
808            set_array(&mut config_value, &["network", "known_addresses"], known)?;
809            set_string(
810                &mut config_value,
811                &["storage", "path"],
812                "../../storage".to_string(),
813            )?;
814            set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
815            set_string(
816                &mut config_value,
817                &["event_stream_server", "address"],
818                sse_address,
819            )?;
820            set_string(
821                &mut config_value,
822                &["diagnostics_port", "socket_path"],
823                diagnostics_socket,
824            )?;
825            set_string(
826                &mut config_value,
827                &["binary_port_server", "address"],
828                binary_address,
829            )?;
830            set_bool(
831                &mut config_value,
832                &["binary_port_server", "allow_request_get_trie"],
833                true,
834            )?;
835            set_bool(
836                &mut config_value,
837                &["binary_port_server", "allow_request_speculative_exec"],
838                true,
839            )?;
840            Ok(toml::to_string(&config_value)?)
841        })
842        .await?;
843        tokio_fs::write(&node_config_dest, updated_config).await?;
844
845        let sidecar_dest = node_config_version_dir.join("sidecar.toml");
846        copy_file(&asset.sidecar_config, &sidecar_dest).await?;
847        let sidecar_contents = tokio_fs::read_to_string(&sidecar_dest).await?;
848        let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
849        let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
850        let updated_sidecar = spawn_blocking_result(move || {
851            let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
852            set_string(
853                &mut sidecar_value,
854                &["rpc_server", "main_server", "ip_address"],
855                "0.0.0.0".to_string(),
856            )?;
857            set_integer(
858                &mut sidecar_value,
859                &["rpc_server", "main_server", "port"],
860                rpc_port,
861            )?;
862            set_string(
863                &mut sidecar_value,
864                &["rpc_server", "node_client", "ip_address"],
865                "0.0.0.0".to_string(),
866            )?;
867            set_integer(
868                &mut sidecar_value,
869                &["rpc_server", "node_client", "port"],
870                binary_port,
871            )?;
872            Ok(toml::to_string(&sidecar_value)?)
873        })
874        .await?;
875        tokio_fs::write(&sidecar_dest, updated_sidecar).await?;
876    }
877
878    for path in overwritten_builtin_paths {
879        eprintln!(
880            "warning: chainspec override {path} is overwritten by stage-protocol command defaults"
881        );
882    }
883
884    clear_post_stage_protocol_hook_state(layout, &protocol_version_chain).await?;
885    if let Err(err) =
886        run_pre_stage_protocol_hook(layout, &protocol_version_chain, opts.activation_point).await
887    {
888        return Err(rollback_staged_protocol_dirs_after_error(
889            layout,
890            total_nodes,
891            &protocol_version_fs,
892            err,
893        )
894        .await);
895    }
896
897    refresh_post_stage_protocol_hook(layout, &asset.display_name, opts, &protocol_version_chain)
898        .await?;
899
900    Ok(StageProtocolResult {
901        staged_nodes: total_nodes,
902    })
903}
904
905async fn rollback_staged_protocol_dirs_after_error(
906    layout: &AssetsLayout,
907    total_nodes: u32,
908    protocol_version_fs: &str,
909    err: anyhow::Error,
910) -> anyhow::Error {
911    match rollback_staged_protocol_dirs(layout, total_nodes, protocol_version_fs).await {
912        Ok(()) => err,
913        Err(rollback_err) => {
914            anyhow!("{err}; additionally failed to remove staged protocol assets: {rollback_err}")
915        }
916    }
917}
918
919async fn rollback_staged_protocol_dirs(
920    layout: &AssetsLayout,
921    total_nodes: u32,
922    protocol_version_fs: &str,
923) -> Result<()> {
924    let mut errors = Vec::new();
925    for node_id in 1..=total_nodes {
926        for path in [
927            layout.node_bin_dir(node_id).join(protocol_version_fs),
928            layout.node_config_root(node_id).join(protocol_version_fs),
929        ] {
930            if !is_dir(&path).await {
931                continue;
932            }
933            let display = path.display().to_string();
934            if let Err(err) = tokio_fs::remove_dir_all(&path).await {
935                errors.push(format!("{display}: {err}"));
936            }
937        }
938    }
939
940    if errors.is_empty() {
941        Ok(())
942    } else {
943        Err(anyhow!("{}", errors.join("; ")))
944    }
945}
946
947/// Remove assets for the given network while preserving network hook state.
948pub async fn teardown(layout: &AssetsLayout) -> Result<()> {
949    let net_dir = layout.net_dir();
950    if !is_dir(&net_dir).await {
951        return Ok(());
952    }
953
954    let hooks_dir = layout.hooks_dir();
955    if !is_dir(&hooks_dir).await {
956        tokio_fs::remove_dir_all(&net_dir).await?;
957        return Ok(());
958    }
959
960    let mut entries = tokio_fs::read_dir(&net_dir).await?;
961    while let Some(entry) = entries.next_entry().await? {
962        let path = entry.path();
963        if path == hooks_dir {
964            continue;
965        }
966        let file_type = entry.file_type().await?;
967        if file_type.is_dir() {
968            tokio_fs::remove_dir_all(path).await?;
969        } else {
970            tokio_fs::remove_file(path).await?;
971        }
972    }
973    Ok(())
974}
975
976/// Remove consensus secret keys for the provided node IDs.
977pub async fn remove_consensus_keys(layout: &AssetsLayout, node_ids: &[u32]) -> Result<usize> {
978    let mut removed = 0;
979    for node_id in node_ids {
980        let path = layout.node_dir(*node_id).join("keys").join(SECRET_KEY_PEM);
981        match tokio_fs::remove_file(&path).await {
982            Ok(()) => removed += 1,
983            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
984            Err(err) => return Err(err.into()),
985        }
986    }
987    Ok(removed)
988}
989
990/// Ensure consensus secret keys are present for all nodes.
991pub async fn ensure_consensus_keys(layout: &AssetsLayout, seed: Arc<str>) -> Result<usize> {
992    let total_nodes = layout.count_nodes().await?;
993    if total_nodes == 0 {
994        return Ok(0);
995    }
996    let seed_for_root = seed.to_string();
997    let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
998    let mut restored = 0;
999
1000    for node_id in 1..=total_nodes {
1001        let key_path = layout.node_dir(node_id).join("keys").join(SECRET_KEY_PEM);
1002        if is_file(&key_path).await {
1003            continue;
1004        }
1005        if let Some(parent) = key_path.parent() {
1006            tokio_fs::create_dir_all(parent).await?;
1007        }
1008        let path =
1009            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
1010        let secret_key_pem = spawn_blocking_result({
1011            let root = root.clone();
1012            move || {
1013                let child = derive_xprv_from_path(&root, &path)?;
1014                let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
1015                Ok(secret_key.to_pem()?)
1016            }
1017        })
1018        .await?;
1019        tokio_fs::write(&key_path, secret_key_pem).await?;
1020        restored += 1;
1021    }
1022
1023    Ok(restored)
1024}
1025
1026/// Prepare filesystem assets for appending managed non-genesis nodes to a live network.
1027pub async fn add_nodes(layout: &AssetsLayout, opts: &AddNodesOptions) -> Result<AddNodesResult> {
1028    add_nodes_with_trusted_hash(layout, opts, None).await
1029}
1030
1031async fn add_nodes_with_trusted_hash(
1032    layout: &AssetsLayout,
1033    opts: &AddNodesOptions,
1034    trusted_hash_override: Option<String>,
1035) -> Result<AddNodesResult> {
1036    if opts.count == 0 {
1037        return Err(anyhow!("count must be greater than 0"));
1038    }
1039
1040    let existing_node_ids = layout.node_ids().await?;
1041    if existing_node_ids.is_empty() {
1042        return Err(anyhow!(
1043            "no nodes found under {}; run start first",
1044            layout.nodes_dir().display()
1045        ));
1046    }
1047    ensure_contiguous_node_ids(&existing_node_ids)?;
1048
1049    let active_version = uniform_active_protocol_version(layout, &existing_node_ids).await?;
1050    let active_version_fs = active_version.to_string().replace('.', "_");
1051    let first_new_node_id = existing_node_ids
1052        .last()
1053        .copied()
1054        .expect("checked non-empty")
1055        .checked_add(1)
1056        .ok_or_else(|| anyhow!("node id overflow"))?;
1057    let total_nodes = existing_node_ids
1058        .len()
1059        .try_into()
1060        .ok()
1061        .and_then(|current: u32| current.checked_add(opts.count))
1062        .ok_or_else(|| anyhow!("node count overflow"))?;
1063    let added_node_ids = (first_new_node_id..=total_nodes).collect::<Vec<_>>();
1064
1065    let source_node_id = existing_node_ids[0];
1066    let source_bin_dir = layout.node_bin_dir(source_node_id).join(&active_version_fs);
1067    let source_config_dir = layout
1068        .node_config_root(source_node_id)
1069        .join(&active_version_fs);
1070    ensure_active_version_assets(&source_bin_dir, &source_config_dir).await?;
1071
1072    let trusted_hash = match trusted_hash_override {
1073        Some(trusted_hash) => trusted_hash,
1074        None => trusted_hash_for_joining_node(&existing_node_ids).await?,
1075    };
1076
1077    for node_id in &added_node_ids {
1078        if let Err(err) = prepare_added_node(
1079            layout,
1080            source_node_id,
1081            *node_id,
1082            total_nodes,
1083            &active_version_fs,
1084            &opts.seed,
1085            &trusted_hash,
1086        )
1087        .await
1088        {
1089            return Err(rollback_added_nodes_after_error(layout, &added_node_ids, err).await);
1090        }
1091    }
1092
1093    if let Err(err) =
1094        append_added_nodes_to_derived_accounts(layout, &added_node_ids, Arc::clone(&opts.seed))
1095            .await
1096    {
1097        return Err(rollback_added_nodes_after_error(layout, &added_node_ids, err).await);
1098    }
1099
1100    Ok(AddNodesResult {
1101        added_node_ids,
1102        total_nodes,
1103    })
1104}
1105
1106pub async fn rollback_added_nodes(layout: &AssetsLayout, node_ids: &[u32]) -> Result<()> {
1107    let mut errors = Vec::new();
1108    for node_id in node_ids {
1109        let node_dir = layout.node_dir(*node_id);
1110        match tokio_fs::remove_dir_all(&node_dir).await {
1111            Ok(()) => {}
1112            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
1113            Err(err) => errors.push(format!("failed to remove {}: {}", node_dir.display(), err)),
1114        }
1115    }
1116
1117    if let Err(err) = remove_added_nodes_from_derived_accounts(layout, node_ids).await {
1118        errors.push(err.to_string());
1119    }
1120
1121    if errors.is_empty() {
1122        Ok(())
1123    } else {
1124        Err(anyhow!(errors.join("; ")))
1125    }
1126}
1127
1128async fn rollback_added_nodes_after_error(
1129    layout: &AssetsLayout,
1130    node_ids: &[u32],
1131    err: anyhow::Error,
1132) -> anyhow::Error {
1133    match rollback_added_nodes(layout, node_ids).await {
1134        Ok(()) => err,
1135        Err(rollback_err) => anyhow!(
1136            "{}; failed to roll back added node assets: {}",
1137            err,
1138            rollback_err
1139        ),
1140    }
1141}
1142
1143async fn trusted_hash_for_joining_node(node_ids: &[u32]) -> Result<String> {
1144    let client = reqwest::Client::builder()
1145        .no_proxy()
1146        .timeout(StdDuration::from_secs(4))
1147        .build()?;
1148    let mut errors = Vec::new();
1149
1150    for node_id in node_ids {
1151        let url = format!("{}/status", rest_endpoint(*node_id));
1152        match fetch_trusted_hash(&client, &url).await {
1153            Ok(trusted_hash) => return Ok(trusted_hash),
1154            Err(err) => errors.push(format!("node-{node_id}: {err}")),
1155        }
1156    }
1157
1158    Err(anyhow!(
1159        "failed to get trusted hash from existing node status endpoints: {}",
1160        errors.join("; ")
1161    ))
1162}
1163
1164async fn fetch_trusted_hash(client: &reqwest::Client, url: &str) -> Result<String> {
1165    let status = client
1166        .get(url)
1167        .send()
1168        .await
1169        .map_err(|err| anyhow!("failed to query {url}: {err}"))?
1170        .error_for_status()
1171        .map_err(|err| anyhow!("failed to query {url}: {err}"))?
1172        .json::<serde_json::Value>()
1173        .await
1174        .map_err(|err| anyhow!("failed to parse {url} response: {err}"))?;
1175
1176    status
1177        .pointer("/last_added_block_info/hash")
1178        .and_then(serde_json::Value::as_str)
1179        .or_else(|| {
1180            status
1181                .pointer("/last_added_block_info/block_hash")
1182                .and_then(serde_json::Value::as_str)
1183        })
1184        .map(str::to_string)
1185        .ok_or_else(|| anyhow!("{url} response missing last_added_block_info.hash"))
1186}
1187
1188fn ensure_contiguous_node_ids(node_ids: &[u32]) -> Result<()> {
1189    for (index, node_id) in node_ids.iter().enumerate() {
1190        let expected = (index as u32) + 1;
1191        if *node_id != expected {
1192            return Err(anyhow!(
1193                "node directories must be contiguous from node-1; expected node-{}, found node-{}",
1194                expected,
1195                node_id
1196            ));
1197        }
1198    }
1199    Ok(())
1200}
1201
1202async fn uniform_active_protocol_version(
1203    layout: &AssetsLayout,
1204    node_ids: &[u32],
1205) -> Result<Version> {
1206    let mut active = None;
1207    for node_id in node_ids {
1208        let version = active_protocol_version_for_node(layout, *node_id).await?;
1209        match &active {
1210            Some(active) if active != &version => {
1211                return Err(anyhow!(
1212                    "active protocol versions are mixed: node-1 is {}, node-{} is {}",
1213                    active,
1214                    node_id,
1215                    version
1216                ));
1217            }
1218            Some(_) => {}
1219            None => active = Some(version),
1220        }
1221    }
1222    active.ok_or_else(|| anyhow!("no active protocol version found"))
1223}
1224
1225async fn active_protocol_version_for_node(layout: &AssetsLayout, node_id: u32) -> Result<Version> {
1226    let state_path = layout
1227        .node_config_root(node_id)
1228        .join(NODE_LAUNCHER_STATE_FILE);
1229    let contents = tokio_fs::read_to_string(&state_path)
1230        .await
1231        .map_err(|err| anyhow!("failed to read {}: {}", state_path.display(), err))?;
1232    let value: toml::Value = toml::from_str(&contents)
1233        .map_err(|err| anyhow!("failed to parse {}: {}", state_path.display(), err))?;
1234    let mode = value
1235        .get("mode")
1236        .and_then(toml::Value::as_str)
1237        .ok_or_else(|| anyhow!("{} missing launcher mode", state_path.display()))?;
1238    if mode != "RunNodeAsValidator" {
1239        return Err(anyhow!(
1240            "node-{} is not running as a validator (launcher mode: {}); try again after migration completes",
1241            node_id,
1242            mode
1243        ));
1244    }
1245    let version = value
1246        .get("version")
1247        .and_then(toml::Value::as_str)
1248        .ok_or_else(|| anyhow!("{} missing active version", state_path.display()))?;
1249    parse_protocol_version(version)
1250}
1251
1252async fn ensure_active_version_assets(
1253    source_bin_dir: &Path,
1254    source_config_dir: &Path,
1255) -> Result<()> {
1256    for path in [
1257        source_bin_dir.join("casper-node"),
1258        source_bin_dir.join("casper-sidecar"),
1259        source_config_dir.join("chainspec.toml"),
1260        source_config_dir.join("accounts.toml"),
1261        source_config_dir.join("config.toml"),
1262        source_config_dir.join("sidecar.toml"),
1263    ] {
1264        if !is_file(&path).await {
1265            return Err(anyhow!("missing active version asset {}", path.display()));
1266        }
1267    }
1268    Ok(())
1269}
1270
1271async fn prepare_added_node(
1272    layout: &AssetsLayout,
1273    source_node_id: u32,
1274    node_id: u32,
1275    total_nodes: u32,
1276    active_version_fs: &str,
1277    seed: &Arc<str>,
1278    trusted_hash: &str,
1279) -> Result<()> {
1280    let source_bin_dir = layout.node_bin_dir(source_node_id).join(active_version_fs);
1281    let source_config_dir = layout
1282        .node_config_root(source_node_id)
1283        .join(active_version_fs);
1284    let node_dir = layout.node_dir(node_id);
1285    let dest_bin_dir = layout.node_bin_dir(node_id).join(active_version_fs);
1286    let dest_config_dir = layout.node_config_root(node_id).join(active_version_fs);
1287
1288    tokio_fs::create_dir_all(&dest_bin_dir).await?;
1289    tokio_fs::create_dir_all(&dest_config_dir).await?;
1290    tokio_fs::create_dir_all(node_dir.join("keys")).await?;
1291    tokio_fs::create_dir_all(node_dir.join("logs")).await?;
1292    tokio_fs::create_dir_all(node_dir.join("storage")).await?;
1293
1294    copy_file(
1295        &source_bin_dir.join("casper-node"),
1296        &dest_bin_dir.join("casper-node"),
1297    )
1298    .await?;
1299    copy_file(
1300        &source_bin_dir.join("casper-sidecar"),
1301        &dest_bin_dir.join("casper-sidecar"),
1302    )
1303    .await?;
1304    copy_file(
1305        &source_config_dir.join("chainspec.toml"),
1306        &dest_config_dir.join("chainspec.toml"),
1307    )
1308    .await?;
1309    copy_file(
1310        &source_config_dir.join("accounts.toml"),
1311        &dest_config_dir.join("accounts.toml"),
1312    )
1313    .await?;
1314    copy_file(
1315        &source_config_dir.join("config.toml"),
1316        &dest_config_dir.join("config.toml"),
1317    )
1318    .await?;
1319    copy_file(
1320        &source_config_dir.join("sidecar.toml"),
1321        &dest_config_dir.join("sidecar.toml"),
1322    )
1323    .await?;
1324
1325    rewrite_added_node_config(
1326        layout,
1327        node_id,
1328        total_nodes,
1329        &dest_config_dir.join("config.toml"),
1330        trusted_hash,
1331    )
1332    .await?;
1333    rewrite_added_sidecar_config(node_id, &dest_config_dir.join("sidecar.toml")).await?;
1334    write_consensus_key_for_node(layout, node_id, Arc::clone(seed)).await
1335}
1336
1337async fn rewrite_added_node_config(
1338    layout: &AssetsLayout,
1339    node_id: u32,
1340    total_nodes: u32,
1341    config_path: &Path,
1342    trusted_hash: &str,
1343) -> Result<()> {
1344    let config_contents = tokio_fs::read_to_string(config_path).await?;
1345    let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
1346    let known = known_addresses(node_id, total_nodes);
1347    let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
1348    let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
1349    let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
1350    let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
1351    let trusted_hash = trusted_hash.to_string();
1352    let updated_config = spawn_blocking_result(move || {
1353        let mut config_value: toml::Value = toml::from_str(&config_contents)?;
1354        set_string(&mut config_value, &["node", "trusted_hash"], trusted_hash)?;
1355        set_joining_sync_mode(&mut config_value)?;
1356        set_string(
1357            &mut config_value,
1358            &["consensus", "secret_key_path"],
1359            "../../keys/secret_key.pem".to_string(),
1360        )?;
1361        set_string(
1362            &mut config_value,
1363            &["network", "bind_address"],
1364            bind_address,
1365        )?;
1366        set_array(&mut config_value, &["network", "known_addresses"], known)?;
1367        set_string(
1368            &mut config_value,
1369            &["storage", "path"],
1370            "../../storage".to_string(),
1371        )?;
1372        set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
1373        set_string(
1374            &mut config_value,
1375            &["event_stream_server", "address"],
1376            sse_address,
1377        )?;
1378        set_string(
1379            &mut config_value,
1380            &["diagnostics_port", "socket_path"],
1381            diagnostics_socket,
1382        )?;
1383        set_string(
1384            &mut config_value,
1385            &["binary_port_server", "address"],
1386            binary_address,
1387        )?;
1388        set_bool(
1389            &mut config_value,
1390            &["binary_port_server", "allow_request_get_trie"],
1391            true,
1392        )?;
1393        set_bool(
1394            &mut config_value,
1395            &["binary_port_server", "allow_request_speculative_exec"],
1396            true,
1397        )?;
1398        Ok(toml::to_string(&config_value)?)
1399    })
1400    .await?;
1401    tokio_fs::write(config_path, updated_config).await?;
1402    Ok(())
1403}
1404
1405async fn rewrite_added_sidecar_config(node_id: u32, sidecar_path: &Path) -> Result<()> {
1406    let sidecar_contents = tokio_fs::read_to_string(sidecar_path).await?;
1407    let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
1408    let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
1409    let updated_sidecar = spawn_blocking_result(move || {
1410        let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
1411        set_string(
1412            &mut sidecar_value,
1413            &["rpc_server", "main_server", "ip_address"],
1414            "0.0.0.0".to_string(),
1415        )?;
1416        set_integer(
1417            &mut sidecar_value,
1418            &["rpc_server", "main_server", "port"],
1419            rpc_port,
1420        )?;
1421        set_string(
1422            &mut sidecar_value,
1423            &["rpc_server", "node_client", "ip_address"],
1424            "0.0.0.0".to_string(),
1425        )?;
1426        set_integer(
1427            &mut sidecar_value,
1428            &["rpc_server", "node_client", "port"],
1429            binary_port,
1430        )?;
1431        Ok(toml::to_string(&sidecar_value)?)
1432    })
1433    .await?;
1434    tokio_fs::write(sidecar_path, updated_sidecar).await?;
1435    Ok(())
1436}
1437
1438async fn write_consensus_key_for_node(
1439    layout: &AssetsLayout,
1440    node_id: u32,
1441    seed: Arc<str>,
1442) -> Result<()> {
1443    let key_path = layout.node_dir(node_id).join("keys").join(SECRET_KEY_PEM);
1444    let account = derive_node_account(seed, node_id, true).await?;
1445    let secret_key_pem = account
1446        .secret_key_pem
1447        .ok_or_else(|| anyhow!("missing secret key material for node-{}", node_id))?;
1448    tokio_fs::write(key_path, secret_key_pem).await?;
1449    Ok(())
1450}
1451
1452async fn append_added_nodes_to_derived_accounts(
1453    layout: &AssetsLayout,
1454    node_ids: &[u32],
1455    seed: Arc<str>,
1456) -> Result<()> {
1457    let mut lines = Vec::new();
1458    for node_id in node_ids {
1459        let account = derive_node_account(Arc::clone(&seed), *node_id, false).await?;
1460        lines.push(
1461            DerivedAccountInfo {
1462                kind: "node",
1463                name: format!("node-{}", node_id),
1464                id: *node_id,
1465                path: account.path,
1466                public_key_hex: account.public_key_hex,
1467                account_hash: account.account_hash,
1468                balance_motes: 0,
1469            }
1470            .line(),
1471        );
1472    }
1473
1474    let path = derived_accounts_path(layout);
1475    let mut contents = match tokio_fs::read_to_string(&path).await {
1476        Ok(contents) => {
1477            let trimmed = contents.trim_end();
1478            if trimmed.is_empty() {
1479                "kind,name,key_type,derivation,path,account_hash,balance".to_string()
1480            } else {
1481                trimmed.to_string()
1482            }
1483        }
1484        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1485            "kind,name,key_type,derivation,path,account_hash,balance".to_string()
1486        }
1487        Err(err) => return Err(err.into()),
1488    };
1489    for line in lines {
1490        contents.push('\n');
1491        contents.push_str(&line);
1492    }
1493    contents.push('\n');
1494    tokio_fs::write(path, contents).await?;
1495    Ok(())
1496}
1497
1498async fn remove_added_nodes_from_derived_accounts(
1499    layout: &AssetsLayout,
1500    node_ids: &[u32],
1501) -> Result<()> {
1502    let path = derived_accounts_path(layout);
1503    let contents = match tokio_fs::read_to_string(&path).await {
1504        Ok(contents) => contents,
1505        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1506        Err(err) => return Err(err.into()),
1507    };
1508    let node_names = node_ids
1509        .iter()
1510        .map(|node_id| format!("node-{}", node_id))
1511        .collect::<Vec<_>>();
1512    let mut retained = Vec::new();
1513    for line in contents.lines() {
1514        if !is_added_node_account_line(line, &node_names) {
1515            retained.push(line);
1516        }
1517    }
1518
1519    let mut updated = retained.join("\n");
1520    if !updated.is_empty() {
1521        updated.push('\n');
1522    }
1523    tokio_fs::write(path, updated).await?;
1524    Ok(())
1525}
1526
1527fn is_added_node_account_line(line: &str, node_names: &[String]) -> bool {
1528    let mut parts = line.splitn(3, ',');
1529    matches!(
1530        (parts.next(), parts.next()),
1531        (Some("node"), Some(name)) if node_names.iter().any(|candidate| candidate == name)
1532    )
1533}
1534
1535pub fn parse_protocol_version(raw: &str) -> Result<Version> {
1536    let trimmed = raw.trim();
1537    let normalized = trimmed.strip_prefix('v').unwrap_or(trimmed);
1538    Version::parse(normalized).map_err(|err| anyhow!("invalid protocol version {}: {}", raw, err))
1539}
1540
1541fn bundle_version_dir(bundle_root: &Path, protocol_version: &Version) -> PathBuf {
1542    bundle_root.join(format!("v{}", protocol_version))
1543}
1544
1545async fn bundle_dir_for_version(bundle_root: &Path, protocol_version: &Version) -> Result<PathBuf> {
1546    let version_dir = bundle_version_dir(bundle_root, protocol_version);
1547    if is_dir(&version_dir).await {
1548        return Ok(version_dir);
1549    }
1550    Err(anyhow!("assets bundle missing {}", version_dir.display()))
1551}
1552
1553pub async fn has_bundle_version(protocol_version: &Version) -> Result<bool> {
1554    let bundle_root = assets_bundle_root()?;
1555    Ok(is_dir(&bundle_version_dir(&bundle_root, protocol_version)).await)
1556}
1557
1558async fn extract_assets_bundle(bundle_path: &Path, bundle_root: &Path) -> Result<()> {
1559    if !is_file(bundle_path).await {
1560        return Err(anyhow!("missing assets bundle {}", bundle_path.display()));
1561    }
1562
1563    let bundle_path = bundle_path.to_path_buf();
1564    let bundle_root = bundle_root.to_path_buf();
1565    spawn_blocking_result(move || {
1566        std::fs::create_dir_all(&bundle_root)?;
1567        let file = File::open(&bundle_path)?;
1568        let decoder = GzDecoder::new(file);
1569        let mut archive = Archive::new(decoder);
1570        println!("unpacking assets into {}", bundle_root.display());
1571        archive.unpack(&bundle_root)?;
1572        Ok(())
1573    })
1574    .await
1575}
1576
1577pub async fn install_assets_bundle(bundle_path: &Path) -> Result<()> {
1578    let bundle_root = assets_bundle_root()?;
1579    println!(
1580        "unpacking local assets bundle {} into {}",
1581        bundle_path.display(),
1582        bundle_root.display()
1583    );
1584    extract_assets_bundle(bundle_path, &bundle_root).await
1585}
1586
1587pub async fn pull_assets_bundles(target_override: Option<&str>, force: bool) -> Result<()> {
1588    let bundle_root = assets_bundle_root()?;
1589    let target = target_override
1590        .map(str::to_string)
1591        .unwrap_or_else(default_target);
1592    println!("assets pull target: {}", target);
1593    let release = fetch_latest_release().await?;
1594    println!("release tag: {}", release.tag_name);
1595
1596    let mut assets = Vec::new();
1597    for asset in release.assets {
1598        if let Some(version) = parse_release_asset_version(&asset.name, &target) {
1599            assets.push(ReleaseAsset {
1600                url: asset.browser_download_url,
1601                version,
1602            });
1603        }
1604    }
1605
1606    if assets.is_empty() {
1607        return Err(anyhow!(
1608            "no assets found for target {} in release {}",
1609            target,
1610            release.tag_name
1611        ));
1612    }
1613
1614    for asset in assets {
1615        let bytes = download_asset(&asset.url, &asset.version).await?;
1616        let expected_hash = download_asset_sha512(&asset.url).await?;
1617        let actual_hash = sha512_hex(&bytes);
1618        if expected_hash != actual_hash {
1619            return Err(anyhow!(
1620                "sha512 mismatch for {} (expected {}, got {})",
1621                asset.url,
1622                expected_hash,
1623                actual_hash
1624            ));
1625        }
1626        let remote_manifest = extract_manifest_from_bytes(&bytes).await?;
1627        let version_dir = bundle_version_dir(&bundle_root, &asset.version);
1628        let local_manifest = read_local_manifest(&version_dir).await?;
1629
1630        if !force
1631            && let (Some(remote), Some(local)) = (&remote_manifest, &local_manifest)
1632            && remote == local
1633        {
1634            println!("already have this file v{}", asset.version);
1635            continue;
1636        }
1637
1638        if is_dir(&version_dir).await {
1639            tokio_fs::remove_dir_all(&version_dir).await?;
1640        }
1641
1642        println!("saving assets bundle v{}", asset.version);
1643        unpack_assets_with_progress(&bytes, &bundle_root, &asset.version).await?;
1644    }
1645
1646    tokio_fs::write(bundle_root.join("latest"), release.tag_name).await?;
1647    Ok(())
1648}
1649
1650pub async fn most_recent_bundle_version() -> Result<Option<Version>> {
1651    let mut versions = list_bundle_versions().await?;
1652    versions.sort();
1653    Ok(versions.pop())
1654}
1655
1656pub async fn list_bundle_versions() -> Result<Vec<Version>> {
1657    let bundle_root = assets_bundle_root()?;
1658    if !is_dir(&bundle_root).await {
1659        return Ok(Vec::new());
1660    }
1661    let mut versions: Vec<Version> = Vec::new();
1662    let mut entries = tokio_fs::read_dir(&bundle_root).await?;
1663    while let Some(entry) = entries.next_entry().await? {
1664        if !entry.file_type().await?.is_dir() {
1665            continue;
1666        }
1667        let name = entry.file_name().to_string_lossy().to_string();
1668        if !name.starts_with('v') {
1669            continue;
1670        }
1671        let dir_version = match parse_protocol_version(&name) {
1672            Ok(version) => version,
1673            Err(err) => {
1674                eprintln!("warning: skipping assets bundle {}: {}", name, err);
1675                continue;
1676            }
1677        };
1678        let dir_path = entry.path();
1679        let chainspec_path = dir_path.join("chainspec.toml");
1680        if !is_file(&chainspec_path).await {
1681            continue;
1682        }
1683        let contents = tokio_fs::read_to_string(&chainspec_path).await?;
1684        let chainspec_version =
1685            spawn_blocking_result(move || parse_chainspec_version(&contents)).await?;
1686        if chainspec_version != dir_version {
1687            eprintln!(
1688                "warning: bundle directory {} does not match chainspec protocol version {}; using directory version {}",
1689                name, chainspec_version, dir_version
1690            );
1691        }
1692        versions.push(dir_version);
1693    }
1694    Ok(versions)
1695}
1696
1697pub async fn list_custom_asset_names() -> Result<Vec<String>> {
1698    let root = custom_assets_root()?;
1699    if !is_dir(&root).await {
1700        return Ok(Vec::new());
1701    }
1702
1703    let mut names = Vec::new();
1704    let mut entries = tokio_fs::read_dir(&root).await?;
1705    while let Some(entry) = entries.next_entry().await? {
1706        if !entry.file_type().await?.is_dir() {
1707            continue;
1708        }
1709        let name = entry.file_name().to_string_lossy().to_string();
1710        if validate_custom_asset_name(&name).is_ok() {
1711            names.push(name);
1712        }
1713    }
1714    names.sort();
1715    Ok(names)
1716}
1717
1718async fn resolve_asset(selector: &AssetSelector) -> Result<ResolvedAsset> {
1719    match selector {
1720        AssetSelector::LatestVersioned => {
1721            let version = most_recent_bundle_version()
1722                .await?
1723                .ok_or_else(|| anyhow!("no assets bundles found"))?;
1724            resolve_versioned_asset(&version).await
1725        }
1726        AssetSelector::Versioned(raw) => {
1727            let version = parse_protocol_version(raw)?;
1728            resolve_versioned_asset(&version).await
1729        }
1730        AssetSelector::Custom(name) => resolve_custom_asset(name).await,
1731    }
1732}
1733
1734async fn resolve_versioned_asset(version: &Version) -> Result<ResolvedAsset> {
1735    let bundle_root = assets_bundle_root()?;
1736    let bundle_dir = bundle_dir_for_version(&bundle_root, version).await?;
1737    let casper_node =
1738        canonicalize_required_file(&bundle_dir.join("bin").join("casper-node"), "casper-node")
1739            .await?;
1740    let casper_sidecar = canonicalize_required_file(
1741        &bundle_dir.join("bin").join("casper-sidecar"),
1742        "casper-sidecar",
1743    )
1744    .await?;
1745    let chainspec =
1746        canonicalize_required_file(&bundle_dir.join("chainspec.toml"), "chainspec").await?;
1747    let node_config =
1748        canonicalize_required_file(&bundle_dir.join("node-config.toml"), "node-config").await?;
1749    let sidecar_config =
1750        canonicalize_required_file(&bundle_dir.join("sidecar-config.toml"), "sidecar-config")
1751            .await?;
1752    let chainspec_protocol_version = parse_chainspec_file(&chainspec).await?;
1753
1754    Ok(ResolvedAsset {
1755        display_name: version.to_string(),
1756        source_kind: AssetSourceKind::Versioned,
1757        casper_node,
1758        casper_sidecar,
1759        chainspec,
1760        node_config,
1761        sidecar_config,
1762        chainspec_protocol_version,
1763    })
1764}
1765
1766async fn resolve_custom_asset(name: &str) -> Result<ResolvedAsset> {
1767    let custom_asset = load_custom_asset(name).await?;
1768    let chainspec_protocol_version = parse_chainspec_file(&custom_asset.chainspec).await?;
1769
1770    Ok(ResolvedAsset {
1771        display_name: format!("custom/{name}"),
1772        source_kind: AssetSourceKind::Custom,
1773        casper_node: custom_asset.casper_node,
1774        casper_sidecar: custom_asset.casper_sidecar,
1775        chainspec: custom_asset.chainspec,
1776        node_config: custom_asset.node_config,
1777        sidecar_config: custom_asset.sidecar_config,
1778        chainspec_protocol_version,
1779    })
1780}
1781
1782async fn parse_chainspec_file(path: &Path) -> Result<Version> {
1783    let contents = tokio_fs::read_to_string(path).await?;
1784    spawn_blocking_result(move || parse_chainspec_version(&contents)).await
1785}
1786
1787fn parse_chainspec_version(contents: &str) -> Result<Version> {
1788    let value: toml::Value = toml::from_str(contents)?;
1789    let protocol = value
1790        .get("protocol")
1791        .and_then(|v| v.as_table())
1792        .ok_or_else(|| anyhow!("chainspec missing [protocol] section"))?;
1793    let version = protocol
1794        .get("version")
1795        .and_then(|v| v.as_str())
1796        .ok_or_else(|| anyhow!("chainspec missing protocol.version"))?;
1797    parse_protocol_version(version)
1798}
1799
1800#[derive(Deserialize)]
1801struct GithubRelease {
1802    tag_name: String,
1803    assets: Vec<GithubAsset>,
1804}
1805
1806#[derive(Deserialize)]
1807struct GithubAsset {
1808    name: String,
1809    browser_download_url: String,
1810}
1811
1812struct ReleaseAsset {
1813    url: String,
1814    version: Version,
1815}
1816
1817async fn fetch_latest_release() -> Result<GithubRelease> {
1818    let client = reqwest::Client::builder()
1819        .user_agent("casper-devnet")
1820        .build()?;
1821    let url = "https://api.github.com/repos/veles-labs/devnet-launcher-assets/releases/latest";
1822    println!("GET {}", url);
1823    let response = client.get(url).send().await?.error_for_status()?;
1824    Ok(response.json::<GithubRelease>().await?)
1825}
1826
1827fn parse_release_asset_version(name: &str, target: &str) -> Option<Version> {
1828    let trimmed = name.strip_prefix("casper-v")?;
1829    let trimmed = trimmed.strip_suffix(".tar.gz")?;
1830    let (version, asset_target) = trimmed.split_once('-')?;
1831    if asset_target != target {
1832        return None;
1833    }
1834    parse_protocol_version(version).ok()
1835}
1836
1837fn download_progress_style() -> ProgressStyle {
1838    ProgressStyle::with_template("{msg} {bar:40.cyan/blue} {bytes:>7}/{total_bytes:7} ({eta})")
1839        .expect("valid download progress template")
1840        .progress_chars("█▉▊▋▌▍▎▏ ")
1841}
1842
1843fn download_spinner_style() -> ProgressStyle {
1844    ProgressStyle::with_template("{msg} {spinner:.cyan} {bytes:>7}")
1845        .expect("valid download spinner template")
1846        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1847}
1848
1849fn unpack_spinner_style() -> ProgressStyle {
1850    ProgressStyle::with_template("{msg} {spinner:.magenta} {elapsed_precise}")
1851        .expect("valid unpack spinner template")
1852        .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
1853}
1854
1855async fn download_asset(url: &str, version: &Version) -> Result<Vec<u8>> {
1856    let client = reqwest::Client::builder()
1857        .user_agent("casper-devnet")
1858        .build()?;
1859    println!("GET {}", url);
1860    let response = client.get(url).send().await?.error_for_status()?;
1861    let total = response.content_length();
1862    let pb = match total {
1863        Some(total) if total > 0 => {
1864            let pb = ProgressBar::new(total);
1865            pb.set_style(download_progress_style());
1866            pb
1867        }
1868        _ => {
1869            let pb = ProgressBar::new_spinner();
1870            pb.set_style(download_spinner_style());
1871            pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
1872            pb
1873        }
1874    };
1875    pb.set_message(format!("⬇️  v{} download", version));
1876
1877    let mut bytes = Vec::new();
1878    if let Some(total) = total
1879        && total <= usize::MAX as u64
1880    {
1881        bytes.reserve(total as usize);
1882    }
1883
1884    let mut stream = response.bytes_stream();
1885    while let Some(chunk) = stream.next().await {
1886        match chunk {
1887            Ok(chunk) => {
1888                pb.inc(chunk.len() as u64);
1889                bytes.extend_from_slice(&chunk);
1890            }
1891            Err(err) => {
1892                pb.finish_with_message(format!("❌  v{} download failed", version));
1893                return Err(err.into());
1894            }
1895        }
1896    }
1897
1898    pb.finish_with_message(format!("✅  v{} downloaded", version));
1899    Ok(bytes)
1900}
1901
1902async fn download_asset_sha512(url: &str) -> Result<String> {
1903    let sha_url = format!("{url}.sha512");
1904    let client = reqwest::Client::builder()
1905        .user_agent("casper-devnet")
1906        .build()?;
1907    println!("GET {}", sha_url);
1908    let response = client.get(sha_url).send().await?.error_for_status()?;
1909    let text = response.text().await?;
1910    parse_sha512(&text)
1911}
1912
1913fn parse_sha512(text: &str) -> Result<String> {
1914    let token = text
1915        .split_whitespace()
1916        .next()
1917        .ok_or_else(|| anyhow!("invalid sha512 file contents"))?;
1918    let token = token.trim();
1919    if token.len() != 128 || !token.chars().all(|c| c.is_ascii_hexdigit()) {
1920        return Err(anyhow!("invalid sha512 hash {}", token));
1921    }
1922    Ok(token.to_lowercase())
1923}
1924
1925fn sha512_hex(bytes: &[u8]) -> String {
1926    let digest = Sha512::digest(bytes);
1927    let mut out = String::with_capacity(digest.len() * 2);
1928    for byte in digest {
1929        use std::fmt::Write;
1930        let _ = write!(&mut out, "{:02x}", byte);
1931    }
1932    out
1933}
1934
1935async fn extract_manifest_from_bytes(bytes: &[u8]) -> Result<Option<serde_json::Value>> {
1936    let bytes = bytes.to_vec();
1937    spawn_blocking_result(move || {
1938        let cursor = Cursor::new(bytes);
1939        let decoder = GzDecoder::new(cursor);
1940        let mut archive = Archive::new(decoder);
1941        let entries = archive.entries()?;
1942        for entry in entries {
1943            let mut entry = entry?;
1944            let path = entry.path()?;
1945            if path.file_name() == Some(OsStr::new("manifest.json")) {
1946                let mut contents = String::new();
1947                entry.read_to_string(&mut contents)?;
1948                let value = serde_json::from_str(&contents)?;
1949                return Ok(Some(value));
1950            }
1951        }
1952        Ok(None)
1953    })
1954    .await
1955}
1956
1957async fn read_local_manifest(version_dir: &Path) -> Result<Option<serde_json::Value>> {
1958    let path = version_dir.join("manifest.json");
1959    if !is_file(&path).await {
1960        return Ok(None);
1961    }
1962    let contents = tokio_fs::read_to_string(&path).await?;
1963    let value = serde_json::from_str(&contents)?;
1964    Ok(Some(value))
1965}
1966
1967async fn unpack_assets_with_progress(
1968    bytes: &[u8],
1969    bundle_root: &Path,
1970    version: &Version,
1971) -> Result<()> {
1972    let pb = ProgressBar::new_spinner();
1973    pb.set_style(unpack_spinner_style());
1974    pb.set_message(format!("📦  v{} unpack", version));
1975    pb.enable_steady_tick(StdDuration::from_millis(PROGRESS_TICK_MS));
1976
1977    let result = extract_assets_from_bytes(bytes, bundle_root).await;
1978    match result {
1979        Ok(()) => pb.finish_with_message(format!("✅  v{} unpacked", version)),
1980        Err(_) => pb.finish_with_message(format!("❌  v{} unpack failed", version)),
1981    }
1982    result
1983}
1984
1985async fn extract_assets_from_bytes(bytes: &[u8], bundle_root: &Path) -> Result<()> {
1986    let bytes = bytes.to_vec();
1987    let bundle_root = bundle_root.to_path_buf();
1988    spawn_blocking_result(move || {
1989        std::fs::create_dir_all(&bundle_root)?;
1990        let cursor = Cursor::new(bytes);
1991        let decoder = GzDecoder::new(cursor);
1992        let mut archive = Archive::new(decoder);
1993        archive.unpack(&bundle_root)?;
1994        Ok(())
1995    })
1996    .await
1997}
1998
1999fn default_target() -> String {
2000    env!("BUILD_TARGET").to_string()
2001}
2002
2003async fn setup_directories(
2004    layout: &AssetsLayout,
2005    total_nodes: u32,
2006    protocol_version_fs: &str,
2007) -> Result<()> {
2008    let net_dir = layout.net_dir();
2009    let chainspec_dir = net_dir.join("chainspec");
2010    let nodes_dir = net_dir.join("nodes");
2011
2012    tokio_fs::create_dir_all(chainspec_dir).await?;
2013    tokio_fs::create_dir_all(&nodes_dir).await?;
2014
2015    for node_id in 1..=total_nodes {
2016        let node_dir = layout.node_dir(node_id);
2017        tokio_fs::create_dir_all(node_dir.join("bin").join(protocol_version_fs)).await?;
2018        tokio_fs::create_dir_all(node_dir.join("config").join(protocol_version_fs)).await?;
2019        tokio_fs::create_dir_all(node_dir.join("keys")).await?;
2020        tokio_fs::create_dir_all(node_dir.join("logs")).await?;
2021        tokio_fs::create_dir_all(node_dir.join("storage")).await?;
2022    }
2023
2024    Ok(())
2025}
2026
2027async fn setup_binaries(
2028    layout: &AssetsLayout,
2029    total_nodes: u32,
2030    asset: &ResolvedAsset,
2031    protocol_version_fs: &str,
2032) -> Result<()> {
2033    for node_id in 1..=total_nodes {
2034        let node_bin_dir = layout.node_bin_dir(node_id);
2035        let version_dir = node_bin_dir.join(protocol_version_fs);
2036
2037        let node_dest = version_dir.join("casper-node");
2038        install_binary_file(asset, &asset.casper_node, &node_dest).await?;
2039
2040        let sidecar_dest = version_dir.join("casper-sidecar");
2041        install_binary_file(asset, &asset.casper_sidecar, &sidecar_dest).await?;
2042    }
2043
2044    Ok(())
2045}
2046
2047async fn install_binary_file(asset: &ResolvedAsset, src: &Path, dest: &Path) -> Result<()> {
2048    match asset.source_kind {
2049        AssetSourceKind::Versioned => hardlink_file(src, dest).await,
2050        AssetSourceKind::Custom => symlink_file(src, dest).await,
2051    }
2052}
2053
2054async fn preflight_resolved_asset(asset: &ResolvedAsset) -> Result<()> {
2055    verify_binary_version(&asset.casper_node, "casper-node").await?;
2056    verify_binary_version(&asset.casper_sidecar, "casper-sidecar").await?;
2057    Ok(())
2058}
2059
2060async fn verify_binary_version(path: &Path, label: &str) -> Result<()> {
2061    let output = Command::new(path).arg("--version").output().await?;
2062    if output.status.success() {
2063        return Ok(());
2064    }
2065    let stderr = String::from_utf8_lossy(&output.stderr);
2066    let stdout = String::from_utf8_lossy(&output.stdout);
2067    Err(anyhow!(
2068        "{} --version failed (status={}): {}{}",
2069        label,
2070        output.status,
2071        stdout,
2072        stderr
2073    ))
2074}
2075
2076///
2077/// Derive an unsafe deterministic root key from an arbitrary seed string.
2078///
2079/// DEVNET ONLY, NOT BIP-39, NOT WALLET-COMPATIBLE.
2080///
2081fn unsafe_root_from_seed(seed: &str) -> Result<XPrv> {
2082    if seed.is_empty() {
2083        return Err(anyhow!("seed must not be empty"));
2084    }
2085    let mut hasher = Blake2bVar::new(32).map_err(|_| anyhow!("invalid blake2b output size"))?;
2086    hasher.update(DEVNET_SEED_DOMAIN);
2087    hasher.update(seed.as_bytes());
2088
2089    let mut entropy = [0u8; 32];
2090    hasher
2091        .finalize_variable(&mut entropy)
2092        .map_err(|_| anyhow!("failed to finalize blake2b"))?;
2093
2094    Ok(XPrv::new(entropy)?)
2095}
2096
2097fn derive_xprv_from_path(root: &XPrv, path: &DerivationPath) -> Result<XPrv> {
2098    let mut key = root.clone();
2099    for child in path.iter() {
2100        key = key.derive_child(child)?;
2101    }
2102    Ok(key)
2103}
2104
2105///
2106/// Derive a single Casper account from a given root and derivation path.
2107///
2108fn derive_account_material(
2109    root: &XPrv,
2110    path: &DerivationPath,
2111    write_secret: bool,
2112) -> Result<DerivedAccountMaterial> {
2113    let child = derive_xprv_from_path(root, path)?;
2114    let secret_key = SecretKey::secp256k1_from_bytes(child.to_bytes())?;
2115    let public_key = PublicKey::from(&secret_key);
2116    let public_key_hex = public_key.to_hex();
2117    let account_hash = AccountHash::from(&public_key).to_hex_string();
2118    let secret_key_pem = if write_secret {
2119        Some(secret_key.to_pem()?)
2120    } else {
2121        None
2122    };
2123
2124    Ok(DerivedAccountMaterial {
2125        path: path.clone(),
2126        public_key_hex,
2127        account_hash,
2128        secret_key_pem,
2129    })
2130}
2131
2132async fn derive_node_account(
2133    seed: Arc<str>,
2134    node_id: u32,
2135    write_secret: bool,
2136) -> Result<DerivedAccountMaterial> {
2137    let seed_for_root = seed.to_string();
2138    spawn_blocking_result(move || {
2139        let root = unsafe_root_from_seed(&seed_for_root)?;
2140        let path =
2141            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
2142        derive_account_material(&root, &path, write_secret)
2143    })
2144    .await
2145}
2146
2147async fn write_node_keys(dir: &Path, account: &DerivedAccountMaterial) -> Result<()> {
2148    tokio_fs::create_dir_all(dir).await?;
2149    if let Some(secret_key_pem) = &account.secret_key_pem {
2150        tokio_fs::write(dir.join(SECRET_KEY_PEM), secret_key_pem).await?;
2151    }
2152    Ok(())
2153}
2154
2155async fn setup_seeded_keys(
2156    layout: &AssetsLayout,
2157    total_nodes: u32,
2158    users: u32,
2159    seed: Arc<str>,
2160) -> Result<DerivedAccounts> {
2161    let seed_for_root = seed.to_string();
2162    let root = spawn_blocking_result(move || unsafe_root_from_seed(&seed_for_root)).await?;
2163    let mut summary = Vec::new();
2164    let mut derived = DerivedAccounts {
2165        nodes: Vec::new(),
2166        users: Vec::new(),
2167    };
2168
2169    for node_id in 1..=total_nodes {
2170        let path =
2171            DerivationPath::from_str(&format!("{}/{}", DERIVATION_PATH_PREFIX, node_id - 1))?;
2172        let account = spawn_blocking_result({
2173            let root = root.clone();
2174            let path = path.clone();
2175            move || derive_account_material(&root, &path, true)
2176        })
2177        .await?;
2178        write_node_keys(&layout.node_dir(node_id).join("keys"), &account).await?;
2179
2180        let info = DerivedAccountInfo {
2181            kind: "validator",
2182            name: format!("node-{}", node_id),
2183            id: node_id,
2184            path: account.path.clone(),
2185            public_key_hex: account.public_key_hex.clone(),
2186            account_hash: account.account_hash.clone(),
2187            balance_motes: DEVNET_INITIAL_BALANCE_VALIDATOR,
2188        };
2189        summary.push(info.clone());
2190        derived.nodes.push(info);
2191    }
2192
2193    for user_id in 1..=users {
2194        let path = DerivationPath::from_str(&format!(
2195            "{}/{}",
2196            DERIVATION_PATH_PREFIX,
2197            USER_DERIVATION_START + user_id - 1
2198        ))?;
2199        let account = spawn_blocking_result({
2200            let root = root.clone();
2201            let path = path.clone();
2202            move || derive_account_material(&root, &path, false)
2203        })
2204        .await?;
2205        let info = DerivedAccountInfo {
2206            kind: "user",
2207            name: format!("user-{}", user_id),
2208            id: user_id,
2209            path: account.path.clone(),
2210            public_key_hex: account.public_key_hex.clone(),
2211            account_hash: account.account_hash.clone(),
2212            balance_motes: DEVNET_INITIAL_BALANCE_USER,
2213        };
2214        summary.push(info.clone());
2215        derived.users.push(info);
2216    }
2217
2218    write_derived_accounts_summary(layout, &summary).await?;
2219
2220    Ok(derived)
2221}
2222
2223async fn setup_chainspec(
2224    layout: &AssetsLayout,
2225    total_nodes: u32,
2226    chainspec_template: &Path,
2227    delay_seconds: u64,
2228    protocol_version_chain: &str,
2229    network_name: &str,
2230    chainspec_overrides: &[String],
2231) -> Result<()> {
2232    let chainspec_dest = layout.net_dir().join("chainspec/chainspec.toml");
2233    copy_file(chainspec_template, &chainspec_dest).await?;
2234
2235    let activation_point = genesis_timestamp(delay_seconds)?;
2236    let chainspec_contents = tokio_fs::read_to_string(&chainspec_dest).await?;
2237    let chainspec_overrides = chainspec_overrides.to_vec();
2238    let protocol_version_chain = protocol_version_chain.to_string();
2239    let network_name = network_name.to_string();
2240    let (updated, overwritten_builtin_paths) = spawn_blocking_result(move || {
2241        let applied_overrides =
2242            chainspec_override::apply(&chainspec_contents, &chainspec_overrides)?;
2243        update_chainspec_contents(
2244            &applied_overrides.contents,
2245            &protocol_version_chain,
2246            &activation_point,
2247            true,
2248            &network_name,
2249            total_nodes,
2250        )
2251        .map(|updated| (updated, applied_overrides.overwritten_builtin_paths))
2252    })
2253    .await?;
2254
2255    for path in overwritten_builtin_paths {
2256        eprintln!("warning: chainspec override {path} is overwritten by start command defaults");
2257    }
2258
2259    tokio_fs::write(&chainspec_dest, updated).await?;
2260
2261    Ok(())
2262}
2263
2264async fn setup_accounts(
2265    layout: &AssetsLayout,
2266    total_nodes: u32,
2267    genesis_nodes: u32,
2268    users: u32,
2269    derived_accounts: &DerivedAccounts,
2270) -> Result<()> {
2271    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
2272    struct NodeAccount {
2273        node_id: u32,
2274        public_key: String,
2275        is_genesis: bool,
2276    }
2277
2278    struct UserAccount {
2279        user_id: u32,
2280        public_key: String,
2281        validator_key: Option<String>,
2282    }
2283
2284    if derived_accounts.nodes.len() != total_nodes as usize {
2285        return Err(anyhow!(
2286            "expected {} validator accounts, got {}",
2287            total_nodes,
2288            derived_accounts.nodes.len()
2289        ));
2290    }
2291    if derived_accounts.users.len() != users as usize {
2292        return Err(anyhow!(
2293            "expected {} user accounts, got {}",
2294            users,
2295            derived_accounts.users.len()
2296        ));
2297    }
2298
2299    let mut node_accounts = Vec::new();
2300    let mut user_accounts = Vec::new();
2301
2302    for node in &derived_accounts.nodes {
2303        node_accounts.push(NodeAccount {
2304            node_id: node.id,
2305            public_key: node.public_key_hex.clone(),
2306            is_genesis: node.id <= genesis_nodes,
2307        });
2308    }
2309
2310    for user in &derived_accounts.users {
2311        let validator_key = if user.id <= genesis_nodes {
2312            Some(
2313                derived_accounts
2314                    .nodes
2315                    .get((user.id - 1) as usize)
2316                    .map(|node| node.public_key_hex.clone())
2317                    .ok_or_else(|| anyhow!("missing validator key for node {}", user.id))?,
2318            )
2319        } else {
2320            None
2321        };
2322        user_accounts.push(UserAccount {
2323            user_id: user.id,
2324            public_key: user.public_key_hex.clone(),
2325            validator_key,
2326        });
2327    }
2328
2329    let contents = spawn_blocking_result(move || {
2330        let mut lines = Vec::new();
2331        for node in node_accounts {
2332            if node.node_id > 1 {
2333                lines.push(String::new());
2334            }
2335            lines.push(format!("# VALIDATOR {}.", node.node_id));
2336            lines.push("[[accounts]]".to_string());
2337            lines.push(format!("public_key = \"{}\"", node.public_key));
2338            lines.push(format!(
2339                "balance = \"{}\"",
2340                DEVNET_INITIAL_BALANCE_VALIDATOR
2341            ));
2342            if node.is_genesis {
2343                lines.push(String::new());
2344                lines.push("[accounts.validator]".to_string());
2345                lines.push(format!(
2346                    "bonded_amount = \"{}\"",
2347                    validator_weight(node.node_id)
2348                ));
2349                lines.push(format!("delegation_rate = {}", node.node_id));
2350            }
2351        }
2352
2353        for user in user_accounts {
2354            lines.push(String::new());
2355            lines.push(format!("# USER {}.", user.user_id));
2356            if let Some(validator_key) = user.validator_key {
2357                lines.push("[[delegators]]".to_string());
2358                lines.push(format!("validator_public_key = \"{}\"", validator_key));
2359                lines.push(format!("delegator_public_key = \"{}\"", user.public_key));
2360                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
2361                lines.push(format!(
2362                    "delegated_amount = \"{}\"",
2363                    DEVNET_INITIAL_DELEGATION_AMOUNT + user.user_id as u128
2364                ));
2365            } else {
2366                lines.push("[[accounts]]".to_string());
2367                lines.push(format!("public_key = \"{}\"", user.public_key));
2368                lines.push(format!("balance = \"{}\"", DEVNET_INITIAL_BALANCE_USER));
2369            }
2370        }
2371
2372        Ok(format!("{}\n", lines.join("\n")))
2373    })
2374    .await?;
2375    tokio_fs::write(&accounts_path, contents).await?;
2376
2377    Ok(())
2378}
2379
2380async fn setup_node_configs(
2381    layout: &AssetsLayout,
2382    total_nodes: u32,
2383    protocol_version_fs: &str,
2384    config_template: &Path,
2385    sidecar_template: &Path,
2386    log_format: &str,
2387) -> Result<()> {
2388    let chainspec_path = layout.net_dir().join("chainspec/chainspec.toml");
2389    let accounts_path = layout.net_dir().join("chainspec/accounts.toml");
2390    let log_format = log_format.to_string();
2391
2392    for node_id in 1..=total_nodes {
2393        let config_root = layout.node_config_root(node_id).join(protocol_version_fs);
2394        tokio_fs::create_dir_all(&config_root).await?;
2395
2396        copy_file(&chainspec_path, &config_root.join("chainspec.toml")).await?;
2397        copy_file(&accounts_path, &config_root.join("accounts.toml")).await?;
2398        copy_file(config_template, &config_root.join("config.toml")).await?;
2399
2400        let config_contents = tokio_fs::read_to_string(config_root.join("config.toml")).await?;
2401        let log_format = log_format.clone();
2402        let bind_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id));
2403        let known = known_addresses(node_id, total_nodes);
2404        let rest_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_REST, node_id));
2405        let sse_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_SSE, node_id));
2406        let binary_address = format!("0.0.0.0:{}", node_port(DEVNET_BASE_PORT_BINARY, node_id));
2407
2408        let diagnostics_socket = diagnostics_socket_path(layout.network_name(), node_id);
2409
2410        let updated_config = spawn_blocking_result(move || {
2411            let mut config_value: toml::Value = toml::from_str(&config_contents)?;
2412
2413            set_string(
2414                &mut config_value,
2415                &["consensus", "secret_key_path"],
2416                "../../keys/secret_key.pem".to_string(),
2417            )?;
2418            set_string(&mut config_value, &["logging", "format"], log_format)?;
2419            set_string(
2420                &mut config_value,
2421                &["network", "bind_address"],
2422                bind_address,
2423            )?;
2424            set_array(&mut config_value, &["network", "known_addresses"], known)?;
2425            set_string(
2426                &mut config_value,
2427                &["storage", "path"],
2428                "../../storage".to_string(),
2429            )?;
2430            set_string(&mut config_value, &["rest_server", "address"], rest_address)?;
2431            set_string(
2432                &mut config_value,
2433                &["event_stream_server", "address"],
2434                sse_address,
2435            )?;
2436
2437            set_string(
2438                &mut config_value,
2439                &["diagnostics_port", "socket_path"],
2440                diagnostics_socket,
2441            )?;
2442
2443            set_string(
2444                &mut config_value,
2445                &["binary_port_server", "address"],
2446                binary_address,
2447            )?;
2448
2449            // Enable requests that are disabled by default for security reasons.
2450            set_bool(
2451                &mut config_value,
2452                &["binary_port_server", "allow_request_get_trie"],
2453                true,
2454            )?;
2455
2456            // Enable speculative execution requests.
2457            set_bool(
2458                &mut config_value,
2459                &["binary_port_server", "allow_request_speculative_exec"],
2460                true,
2461            )?;
2462
2463            Ok(toml::to_string(&config_value)?)
2464        })
2465        .await?;
2466
2467        tokio_fs::write(config_root.join("config.toml"), updated_config).await?;
2468
2469        if is_file(sidecar_template).await {
2470            let sidecar_path = config_root.join("sidecar.toml");
2471            copy_file(sidecar_template, &sidecar_path).await?;
2472
2473            let sidecar_contents = tokio_fs::read_to_string(&sidecar_path).await?;
2474            let rpc_port = node_port(DEVNET_BASE_PORT_RPC, node_id) as i64;
2475            let binary_port = node_port(DEVNET_BASE_PORT_BINARY, node_id) as i64;
2476
2477            let updated_sidecar = spawn_blocking_result(move || {
2478                let mut sidecar_value: toml::Value = toml::from_str(&sidecar_contents)?;
2479                set_string(
2480                    &mut sidecar_value,
2481                    &["rpc_server", "main_server", "ip_address"],
2482                    "0.0.0.0".to_string(),
2483                )?;
2484                set_integer(
2485                    &mut sidecar_value,
2486                    &["rpc_server", "main_server", "port"],
2487                    rpc_port,
2488                )?;
2489                set_string(
2490                    &mut sidecar_value,
2491                    &["rpc_server", "node_client", "ip_address"],
2492                    "0.0.0.0".to_string(),
2493                )?;
2494                set_integer(
2495                    &mut sidecar_value,
2496                    &["rpc_server", "node_client", "port"],
2497                    binary_port,
2498                )?;
2499
2500                Ok(toml::to_string(&sidecar_value)?)
2501            })
2502            .await?;
2503
2504            tokio_fs::write(&sidecar_path, updated_sidecar).await?;
2505        }
2506    }
2507
2508    Ok(())
2509}
2510
2511fn node_port(base: u32, node_id: u32) -> u32 {
2512    base + DEVNET_NET_PORT_OFFSET + node_id
2513}
2514
2515fn bootstrap_address(node_id: u32) -> String {
2516    format!("127.0.0.1:{}", node_port(DEVNET_BASE_PORT_NETWORK, node_id))
2517}
2518
2519fn known_addresses(node_id: u32, total_nodes: u32) -> Vec<String> {
2520    let bootstrap_nodes = BOOTSTRAP_NODES.min(total_nodes);
2521    let mut addresses = Vec::new();
2522    addresses.push(bootstrap_address(1));
2523
2524    if node_id < bootstrap_nodes {
2525        for id in 2..=bootstrap_nodes {
2526            addresses.push(bootstrap_address(id));
2527        }
2528    } else {
2529        let limit = node_id.min(total_nodes);
2530        for id in 2..=limit {
2531            addresses.push(bootstrap_address(id));
2532        }
2533    }
2534
2535    addresses
2536}
2537
2538fn validator_weight(node_id: u32) -> u128 {
2539    DEVNET_VALIDATOR_BASE_WEIGHT + node_id as u128
2540}
2541
2542fn genesis_timestamp(delay_seconds: u64) -> Result<String> {
2543    let ts = OffsetDateTime::now_utc() + Duration::seconds(delay_seconds as i64);
2544    Ok(ts.format(&Rfc3339)?)
2545}
2546
2547fn format_cspr(motes: u128) -> String {
2548    let whole = motes / MOTE_PER_CSPR;
2549    let rem = motes % MOTE_PER_CSPR;
2550    if rem == 0 {
2551        return whole.to_string();
2552    }
2553    let frac = format!("{:09}", rem);
2554    let frac = frac.trim_end_matches('0');
2555    format!("{}.{}", whole, frac)
2556}
2557
2558fn derived_accounts_path(layout: &AssetsLayout) -> PathBuf {
2559    layout.net_dir().join(DERIVED_ACCOUNTS_FILE)
2560}
2561
2562async fn write_derived_accounts_summary(
2563    layout: &AssetsLayout,
2564    accounts: &[DerivedAccountInfo],
2565) -> Result<()> {
2566    let mut lines = Vec::new();
2567    lines.push("kind,name,key_type,derivation,path,account_hash,balance".to_string());
2568    for account in accounts {
2569        lines.push(account.line());
2570    }
2571    tokio_fs::write(derived_accounts_path(layout), lines.join("\n")).await?;
2572    Ok(())
2573}
2574
2575pub async fn derived_accounts_summary(layout: &AssetsLayout) -> Option<String> {
2576    tokio_fs::read_to_string(derived_accounts_path(layout))
2577        .await
2578        .ok()
2579}
2580
2581pub(crate) async fn derive_account_from_seed_path(
2582    seed: Arc<str>,
2583    path: &str,
2584) -> Result<DerivedPathMaterial> {
2585    let seed_for_root = seed.to_string();
2586    let path_for_parse = path.to_string();
2587    spawn_blocking_result(move || {
2588        let root = unsafe_root_from_seed(&seed_for_root)?;
2589        let path = DerivationPath::from_str(&path_for_parse)?;
2590        let material = derive_account_material(&root, &path, true)?;
2591        let secret_key_pem = material
2592            .secret_key_pem
2593            .ok_or_else(|| anyhow!("missing secret key material for {}", path_for_parse))?;
2594
2595        Ok(DerivedPathMaterial {
2596            public_key_hex: material.public_key_hex,
2597            account_hash: material.account_hash,
2598            secret_key_pem,
2599        })
2600    })
2601    .await
2602}
2603
2604struct CustomAssetPaths {
2605    casper_node: PathBuf,
2606    casper_sidecar: PathBuf,
2607    chainspec: PathBuf,
2608    node_config: PathBuf,
2609    sidecar_config: PathBuf,
2610}
2611
2612pub async fn ensure_network_hook_samples(layout: &AssetsLayout) -> Result<()> {
2613    let hooks_dir = layout.hooks_dir();
2614    tokio_fs::create_dir_all(&hooks_dir).await?;
2615    write_executable_script_if_missing(
2616        &hooks_dir.join(PRE_GENESIS_SAMPLE),
2617        PRE_GENESIS_SAMPLE_SCRIPT,
2618    )
2619    .await?;
2620    write_executable_script_if_missing(
2621        &hooks_dir.join(POST_GENESIS_SAMPLE),
2622        POST_GENESIS_SAMPLE_SCRIPT,
2623    )
2624    .await?;
2625    write_executable_script_if_missing(
2626        &hooks_dir.join(BLOCK_ADDED_SAMPLE),
2627        BLOCK_ADDED_SAMPLE_SCRIPT,
2628    )
2629    .await?;
2630    write_executable_script_if_missing(
2631        &hooks_dir.join(PRE_STAGE_PROTOCOL_SAMPLE),
2632        PRE_STAGE_PROTOCOL_SAMPLE_SCRIPT,
2633    )
2634    .await?;
2635    write_executable_script_if_missing(
2636        &hooks_dir.join(POST_STAGE_PROTOCOL_SAMPLE),
2637        POST_STAGE_PROTOCOL_SAMPLE_SCRIPT,
2638    )
2639    .await?;
2640    Ok(())
2641}
2642
2643async fn write_executable_script(path: &Path, contents: impl AsRef<[u8]>) -> Result<()> {
2644    if let Some(parent) = path.parent() {
2645        tokio_fs::create_dir_all(parent).await?;
2646    }
2647    tokio_fs::write(path, contents.as_ref()).await?;
2648    tokio_fs::set_permissions(path, std::fs::Permissions::from_mode(HOOK_FILE_MODE)).await?;
2649    Ok(())
2650}
2651
2652async fn write_executable_script_if_missing(path: &Path, contents: &[u8]) -> Result<()> {
2653    if tokio_fs::symlink_metadata(path).await.is_ok() {
2654        return Ok(());
2655    }
2656    write_executable_script(path, contents).await
2657}
2658
2659fn protocol_version_fs(protocol_version: &str) -> String {
2660    protocol_version.replace('.', "_")
2661}
2662
2663fn hook_log_paths(logs_dir: &Path, hook_name: &str, protocol_version: &str) -> HookLogPaths {
2664    let base = format!("{hook_name}-{}", protocol_version_fs(protocol_version));
2665    HookLogPaths {
2666        stdout: logs_dir.join(format!("{base}.stdout.log")),
2667        stderr: logs_dir.join(format!("{base}.stderr.log")),
2668    }
2669}
2670
2671fn pending_post_stage_protocol_hook_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2672    hooks_dir
2673        .join(format!(".{HOOKS_PENDING_DIR_NAME}"))
2674        .join(format!(
2675            "{POST_STAGE_PROTOCOL_HOOK}-{}.json",
2676            protocol_version_fs(protocol_version)
2677        ))
2678}
2679
2680fn post_stage_protocol_claim_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2681    hooks_dir
2682        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2683        .join(format!(
2684            "{POST_STAGE_PROTOCOL_HOOK}-{}.claimed",
2685            protocol_version_fs(protocol_version)
2686        ))
2687}
2688
2689fn post_stage_protocol_completion_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2690    hooks_dir
2691        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2692        .join(format!(
2693            "{POST_STAGE_PROTOCOL_HOOK}-{}.json",
2694            protocol_version_fs(protocol_version)
2695        ))
2696}
2697
2698fn pending_post_genesis_hook_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2699    hooks_dir
2700        .join(format!(".{HOOKS_PENDING_DIR_NAME}"))
2701        .join(format!(
2702            "{POST_GENESIS_HOOK}-{}.json",
2703            protocol_version_fs(protocol_version)
2704        ))
2705}
2706
2707fn post_genesis_claim_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2708    hooks_dir
2709        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2710        .join(format!(
2711            "{POST_GENESIS_HOOK}-{}.claimed",
2712            protocol_version_fs(protocol_version)
2713        ))
2714}
2715
2716fn post_genesis_completion_path(hooks_dir: &Path, protocol_version: &str) -> PathBuf {
2717    hooks_dir
2718        .join(format!(".{HOOKS_STATUS_DIR_NAME}"))
2719        .join(format!(
2720            "{POST_GENESIS_HOOK}-{}.json",
2721            protocol_version_fs(protocol_version)
2722        ))
2723}
2724
2725pub async fn prepare_genesis_hooks(layout: &AssetsLayout, protocol_version: &str) -> Result<()> {
2726    let protocol_version = parse_protocol_version(protocol_version)?.to_string();
2727    ensure_network_hook_samples(layout).await?;
2728    run_pre_genesis_hook(layout, &protocol_version).await?;
2729    refresh_post_genesis_hook(layout, &protocol_version).await?;
2730    Ok(())
2731}
2732
2733async fn run_pre_stage_protocol_hook(
2734    layout: &AssetsLayout,
2735    protocol_version: &str,
2736    activation_point: u64,
2737) -> Result<()> {
2738    let Some(command_path) = lookup_network_hook(layout, PRE_STAGE_PROTOCOL_HOOK).await? else {
2739        return Ok(());
2740    };
2741
2742    let log_paths = hook_log_paths(
2743        &layout.hook_logs_dir(),
2744        PRE_STAGE_PROTOCOL_HOOK,
2745        protocol_version,
2746    );
2747    let args = vec![
2748        layout.network_name().to_string(),
2749        protocol_version.to_string(),
2750        activation_point.to_string(),
2751    ];
2752    let exit_status = execute_hook_command(
2753        &command_path,
2754        &args,
2755        &layout.hook_work_dir(PRE_STAGE_PROTOCOL_HOOK),
2756        &log_paths,
2757        None,
2758        false,
2759    )
2760    .await?;
2761    if !exit_status.success() {
2762        return Err(anyhow!(
2763            "{} hook {} exited with status {}",
2764            PRE_STAGE_PROTOCOL_HOOK,
2765            command_path.display(),
2766            exit_status
2767        ));
2768    }
2769    Ok(())
2770}
2771
2772async fn lookup_network_hook(layout: &AssetsLayout, hook_name: &str) -> Result<Option<PathBuf>> {
2773    canonicalize_optional_hook(&layout.hooks_dir().join(hook_name)).await
2774}
2775
2776async fn run_pre_genesis_hook(layout: &AssetsLayout, protocol_version: &str) -> Result<()> {
2777    let Some(command_path) = lookup_network_hook(layout, PRE_GENESIS_HOOK).await? else {
2778        return Ok(());
2779    };
2780
2781    let log_paths = hook_log_paths(&layout.hook_logs_dir(), PRE_GENESIS_HOOK, protocol_version);
2782    let args = vec![
2783        layout.network_name().to_string(),
2784        protocol_version.to_string(),
2785    ];
2786    let exit_status = execute_hook_command(
2787        &command_path,
2788        &args,
2789        &layout.hook_work_dir(PRE_GENESIS_HOOK),
2790        &log_paths,
2791        None,
2792        false,
2793    )
2794    .await?;
2795    if !exit_status.success() {
2796        return Err(anyhow!(
2797            "{} hook {} exited with status {}",
2798            PRE_GENESIS_HOOK,
2799            command_path.display(),
2800            exit_status
2801        ));
2802    }
2803    Ok(())
2804}
2805
2806async fn refresh_post_genesis_hook(layout: &AssetsLayout, protocol_version: &str) -> Result<()> {
2807    clear_post_genesis_hook_state(layout, protocol_version).await?;
2808
2809    let Some(command_path) = lookup_network_hook(layout, POST_GENESIS_HOOK).await? else {
2810        return Ok(());
2811    };
2812
2813    tokio_fs::create_dir_all(layout.hooks_pending_dir()).await?;
2814    tokio_fs::create_dir_all(layout.hooks_status_dir()).await?;
2815    tokio_fs::create_dir_all(layout.hook_logs_dir()).await?;
2816
2817    let log_paths = hook_log_paths(&layout.hook_logs_dir(), POST_GENESIS_HOOK, protocol_version);
2818    let pending = PendingPostGenesisHook {
2819        network_name: layout.network_name().to_string(),
2820        protocol_version: protocol_version.to_string(),
2821        command_path,
2822        stdout_path: log_paths.stdout,
2823        stderr_path: log_paths.stderr,
2824    };
2825    write_json_atomic(
2826        &pending_post_genesis_hook_path(&layout.hooks_dir(), protocol_version),
2827        &pending,
2828    )
2829    .await
2830}
2831
2832async fn clear_post_genesis_hook_state(
2833    layout: &AssetsLayout,
2834    protocol_version: &str,
2835) -> Result<()> {
2836    let hooks_dir = layout.hooks_dir();
2837    remove_file_if_exists(&pending_post_genesis_hook_path(
2838        &hooks_dir,
2839        protocol_version,
2840    ))
2841    .await?;
2842    remove_file_if_exists(&post_genesis_claim_path(&hooks_dir, protocol_version)).await?;
2843    remove_file_if_exists(&post_genesis_completion_path(&hooks_dir, protocol_version)).await?;
2844    Ok(())
2845}
2846
2847async fn refresh_post_stage_protocol_hook(
2848    layout: &AssetsLayout,
2849    asset_name: &str,
2850    opts: &StageProtocolOptions,
2851    protocol_version: &str,
2852) -> Result<()> {
2853    clear_post_stage_protocol_hook_state(layout, protocol_version).await?;
2854
2855    let Some(command_path) = lookup_network_hook(layout, POST_STAGE_PROTOCOL_HOOK).await? else {
2856        return Ok(());
2857    };
2858
2859    tokio_fs::create_dir_all(layout.hooks_pending_dir()).await?;
2860    tokio_fs::create_dir_all(layout.hooks_status_dir()).await?;
2861    tokio_fs::create_dir_all(layout.hook_logs_dir()).await?;
2862
2863    let log_paths = hook_log_paths(
2864        &layout.hook_logs_dir(),
2865        POST_STAGE_PROTOCOL_HOOK,
2866        protocol_version,
2867    );
2868    let pending = PendingPostStageProtocolHook {
2869        asset_name: asset_name.to_string(),
2870        network_name: layout.network_name().to_string(),
2871        protocol_version: protocol_version.to_string(),
2872        activation_point: opts.activation_point,
2873        command_path,
2874        stdout_path: log_paths.stdout,
2875        stderr_path: log_paths.stderr,
2876    };
2877    write_json_atomic(
2878        &pending_post_stage_protocol_hook_path(&layout.hooks_dir(), protocol_version),
2879        &pending,
2880    )
2881    .await
2882}
2883
2884async fn clear_post_stage_protocol_hook_state(
2885    layout: &AssetsLayout,
2886    protocol_version: &str,
2887) -> Result<()> {
2888    let hooks_dir = layout.hooks_dir();
2889    remove_file_if_exists(&pending_post_stage_protocol_hook_path(
2890        &hooks_dir,
2891        protocol_version,
2892    ))
2893    .await?;
2894    remove_file_if_exists(&post_stage_protocol_claim_path(
2895        &hooks_dir,
2896        protocol_version,
2897    ))
2898    .await?;
2899    remove_file_if_exists(&post_stage_protocol_completion_path(
2900        &hooks_dir,
2901        protocol_version,
2902    ))
2903    .await?;
2904    Ok(())
2905}
2906
2907pub(crate) async fn run_pending_post_stage_protocol_hook(
2908    hooks_dir: &Path,
2909    network_dir: &Path,
2910    protocol_version: &Version,
2911) -> Result<PendingPostStageProtocolHookResult> {
2912    let protocol_version = protocol_version.to_string();
2913    let pending_path = pending_post_stage_protocol_hook_path(hooks_dir, &protocol_version);
2914    if !is_file(&pending_path).await {
2915        return Ok(PendingPostStageProtocolHookResult::NotRun);
2916    }
2917
2918    let claim_path = post_stage_protocol_claim_path(hooks_dir, &protocol_version);
2919    if !try_create_claim_marker(&claim_path).await? {
2920        return Ok(PendingPostStageProtocolHookResult::NotRun);
2921    }
2922
2923    let pending = read_json_file::<PendingPostStageProtocolHook>(&pending_path).await?;
2924    let args = vec![
2925        pending.network_name.clone(),
2926        pending.protocol_version.clone(),
2927    ];
2928    let completion = match execute_hook_command(
2929        &pending.command_path,
2930        &args,
2931        &network_hook_work_dir(network_dir, POST_STAGE_PROTOCOL_HOOK),
2932        &HookLogPaths {
2933            stdout: pending.stdout_path.clone(),
2934            stderr: pending.stderr_path.clone(),
2935        },
2936        None,
2937        false,
2938    )
2939    .await
2940    {
2941        Ok(exit_status) if exit_status.success() => CompletedPostStageProtocolHook {
2942            asset_name: pending.asset_name.clone(),
2943            network_name: pending.network_name.clone(),
2944            protocol_version: pending.protocol_version.clone(),
2945            activation_point: pending.activation_point,
2946            command_path: pending.command_path.clone(),
2947            stdout_path: pending.stdout_path.clone(),
2948            stderr_path: pending.stderr_path.clone(),
2949            status: HookRunStatus::Success,
2950            exit_code: exit_status.code(),
2951            error: None,
2952            completed_at: OffsetDateTime::now_utc(),
2953        },
2954        Ok(exit_status) => CompletedPostStageProtocolHook {
2955            asset_name: pending.asset_name.clone(),
2956            network_name: pending.network_name.clone(),
2957            protocol_version: pending.protocol_version.clone(),
2958            activation_point: pending.activation_point,
2959            command_path: pending.command_path.clone(),
2960            stdout_path: pending.stdout_path.clone(),
2961            stderr_path: pending.stderr_path.clone(),
2962            status: HookRunStatus::Failure,
2963            exit_code: exit_status.code(),
2964            error: Some(format!(
2965                "{} hook {} exited with status {}",
2966                POST_STAGE_PROTOCOL_HOOK,
2967                pending.command_path.display(),
2968                exit_status
2969            )),
2970            completed_at: OffsetDateTime::now_utc(),
2971        },
2972        Err(err) => CompletedPostStageProtocolHook {
2973            asset_name: pending.asset_name.clone(),
2974            network_name: pending.network_name.clone(),
2975            protocol_version: pending.protocol_version.clone(),
2976            activation_point: pending.activation_point,
2977            command_path: pending.command_path.clone(),
2978            stdout_path: pending.stdout_path.clone(),
2979            stderr_path: pending.stderr_path.clone(),
2980            status: HookRunStatus::Failure,
2981            exit_code: None,
2982            error: Some(err.to_string()),
2983            completed_at: OffsetDateTime::now_utc(),
2984        },
2985    };
2986
2987    write_json_atomic(
2988        &post_stage_protocol_completion_path(hooks_dir, &protocol_version),
2989        &completion,
2990    )
2991    .await?;
2992    remove_file_if_exists(&pending_path).await?;
2993    Ok(match completion.status {
2994        HookRunStatus::Success => PendingPostStageProtocolHookResult::Succeeded,
2995        HookRunStatus::Failure => PendingPostStageProtocolHookResult::Failed(
2996            completion
2997                .error
2998                .clone()
2999                .unwrap_or_else(|| format!("{POST_STAGE_PROTOCOL_HOOK} hook failed")),
3000        ),
3001    })
3002}
3003
3004pub(crate) async fn run_pending_post_genesis_hook(
3005    layout: &AssetsLayout,
3006) -> Result<PendingPostGenesisHookResult> {
3007    let Some(pending_path) =
3008        find_pending_hook_path(&layout.hooks_pending_dir(), POST_GENESIS_HOOK).await?
3009    else {
3010        return Ok(PendingPostGenesisHookResult::NotRun);
3011    };
3012
3013    let pending = read_json_file::<PendingPostGenesisHook>(&pending_path).await?;
3014    let claim_path = post_genesis_claim_path(&layout.hooks_dir(), &pending.protocol_version);
3015    if !try_create_claim_marker(&claim_path).await? {
3016        return Ok(PendingPostGenesisHookResult::NotRun);
3017    }
3018
3019    let args = vec![
3020        pending.network_name.clone(),
3021        pending.protocol_version.clone(),
3022    ];
3023    let completion = match execute_hook_command(
3024        &pending.command_path,
3025        &args,
3026        &layout.hook_work_dir(POST_GENESIS_HOOK),
3027        &HookLogPaths {
3028            stdout: pending.stdout_path.clone(),
3029            stderr: pending.stderr_path.clone(),
3030        },
3031        None,
3032        false,
3033    )
3034    .await
3035    {
3036        Ok(exit_status) if exit_status.success() => CompletedPostGenesisHook {
3037            network_name: pending.network_name.clone(),
3038            protocol_version: pending.protocol_version.clone(),
3039            command_path: pending.command_path.clone(),
3040            stdout_path: pending.stdout_path.clone(),
3041            stderr_path: pending.stderr_path.clone(),
3042            status: HookRunStatus::Success,
3043            exit_code: exit_status.code(),
3044            error: None,
3045            completed_at: OffsetDateTime::now_utc(),
3046        },
3047        Ok(exit_status) => CompletedPostGenesisHook {
3048            network_name: pending.network_name.clone(),
3049            protocol_version: pending.protocol_version.clone(),
3050            command_path: pending.command_path.clone(),
3051            stdout_path: pending.stdout_path.clone(),
3052            stderr_path: pending.stderr_path.clone(),
3053            status: HookRunStatus::Failure,
3054            exit_code: exit_status.code(),
3055            error: Some(format!(
3056                "{} hook {} exited with status {}",
3057                POST_GENESIS_HOOK,
3058                pending.command_path.display(),
3059                exit_status
3060            )),
3061            completed_at: OffsetDateTime::now_utc(),
3062        },
3063        Err(err) => CompletedPostGenesisHook {
3064            network_name: pending.network_name.clone(),
3065            protocol_version: pending.protocol_version.clone(),
3066            command_path: pending.command_path.clone(),
3067            stdout_path: pending.stdout_path.clone(),
3068            stderr_path: pending.stderr_path.clone(),
3069            status: HookRunStatus::Failure,
3070            exit_code: None,
3071            error: Some(err.to_string()),
3072            completed_at: OffsetDateTime::now_utc(),
3073        },
3074    };
3075
3076    write_json_atomic(
3077        &post_genesis_completion_path(&layout.hooks_dir(), &pending.protocol_version),
3078        &completion,
3079    )
3080    .await?;
3081    remove_file_if_exists(&pending_path).await?;
3082    Ok(match completion.status {
3083        HookRunStatus::Success => PendingPostGenesisHookResult::Succeeded,
3084        HookRunStatus::Failure => PendingPostGenesisHookResult::Failed(
3085            completion
3086                .error
3087                .clone()
3088                .unwrap_or_else(|| format!("{POST_GENESIS_HOOK} hook failed")),
3089        ),
3090    })
3091}
3092
3093pub fn spawn_pending_post_genesis_hook(layout: AssetsLayout) {
3094    tokio::spawn(async move {
3095        match run_pending_post_genesis_hook(&layout).await {
3096            Ok(PendingPostGenesisHookResult::NotRun | PendingPostGenesisHookResult::Succeeded) => {}
3097            Ok(PendingPostGenesisHookResult::Failed(err)) => {
3098                eprintln!("warning: {}", err);
3099            }
3100            Err(err) => {
3101                eprintln!("warning: failed to run {POST_GENESIS_HOOK} hook: {}", err);
3102            }
3103        }
3104    });
3105}
3106
3107pub fn spawn_block_added_hook(
3108    layout: AssetsLayout,
3109    protocol_version: String,
3110    payload: serde_json::Value,
3111) {
3112    tokio::spawn(async move {
3113        if let Err(err) = run_block_added_hook(&layout, &protocol_version, payload).await {
3114            eprintln!("warning: failed to run {BLOCK_ADDED_HOOK} hook: {}", err);
3115        }
3116    });
3117}
3118
3119async fn run_block_added_hook(
3120    layout: &AssetsLayout,
3121    protocol_version: &str,
3122    payload: serde_json::Value,
3123) -> Result<()> {
3124    let Some(command_path) = lookup_network_hook(layout, BLOCK_ADDED_HOOK).await? else {
3125        return Ok(());
3126    };
3127
3128    let log_paths = hook_log_paths(&layout.hook_logs_dir(), BLOCK_ADDED_HOOK, protocol_version);
3129    let args = vec![
3130        layout.network_name().to_string(),
3131        protocol_version.to_string(),
3132    ];
3133    let stdin = hook_payload_stdin(&payload)?;
3134    let exit_status = execute_hook_command(
3135        &command_path,
3136        &args,
3137        &layout.hook_work_dir(BLOCK_ADDED_HOOK),
3138        &log_paths,
3139        Some(&stdin),
3140        true,
3141    )
3142    .await?;
3143    if !exit_status.success() {
3144        return Err(anyhow!(
3145            "{} hook {} exited with status {}",
3146            BLOCK_ADDED_HOOK,
3147            command_path.display(),
3148            exit_status
3149        ));
3150    }
3151    Ok(())
3152}
3153
3154async fn execute_hook_command(
3155    command_path: &Path,
3156    args: &[String],
3157    cwd: &Path,
3158    log_paths: &HookLogPaths,
3159    stdin: Option<&[u8]>,
3160    append_logs: bool,
3161) -> Result<std::process::ExitStatus> {
3162    ensure_executable_hook(command_path).await?;
3163    tokio_fs::create_dir_all(cwd).await?;
3164    let hook_name = command_path
3165        .file_name()
3166        .and_then(OsStr::to_str)
3167        .unwrap_or("hook")
3168        .to_string();
3169    let stdout = open_hook_log_file(&log_paths.stdout, append_logs).await?;
3170    let stderr = open_hook_log_file(&log_paths.stderr, append_logs).await?;
3171    let mut command = Command::new(command_path);
3172    command
3173        .args(args)
3174        .current_dir(cwd)
3175        .stdin(if stdin.is_some() {
3176            Stdio::piped()
3177        } else {
3178            Stdio::null()
3179        })
3180        .stdout(Stdio::piped())
3181        .stderr(Stdio::piped());
3182    let mut child = command.spawn()?;
3183    if let Some(stdin) = stdin {
3184        let mut child_stdin = child
3185            .stdin
3186            .take()
3187            .ok_or_else(|| anyhow!("failed to capture stdin for {}", command_path.display()))?;
3188        child_stdin.write_all(stdin).await?;
3189        child_stdin.shutdown().await?;
3190    }
3191    let child_stdout = child
3192        .stdout
3193        .take()
3194        .ok_or_else(|| anyhow!("failed to capture stdout for {}", command_path.display()))?;
3195    let child_stderr = child
3196        .stderr
3197        .take()
3198        .ok_or_else(|| anyhow!("failed to capture stderr for {}", command_path.display()))?;
3199
3200    let stdout_task = tokio::spawn(stream_hook_output(
3201        hook_name.clone(),
3202        "stdout",
3203        BufReader::new(child_stdout),
3204        stdout,
3205    ));
3206    let stderr_task = tokio::spawn(stream_hook_output(
3207        hook_name.clone(),
3208        "stderr",
3209        BufReader::new(child_stderr),
3210        stderr,
3211    ));
3212
3213    let exit_status = child.wait().await?;
3214    stdout_task
3215        .await
3216        .map_err(|err| anyhow!("failed to join {} stdout task: {}", hook_name, err))??;
3217    stderr_task
3218        .await
3219        .map_err(|err| anyhow!("failed to join {} stderr task: {}", hook_name, err))??;
3220    if let Some(message) = hook_finished_message(&hook_name, &exit_status) {
3221        eprintln!("{}", message);
3222    }
3223    Ok(exit_status)
3224}
3225
3226async fn open_hook_log_file(path: &Path, append: bool) -> Result<tokio_fs::File> {
3227    if let Some(parent) = path.parent() {
3228        tokio_fs::create_dir_all(parent).await?;
3229    }
3230    let mut options = tokio_fs::OpenOptions::new();
3231    options.create(true).write(true);
3232    if append {
3233        options.append(true);
3234    } else {
3235        options.truncate(true);
3236    }
3237    options.open(path).await.map_err(Into::into)
3238}
3239
3240async fn stream_hook_output<R>(
3241    hook_name: String,
3242    stream_name: &'static str,
3243    mut reader: BufReader<R>,
3244    mut log_file: tokio_fs::File,
3245) -> Result<()>
3246where
3247    R: tokio::io::AsyncRead + Unpin,
3248{
3249    let mut buf = Vec::new();
3250    loop {
3251        buf.clear();
3252        let bytes_read = reader.read_until(b'\n', &mut buf).await?;
3253        if bytes_read == 0 {
3254            break;
3255        }
3256
3257        log_file.write_all(&buf).await?;
3258        let line = normalize_hook_output_line(&buf);
3259        eprintln!("{hook_name} {stream_name}: {line}");
3260    }
3261    log_file.flush().await?;
3262    Ok(())
3263}
3264
3265fn normalize_hook_output_line(bytes: &[u8]) -> String {
3266    let mut line = String::from_utf8_lossy(bytes).into_owned();
3267    while line.ends_with('\n') || line.ends_with('\r') {
3268        line.pop();
3269    }
3270    line
3271}
3272
3273fn hook_finished_message(hook_name: &str, status: &std::process::ExitStatus) -> Option<String> {
3274    match status.code() {
3275        Some(0) => None,
3276        Some(code) => Some(format!("{hook_name} finished with exit code {code}")),
3277        None => Some(format!("{hook_name} finished with status {status}")),
3278    }
3279}
3280
3281fn hook_payload_stdin(payload: &serde_json::Value) -> Result<Vec<u8>> {
3282    let mut bytes = serde_json::to_vec(payload)?;
3283    bytes.push(b'\n');
3284    Ok(bytes)
3285}
3286
3287async fn ensure_executable_hook(path: &Path) -> Result<()> {
3288    let metadata = tokio_fs::metadata(path).await?;
3289    if !metadata.is_file() {
3290        return Err(anyhow!("hook path {} is not a file", path.display()));
3291    }
3292    if metadata.permissions().mode() & 0o111 == 0 {
3293        return Err(anyhow!("hook path {} is not executable", path.display()));
3294    }
3295    Ok(())
3296}
3297
3298async fn try_create_claim_marker(path: &Path) -> Result<bool> {
3299    if let Some(parent) = path.parent() {
3300        tokio_fs::create_dir_all(parent).await?;
3301    }
3302    match tokio_fs::OpenOptions::new()
3303        .create_new(true)
3304        .write(true)
3305        .open(path)
3306        .await
3307    {
3308        Ok(_) => Ok(true),
3309        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
3310        Err(err) => Err(err.into()),
3311    }
3312}
3313
3314async fn find_pending_hook_path(dir: &Path, hook_name: &str) -> Result<Option<PathBuf>> {
3315    if !is_dir(dir).await {
3316        return Ok(None);
3317    }
3318
3319    let prefix = format!("{hook_name}-");
3320    let mut matches = Vec::new();
3321    let mut entries = tokio_fs::read_dir(dir).await?;
3322    while let Some(entry) = entries.next_entry().await? {
3323        if !entry.file_type().await?.is_file() {
3324            continue;
3325        }
3326        let file_name = entry.file_name();
3327        let file_name = file_name.to_string_lossy();
3328        if file_name.starts_with(&prefix) && file_name.ends_with(".json") {
3329            matches.push(entry.path());
3330        }
3331    }
3332    matches.sort();
3333    Ok(matches.into_iter().next())
3334}
3335
3336async fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<()> {
3337    let bytes = serde_json::to_vec_pretty(value)?;
3338    if let Some(parent) = path.parent() {
3339        tokio_fs::create_dir_all(parent).await?;
3340    }
3341    let tmp_path = path.with_extension(format!(
3342        "{}.tmp-{}",
3343        path.extension().and_then(OsStr::to_str).unwrap_or("json"),
3344        std::process::id()
3345    ));
3346    tokio_fs::write(&tmp_path, bytes).await?;
3347    tokio_fs::rename(&tmp_path, path).await?;
3348    Ok(())
3349}
3350
3351async fn read_json_file<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
3352    let contents = tokio_fs::read(path).await?;
3353    Ok(serde_json::from_slice(&contents)?)
3354}
3355
3356async fn remove_file_if_exists(path: &Path) -> Result<()> {
3357    match tokio_fs::remove_file(path).await {
3358        Ok(()) => Ok(()),
3359        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
3360        Err(err) => Err(err.into()),
3361    }
3362}
3363
3364async fn detect_current_node_log_format(layout: &AssetsLayout) -> Result<String> {
3365    let version_dir = match layout.latest_protocol_version_dir(1).await {
3366        Ok(version_dir) => version_dir,
3367        Err(_) => return Ok("json".to_string()),
3368    };
3369    let config_path = layout
3370        .node_config_root(1)
3371        .join(version_dir)
3372        .join("config.toml");
3373    if !is_file(&config_path).await {
3374        return Ok("json".to_string());
3375    }
3376
3377    let config_contents = tokio_fs::read_to_string(config_path).await?;
3378    spawn_blocking_result(move || {
3379        let value: toml::Value = toml::from_str(&config_contents)?;
3380        let format = value
3381            .get("logging")
3382            .and_then(|logging| logging.get("format"))
3383            .and_then(|format| format.as_str())
3384            .unwrap_or("json")
3385            .to_string();
3386        Ok(format)
3387    })
3388    .await
3389}
3390
3391async fn load_custom_asset(name: &str) -> Result<CustomAssetPaths> {
3392    let asset_dir = custom_asset_path(name).await?;
3393
3394    let casper_node =
3395        canonicalize_required_file(&asset_dir.join("bin").join("casper-node"), "casper-node")
3396            .await?;
3397    let casper_sidecar = canonicalize_required_file(
3398        &asset_dir.join("bin").join("casper-sidecar"),
3399        "casper-sidecar",
3400    )
3401    .await?;
3402    let chainspec =
3403        canonicalize_required_file(&asset_dir.join("chainspec.toml"), "chainspec").await?;
3404    let node_config =
3405        canonicalize_required_file(&asset_dir.join("node-config.toml"), "node-config").await?;
3406    let sidecar_config =
3407        canonicalize_required_file(&asset_dir.join("sidecar-config.toml"), "sidecar-config")
3408            .await?;
3409
3410    Ok(CustomAssetPaths {
3411        casper_node,
3412        casper_sidecar,
3413        chainspec,
3414        node_config,
3415        sidecar_config,
3416    })
3417}
3418
3419async fn canonicalize_optional_hook(path: &Path) -> Result<Option<PathBuf>> {
3420    match tokio_fs::symlink_metadata(path).await {
3421        Ok(metadata) => {
3422            if metadata.is_dir() || !is_file(path).await {
3423                return Err(anyhow!("hook path {} is not a file", path.display()));
3424            }
3425            Ok(Some(tokio_fs::canonicalize(path).await?))
3426        }
3427        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
3428        Err(err) => Err(err.into()),
3429    }
3430}
3431
3432async fn canonicalize_required_file(path: &Path, label: &str) -> Result<PathBuf> {
3433    if !is_file(path).await {
3434        return Err(anyhow!("missing {} file {}", label, path.display()));
3435    }
3436    let canonical = tokio_fs::canonicalize(path).await?;
3437    if !is_file(&canonical).await {
3438        return Err(anyhow!("missing {} file {}", label, canonical.display()));
3439    }
3440    Ok(canonical)
3441}
3442
3443fn validate_custom_asset_name(name: &str) -> Result<()> {
3444    let trimmed = name.trim();
3445    if trimmed.is_empty() {
3446        return Err(anyhow!("custom asset name must not be empty"));
3447    }
3448    if trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") {
3449        return Err(anyhow!(
3450            "custom asset name '{}' contains forbidden path characters",
3451            name
3452        ));
3453    }
3454    if !trimmed
3455        .chars()
3456        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
3457    {
3458        return Err(anyhow!(
3459            "custom asset name '{}' must use [A-Za-z0-9._-]",
3460            name
3461        ));
3462    }
3463    Ok(())
3464}
3465
3466async fn copy_file(src: &Path, dest: &Path) -> Result<()> {
3467    if !is_file(src).await {
3468        return Err(anyhow!("missing source file {}", src.display()));
3469    }
3470    if let Some(parent) = dest.parent() {
3471        tokio_fs::create_dir_all(parent).await?;
3472    }
3473    tokio_fs::copy(src, dest).await?;
3474    Ok(())
3475}
3476
3477async fn symlink_file(src: &Path, dest: &Path) -> Result<()> {
3478    if !is_file(src).await {
3479        return Err(anyhow!("missing source file {}", src.display()));
3480    }
3481    if let Some(parent) = dest.parent() {
3482        tokio_fs::create_dir_all(parent).await?;
3483    }
3484    if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
3485        if metadata.is_dir() {
3486            tokio_fs::remove_dir_all(dest).await?;
3487        } else {
3488            tokio_fs::remove_file(dest).await?;
3489        }
3490    }
3491    tokio_fs::symlink(src, dest).await?;
3492    Ok(())
3493}
3494
3495async fn hardlink_file(src: &Path, dest: &Path) -> Result<()> {
3496    if !is_file(src).await {
3497        return Err(anyhow!("missing source file {}", src.display()));
3498    }
3499    if let Some(parent) = dest.parent() {
3500        tokio_fs::create_dir_all(parent).await?;
3501    }
3502    if let Ok(metadata) = tokio_fs::symlink_metadata(dest).await {
3503        if metadata.is_dir() {
3504            return Err(anyhow!("destination {} is a directory", dest.display()));
3505        }
3506        tokio_fs::remove_file(dest).await?;
3507    }
3508    tokio_fs::hard_link(src, dest).await?;
3509    Ok(())
3510}
3511
3512async fn is_dir(path: &Path) -> bool {
3513    tokio_fs::metadata(path)
3514        .await
3515        .map(|meta| meta.is_dir())
3516        .unwrap_or(false)
3517}
3518
3519async fn is_file(path: &Path) -> bool {
3520    tokio_fs::metadata(path)
3521        .await
3522        .map(|meta| meta.is_file())
3523        .unwrap_or(false)
3524}
3525
3526async fn spawn_blocking_result<F, T>(f: F) -> Result<T>
3527where
3528    F: FnOnce() -> Result<T> + Send + 'static,
3529    T: Send + 'static,
3530{
3531    match task::spawn_blocking(f).await {
3532        Ok(result) => result,
3533        Err(err) => Err(anyhow!("blocking task failed: {}", err)),
3534    }
3535}
3536
3537fn update_chainspec_contents(
3538    contents: &str,
3539    protocol_version: &str,
3540    activation_point: &str,
3541    activation_is_string: bool,
3542    network_name: &str,
3543    total_nodes: u32,
3544) -> Result<String> {
3545    let activation_value = if activation_is_string {
3546        format!("'{activation_point}'")
3547    } else {
3548        activation_point.to_string()
3549    };
3550    let updated =
3551        replace_toml_section_value(contents, "protocol", "activation_point", &activation_value)?;
3552    let updated = replace_toml_section_value(
3553        &updated,
3554        "protocol",
3555        "version",
3556        &format!("'{protocol_version}'"),
3557    )?;
3558    let updated =
3559        replace_toml_section_value(&updated, "network", "name", &format!("'{network_name}'"))?;
3560    replace_toml_section_value(
3561        &updated,
3562        "core",
3563        "validator_slots",
3564        &total_nodes.to_string(),
3565    )
3566}
3567
3568fn replace_toml_section_value(
3569    contents: &str,
3570    section: &str,
3571    key: &str,
3572    value: &str,
3573) -> Result<String> {
3574    let mut lines = Vec::new();
3575    let mut current_section = String::new();
3576    let mut replaced = false;
3577
3578    for line in contents.lines() {
3579        let trimmed = line.trim_start();
3580        if trimmed.starts_with('[') && trimmed.ends_with(']') {
3581            current_section = trimmed
3582                .trim_start_matches('[')
3583                .trim_end_matches(']')
3584                .to_string();
3585            lines.push(line.to_string());
3586            continue;
3587        }
3588
3589        if current_section == section && trimmed.starts_with(&format!("{key} =")) {
3590            let indent = &line[..line.len() - trimmed.len()];
3591            lines.push(format!("{indent}{key} = {value}"));
3592            replaced = true;
3593            continue;
3594        }
3595
3596        lines.push(line.to_string());
3597    }
3598
3599    if !replaced {
3600        return Err(anyhow!("missing {}.{} in chainspec", section, key));
3601    }
3602
3603    let mut output = lines.join("\n");
3604    if contents.ends_with('\n') {
3605        output.push('\n');
3606    }
3607    Ok(output)
3608}
3609
3610fn set_string(root: &mut toml::Value, path: &[&str], value: String) -> Result<()> {
3611    set_value(root, path, toml::Value::String(value))
3612}
3613
3614fn set_integer(root: &mut toml::Value, path: &[&str], value: i64) -> Result<()> {
3615    set_value(root, path, toml::Value::Integer(value))
3616}
3617
3618fn set_array(root: &mut toml::Value, path: &[&str], values: Vec<String>) -> Result<()> {
3619    let array = values.into_iter().map(toml::Value::String).collect();
3620    set_value(root, path, toml::Value::Array(array))
3621}
3622
3623fn set_bool(root: &mut toml::Value, path: &[&str], value: bool) -> Result<()> {
3624    set_value(root, path, toml::Value::Boolean(value))
3625}
3626
3627fn set_joining_sync_mode(root: &mut toml::Value) -> Result<()> {
3628    let table = root
3629        .as_table()
3630        .ok_or_else(|| anyhow!("TOML root is not a table"))?;
3631    let node = table.get("node").and_then(toml::Value::as_table);
3632
3633    if node
3634        .map(|node| node.contains_key("sync_to_genesis"))
3635        .unwrap_or(false)
3636    {
3637        set_bool(root, &["node", "sync_to_genesis"], false)
3638    } else if table.contains_key("sync_to_genesis") {
3639        set_bool(root, &["sync_to_genesis"], false)
3640    } else {
3641        set_string(root, &["node", "sync_handling"], "ttl".to_string())
3642    }
3643}
3644
3645fn set_value(root: &mut toml::Value, path: &[&str], value: toml::Value) -> Result<()> {
3646    let table = root
3647        .as_table_mut()
3648        .ok_or_else(|| anyhow!("TOML root is not a table"))?;
3649
3650    let mut current = table;
3651    for key in &path[..path.len() - 1] {
3652        current = ensure_table(current, key);
3653    }
3654    current.insert(path[path.len() - 1].to_string(), value);
3655    Ok(())
3656}
3657
3658fn ensure_table<'a>(table: &'a mut toml::value::Table, key: &str) -> &'a mut toml::value::Table {
3659    if !table.contains_key(key) {
3660        table.insert(
3661            key.to_string(),
3662            toml::Value::Table(toml::value::Table::new()),
3663        );
3664    }
3665    table
3666        .get_mut(key)
3667        .and_then(|v| v.as_table_mut())
3668        .expect("table entry is not a table")
3669}
3670
3671#[cfg(test)]
3672pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
3673    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
3674    LOCK.get_or_init(|| std::sync::Mutex::new(()))
3675}
3676
3677#[cfg(test)]
3678mod tests {
3679    use super::*;
3680    use serde_json::{Value, json};
3681    use std::env;
3682    use std::ffi::OsString;
3683    use std::os::unix::process::ExitStatusExt;
3684    use std::path::{Path, PathBuf};
3685    use std::sync::{Arc, MutexGuard};
3686    use tempfile::TempDir;
3687
3688    struct TestDataEnv {
3689        _lock: MutexGuard<'static, ()>,
3690        temp_dir: TempDir,
3691        old_home: Option<OsString>,
3692        old_xdg_data_home: Option<OsString>,
3693    }
3694
3695    impl TestDataEnv {
3696        fn new() -> Self {
3697            let lock = crate::assets::test_env_lock()
3698                .lock()
3699                .unwrap_or_else(|poisoned| poisoned.into_inner());
3700            let temp_dir = tempfile::tempdir().expect("temp dir");
3701            let old_home = env::var_os("HOME");
3702            let old_xdg_data_home = env::var_os("XDG_DATA_HOME");
3703            unsafe {
3704                env::set_var("HOME", temp_dir.path());
3705                env::set_var("XDG_DATA_HOME", temp_dir.path().join("xdg-data"));
3706            }
3707            Self {
3708                _lock: lock,
3709                temp_dir,
3710                old_home,
3711                old_xdg_data_home,
3712            }
3713        }
3714
3715        fn root(&self) -> &Path {
3716            self.temp_dir.path()
3717        }
3718    }
3719
3720    impl Drop for TestDataEnv {
3721        fn drop(&mut self) {
3722            if let Some(value) = &self.old_home {
3723                unsafe {
3724                    env::set_var("HOME", value);
3725                }
3726            } else {
3727                unsafe {
3728                    env::remove_var("HOME");
3729                }
3730            }
3731            if let Some(value) = &self.old_xdg_data_home {
3732                unsafe {
3733                    env::set_var("XDG_DATA_HOME", value);
3734                }
3735            } else {
3736                unsafe {
3737                    env::remove_var("XDG_DATA_HOME");
3738                }
3739            }
3740        }
3741    }
3742
3743    async fn create_fake_binary(path: &Path, label: &str) -> Result<()> {
3744        write_executable_script(
3745            path,
3746            &format!(
3747                "#!/bin/sh\nset -eu\nif [ \"${{1:-}}\" = \"--version\" ]; then\n  echo \"{label} 1.0.0\"\n  exit 0\nfi\nexit 0\n"
3748            ),
3749        )
3750        .await
3751    }
3752
3753    async fn create_test_custom_asset_sources(
3754        root: &Path,
3755        name: &str,
3756    ) -> Result<CustomAssetInstallOptions> {
3757        let source_dir = root.join("sources").join(name);
3758        tokio_fs::create_dir_all(&source_dir).await?;
3759        let node_path = source_dir.join("casper-node");
3760        let sidecar_path = source_dir.join("casper-sidecar");
3761        create_fake_binary(&node_path, "casper-node").await?;
3762        create_fake_binary(&sidecar_path, "casper-sidecar").await?;
3763        let chainspec = source_dir.join("chainspec.toml");
3764        let node_config = source_dir.join("node-config.toml");
3765        let sidecar_config = source_dir.join("sidecar-config.toml");
3766        tokio_fs::write(
3767            &chainspec,
3768            "\
3769[protocol]
3770activation_point = 1
3771version = '1.0.0'
3772
3773[network]
3774name = 'casper-dev'
3775
3776[core]
3777validator_slots = 4
3778rewards_handling = { type = 'standard' }
3779",
3780        )
3781        .await?;
3782        tokio_fs::write(&node_config, "").await?;
3783        tokio_fs::write(&sidecar_config, "").await?;
3784        Ok(CustomAssetInstallOptions {
3785            name: name.to_string(),
3786            casper_node: node_path,
3787            casper_sidecar: sidecar_path,
3788            chainspec,
3789            node_config,
3790            sidecar_config,
3791        })
3792    }
3793
3794    async fn install_test_custom_asset(env: &TestDataEnv, name: &str) -> Result<PathBuf> {
3795        let options = create_test_custom_asset_sources(env.root(), name).await?;
3796        install_custom_asset(&options).await?;
3797        custom_asset_path(name).await
3798    }
3799
3800    async fn install_test_versioned_asset(
3801        asset_version: &str,
3802        chainspec_version: &str,
3803    ) -> Result<PathBuf> {
3804        let version = parse_protocol_version(asset_version)?;
3805        let asset_dir = assets_bundle_root()?.join(format!("v{version}"));
3806        tokio_fs::create_dir_all(asset_dir.join("bin")).await?;
3807        create_fake_binary(&asset_dir.join("bin").join("casper-node"), "casper-node").await?;
3808        create_fake_binary(
3809            &asset_dir.join("bin").join("casper-sidecar"),
3810            "casper-sidecar",
3811        )
3812        .await?;
3813        tokio_fs::write(
3814            asset_dir.join("chainspec.toml"),
3815            format!(
3816                "\
3817[protocol]
3818activation_point = 1
3819version = '{chainspec_version}'
3820
3821[network]
3822name = 'casper-dev'
3823
3824[core]
3825validator_slots = 4
3826rewards_handling = {{ type = 'standard' }}
3827"
3828            ),
3829        )
3830        .await?;
3831        tokio_fs::write(asset_dir.join("node-config.toml"), "").await?;
3832        tokio_fs::write(asset_dir.join("sidecar-config.toml"), "").await?;
3833        Ok(asset_dir)
3834    }
3835
3836    async fn create_test_network_layout(
3837        root: &Path,
3838        network_name: &str,
3839        current_version: &str,
3840    ) -> Result<AssetsLayout> {
3841        let layout = AssetsLayout::new(root.join("networks"), network_name.to_string());
3842        let version_fs = protocol_version_fs(current_version);
3843        tokio_fs::create_dir_all(layout.node_bin_dir(1).join(&version_fs)).await?;
3844        tokio_fs::create_dir_all(layout.node_config_root(1).join(&version_fs)).await?;
3845        tokio_fs::create_dir_all(layout.node_dir(1).join("logs")).await?;
3846        tokio_fs::create_dir_all(layout.node_dir(1).join("storage")).await?;
3847        tokio_fs::create_dir_all(layout.node_dir(1).join("keys")).await?;
3848        tokio_fs::create_dir_all(layout.net_dir().join("chainspec")).await?;
3849        tokio_fs::write(
3850            layout
3851                .node_config_root(1)
3852                .join(&version_fs)
3853                .join("config.toml"),
3854            "[logging]\nformat = \"text\"\n",
3855        )
3856        .await?;
3857        tokio_fs::write(layout.net_dir().join("chainspec/accounts.toml"), "").await?;
3858        Ok(layout)
3859    }
3860
3861    async fn create_add_nodes_network_layout(
3862        root: &Path,
3863        network_name: &str,
3864        current_version: &str,
3865        node_count: u32,
3866    ) -> Result<AssetsLayout> {
3867        let layout = AssetsLayout::new(root.join("networks"), network_name.to_string());
3868        let version_fs = protocol_version_fs(current_version);
3869        tokio_fs::create_dir_all(layout.net_dir().join("chainspec")).await?;
3870        tokio_fs::write(
3871            layout.net_dir().join("chainspec/accounts.toml"),
3872            "network accounts\n",
3873        )
3874        .await?;
3875        tokio_fs::write(
3876            derived_accounts_path(&layout),
3877            "kind,name,key_type,derivation,path,account_hash,balance\n",
3878        )
3879        .await?;
3880
3881        for node_id in 1..=node_count {
3882            let bin_dir = layout.node_bin_dir(node_id).join(&version_fs);
3883            let config_dir = layout.node_config_root(node_id).join(&version_fs);
3884            tokio_fs::create_dir_all(&bin_dir).await?;
3885            tokio_fs::create_dir_all(&config_dir).await?;
3886            tokio_fs::create_dir_all(layout.node_dir(node_id).join("keys")).await?;
3887            tokio_fs::create_dir_all(layout.node_dir(node_id).join("logs")).await?;
3888            tokio_fs::create_dir_all(layout.node_dir(node_id).join("storage")).await?;
3889            tokio_fs::write(bin_dir.join("casper-node"), "node binary\n").await?;
3890            tokio_fs::write(bin_dir.join("casper-sidecar"), "sidecar binary\n").await?;
3891            tokio_fs::write(
3892                config_dir.join("chainspec.toml"),
3893                format!("chainspec for node {node_id}\n"),
3894            )
3895            .await?;
3896            tokio_fs::write(config_dir.join("accounts.toml"), "accounts snapshot\n").await?;
3897            tokio_fs::write(
3898                config_dir.join("config.toml"),
3899                "\
3900[logging]
3901format = \"text\"
3902
3903[consensus]
3904secret_key_path = \"old.pem\"
3905
3906[network]
3907bind_address = \"0.0.0.0:1\"
3908known_addresses = []
3909
3910[storage]
3911path = \"old-storage\"
3912
3913[rest_server]
3914address = \"0.0.0.0:2\"
3915
3916[event_stream_server]
3917address = \"0.0.0.0:3\"
3918
3919[diagnostics_port]
3920socket_path = \"old.sock\"
3921
3922[binary_port_server]
3923address = \"0.0.0.0:4\"
3924allow_request_get_trie = false
3925allow_request_speculative_exec = false
3926",
3927            )
3928            .await?;
3929            tokio_fs::write(
3930                config_dir.join("sidecar.toml"),
3931                "\
3932[rpc_server.main_server]
3933ip_address = \"127.0.0.1\"
3934port = 1
3935
3936[rpc_server.node_client]
3937ip_address = \"127.0.0.1\"
3938port = 2
3939",
3940            )
3941            .await?;
3942            tokio_fs::write(
3943                layout.node_config_root(node_id).join(NODE_LAUNCHER_STATE_FILE),
3944                format!(
3945                    "mode = \"RunNodeAsValidator\"\nversion = \"{}\"\nbinary_path = \"{}\"\nconfig_path = \"{}\"\n",
3946                    current_version,
3947                    bin_dir.join("casper-node").display(),
3948                    config_dir.join("config.toml").display(),
3949                ),
3950            )
3951            .await?;
3952        }
3953
3954        Ok(layout)
3955    }
3956
3957    fn stage_options(asset_name: &str, protocol_version: &str) -> StageProtocolOptions {
3958        StageProtocolOptions {
3959            asset: AssetSelector::Custom(asset_name.to_string()),
3960            protocol_version: protocol_version.to_string(),
3961            activation_point: 123,
3962            chainspec_overrides: Vec::new(),
3963        }
3964    }
3965
3966    fn setup_options(asset: AssetSelector, protocol_version: Option<&str>) -> SetupOptions {
3967        SetupOptions {
3968            nodes: 1,
3969            users: None,
3970            delay_seconds: 1,
3971            network_name: "casper-dev".to_string(),
3972            asset,
3973            protocol_version: protocol_version.map(str::to_string),
3974            chainspec_overrides: Vec::new(),
3975            node_log_format: "json".to_string(),
3976            seed: Arc::from("default"),
3977        }
3978    }
3979
3980    async fn generated_chainspec_version(layout: &AssetsLayout, protocol_version: &str) -> String {
3981        let version_fs = protocol_version_fs(protocol_version);
3982        let chainspec = tokio_fs::read_to_string(
3983            layout
3984                .node_config_root(1)
3985                .join(version_fs)
3986                .join("chainspec.toml"),
3987        )
3988        .await
3989        .unwrap();
3990        parse_chainspec_version(&chainspec).unwrap().to_string()
3991    }
3992
3993    fn add_nodes_options(count: u32) -> AddNodesOptions {
3994        AddNodesOptions {
3995            count,
3996            seed: Arc::from("default"),
3997        }
3998    }
3999
4000    async fn add_nodes_for_test(
4001        layout: &AssetsLayout,
4002        opts: &AddNodesOptions,
4003    ) -> Result<AddNodesResult> {
4004        add_nodes_with_trusted_hash(layout, opts, Some("trusted-hash".to_string())).await
4005    }
4006
4007    #[test]
4008    fn format_cspr_handles_whole_and_fractional() {
4009        assert_eq!(format_cspr(0), "0");
4010        assert_eq!(format_cspr(1), "0.000000001");
4011        assert_eq!(format_cspr(1_000_000_000), "1");
4012        assert_eq!(format_cspr(1_000_000_001), "1.000000001");
4013        assert_eq!(format_cspr(123_000_000_000), "123");
4014        assert_eq!(format_cspr(123_000_000_456), "123.000000456");
4015    }
4016
4017    #[test]
4018    fn custom_asset_name_validation() {
4019        assert!(validate_custom_asset_name("dev").is_ok());
4020        assert!(validate_custom_asset_name("v2_2_0-local").is_ok());
4021        assert!(validate_custom_asset_name("").is_err());
4022        assert!(validate_custom_asset_name("../bad").is_err());
4023        assert!(validate_custom_asset_name("with space").is_err());
4024    }
4025
4026    #[test]
4027    fn control_socket_path_uses_system_temp_dir() {
4028        let layout = AssetsLayout::new(PathBuf::from("/tmp/networks"), "casper-dev".to_string());
4029        let socket_path = layout.control_socket_path();
4030        assert_eq!(socket_path, std::env::temp_dir().join("casper-dev.socket"));
4031    }
4032
4033    #[test]
4034    fn control_socket_path_sanitizes_network_name() {
4035        let layout = AssetsLayout::new(
4036            PathBuf::from("/tmp/networks"),
4037            "my/network with spaces".to_string(),
4038        );
4039        let socket_path = layout.control_socket_path();
4040        assert_eq!(
4041            socket_path,
4042            std::env::temp_dir().join("my_network_with_spaces.socket")
4043        );
4044    }
4045
4046    #[test]
4047    fn normalize_hook_output_line_trims_newlines() {
4048        assert_eq!(normalize_hook_output_line(b"hello\n"), "hello");
4049        assert_eq!(normalize_hook_output_line(b"hello\r\n"), "hello");
4050        assert_eq!(normalize_hook_output_line(b"hello"), "hello");
4051        assert_eq!(normalize_hook_output_line(b"\n"), "");
4052    }
4053
4054    #[test]
4055    fn hook_finished_message_uses_exit_code_when_available() {
4056        let status = std::process::ExitStatus::from_raw(7 << 8);
4057        assert_eq!(
4058            hook_finished_message("post-stage-protocol", &status),
4059            Some("post-stage-protocol finished with exit code 7".to_string())
4060        );
4061    }
4062
4063    #[test]
4064    fn hook_finished_message_omits_zero_exit_code() {
4065        let status = std::process::ExitStatus::from_raw(0);
4066        assert_eq!(hook_finished_message("block-added", &status), None);
4067    }
4068
4069    #[test]
4070    fn update_chainspec_contents_preserves_inline_core_tables() {
4071        let original = "\
4072[protocol]
4073activation_point = 1
4074version = '2.1.2'
4075
4076[network]
4077name = 'casper-dev'
4078
4079[core]
4080validator_slots = 4
4081rewards_handling = { type = 'sustain', purse_address = 'uref-abc-007' }
4082trap_on_ambiguous_entity_version = false
4083
4084[highway]
4085maximum_round_length = '17 seconds'
4086";
4087
4088        let updated =
4089            update_chainspec_contents(original, "2.1.3", "2", false, "casper-dev", 5).unwrap();
4090
4091        assert!(
4092            updated.contains(
4093                "rewards_handling = { type = 'sustain', purse_address = 'uref-abc-007' }"
4094            )
4095        );
4096        assert!(!updated.contains("[core.rewards_handling]"));
4097        assert!(updated.contains("activation_point = 2"));
4098        assert!(updated.contains("version = '2.1.3'"));
4099        assert!(updated.contains("validator_slots = 5"));
4100    }
4101
4102    #[tokio::test(flavor = "current_thread")]
4103    async fn setup_versioned_asset_without_override_uses_chainspec_version() {
4104        let env = TestDataEnv::new();
4105        install_test_versioned_asset("2.1.3", "2.2.0")
4106            .await
4107            .unwrap();
4108        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4109
4110        let result = setup_local(
4111            &layout,
4112            &setup_options(AssetSelector::Versioned("2.1.3".to_string()), None),
4113        )
4114        .await
4115        .unwrap();
4116
4117        assert_eq!(result.protocol_version, "2.2.0");
4118        assert!(is_dir(&layout.node_config_root(1).join("2_2_0")).await);
4119        assert_eq!(generated_chainspec_version(&layout, "2.2.0").await, "2.2.0");
4120    }
4121
4122    #[tokio::test(flavor = "current_thread")]
4123    async fn setup_versioned_asset_with_override_uses_override_version() {
4124        let env = TestDataEnv::new();
4125        install_test_versioned_asset("2.1.3", "2.1.3")
4126            .await
4127            .unwrap();
4128        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4129
4130        let result = setup_local(
4131            &layout,
4132            &setup_options(
4133                AssetSelector::Versioned("v2.1.3".to_string()),
4134                Some("2.2.0"),
4135            ),
4136        )
4137        .await
4138        .unwrap();
4139
4140        assert_eq!(result.protocol_version, "2.2.0");
4141        assert!(is_dir(&layout.node_config_root(1).join("2_2_0")).await);
4142        assert_eq!(generated_chainspec_version(&layout, "2.2.0").await, "2.2.0");
4143    }
4144
4145    #[tokio::test(flavor = "current_thread")]
4146    async fn setup_custom_asset_without_override_uses_chainspec_version() {
4147        let env = TestDataEnv::new();
4148        install_test_custom_asset(&env, "dev").await.unwrap();
4149        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4150
4151        let result = setup_local(
4152            &layout,
4153            &setup_options(AssetSelector::Custom("dev".to_string()), None),
4154        )
4155        .await
4156        .unwrap();
4157
4158        assert_eq!(result.protocol_version, "1.0.0");
4159        assert!(is_dir(&layout.node_config_root(1).join("1_0_0")).await);
4160        assert_eq!(generated_chainspec_version(&layout, "1.0.0").await, "1.0.0");
4161    }
4162
4163    #[tokio::test(flavor = "current_thread")]
4164    async fn setup_custom_asset_with_override_uses_override_version() {
4165        let env = TestDataEnv::new();
4166        install_test_custom_asset(&env, "dev").await.unwrap();
4167        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4168
4169        let result = setup_local(
4170            &layout,
4171            &setup_options(AssetSelector::Custom("dev".to_string()), Some("2.0.0")),
4172        )
4173        .await
4174        .unwrap();
4175
4176        assert_eq!(result.protocol_version, "2.0.0");
4177        assert!(is_dir(&layout.node_config_root(1).join("2_0_0")).await);
4178        assert_eq!(generated_chainspec_version(&layout, "2.0.0").await, "2.0.0");
4179    }
4180
4181    #[tokio::test(flavor = "current_thread")]
4182    async fn setup_applies_chainspec_overrides_to_network_and_node_configs() {
4183        let env = TestDataEnv::new();
4184        install_test_versioned_asset("2.1.3", "2.1.3")
4185            .await
4186            .unwrap();
4187        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4188        let mut options = setup_options(AssetSelector::Versioned("2.1.3".to_string()), None);
4189        options.chainspec_overrides = vec![
4190            "core.minimum_era_height=1".to_string(),
4191            "core.test_values=[1, 10]".to_string(),
4192        ];
4193
4194        setup_local(&layout, &options).await.unwrap();
4195
4196        let network_chainspec =
4197            tokio_fs::read_to_string(layout.net_dir().join("chainspec/chainspec.toml"))
4198                .await
4199                .unwrap();
4200        let node_chainspec = tokio_fs::read_to_string(
4201            layout
4202                .node_config_root(1)
4203                .join("2_1_3")
4204                .join("chainspec.toml"),
4205        )
4206        .await
4207        .unwrap();
4208
4209        for chainspec in [network_chainspec, node_chainspec] {
4210            let value: toml::Value = toml::from_str(&chainspec).unwrap();
4211            assert_eq!(
4212                value
4213                    .get("core")
4214                    .and_then(|core| core.get("minimum_era_height"))
4215                    .and_then(toml::Value::as_integer),
4216                Some(1)
4217            );
4218            assert_eq!(
4219                value
4220                    .get("core")
4221                    .and_then(|core| core.get("test_values"))
4222                    .and_then(toml::Value::as_array)
4223                    .map(Vec::len),
4224                Some(2)
4225            );
4226        }
4227    }
4228
4229    #[tokio::test(flavor = "current_thread")]
4230    async fn setup_built_in_chainspec_fields_override_chainspec_overrides() {
4231        let env = TestDataEnv::new();
4232        install_test_versioned_asset("2.1.3", "2.1.3")
4233            .await
4234            .unwrap();
4235        let layout = AssetsLayout::new(env.root().join("networks"), "casper-dev".to_string());
4236        let mut options = setup_options(AssetSelector::Versioned("2.1.3".to_string()), None);
4237        options.nodes = 3;
4238        options.chainspec_overrides = vec!["core.validator_slots=99".to_string()];
4239
4240        setup_local(&layout, &options).await.unwrap();
4241
4242        let chainspec = tokio_fs::read_to_string(
4243            layout
4244                .node_config_root(1)
4245                .join("2_1_3")
4246                .join("chainspec.toml"),
4247        )
4248        .await
4249        .unwrap();
4250        let value: toml::Value = toml::from_str(&chainspec).unwrap();
4251        assert_eq!(
4252            value
4253                .get("core")
4254                .and_then(|core| core.get("validator_slots"))
4255                .and_then(toml::Value::as_integer),
4256            Some(3)
4257        );
4258    }
4259
4260    #[tokio::test(flavor = "current_thread")]
4261    async fn stage_protocol_from_versioned_asset_uses_target_protocol_version() {
4262        let env = TestDataEnv::new();
4263        install_test_versioned_asset("2.1.3", "2.1.3")
4264            .await
4265            .unwrap();
4266        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4267            .await
4268            .unwrap();
4269
4270        stage_protocol(
4271            &layout,
4272            &StageProtocolOptions {
4273                asset: AssetSelector::Versioned("2.1.3".to_string()),
4274                protocol_version: "3.0.0".to_string(),
4275                activation_point: 123,
4276                chainspec_overrides: Vec::new(),
4277            },
4278        )
4279        .await
4280        .unwrap();
4281
4282        assert!(is_dir(&layout.node_config_root(1).join("3_0_0")).await);
4283        assert_eq!(generated_chainspec_version(&layout, "3.0.0").await, "3.0.0");
4284        assert!(
4285            !is_file(&pending_post_stage_protocol_hook_path(
4286                &layout.hooks_dir(),
4287                "3.0.0"
4288            ))
4289            .await
4290        );
4291    }
4292
4293    #[tokio::test(flavor = "current_thread")]
4294    async fn stage_protocol_applies_chainspec_overrides_to_staged_node_configs() {
4295        let env = TestDataEnv::new();
4296        install_test_custom_asset(&env, "dev").await.unwrap();
4297        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 2)
4298            .await
4299            .unwrap();
4300        let mut options = stage_options("dev", "2.0.0");
4301        options.chainspec_overrides = vec![
4302            "core.minimum_era_height=1".to_string(),
4303            "core.test_values=[1, 10]".to_string(),
4304        ];
4305
4306        stage_protocol(&layout, &options).await.unwrap();
4307
4308        for node_id in 1..=2 {
4309            let chainspec = tokio_fs::read_to_string(
4310                layout
4311                    .node_config_root(node_id)
4312                    .join("2_0_0")
4313                    .join("chainspec.toml"),
4314            )
4315            .await
4316            .unwrap();
4317            let value: toml::Value = toml::from_str(&chainspec).unwrap();
4318            assert_eq!(
4319                value
4320                    .get("core")
4321                    .and_then(|core| core.get("minimum_era_height"))
4322                    .and_then(toml::Value::as_integer),
4323                Some(1)
4324            );
4325            assert_eq!(
4326                value
4327                    .get("core")
4328                    .and_then(|core| core.get("test_values"))
4329                    .and_then(toml::Value::as_array)
4330                    .map(Vec::len),
4331                Some(2)
4332            );
4333        }
4334    }
4335
4336    #[tokio::test(flavor = "current_thread")]
4337    async fn stage_protocol_built_in_fields_override_chainspec_overrides() {
4338        let env = TestDataEnv::new();
4339        install_test_custom_asset(&env, "dev").await.unwrap();
4340        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4341            .await
4342            .unwrap();
4343        let mut options = stage_options("dev", "2.0.0");
4344        options.chainspec_overrides = vec![
4345            "protocol.activation_point=999".to_string(),
4346            "core.validator_slots=99".to_string(),
4347        ];
4348
4349        stage_protocol(&layout, &options).await.unwrap();
4350
4351        let chainspec = tokio_fs::read_to_string(
4352            layout
4353                .node_config_root(1)
4354                .join("2_0_0")
4355                .join("chainspec.toml"),
4356        )
4357        .await
4358        .unwrap();
4359        let value: toml::Value = toml::from_str(&chainspec).unwrap();
4360        assert_eq!(
4361            value
4362                .get("protocol")
4363                .and_then(|protocol| protocol.get("activation_point"))
4364                .and_then(toml::Value::as_integer),
4365            Some(123)
4366        );
4367        assert_eq!(
4368            value
4369                .get("core")
4370                .and_then(|core| core.get("validator_slots"))
4371                .and_then(toml::Value::as_integer),
4372            Some(1)
4373        );
4374    }
4375
4376    #[tokio::test(flavor = "current_thread")]
4377    async fn add_nodes_creates_managed_non_genesis_node_assets() {
4378        let env = TestDataEnv::new();
4379        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4380            .await
4381            .unwrap();
4382        let accounts_before =
4383            tokio_fs::read_to_string(layout.net_dir().join("chainspec/accounts.toml"))
4384                .await
4385                .unwrap();
4386
4387        let result = add_nodes_for_test(&layout, &add_nodes_options(1))
4388            .await
4389            .unwrap();
4390
4391        assert_eq!(result.added_node_ids, vec![2]);
4392        assert_eq!(result.total_nodes, 2);
4393        let version_dir = layout.node_config_root(2).join("1_0_0");
4394        assert!(is_file(&layout.node_bin_dir(2).join("1_0_0/casper-node")).await);
4395        assert!(is_file(&layout.node_bin_dir(2).join("1_0_0/casper-sidecar")).await);
4396        assert!(is_file(&version_dir.join("chainspec.toml")).await);
4397        assert!(is_file(&version_dir.join("accounts.toml")).await);
4398        assert!(is_file(&version_dir.join("config.toml")).await);
4399        assert!(is_file(&version_dir.join("sidecar.toml")).await);
4400        assert!(is_file(&layout.node_dir(2).join("keys/secret_key.pem")).await);
4401        assert_eq!(
4402            tokio_fs::read_to_string(layout.net_dir().join("chainspec/accounts.toml"))
4403                .await
4404                .unwrap(),
4405            accounts_before
4406        );
4407
4408        let config: toml::Value = toml::from_str(
4409            &tokio_fs::read_to_string(version_dir.join("config.toml"))
4410                .await
4411                .unwrap(),
4412        )
4413        .unwrap();
4414        assert_eq!(
4415            config
4416                .get("network")
4417                .and_then(|value| value.get("bind_address"))
4418                .and_then(toml::Value::as_str),
4419            Some("0.0.0.0:22102")
4420        );
4421        assert_eq!(
4422            config
4423                .get("rest_server")
4424                .and_then(|value| value.get("address"))
4425                .and_then(toml::Value::as_str),
4426            Some("0.0.0.0:14102")
4427        );
4428        assert_eq!(
4429            config
4430                .get("node")
4431                .and_then(|value| value.get("trusted_hash"))
4432                .and_then(toml::Value::as_str),
4433            Some("trusted-hash")
4434        );
4435        assert_eq!(
4436            config.get("sync_handling").and_then(toml::Value::as_str),
4437            None
4438        );
4439        assert_eq!(
4440            config
4441                .get("node")
4442                .and_then(|value| value.get("sync_handling"))
4443                .and_then(toml::Value::as_str),
4444            Some("ttl")
4445        );
4446
4447        let summary = tokio_fs::read_to_string(derived_accounts_path(&layout))
4448            .await
4449            .unwrap();
4450        assert!(summary.contains("node,node-2,secp256k1,bip32,m/44'/506'/0'/0/1,"));
4451        assert!(summary.trim_end().ends_with(",0"));
4452    }
4453
4454    #[tokio::test(flavor = "current_thread")]
4455    async fn rollback_added_nodes_removes_assets_and_summary_rows() {
4456        let env = TestDataEnv::new();
4457        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4458            .await
4459            .unwrap();
4460        add_nodes_for_test(&layout, &add_nodes_options(1))
4461            .await
4462            .unwrap();
4463
4464        rollback_added_nodes(&layout, &[2]).await.unwrap();
4465
4466        assert!(!is_dir(&layout.node_dir(2)).await);
4467        let summary = tokio_fs::read_to_string(derived_accounts_path(&layout))
4468            .await
4469            .unwrap();
4470        assert!(!summary.contains("node,node-2,"));
4471        assert_eq!(
4472            summary.trim_end(),
4473            "kind,name,key_type,derivation,path,account_hash,balance"
4474        );
4475    }
4476
4477    #[tokio::test(flavor = "current_thread")]
4478    async fn add_nodes_rejects_zero_count() {
4479        let env = TestDataEnv::new();
4480        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4481            .await
4482            .unwrap();
4483
4484        let err = add_nodes_for_test(&layout, &add_nodes_options(0))
4485            .await
4486            .unwrap_err();
4487
4488        assert!(err.to_string().contains("count must be greater than 0"));
4489    }
4490
4491    #[tokio::test(flavor = "current_thread")]
4492    async fn add_nodes_rejects_non_contiguous_existing_nodes() {
4493        let env = TestDataEnv::new();
4494        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4495            .await
4496            .unwrap();
4497        tokio_fs::rename(layout.node_dir(1), layout.node_dir(2))
4498            .await
4499            .unwrap();
4500
4501        let err = add_nodes_for_test(&layout, &add_nodes_options(1))
4502            .await
4503            .unwrap_err();
4504
4505        assert!(err.to_string().contains("contiguous"));
4506    }
4507
4508    #[tokio::test(flavor = "current_thread")]
4509    async fn add_nodes_copies_only_active_version() {
4510        let env = TestDataEnv::new();
4511        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4512            .await
4513            .unwrap();
4514        tokio_fs::create_dir_all(layout.node_bin_dir(1).join("2_0_0"))
4515            .await
4516            .unwrap();
4517        tokio_fs::create_dir_all(layout.node_config_root(1).join("2_0_0"))
4518            .await
4519            .unwrap();
4520        tokio_fs::write(layout.node_bin_dir(1).join("2_0_0/casper-node"), "future")
4521            .await
4522            .unwrap();
4523
4524        add_nodes_for_test(&layout, &add_nodes_options(1))
4525            .await
4526            .unwrap();
4527
4528        assert!(is_dir(&layout.node_bin_dir(2).join("1_0_0")).await);
4529        assert!(!is_dir(&layout.node_bin_dir(2).join("2_0_0")).await);
4530        assert!(!is_dir(&layout.node_config_root(2).join("2_0_0")).await);
4531    }
4532
4533    #[tokio::test(flavor = "current_thread")]
4534    async fn add_nodes_disables_legacy_genesis_sync_mode() {
4535        let env = TestDataEnv::new();
4536        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4537            .await
4538            .unwrap();
4539        let source_config = layout.node_config_root(1).join("1_0_0/config.toml");
4540        let contents = tokio_fs::read_to_string(&source_config).await.unwrap();
4541        tokio_fs::write(
4542            &source_config,
4543            format!("sync_to_genesis = true\n\n{contents}"),
4544        )
4545        .await
4546        .unwrap();
4547
4548        add_nodes_for_test(&layout, &add_nodes_options(1))
4549            .await
4550            .unwrap();
4551
4552        let generated_config = layout.node_config_root(2).join("1_0_0/config.toml");
4553        let config: toml::Value =
4554            toml::from_str(&tokio_fs::read_to_string(generated_config).await.unwrap()).unwrap();
4555        assert_eq!(
4556            config.get("sync_to_genesis").and_then(toml::Value::as_bool),
4557            Some(false)
4558        );
4559        assert!(config.get("sync_handling").is_none());
4560    }
4561
4562    #[tokio::test(flavor = "current_thread")]
4563    async fn add_nodes_disables_legacy_node_genesis_sync_mode() {
4564        let env = TestDataEnv::new();
4565        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4566            .await
4567            .unwrap();
4568        let source_config = layout.node_config_root(1).join("1_0_0/config.toml");
4569        let contents = tokio_fs::read_to_string(&source_config).await.unwrap();
4570        tokio_fs::write(
4571            &source_config,
4572            format!("{contents}\n[node]\nsync_to_genesis = true\n"),
4573        )
4574        .await
4575        .unwrap();
4576
4577        add_nodes_for_test(&layout, &add_nodes_options(1))
4578            .await
4579            .unwrap();
4580
4581        let generated_config = layout.node_config_root(2).join("1_0_0/config.toml");
4582        let config: toml::Value =
4583            toml::from_str(&tokio_fs::read_to_string(generated_config).await.unwrap()).unwrap();
4584        assert_eq!(
4585            config
4586                .get("node")
4587                .and_then(|value| value.get("sync_to_genesis"))
4588                .and_then(toml::Value::as_bool),
4589            Some(false)
4590        );
4591        assert!(
4592            config
4593                .get("node")
4594                .and_then(|value| value.get("sync_handling"))
4595                .is_none()
4596        );
4597    }
4598
4599    #[tokio::test(flavor = "current_thread")]
4600    async fn add_nodes_rejects_migrating_launcher_state() {
4601        let env = TestDataEnv::new();
4602        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 1)
4603            .await
4604            .unwrap();
4605        tokio_fs::write(
4606            layout.node_config_root(1).join(NODE_LAUNCHER_STATE_FILE),
4607            "mode = \"MigrateData\"\n",
4608        )
4609        .await
4610        .unwrap();
4611
4612        let err = add_nodes_for_test(&layout, &add_nodes_options(1))
4613            .await
4614            .unwrap_err();
4615
4616        assert!(err.to_string().contains("migration completes"));
4617    }
4618
4619    #[tokio::test(flavor = "current_thread")]
4620    async fn add_nodes_rejects_mixed_active_versions() {
4621        let env = TestDataEnv::new();
4622        let layout = create_add_nodes_network_layout(env.root(), "casper-dev", "1.0.0", 2)
4623            .await
4624            .unwrap();
4625        tokio_fs::write(
4626            layout.node_config_root(2).join(NODE_LAUNCHER_STATE_FILE),
4627            "mode = \"RunNodeAsValidator\"\nversion = \"2.0.0\"\n",
4628        )
4629        .await
4630        .unwrap();
4631
4632        let err = add_nodes_for_test(&layout, &add_nodes_options(1))
4633            .await
4634            .unwrap_err();
4635
4636        assert!(
4637            err.to_string()
4638                .contains("active protocol versions are mixed")
4639        );
4640    }
4641
4642    #[tokio::test(flavor = "current_thread")]
4643    async fn ensure_network_hook_samples_creates_executable_samples() {
4644        let env = TestDataEnv::new();
4645        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4646            .await
4647            .unwrap();
4648
4649        ensure_network_hook_samples(&layout).await.unwrap();
4650
4651        for sample in [
4652            PRE_GENESIS_SAMPLE,
4653            POST_GENESIS_SAMPLE,
4654            BLOCK_ADDED_SAMPLE,
4655            PRE_STAGE_PROTOCOL_SAMPLE,
4656            POST_STAGE_PROTOCOL_SAMPLE,
4657        ] {
4658            let sample_path = layout.hooks_dir().join(sample);
4659            assert!(is_file(&sample_path).await);
4660            assert_ne!(
4661                tokio_fs::metadata(&sample_path)
4662                    .await
4663                    .unwrap()
4664                    .permissions()
4665                    .mode()
4666                    & 0o111,
4667                0
4668            );
4669        }
4670    }
4671
4672    #[tokio::test(flavor = "current_thread")]
4673    async fn teardown_preserves_network_hooks_and_removes_other_assets() {
4674        let env = TestDataEnv::new();
4675        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4676            .await
4677            .unwrap();
4678        ensure_network_hook_samples(&layout).await.unwrap();
4679
4680        let active_hook = layout.hooks_dir().join(PRE_GENESIS_HOOK);
4681        let hook_marker = layout.hook_work_dir(PRE_GENESIS_HOOK).join("marker.txt");
4682        let node_marker = layout.node_dir(1).join("storage").join("marker.txt");
4683        let state_path = layout.net_dir().join("state.json");
4684
4685        write_executable_script(&active_hook, "#!/bin/sh\nset -eu\n")
4686            .await
4687            .unwrap();
4688        tokio_fs::create_dir_all(layout.hook_work_dir(PRE_GENESIS_HOOK))
4689            .await
4690            .unwrap();
4691        tokio_fs::write(&hook_marker, "keep").await.unwrap();
4692        tokio_fs::write(&node_marker, "remove").await.unwrap();
4693        tokio_fs::write(&state_path, "{}").await.unwrap();
4694
4695        teardown(&layout).await.unwrap();
4696
4697        assert!(is_dir(&layout.net_dir()).await);
4698        assert!(is_file(&active_hook).await);
4699        assert!(is_file(&hook_marker).await);
4700        assert!(!is_dir(&layout.nodes_dir()).await);
4701        assert!(!is_dir(&layout.net_dir().join("chainspec")).await);
4702        assert!(!is_file(&state_path).await);
4703    }
4704
4705    #[tokio::test(flavor = "current_thread")]
4706    async fn install_custom_asset_installs_only_asset_symlinks() {
4707        let env = TestDataEnv::new();
4708        let asset_dir = install_test_custom_asset(&env, "dev").await.unwrap();
4709
4710        assert!(is_file(&asset_dir.join("bin").join("casper-node")).await);
4711        assert!(is_file(&asset_dir.join("bin").join("casper-sidecar")).await);
4712        assert!(is_file(&asset_dir.join("chainspec.toml")).await);
4713        assert!(is_file(&asset_dir.join("node-config.toml")).await);
4714        assert!(is_file(&asset_dir.join("sidecar-config.toml")).await);
4715        assert!(!is_dir(&asset_dir.join(HOOKS_DIR_NAME)).await);
4716    }
4717
4718    #[tokio::test(flavor = "current_thread")]
4719    async fn install_custom_asset_rejects_duplicate_names_without_mutation() {
4720        let env = TestDataEnv::new();
4721        let asset_dir = install_test_custom_asset(&env, "dev").await.unwrap();
4722        let sentinel = asset_dir.join("sentinel.txt");
4723        tokio_fs::write(&sentinel, "keep").await.unwrap();
4724
4725        let duplicate = create_test_custom_asset_sources(env.root(), "dev")
4726            .await
4727            .unwrap();
4728        let err = install_custom_asset(&duplicate).await.unwrap_err();
4729
4730        assert!(err.to_string().contains("already exists"));
4731        assert_eq!(tokio_fs::read_to_string(&sentinel).await.unwrap(), "keep");
4732    }
4733
4734    #[tokio::test(flavor = "current_thread")]
4735    async fn stage_protocol_ignores_sample_hook_files() {
4736        let env = TestDataEnv::new();
4737        let asset_dir = install_test_custom_asset(&env, "dev").await.unwrap();
4738        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4739            .await
4740            .unwrap();
4741        write_executable_script(
4742            &asset_dir.join(HOOKS_DIR_NAME).join(PRE_STAGE_PROTOCOL_HOOK),
4743            "#!/bin/sh\nset -eu\necho custom-ran > \"$PWD/custom-hook-ran\"\n",
4744        )
4745        .await
4746        .unwrap();
4747        write_executable_script(
4748            &layout.hooks_dir().join(PRE_STAGE_PROTOCOL_SAMPLE),
4749            "#!/bin/sh\nset -eu\necho sample-ran > \"$PWD/sample-hook-ran\"\n",
4750        )
4751        .await
4752        .unwrap();
4753
4754        stage_protocol(&layout, &stage_options("dev", "2.0.0"))
4755            .await
4756            .unwrap();
4757
4758        assert!(
4759            !is_file(
4760                &layout
4761                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
4762                    .join("sample-hook-ran")
4763            )
4764            .await
4765        );
4766        assert!(
4767            !is_file(
4768                &layout
4769                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
4770                    .join("custom-hook-ran")
4771            )
4772            .await
4773        );
4774    }
4775
4776    #[tokio::test(flavor = "current_thread")]
4777    async fn stage_protocol_rolls_back_when_pre_hook_fails() {
4778        let env = TestDataEnv::new();
4779        install_test_custom_asset(&env, "dev").await.unwrap();
4780        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4781            .await
4782            .unwrap();
4783        write_executable_script(
4784            &layout.hooks_dir().join(PRE_STAGE_PROTOCOL_HOOK),
4785            "#!/bin/sh\nset -eu\necho pre-hook-start >&2\nexit 7\n",
4786        )
4787        .await
4788        .unwrap();
4789        write_executable_script(
4790            &layout.hooks_dir().join(POST_STAGE_PROTOCOL_HOOK),
4791            "#!/bin/sh\nset -eu\necho post-hook\n",
4792        )
4793        .await
4794        .unwrap();
4795        let stale_pending = pending_post_stage_protocol_hook_path(&layout.hooks_dir(), "2.0.0");
4796        tokio_fs::create_dir_all(layout.hooks_pending_dir())
4797            .await
4798            .unwrap();
4799        tokio_fs::write(&stale_pending, "stale").await.unwrap();
4800
4801        let err = stage_protocol(&layout, &stage_options("dev", "2.0.0"))
4802            .await
4803            .unwrap_err();
4804
4805        assert!(err.to_string().contains(PRE_STAGE_PROTOCOL_HOOK));
4806        assert!(!is_dir(&layout.node_bin_dir(1).join("2_0_0")).await);
4807        assert!(!is_dir(&layout.node_config_root(1).join("2_0_0")).await);
4808        assert!(
4809            !is_file(&pending_post_stage_protocol_hook_path(
4810                &layout.hooks_dir(),
4811                "2.0.0"
4812            ))
4813            .await
4814        );
4815    }
4816
4817    #[tokio::test(flavor = "current_thread")]
4818    async fn prepare_genesis_hooks_runs_pre_hook_and_writes_pending_post_hook_metadata() {
4819        let env = TestDataEnv::new();
4820        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4821            .await
4822            .unwrap();
4823        write_executable_script(
4824            &layout.hooks_dir().join(PRE_GENESIS_HOOK),
4825            "#!/bin/sh\nset -eu\nprintf '%s\n' \"$PWD\" > \"$PWD/pre-genesis-cwd\"\nprintf '%s,%s\n' \"$1\" \"$2\" > \"$PWD/pre-genesis-args\"\n",
4826        )
4827        .await
4828        .unwrap();
4829        write_executable_script(
4830            &layout.hooks_dir().join(POST_GENESIS_HOOK),
4831            "#!/bin/sh\nset -eu\necho post-genesis\n",
4832        )
4833        .await
4834        .unwrap();
4835
4836        prepare_genesis_hooks(&layout, "1.0.0").await.unwrap();
4837
4838        let expected_hook_dir = tokio_fs::canonicalize(layout.hook_work_dir(PRE_GENESIS_HOOK))
4839            .await
4840            .unwrap();
4841        assert_eq!(
4842            tokio_fs::read_to_string(
4843                layout
4844                    .hook_work_dir(PRE_GENESIS_HOOK)
4845                    .join("pre-genesis-cwd")
4846            )
4847            .await
4848            .unwrap()
4849            .trim(),
4850            expected_hook_dir.display().to_string()
4851        );
4852        assert_eq!(
4853            tokio_fs::read_to_string(
4854                layout
4855                    .hook_work_dir(PRE_GENESIS_HOOK)
4856                    .join("pre-genesis-args"),
4857            )
4858            .await
4859            .unwrap()
4860            .trim(),
4861            "casper-dev,1.0.0"
4862        );
4863
4864        let pending_path = pending_post_genesis_hook_path(&layout.hooks_dir(), "1.0.0");
4865        let pending: Value =
4866            serde_json::from_slice(&tokio_fs::read(&pending_path).await.unwrap()).unwrap();
4867        assert_eq!(pending["network_name"], "casper-dev");
4868        assert_eq!(pending["protocol_version"], "1.0.0");
4869        assert_eq!(
4870            pending["stdout_path"],
4871            layout
4872                .hook_logs_dir()
4873                .join("post-genesis-1_0_0.stdout.log")
4874                .display()
4875                .to_string()
4876        );
4877        assert_eq!(
4878            pending["stderr_path"],
4879            layout
4880                .hook_logs_dir()
4881                .join("post-genesis-1_0_0.stderr.log")
4882                .display()
4883                .to_string()
4884        );
4885    }
4886
4887    #[tokio::test(flavor = "current_thread")]
4888    async fn stage_protocol_runs_pre_hook_and_writes_pending_post_hook_metadata() {
4889        let env = TestDataEnv::new();
4890        install_test_custom_asset(&env, "dev").await.unwrap();
4891        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
4892            .await
4893            .unwrap();
4894        let staged_config_dir = layout.node_config_root(1).join("2_0_0");
4895        write_executable_script(
4896            &layout.hooks_dir().join(PRE_STAGE_PROTOCOL_HOOK),
4897            format!(
4898                "#!/bin/sh\nset -eu\nconfig_dir='{}'\n[ -d \"$config_dir\" ]\n[ -f \"$config_dir/chainspec.toml\" ]\ngrep -q 'minimum_era_height = 1' \"$config_dir/chainspec.toml\"\nprintf '%s\n' \"$PWD\" > \"$PWD/pre-hook-cwd\"\nprintf '%s,%s,%s\n' \"$1\" \"$2\" \"$3\" > \"$PWD/pre-hook-args\"\nprintf '\n[hook]\nran = true\n' >> \"$config_dir/chainspec.toml\"\necho pre-stdout\necho pre-stderr >&2\n",
4899                staged_config_dir.display()
4900            ),
4901        )
4902        .await
4903        .unwrap();
4904        write_executable_script(
4905            &layout.hooks_dir().join(POST_STAGE_PROTOCOL_HOOK),
4906            "#!/bin/sh\nset -eu\necho post-hook\n",
4907        )
4908        .await
4909        .unwrap();
4910
4911        let stale_claim = post_stage_protocol_claim_path(&layout.hooks_dir(), "2.0.0");
4912        let stale_completion = post_stage_protocol_completion_path(&layout.hooks_dir(), "2.0.0");
4913        tokio_fs::create_dir_all(layout.hooks_status_dir())
4914            .await
4915            .unwrap();
4916        tokio_fs::write(&stale_claim, "stale").await.unwrap();
4917        tokio_fs::write(&stale_completion, "stale").await.unwrap();
4918
4919        let mut options = stage_options("dev", "2.0.0");
4920        options.chainspec_overrides = vec!["core.minimum_era_height=1".to_string()];
4921        stage_protocol(&layout, &options).await.unwrap();
4922
4923        let expected_net_dir =
4924            tokio_fs::canonicalize(layout.hook_work_dir(PRE_STAGE_PROTOCOL_HOOK))
4925                .await
4926                .unwrap();
4927        assert_eq!(
4928            tokio_fs::read_to_string(
4929                layout
4930                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
4931                    .join("pre-hook-cwd")
4932            )
4933            .await
4934            .unwrap()
4935            .trim(),
4936            expected_net_dir.display().to_string()
4937        );
4938        assert_eq!(
4939            tokio_fs::read_to_string(
4940                layout
4941                    .hook_work_dir(PRE_STAGE_PROTOCOL_HOOK)
4942                    .join("pre-hook-args"),
4943            )
4944            .await
4945            .unwrap()
4946            .trim(),
4947            "casper-dev,2.0.0,123"
4948        );
4949
4950        let pre_logs = hook_log_paths(&layout.hook_logs_dir(), PRE_STAGE_PROTOCOL_HOOK, "2.0.0");
4951        assert_eq!(
4952            tokio_fs::read_to_string(&pre_logs.stdout)
4953                .await
4954                .unwrap()
4955                .trim(),
4956            "pre-stdout"
4957        );
4958        assert_eq!(
4959            tokio_fs::read_to_string(&pre_logs.stderr)
4960                .await
4961                .unwrap()
4962                .trim(),
4963            "pre-stderr"
4964        );
4965        assert!(
4966            tokio_fs::read_to_string(staged_config_dir.join("chainspec.toml"))
4967                .await
4968                .unwrap()
4969                .contains("[hook]\nran = true")
4970        );
4971
4972        let pending_path = pending_post_stage_protocol_hook_path(&layout.hooks_dir(), "2.0.0");
4973        let pending: Value =
4974            serde_json::from_slice(&tokio_fs::read(&pending_path).await.unwrap()).unwrap();
4975        let expected_post_hook =
4976            tokio_fs::canonicalize(layout.hooks_dir().join(POST_STAGE_PROTOCOL_HOOK))
4977                .await
4978                .unwrap();
4979        assert_eq!(pending["asset_name"], "custom/dev");
4980        assert_eq!(pending["network_name"], "casper-dev");
4981        assert_eq!(pending["protocol_version"], "2.0.0");
4982        assert_eq!(pending["activation_point"], 123);
4983        assert_eq!(
4984            pending["command_path"],
4985            expected_post_hook.display().to_string()
4986        );
4987        assert_eq!(
4988            pending["stdout_path"],
4989            layout
4990                .hook_logs_dir()
4991                .join("post-stage-protocol-2_0_0.stdout.log")
4992                .display()
4993                .to_string()
4994        );
4995        assert_eq!(
4996            pending["stderr_path"],
4997            layout
4998                .hook_logs_dir()
4999                .join("post-stage-protocol-2_0_0.stderr.log")
5000                .display()
5001                .to_string()
5002        );
5003        assert!(!is_file(&stale_claim).await);
5004        assert!(!is_file(&stale_completion).await);
5005    }
5006
5007    #[tokio::test(flavor = "current_thread")]
5008    async fn run_pending_post_genesis_hook_runs_once_with_network_cwd_and_log_redirection() {
5009        let env = TestDataEnv::new();
5010        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
5011            .await
5012            .unwrap();
5013        write_executable_script(
5014            &layout.hooks_dir().join(POST_GENESIS_HOOK),
5015            "#!/bin/sh\nset -eu\nprintf '%s\n' \"$PWD\" > \"$PWD/post-genesis-cwd\"\nprintf '%s,%s\n' \"$1\" \"$2\" > \"$PWD/post-genesis-args\"\necho hook-stdout\necho hook-stderr >&2\necho run >> \"$PWD/post-genesis-count\"\n",
5016        )
5017        .await
5018        .unwrap();
5019
5020        prepare_genesis_hooks(&layout, "1.0.0").await.unwrap();
5021
5022        assert!(matches!(
5023            run_pending_post_genesis_hook(&layout).await.unwrap(),
5024            PendingPostGenesisHookResult::Succeeded
5025        ));
5026        assert!(matches!(
5027            run_pending_post_genesis_hook(&layout).await.unwrap(),
5028            PendingPostGenesisHookResult::NotRun
5029        ));
5030
5031        let expected_net_dir = tokio_fs::canonicalize(layout.hook_work_dir(POST_GENESIS_HOOK))
5032            .await
5033            .unwrap();
5034        assert_eq!(
5035            tokio_fs::read_to_string(
5036                layout
5037                    .hook_work_dir(POST_GENESIS_HOOK)
5038                    .join("post-genesis-cwd"),
5039            )
5040            .await
5041            .unwrap()
5042            .trim(),
5043            expected_net_dir.display().to_string()
5044        );
5045        assert_eq!(
5046            tokio_fs::read_to_string(
5047                layout
5048                    .hook_work_dir(POST_GENESIS_HOOK)
5049                    .join("post-genesis-args"),
5050            )
5051            .await
5052            .unwrap()
5053            .trim(),
5054            "casper-dev,1.0.0"
5055        );
5056        assert_eq!(
5057            tokio_fs::read_to_string(
5058                layout
5059                    .hook_work_dir(POST_GENESIS_HOOK)
5060                    .join("post-genesis-count"),
5061            )
5062            .await
5063            .unwrap()
5064            .trim(),
5065            "run"
5066        );
5067
5068        let post_logs = hook_log_paths(&layout.hook_logs_dir(), POST_GENESIS_HOOK, "1.0.0");
5069        assert_eq!(
5070            tokio_fs::read_to_string(&post_logs.stdout)
5071                .await
5072                .unwrap()
5073                .trim(),
5074            "hook-stdout"
5075        );
5076        assert_eq!(
5077            tokio_fs::read_to_string(&post_logs.stderr)
5078                .await
5079                .unwrap()
5080                .trim(),
5081            "hook-stderr"
5082        );
5083        assert!(is_file(&post_genesis_completion_path(&layout.hooks_dir(), "1.0.0")).await);
5084    }
5085
5086    #[tokio::test(flavor = "current_thread")]
5087    async fn run_block_added_hook_passes_payload_on_stdin_and_appends_logs() {
5088        let env = TestDataEnv::new();
5089        let layout = create_test_network_layout(env.root(), "casper-dev", "1.0.0")
5090            .await
5091            .unwrap();
5092        write_executable_script(
5093            &layout.hooks_dir().join(BLOCK_ADDED_HOOK),
5094            "#!/bin/sh\nset -eu\nprintf '%s,%s\n' \"$1\" \"$2\" >> \"$PWD/block-added-args\"\ncat >> \"$PWD/block-added-payloads\"\necho hook-stdout\necho hook-stderr >&2\n",
5095        )
5096        .await
5097        .unwrap();
5098
5099        run_block_added_hook(
5100            &layout,
5101            "1.0.0",
5102            json!({
5103                "block_hash": "abc",
5104                "height": 1,
5105                "era_id": 0,
5106            }),
5107        )
5108        .await
5109        .unwrap();
5110        run_block_added_hook(
5111            &layout,
5112            "1.0.0",
5113            json!({
5114                "block_hash": "def",
5115                "height": 2,
5116                "era_id": 0,
5117            }),
5118        )
5119        .await
5120        .unwrap();
5121
5122        assert_eq!(
5123            tokio_fs::read_to_string(
5124                layout
5125                    .hook_work_dir(BLOCK_ADDED_HOOK)
5126                    .join("block-added-args"),
5127            )
5128            .await
5129            .unwrap(),
5130            "casper-dev,1.0.0\ncasper-dev,1.0.0\n"
5131        );
5132        assert_eq!(
5133            tokio_fs::read_to_string(
5134                layout
5135                    .hook_work_dir(BLOCK_ADDED_HOOK)
5136                    .join("block-added-payloads"),
5137            )
5138            .await
5139            .unwrap(),
5140            "{\"block_hash\":\"abc\",\"height\":1,\"era_id\":0}\n{\"block_hash\":\"def\",\"height\":2,\"era_id\":0}\n"
5141        );
5142
5143        let logs = hook_log_paths(&layout.hook_logs_dir(), BLOCK_ADDED_HOOK, "1.0.0");
5144        assert_eq!(
5145            tokio_fs::read_to_string(&logs.stdout).await.unwrap(),
5146            "hook-stdout\nhook-stdout\n"
5147        );
5148        assert_eq!(
5149            tokio_fs::read_to_string(&logs.stderr).await.unwrap(),
5150            "hook-stderr\nhook-stderr\n"
5151        );
5152    }
5153}