diff --git a/PLUGIN_TRUST.md b/PLUGIN_TRUST.md index 5e47602a..f1db51d5 100644 --- a/PLUGIN_TRUST.md +++ b/PLUGIN_TRUST.md @@ -10,21 +10,21 @@ and the full plugin lifecycle. Every installed plugin is assigned one of three trust levels at install time: -| Level | When assigned | StarForge behaviour | -|---|---|---| -| `local` | Plugin installed via `--path` (no source URL) | Always loaded without warnings | -| `trusted` | Source URL matches a known trusted prefix | Loaded without warnings | +| Level | When assigned | StarForge behaviour | +| --------- | --------------------------------------------- | ---------------------------------------------------- | +| `local` | Plugin installed via `--path` (no source URL) | Always loaded without warnings | +| `trusted` | Source URL matches a known trusted prefix | Loaded without warnings | | `unknown` | Source URL provided but not in the allow-list | Warning shown on load; `--force` required to install | -### Trusted source prefixes +### Trusted sources -The following URL prefixes are automatically classified as `trusted`: +Trusted sources are defined in the StarForge configuration (`~/.starforge/config.toml`). By default, it includes: - `https://github.com/Nanle-code/starforge-*` - `https://github.com/StarForge-Labs/*` - `https://crates.io/crates/starforge-plugin-*` -Any other source is `unknown`. +Any other source is `unknown` unless explicitly added to the `plugin_trust.trusted_sources` list in your configuration. --- @@ -34,11 +34,11 @@ Plugins are native shared libraries loaded at runtime via `libloading`. Two compatibility checks run before a plugin is executed: 1. **rustc ABI check** — the plugin must be compiled with the same Rust - toolchain version as StarForge. Mismatches cause undefined behaviour + toolchain version as StarForge. Mismatches cause undefined behaviour and are rejected immediately. 2. **Core version check** — the plugin's declared `core_version` major - number must match the running StarForge major version. A plugin built + number must match the running StarForge major version. A plugin built for StarForge `0.x.y` is incompatible with StarForge `1.x.y`. Both checks produce actionable error messages that tell you exactly what @@ -87,6 +87,7 @@ starforge plugin verify my-plugin # verify a specific plugin ``` Checks: + - Library file exists on disk at the registered path - Trust level is `local` or `trusted` diff --git a/src/commands/config.rs b/src/commands/config.rs index 8ccfa4f5..d89519e1 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -1,4 +1,4 @@ -use crate::utils::{config, crypto, print as p}; +use crate::utils::{config, print as p}; use anyhow::Result; use clap::Subcommand; @@ -6,6 +6,16 @@ use clap::Subcommand; pub enum ConfigCommands { /// Show current global configuration Show, + /// Set a scalar configuration value + Set { + /// Configuration key, e.g. telemetry.enabled or network + key: String, + /// New value + value: String, + }, + /// Manage trusted plugin source allowlist + #[command(subcommand)] + PluginTrust(PluginTrustCommands), /// Set global wallet encryption parameters (Argon2id) SetEncryption { /// Argon2 memory cost in KiB (e.g. 65536) @@ -23,9 +33,29 @@ pub enum ConfigCommands { }, } +#[derive(Subcommand)] +pub enum PluginTrustCommands { + /// List trusted plugin sources + List, + /// Add a trusted plugin domain or URL prefix + Add { + /// Domain or URL prefix to trust + source: String, + }, + /// Remove a trusted plugin source + Remove { + /// Domain or URL prefix to remove + source: String, + }, + /// Reset trusted plugin sources to StarForge defaults + Reset, +} + pub fn handle(cmd: ConfigCommands) -> Result<()> { match cmd { ConfigCommands::Show => show(), + ConfigCommands::Set { key, value } => set_value(&key, &value), + ConfigCommands::PluginTrust(cmd) => plugin_trust(cmd), ConfigCommands::SetEncryption { mem, iterations, @@ -42,7 +72,20 @@ fn show() -> Result<()> { p::kv("Config file", &config::config_path().display().to_string()); p::kv("Active network", &cfg.network); - p::kv("Telemetry", if cfg.telemetry_enabled.unwrap_or(false) { "enabled" } else { "disabled" }); + p::kv( + "telemetry.enabled", + &cfg.telemetry_enabled.unwrap_or(false).to_string(), + ); + + println!(); + p::header("Plugin Trust"); + if cfg.plugin_trust.trusted_sources.is_empty() { + p::warn("No trusted remote plugin sources configured."); + } else { + for source in &cfg.plugin_trust.trusted_sources { + p::kv("trusted source", source); + } + } println!(); p::header("Wallet Encryption (Argon2id)"); @@ -61,6 +104,90 @@ fn show() -> Result<()> { Ok(()) } +fn set_value(key: &str, value: &str) -> Result<()> { + let mut cfg = config::load()?; + match key { + "telemetry" | "telemetry.enabled" => { + cfg.telemetry_enabled = Some(parse_bool(value)?); + } + "network" => { + config::validate_network_exists(&cfg, value)?; + cfg.network = value.to_string(); + } + _ => { + anyhow::bail!( + "Unsupported config key '{}'. Supported keys: telemetry.enabled, network", + key + ); + } + } + config::save(&cfg)?; + p::success(&format!("{} set to '{}'", key, value)); + Ok(()) +} + +fn parse_bool(value: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" | "enabled" => Ok(true), + "false" | "0" | "no" | "off" | "disabled" => Ok(false), + _ => anyhow::bail!( + "Expected boolean value for telemetry.enabled, got '{}'", + value + ), + } +} + +fn plugin_trust(cmd: PluginTrustCommands) -> Result<()> { + match cmd { + PluginTrustCommands::List => { + let cfg = config::load()?; + print_plugin_trust_sources(&cfg); + } + PluginTrustCommands::Add { source } => { + let mut cfg = config::load()?; + let added = config::add_trusted_plugin_source(&mut cfg, source.clone())?; + config::save(&cfg)?; + if added { + p::success(&format!("Trusted plugin source added: {}", source.trim())); + } else { + p::info(&format!( + "Trusted plugin source already exists: {}", + source.trim() + )); + } + print_plugin_trust_sources(&cfg); + } + PluginTrustCommands::Remove { source } => { + let mut cfg = config::load()?; + if !config::remove_trusted_plugin_source(&mut cfg, &source) { + anyhow::bail!("Trusted plugin source not found: {}", source.trim()); + } + config::save(&cfg)?; + p::success(&format!("Trusted plugin source removed: {}", source.trim())); + print_plugin_trust_sources(&cfg); + } + PluginTrustCommands::Reset => { + let mut cfg = config::load()?; + config::reset_trusted_plugin_sources(&mut cfg); + config::save(&cfg)?; + p::success("Trusted plugin sources reset to defaults."); + print_plugin_trust_sources(&cfg); + } + } + Ok(()) +} + +fn print_plugin_trust_sources(cfg: &config::Config) { + p::header("Trusted Plugin Sources"); + if cfg.plugin_trust.trusted_sources.is_empty() { + p::warn("No trusted remote plugin sources configured."); + return; + } + for source in &cfg.plugin_trust.trusted_sources { + p::info(&format!("- {}", source)); + } +} + fn set_encryption( mem: Option, iterations: Option, @@ -81,9 +208,15 @@ fn set_encryption( } let mut kdf = cfg.wallet_encryption.unwrap_or_default(); - if let Some(m) = mem { kdf.mem = Some(m); } - if let Some(i) = iterations { kdf.iterations = Some(i); } - if let Some(p) = parallelism { kdf.parallelism = Some(p); } + if let Some(m) = mem { + kdf.mem = Some(m); + } + if let Some(i) = iterations { + kdf.iterations = Some(i); + } + if let Some(p) = parallelism { + kdf.parallelism = Some(p); + } cfg.wallet_encryption = Some(kdf); config::save(&cfg)?; diff --git a/src/commands/deploy.rs b/src/commands/deploy.rs index a645a01d..204201db 100644 --- a/src/commands/deploy.rs +++ b/src/commands/deploy.rs @@ -112,7 +112,9 @@ fn run_dry_run( } // Verify the bytes are non-empty and start with the WASM magic header. if wasm_bytes.len() < 4 || &wasm_bytes[..4] != b"\0asm" { - warnings.push("File does not appear to be a valid WASM binary (missing magic header).".to_string()); + warnings.push( + "File does not appear to be a valid WASM binary (missing magic header).".to_string(), + ); } else { checks_passed += 1; p::success(" Artifact is a valid WASM binary"); @@ -161,7 +163,10 @@ fn run_dry_run( p::info("[ 4/4 ] Estimating Soroban fees via RPC simulation..."); match soroban::simulate_deploy_transaction(wasm_hash, network, wallet) { Ok(simulation) => { - p::kv(" Estimated fee", &format!("{} stroops", simulation.fee)); + p::kv( + " Estimated fee", + &format!("{} stroops", simulation.fee), + ); if !simulation.errors.is_empty() { for error in &simulation.errors { warnings.push(format!("RPC simulation warning: {}", error)); @@ -186,7 +191,10 @@ fn run_dry_run( // ── Summary ─────────────────────────────────────────────────────────── p::separator(); p::header("Deployment Plan Summary"); - p::kv("Checks passed", &format!("{}/{}", checks_passed, checks_total)); + p::kv( + "Checks passed", + &format!("{}/{}", checks_passed, checks_total), + ); p::kv("Network", network); p::kv("Wallet", &wallet.name); p::kv("WASM", &wasm_path.display().to_string()); @@ -312,7 +320,14 @@ pub fn handle(args: DeployArgs) -> Result<()> { // --dry-run: validate everything and print deployment plan, then exit. if args.dry_run { - return run_dry_run(&wasm_path, &wasm_bytes, &wasm_hash, wasm_size_kb, wallet, &args.network); + return run_dry_run( + &wasm_path, + &wasm_bytes, + &wasm_hash, + wasm_size_kb, + wallet, + &args.network, + ); } if args.simulate { diff --git a/src/commands/diagnostics.rs b/src/commands/diagnostics.rs index 48f08125..8f6a5d27 100644 --- a/src/commands/diagnostics.rs +++ b/src/commands/diagnostics.rs @@ -1,7 +1,7 @@ -use clap::Args; -use std::process::Command; use anyhow::{Context, Result}; +use clap::Args; use colored::*; +use std::process::Command; #[derive(Args, Debug)] pub struct DiagnosticsArgs { @@ -13,12 +13,13 @@ pub struct DiagnosticsArgs { /// Handles the `starforge diagnostics` command by bridging execution /// to the internal TypeScript/JavaScript hardware utility layer. pub fn handle(args: DiagnosticsArgs) -> Result<()> { - println!("{}", "🔍 Checking system environment for Node.js runtime...".dimmed()); + println!( + "{}", + "🔍 Checking system environment for Node.js runtime...".dimmed() + ); // 1. Verify Node.js is installed on the user's machine to run TS diagnostics - let node_check = Command::new("node") - .arg("-v") - .output(); + let node_check = Command::new("node").arg("-v").output(); if node_check.is_err() { anyhow::bail!( @@ -31,16 +32,22 @@ pub fn handle(args: DiagnosticsArgs) -> Result<()> { // 2. Prepare arguments to pass downstream to the TypeScript runner // Assumes your built runner script is located in the distribution path or run via ts-node/bundler let mut runner = Command::new("node"); - + // Path points to your project's diagnostics script executor - runner.arg("./dist/diagnostics/run.js"); + runner.arg("./dist/diagnostics/run.js"); if let Some(wallet_type) = args.wallet { runner.arg("--wallet").arg(wallet_type); } - println!("{}", "🚀 Running hardware wallet connectivity utility...".cyan()); - println!("{}\n", "--------------------------------------------------".dimmed()); + println!( + "{}", + "🚀 Running hardware wallet connectivity utility...".cyan() + ); + println!( + "{}\n", + "--------------------------------------------------".dimmed() + ); // 3. Execute the process and inherit standard output streams so colors/formatting are preserved let status = runner @@ -52,4 +59,4 @@ pub fn handle(args: DiagnosticsArgs) -> Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/src/commands/invoke.rs b/src/commands/invoke.rs index 344f2a98..80ad32cb 100644 --- a/src/commands/invoke.rs +++ b/src/commands/invoke.rs @@ -134,7 +134,10 @@ pub fn handle(args: InvokeArgs) -> Result<()> { .add("Contract ID", &args.contract_id) .add("Function", &args.function) .add("Wallet", &args.wallet) - .add("Estimated Fee", &format!("{} stroops", outcome.simulation.fee)) + .add( + "Estimated Fee", + &format!("{} stroops", outcome.simulation.fee), + ) .add("Return Value", &outcome.simulation.return_value); // Add arguments to summary if present diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 4eee5f63..dbce2ff7 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod completions; pub mod config; pub mod contract; pub mod deploy; +pub mod diagnostics; pub mod gas; pub mod info; pub mod inspect; @@ -22,4 +23,3 @@ pub mod tutorial; pub mod tx; pub mod upgrade; pub mod wallet; -pub mod diagnostics; diff --git a/src/commands/network.rs b/src/commands/network.rs index 6fa2621b..b4c56f0a 100644 --- a/src/commands/network.rs +++ b/src/commands/network.rs @@ -245,6 +245,9 @@ fn rename_network(old_name: String, new_name: String) -> Result<()> { config::rename_custom_network(&mut cfg, &old_name, &new_name)?; config::save(&cfg)?; - p::success(&format!("Network renamed from '{}' to '{}'", old_name, new_name)); + p::success(&format!( + "Network renamed from '{}' to '{}'", + old_name, new_name + )); Ok(()) } diff --git a/src/commands/new.rs b/src/commands/new.rs index 2151effd..d4aef458 100644 --- a/src/commands/new.rs +++ b/src/commands/new.rs @@ -223,14 +223,20 @@ fn scaffold_contract( let entry = templates::get_template(&template)?; match templates::check_template_compatibility(&entry) { templates::CompatibilityStatus::Compatible => {} - templates::CompatibilityStatus::TooOld { required_min, running } => { + templates::CompatibilityStatus::TooOld { + required_min, + running, + } => { p::error(&format!( "Template '{}' requires StarForge >= {} but you are running {}.\nPlease upgrade StarForge: https://github.com/Nanle-code/StarForge#installation", entry.name, required_min, running )); return Ok(()); } - templates::CompatibilityStatus::TooNew { required_max, running } => { + templates::CompatibilityStatus::TooNew { + required_max, + running, + } => { p::error(&format!( "Template '{}' only supports StarForge <= {} but you are running {}.\nUse an older StarForge version or choose a compatible template.", entry.name, required_max, running @@ -245,7 +251,7 @@ fn scaffold_contract( return Ok(()); } } - + // Roll back the partially-created directory if any step below fails. let mut target_guard = PathCleanup::new(dir.to_path_buf()); diff --git a/src/commands/plugin.rs b/src/commands/plugin.rs index abc04ec6..281d1107 100644 --- a/src/commands/plugin.rs +++ b/src/commands/plugin.rs @@ -112,7 +112,8 @@ pub fn handle(cmd: PluginCommands) -> Result<()> { fn install(name: String, path: Option, source: Option, force: bool) -> Result<()> { let lib_path = registry::resolve_plugin_library_path(&name, path)?; let source_str = source.as_deref().unwrap_or(""); - let trust = registry::classify_source(source_str); + let config = crate::utils::config::load().unwrap_or_default(); + let trust = registry::classify_source_with_config(source_str, &config); // Warn the user about untrusted sources and require --force to proceed. if trust == TrustLevel::Unknown && !source_str.is_empty() && !force { @@ -122,9 +123,9 @@ fn install(name: String, path: Option, source: Option, force: b source_str )); p::info("Trusted sources:"); - p::info(" • https://github.com/Nanle-code/starforge-*"); - p::info(" • https://github.com/StarForge-Labs/*"); - p::info(" • https://crates.io/crates/starforge-plugin-*"); + for src in &config.plugin_trust.trusted_sources { + p::info(&format!(" • {}", src)); + } p::info(""); p::info("To install anyway: starforge plugin install --source --force"); p::info("To install from a local path (always trusted): starforge plugin install --path "); @@ -137,8 +138,9 @@ fn install(name: String, path: Option, source: Option, force: b let discovered_commands: Vec = { let mut pm = PluginManager::new(); unsafe { - pm.load_plugin(&lib_path) - .with_context(|| format!("Failed to load plugin '{}' to discover commands", name))?; + pm.load_plugin(&lib_path).with_context(|| { + format!("Failed to load plugin '{}' to discover commands", name) + })?; } pm.list_commands() .into_iter() @@ -163,7 +165,10 @@ fn install(name: String, path: Option, source: Option, force: b p::kv_accent("Name", &name); p::kv("Library", &lib_path.display().to_string()); p::kv("Plugin version", &plugin_manifest.version); - p::kv("StarForge compatibility", &plugin_manifest.starforge_version); + p::kv( + "StarForge compatibility", + &plugin_manifest.starforge_version, + ); p::kv("Trust", trust.label()); if !source_str.is_empty() { p::kv("Source", source_str); @@ -215,12 +220,13 @@ fn load() -> Result<()> { return Ok(()); } + let config = crate::utils::config::load().unwrap_or_default(); + // Warn about any unknown-trust plugins before loading. - for pl in reg - .plugins - .iter() - .filter(|p| p.trust == TrustLevel::Unknown && !p.source.is_empty()) - { + for pl in reg.plugins.iter().filter(|p| { + registry::classify_source_with_config(&p.source, &config) == TrustLevel::Unknown + && !p.source.is_empty() + }) { p::warn(&format!( "Plugin '{}' is from an unknown/untrusted source: {}", pl.name, pl.source @@ -239,10 +245,7 @@ fn load() -> Result<()> { // ── Report failures with structured diagnostics ────────────────────────── if !failed.is_empty() { - p::warn(&format!( - "{} plugin(s) failed to load:", - failed.len() - )); + p::warn(&format!("{} plugin(s) failed to load:", failed.len())); for (name, err) in &failed { println!(); p::error(&format!("[{}] {}", err.category(), name)); @@ -281,16 +284,12 @@ fn load() -> Result<()> { fn uninstall(name: String, purge: bool, yes: bool) -> Result<()> { let reg = registry::load_registry().unwrap_or_default(); - let plugin = reg - .plugins - .iter() - .find(|p| p.name == name) - .ok_or_else(|| { - anyhow::anyhow!( - "Plugin '{}' is not installed. Run `starforge plugin list` to see installed plugins.", - name - ) - })?; + let plugin = reg.plugins.iter().find(|p| p.name == name).ok_or_else(|| { + anyhow::anyhow!( + "Plugin '{}' is not installed. Run `starforge plugin list` to see installed plugins.", + name + ) + })?; let lib_path = PathBuf::from(&plugin.path); let lib_exists = lib_path.exists(); @@ -354,11 +353,16 @@ fn update(name: Option, yes: bool) -> Result<()> { return Ok(()); } + let config = crate::utils::config::load().unwrap_or_default(); + let to_update: Vec<_> = match &name { Some(n) => { let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); if found.is_empty() { - anyhow::bail!("Plugin '{}' is not installed. Run `starforge plugin list`.", n); + anyhow::bail!( + "Plugin '{}' is not installed. Run `starforge plugin list`.", + n + ); } found } @@ -400,8 +404,8 @@ fn update(name: Option, yes: bool) -> Result<()> { continue; } - let trust = registry::classify_source(&pl.source); - if trust == registry::TrustLevel::Unknown && !yes { + let trust = registry::classify_source_with_config(&pl.source, &config); + if trust == TrustLevel::Unknown && !yes { p::warn(&format!( " '{}' source '{}' is not trusted. Use --yes to force update from unknown sources.", pl.name, pl.source @@ -438,6 +442,7 @@ fn update(name: Option, yes: bool) -> Result<()> { &pl.source, &pl.starforge_version, &pl.plugin_version, + pl.commands.clone(), )?; p::success(&format!(" '{}' updated via cargo install", pl.name)); updated += 1; @@ -450,7 +455,10 @@ fn update(name: Option, yes: bool) -> Result<()> { failed += 1; } Err(e) => { - p::warn(&format!(" Failed to run cargo: {}. Is Cargo installed?", e)); + p::warn(&format!( + " Failed to run cargo: {}. Is Cargo installed?", + e + )); failed += 1; } } @@ -459,19 +467,47 @@ fn update(name: Option, yes: bool) -> Result<()> { // has been updated since install and refresh the registry timestamp. let metadata = std::fs::metadata(&pl.path); match metadata { - Ok(_) => { - registry::install_plugin( - &pl.name, - std::path::Path::new(&pl.path), - &pl.source, - &pl.starforge_version, - &pl.plugin_version, - )?; - p::success(&format!( - " '{}' registry refreshed from the current library metadata.", - pl.name - )); - updated += 1; + Ok(m) => { + let modified = m + .modified() + .ok() + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + }) + .unwrap_or(0); + + let installed_epoch = 0u64; + + if modified > installed_epoch { + // Library on disk is newer — refresh the registry entry. + let cmds = discover_commands_from_library(&pl.path) + .unwrap_or_else(|_| pl.commands.clone()); + registry::install_plugin( + &pl.name, + std::path::Path::new(&pl.path), + &pl.source, + &pl.starforge_version, + &pl.plugin_version, + cmds, + )?; + p::success(&format!( + " '{}' library on disk is newer — registry refreshed.", + pl.name + )); + updated += 1; + } else { + p::info(&format!( + " '{}' is already up to date. Source: {}", + pl.name, pl.source + )); + p::info( + " To update manually: replace the library at the registered path,", + ); + p::info(&format!(" then run: starforge plugin update {}", pl.name)); + skipped += 1; + } } Err(e) => { p::warn(&format!(" Could not read library metadata: {}", e)); @@ -519,12 +555,14 @@ fn verify(name: Option, deep: bool, runtime_check: bool) -> Result<()> { None => reg.plugins.iter().collect(), }; + let config = crate::utils::config::load().unwrap_or_default(); let mut all_ok = true; for pl in &to_check { let lib_exists = std::path::Path::new(&pl.path).exists(); - let trust_ok = match pl.trust { + let current_trust = registry::classify_source_with_config(&pl.source, &config); + let trust_ok = match current_trust { TrustLevel::Local | TrustLevel::Trusted => true, TrustLevel::Unknown => false, }; @@ -548,7 +586,12 @@ fn verify(name: Option, deep: bool, runtime_check: bool) -> Result<()> { "⚠ untrusted source" }; - println!(" {:<24} [{}] trust={}", pl.name, status, pl.trust.label()); + println!( + " {:<24} [{}] trust={}", + pl.name, + status, + current_trust.label() + ); if !pl.starforge_version.is_empty() { p::kv("StarForge", &pl.starforge_version); } @@ -559,9 +602,9 @@ fn verify(name: Option, deep: bool, runtime_check: bool) -> Result<()> { p::warn(&format!("Library not found at: {}", pl.path)); p::info("Re-install with: starforge plugin install --path "); } - if pl.trust == TrustLevel::Unknown && !pl.source.is_empty() { + if current_trust == TrustLevel::Unknown && !pl.source.is_empty() { p::warn("Source is not in the trusted sources list."); - p::info("See: starforge plugin install --help for trusted source prefixes."); + p::info("Check your CLI config for trusted sources."); } if !compat_ok && !pl.starforge_version.is_empty() { p::warn(&format!( @@ -640,7 +683,10 @@ fn run_audit(name: Option, runtime_check: bool) -> Result<()> { Some(n) => { let found: Vec<_> = reg.plugins.iter().filter(|p| &p.name == n).collect(); if found.is_empty() { - anyhow::bail!("Plugin '{}' is not installed.", n); + anyhow::bail!( + "Plugin '{}' is not installed. Run `starforge plugin list`.", + n + ); } found } diff --git a/src/commands/telemetry.rs b/src/commands/telemetry.rs index 06260091..8b8d30a6 100644 --- a/src/commands/telemetry.rs +++ b/src/commands/telemetry.rs @@ -26,7 +26,7 @@ pub fn handle(cmd: TelemetryCommands) -> Result<()> { let cfg = config::load()?; let enabled = cfg.telemetry_enabled.unwrap_or(true); let env_override = std::env::var("STARFORGE_TELEMETRY").ok(); - + p::header("Telemetry Status"); p::separator(); p::kv("Configured Enabled", &enabled.to_string()); diff --git a/src/commands/template.rs b/src/commands/template.rs index 753d48e1..cfbc9106 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -30,8 +30,8 @@ pub enum TemplateCommands { /// Template name name: String, }, - /// Install a template from a directory or .zip archive into the local registry - Install { + /// Import a template from a directory or .zip archive into the local registry + Import { /// Path to template directory or .zip package path: PathBuf, /// Template name (defaults to directory/archive stem) @@ -133,7 +133,7 @@ pub enum TemplateCommands { pub fn handle(cmd: TemplateCommands) -> Result<()> { match cmd { - TemplateCommands::Install { + TemplateCommands::Import { path, name, description, @@ -142,7 +142,7 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> { version, cli_version_min, cli_version_max, - } => install( + } => import( path, name, description, @@ -201,7 +201,7 @@ pub fn handle(cmd: TemplateCommands) -> Result<()> { } } -fn install( +fn import( path: PathBuf, name: Option, description: Option, @@ -225,8 +225,8 @@ fn install( None, None, )?; - p::header("Template Install"); - p::info("Template package installed into the local registry."); + p::header("Template Import"); + p::info("Template package imported into the local registry."); Ok(()) } @@ -574,7 +574,7 @@ fn info(name: String) -> Result<()> { println!(); p::info("Source & Repository"); p::kv("Source", &template.source.to_string()); - if let Some(ref repo) = template.repository_url { + if let Some(ref repo) = template.repository { p::kv("Repository", repo); } diff --git a/src/commands/tx.rs b/src/commands/tx.rs index 6b9f8538..299779a6 100644 --- a/src/commands/tx.rs +++ b/src/commands/tx.rs @@ -206,7 +206,10 @@ fn handle_batch(args: BatchArgs) -> Result<()> { .add("From Address", &wallet.public_key) .add("Operations", &doc.operations.len().to_string()) .add("Batch File", &args.file.display().to_string()) - .add("Estimated Fee", &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0)); + .add( + "Estimated Fee", + &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0), + ); // Add operation details to summary for (i, op) in payment_ops.iter().enumerate() { @@ -426,7 +429,10 @@ fn handle_send(args: SendArgs) -> Result<()> { .add("From Address", &wallet.public_key) .add("To Address", &args.to) .add("Amount", &format!("{} {}", args.amount, args.asset)) - .add("Estimated Fee", &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0)); + .add( + "Estimated Fee", + &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0), + ); let confirm_config = confirmation::ConfirmationConfig { risk_level, diff --git a/src/commands/upgrade.rs b/src/commands/upgrade.rs index 380a5b83..1de1b85c 100644 --- a/src/commands/upgrade.rs +++ b/src/commands/upgrade.rs @@ -571,7 +571,10 @@ fn handle_execute(args: ExecuteArgs) -> Result<()> { .add("New WASM hash", &proposal.new_wasm_hash) .add("Network", &proposal.network) .add("Executor", &wallet.public_key) - .add("Approvals", &format!("{}/{}", proposal.approvals.len(), proposal.threshold)); + .add( + "Approvals", + &format!("{}/{}", proposal.approvals.len(), proposal.threshold), + ); let confirm_config = confirmation::ConfirmationConfig { risk_level, diff --git a/src/commands/wallet.rs b/src/commands/wallet.rs index 27bdaf8e..d2820d61 100644 --- a/src/commands/wallet.rs +++ b/src/commands/wallet.rs @@ -1,4 +1,6 @@ -use crate::utils::{config, confirmation, crypto, hardware_wallet, horizon, mnemonic, multisig, print as p}; +use crate::utils::{ + config, confirmation, crypto, hardware_wallet, horizon, mnemonic, multisig, print as p, +}; use anyhow::{Context, Result}; use chrono::Utc; use clap::Subcommand; @@ -170,6 +172,9 @@ pub enum WalletCommands { /// Argon2 parallelism factor (requires --encrypt) #[arg(long, requires = "encrypt")] parallelism: Option, + /// Optional backup file path to save a snapshot before rotation + #[arg(long)] + backup: Option, }, /// Export a wallet to a JSON backup file Export { @@ -354,7 +359,9 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { strict, mem, iterations, - } => rotate_wallet(name, fund, network, encrypt, strict, mem, iterations), + parallelism, + backup, + } => rotate_wallet(name, fund, network, encrypt, strict, mem, iterations, parallelism, backup), WalletCommands::Export { name, all, @@ -374,6 +381,7 @@ pub fn handle(cmd: WalletCommands) -> Result<()> { name, file, from_mnemonic, + key, account_index, network, encrypt, @@ -589,7 +597,11 @@ fn create( strict, &context, )?; - crypto::encrypt_secret(&pwd, &secret_key, None)? + crypto::encrypt_secret( + &pwd, + &secret_key, + kdf_options(mem, iterations, parallelism, cfg.wallet_encryption.as_ref()).as_ref(), + )? } else { secret_key.clone() }; @@ -963,16 +975,22 @@ fn merge_wallet( .add("Source Wallet", &wallet.name) .add("Source Address", &wallet.public_key) .add("Destination", &destination) - .add("XLM to Transfer", &format!("{:.7} XLM (minus fee)", xlm_balance)) - .add("Estimated Fee", &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0)) + .add( + "XLM to Transfer", + &format!("{:.7} XLM (minus fee)", xlm_balance), + ) + .add( + "Estimated Fee", + &format!("{:.7} XLM", tx_result.fee as f64 / 10_000_000.0), + ) .add("Remove Local", if remove_local { "Yes" } else { "No" }); let confirm_config = confirmation::ConfirmationConfig { risk_level, network: network.clone(), - skip_confirm: skip_confirm, + skip_confirm, dry_run: false, - prompt: Some(&format!( + prompt: Some(format!( "Type '{}' to confirm merge of account {}:", wallet.name, wallet.name )), @@ -1064,6 +1082,7 @@ fn rotate_wallet( mem: Option, iterations: Option, parallelism: Option, + backup: Option, ) -> Result<()> { config::validate_wallet_name(&name)?; let mut cfg = config::load()?; @@ -1101,17 +1120,19 @@ fn rotate_wallet( .with_context(|| format!("Failed to create {}", parent.display()))?; } } - let passphrase = crypto::prompt_passphrase( - "Set a passphrase to encrypt the backup snapshot", - false, - )?; + let passphrase = + crypto::prompt_passphrase("Set a passphrase to encrypt the backup snapshot", false)?; let encrypted = crypto::encrypt_secret(&passphrase, &json, None)?; fs::write(backup_path, encrypted) .with_context(|| format!("Failed to write backup to {}", backup_path.display()))?; p::success(&format!("Backup written to {}", backup_path.display())); p::info("Keep this file safe — it contains the previous secret key."); } else { - p::step(1, steps, "Skipping backup (pass --backup to save a snapshot)..."); + p::step( + 1, + steps, + "Skipping backup (pass --backup to save a snapshot)...", + ); } p::step(2, steps, "Generating replacement keypair..."); @@ -1138,7 +1159,11 @@ fn rotate_wallet( secret_key.clone() }; - p::step(3, steps, "Archiving previous keypair in rotation history..."); + p::step( + 3, + steps, + "Archiving previous keypair in rotation history...", + ); { let wallet = &mut cfg.wallets[wallet_index]; wallet.rotation_history.push(config::WalletRotationRecord { @@ -1146,7 +1171,11 @@ fn rotate_wallet( previous_public_key: original_public_key.clone(), previous_network: wallet.network.clone(), previous_funded: wallet.funded, - previous_secret_key: if preserve_secret { original_secret_key } else { None }, + previous_secret_key: if preserve_secret { + original_secret_key + } else { + None + }, }); wallet.public_key = public_key.clone(); wallet.secret_key = Some(secret_to_store); @@ -1209,7 +1238,10 @@ fn wallet_history(name: String, reveal: bool) -> Result<()> { return Ok(()); } - p::kv("Total rotations", &wallet.rotation_history.len().to_string()); + p::kv( + "Total rotations", + &wallet.rotation_history.len().to_string(), + ); p::separator(); for (i, record) in wallet.rotation_history.iter().enumerate().rev() { @@ -1217,19 +1249,27 @@ fn wallet_history(name: String, reveal: bool) -> Result<()> { p::kv(" Rotated At", &record.rotated_at); p::kv(" Previous Public Key", &record.previous_public_key); p::kv(" Previous Network", &record.previous_network); - p::kv(" Was Funded", if record.previous_funded { "yes" } else { "no" }); + p::kv( + " Was Funded", + if record.previous_funded { "yes" } else { "no" }, + ); match &record.previous_secret_key { Some(sk) if reveal => { if sk.contains(':') { // Encrypted bundle — prompt for passphrase let pwd = crypto::prompt_password( - &format!("Enter passphrase to decrypt previous key for rotation #{}", i + 1), + &format!( + "Enter passphrase to decrypt previous key for rotation #{}", + i + 1 + ), false, )?; match crypto::decrypt_secret(&pwd, sk) { Ok(plain) => p::kv(" Previous Secret Key", &plain), - Err(_) => p::warn(" Could not decrypt previous secret key (wrong passphrase?)"), + Err(_) => { + p::warn(" Could not decrypt previous secret key (wrong passphrase?)") + } } } else { p::kv(" Previous Secret Key", sk); @@ -1239,7 +1279,10 @@ fn wallet_history(name: String, reveal: bool) -> Result<()> { p::kv(" Previous Secret Key", "(stored — use --reveal to show)"); } None => { - p::kv(" Previous Secret Key", "(not preserved — use --backup on next rotation)"); + p::kv( + " Previous Secret Key", + "(not preserved — use --backup on next rotation)", + ); } } @@ -1253,12 +1296,7 @@ fn wallet_history(name: String, reveal: bool) -> Result<()> { Ok(()) } -fn export_wallet( - name_opt: Option, - all: bool, - output: PathBuf, - strict: bool, -) -> Result<()> { +fn export_wallet(name_opt: Option, all: bool, output: PathBuf, strict: bool) -> Result<()> { let cfg = config::load()?; let wallets_to_export: Vec = if all { cfg.wallets @@ -1294,9 +1332,8 @@ fn export_wallet( wallets: wallets_to_export, }; - let json = serde_json::to_string_pretty(&backup) - .with_context(|| "Failed to serialize wallet backup")?; - let context: Vec<&str> = wallets_to_export + let context: Vec<&str> = backup + .wallets .iter() .flat_map(|wallet| { [ @@ -1306,6 +1343,9 @@ fn export_wallet( ] }) .collect(); + + let json = serde_json::to_string_pretty(&backup) + .with_context(|| "Failed to serialize wallet backup")?; let passphrase = crypto::prompt_passphrase_with_inputs( "Enter passphrase to encrypt backup", strict, @@ -1587,7 +1627,9 @@ mod tests { WalletEntry { name: name.to_string(), public_key: public_key.to_string(), - secret_key: Some("SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string()), + secret_key: Some( + "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(), + ), network: "testnet".to_string(), created_at: "2025-01-01T00:00:00Z".to_string(), funded: true, diff --git a/src/main.rs b/src/main.rs index c0931da1..85dc3484 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,9 +60,9 @@ enum Commands { #[command(subcommand)] Config(commands::config::ConfigCommands), - /// Manage global configuration + /// Manage telemetry collection #[command(subcommand)] - Config(commands::config::ConfigCommands), + Telemetry(commands::telemetry::TelemetryCommands), Tx(commands::tx::TxArgs), // fetch transaction for the account @@ -140,6 +140,7 @@ fn main() { Commands::Deploy(_) => "deploy", Commands::Info => "info", Commands::Config(_) => "config", + Commands::Telemetry(_) => "telemetry", Commands::Tx(_) => "tx", Commands::Network(_) => "network", Commands::Node(_) => "node", @@ -168,6 +169,7 @@ fn main() { Commands::Deploy(args) => commands::deploy::handle(args), Commands::Info => commands::info::handle(), Commands::Config(cmd) => commands::config::handle(cmd), + Commands::Telemetry(cmd) => commands::telemetry::handle(cmd), Commands::Tx(args) => commands::tx::handle(args), Commands::Network(cmd) => commands::network::handle(cmd), Commands::Node(cmd) => commands::node::handle(cmd), @@ -212,6 +214,7 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { let plugin_name = &args[0]; let plugin_args = &args[1..]; + let cfg = utils::config::load()?; let reg = plugins::registry::load_registry().unwrap_or_default(); if reg.plugins.is_empty() { anyhow::bail!( @@ -224,9 +227,13 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { let all_commands = plugins::registry::load_all_registered_commands(); let known = all_commands.iter().any(|c| c.name == *plugin_name); if !known { - let available: Vec = all_commands.iter().map(|c| format!(" • {}", c.name)).collect(); + let available: Vec = all_commands + .iter() + .map(|c| format!(" • {}", c.name)) + .collect(); let hint = if available.is_empty() { - "No plugin commands registered. Re-install plugins to discover their commands.".to_string() + "No plugin commands registered. Re-install plugins to discover their commands." + .to_string() } else { format!("Available plugin commands:\n{}", available.join("\n")) }; @@ -234,11 +241,10 @@ fn handle_external_plugin(args: Vec) -> anyhow::Result<()> { } // Warn about unknown-trust plugins before loading. - for pl in reg - .plugins - .iter() - .filter(|p| p.trust == TrustLevel::Unknown && !p.source.is_empty()) - { + for pl in reg.plugins.iter().filter(|p| { + plugins::registry::classify_source_with_config(&p.source, &cfg) == TrustLevel::Unknown + && !p.source.is_empty() + }) { eprintln!( " ⚠ Warning: plugin '{}' is from an untrusted source: {}", pl.name, pl.source @@ -268,4 +274,4 @@ fn print_banner() { "⚡ Stellar & Soroban Developer CLI".bright_white(), "v0.1.0".dimmed() ); -} \ No newline at end of file +} diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index 7b10c126..63c68402 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -2,12 +2,12 @@ use crate::plugins::interface::{ is_core_version_compatible, Plugin, PluginDeclaration, PluginRegistrar, CORE_VERSION, RUSTC_VERSION, }; -use std::path::Path; use crate::plugins::manifest; use anyhow::Result; use libloading::{Library, Symbol}; use std::collections::HashMap; use std::ffi::OsStr; +use std::path::Path; use std::rc::Rc; /// Structured diagnostic for a plugin loading failure. @@ -18,16 +18,10 @@ use std::rc::Rc; pub enum PluginLoadError { /// The file could not be opened as a shared library (wrong format, missing /// file, OS-level load error, etc.). - InvalidLibrary { - path: String, - detail: String, - }, + InvalidLibrary { path: String, detail: String }, /// The `PLUGIN_DECLARATION` symbol was absent — the binary is not a /// StarForge plugin or was stripped. - MissingRequiredSymbol { - path: String, - symbol: String, - }, + MissingRequiredSymbol { path: String, symbol: String }, /// The plugin was compiled with a different `rustc` version, making the /// Rust ABI incompatible. AbiBuildMismatch { @@ -42,10 +36,7 @@ pub enum PluginLoadError { running_core: String, }, /// The `starforge-plugin.toml` manifest failed validation. - ManifestIncompatible { - path: String, - detail: String, - }, + ManifestIncompatible { path: String, detail: String }, } impl PluginLoadError { @@ -155,12 +146,12 @@ impl PluginManager { // ── Locate the required export symbol ──────────────────────────────── let decl: Symbol<*mut PluginDeclaration> = - library - .get(b"PLUGIN_DECLARATION") - .map_err(|_| PluginLoadError::MissingRequiredSymbol { + library.get(b"PLUGIN_DECLARATION").map_err(|_| { + PluginLoadError::MissingRequiredSymbol { path: path_display.clone(), symbol: "PLUGIN_DECLARATION".to_string(), - })?; + } + })?; let decl = &**decl; @@ -184,10 +175,11 @@ impl PluginManager { // ── Manifest compatibility (if present beside the library) ─────────── if let Ok(Some(mf)) = manifest::load_manifest_for_library(Path::new(path_ref)) { - mf.validate().map_err(|e| PluginLoadError::ManifestIncompatible { - path: path_display.clone(), - detail: e.to_string(), - })?; + mf.validate() + .map_err(|e| PluginLoadError::ManifestIncompatible { + path: path_display.clone(), + detail: e.to_string(), + })?; } let mut registrar = ProxyRegistrar::new(); diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index e289effa..d243912b 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -111,7 +111,10 @@ pub fn load_manifest_for_library(library_path: &Path) -> Result Result { +pub fn require_compatible_manifest( + library_path: &Path, + install_name: &str, +) -> Result { match load_manifest_for_library(library_path)? { Some(manifest) => { if manifest.name != install_name { @@ -163,14 +166,20 @@ fn parse_version_parts(v: &str) -> Option<(u64, u64, u64)> { } fn version_at_least(running: &str, required_min: &str) -> bool { - match (parse_version_parts(running), parse_version_parts(required_min)) { + match ( + parse_version_parts(running), + parse_version_parts(required_min), + ) { (Some(a), Some(b)) => a >= b, _ => true, } } fn version_at_most(running: &str, required_max: &str) -> bool { - match (parse_version_parts(running), parse_version_parts(required_max)) { + match ( + parse_version_parts(running), + parse_version_parts(required_max), + ) { (Some(a), Some(b)) => a <= b, _ => true, } diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index aff2ce00..a4e120d7 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -1,3 +1,4 @@ +use crate::utils::config::{self, Config}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; @@ -28,36 +29,170 @@ impl TrustLevel { } } -/// Prefixes of sources that are automatically given `Trusted` status. -const TRUSTED_SOURCE_PREFIXES: &[&str] = &[ - "https://github.com/Nanle-code/starforge-", - "https://github.com/StarForge-Labs/", - "https://crates.io/crates/starforge-plugin-", -]; - -/// Classify a source URL/path into a trust level. -/// -/// - An empty source (i.e., `--path` was used) → `Local` -/// - A source matching a known trusted prefix → `Trusted` -/// - Everything else → `Unknown` +/// Classify a source URL/path into a trust level using the built-in default +/// allowlist. pub fn classify_source(source: &str) -> TrustLevel { + classify_source_with_config(source, &Config::default()) +} + +/// Classify a source URL/path into a trust level using the configured +/// allowlist. +pub fn classify_source_with_config(source: &str, config: &Config) -> TrustLevel { if source.is_empty() { return TrustLevel::Local; } - for prefix in TRUSTED_SOURCE_PREFIXES { - if source.starts_with(prefix) { + for trusted_source in &config.plugin_trust.trusted_sources { + if source_matches_trusted_source(source, trusted_source) { return TrustLevel::Trusted; } } TrustLevel::Unknown } +pub fn classify_source_from_cli_config(source: &str) -> Result { + let config = config::load()?; + Ok(classify_source_with_config(source, &config)) +} + +pub fn source_matches_trusted_source(source: &str, trusted_source: &str) -> bool { + let source = source.trim(); + let trusted_source = trusted_source.trim(); + if source.is_empty() || trusted_source.is_empty() { + return false; + } + + if let Some(trusted_url) = ParsedSourceUrl::parse(trusted_source) { + let Some(source_url) = ParsedSourceUrl::parse(source) else { + return false; + }; + return trusted_url.matches(&source_url); + } + + let trusted_domain = trusted_source + .strip_prefix("*.") + .unwrap_or(trusted_source) + .strip_suffix('*') + .unwrap_or_else(|| trusted_source.strip_prefix("*.").unwrap_or(trusted_source)) + .trim_end_matches('.') + .to_ascii_lowercase(); + + if trusted_domain.is_empty() { + return false; + } + + let Some(source_host) = extract_host(source) else { + return false; + }; + + source_host == trusted_domain || source_host.ends_with(&format!(".{trusted_domain}")) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ParsedSourceUrl { + scheme: String, + host: String, + path: String, + prefix_match: bool, +} + +impl ParsedSourceUrl { + fn parse(input: &str) -> Option { + let input = input.trim(); + let (scheme, rest) = input.split_once("://")?; + let scheme = scheme.to_ascii_lowercase(); + let prefix_match = input.ends_with('*') || input.ends_with('/') || input.ends_with('-'); + let rest = rest.strip_suffix('*').unwrap_or(rest); + let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..authority_end]; + let host = authority + .rsplit('@') + .next() + .unwrap_or("") + .trim_matches(['[', ']']) + .split(':') + .next() + .unwrap_or("") + .trim_end_matches('.') + .to_ascii_lowercase(); + if host.is_empty() { + return None; + } + + let raw_path = if authority_end < rest.len() { + &rest[authority_end..] + } else { + "/" + }; + let path_end = raw_path.find(['?', '#']).unwrap_or(raw_path.len()); + let path = raw_path[..path_end] + .strip_suffix('*') + .unwrap_or(&raw_path[..path_end]); + let path = if path.is_empty() { "/" } else { path }.to_string(); + + Some(Self { + scheme, + host, + path, + prefix_match, + }) + } + + fn matches(&self, source: &Self) -> bool { + if self.scheme != source.scheme || self.host != source.host { + return false; + } + if self.path == "/" { + return true; + } + if self.prefix_match { + return source.path.starts_with(&self.path); + } + source.path == self.path + } +} + +fn extract_host(source: &str) -> Option { + if let Some(parsed) = ParsedSourceUrl::parse(source) { + return Some(parsed.host); + } + + let source = source.trim(); + if source.is_empty() || source.contains(char::is_whitespace) { + return None; + } + let authority = source + .split(['/', '?', '#']) + .next() + .unwrap_or("") + .rsplit('@') + .next() + .unwrap_or("") + .trim_matches(['[', ']']); + let host = authority + .split(':') + .next() + .unwrap_or("") + .trim_end_matches('.') + .to_ascii_lowercase(); + if host.contains('.') { + Some(host) + } else { + None + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PluginRegistry { #[serde(default)] pub plugins: Vec, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegisteredCommand { + pub name: String, + pub description: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstalledPlugin { pub name: String, @@ -78,6 +213,9 @@ pub struct InstalledPlugin { /// RFC3339 timestamp of when the plugin was installed. #[serde(default)] pub installed_at: Option, + /// Commands registered by the plugin at install time. + #[serde(default)] + pub commands: Vec, } fn registry_path() -> Result { @@ -162,7 +300,9 @@ pub fn install_plugin( anyhow::bail!("Plugin library not found: {}", library_path.display()); } - let trust = classify_source(source); + let trust = classify_source_from_cli_config(source)?; + let now = chrono::Utc::now().to_rfc3339(); + let mut reg = load_registry().unwrap_or_default(); reg.plugins.retain(|p| p.name != name); reg.plugins.push(InstalledPlugin { @@ -173,6 +313,7 @@ pub fn install_plugin( starforge_version: starforge_version.to_string(), plugin_version: plugin_version.to_string(), installed_at: Some(now), + commands, }); reg.plugins.sort_by(|a, b| a.name.cmp(&b.name)); save_registry(®)?; @@ -355,6 +496,64 @@ mod tests { ); } + #[test] + fn configured_domain_trusts_exact_and_subdomains_only() { + let mut cfg = Config::default(); + cfg.plugin_trust.trusted_sources = vec!["plugins.example.com".to_string()]; + + assert_eq!( + classify_source_with_config("https://plugins.example.com/releases/foo", &cfg), + TrustLevel::Trusted + ); + assert_eq!( + classify_source_with_config("https://cdn.plugins.example.com/foo", &cfg), + TrustLevel::Trusted + ); + assert_eq!( + classify_source_with_config("https://plugins.example.com.evil/foo", &cfg), + TrustLevel::Unknown + ); + } + + #[test] + fn configured_url_prefix_trusts_only_matching_scheme_host_and_path() { + let mut cfg = Config::default(); + cfg.plugin_trust.trusted_sources = + vec!["https://plugins.example.com/starforge/".to_string()]; + + assert_eq!( + classify_source_with_config( + "https://plugins.example.com/starforge/plugin.tar.gz", + &cfg + ), + TrustLevel::Trusted + ); + assert_eq!( + classify_source_with_config("http://plugins.example.com/starforge/plugin.tar.gz", &cfg), + TrustLevel::Unknown + ); + assert_eq!( + classify_source_with_config("https://plugins.example.com/other/plugin.tar.gz", &cfg), + TrustLevel::Unknown + ); + assert_eq!( + classify_source_with_config("https://plugins.example.com.evil/starforge/plugin", &cfg), + TrustLevel::Unknown + ); + } + + #[test] + fn empty_config_allowlist_treats_remote_sources_as_unknown() { + let mut cfg = Config::default(); + cfg.plugin_trust.trusted_sources.clear(); + + assert_eq!( + classify_source_with_config("https://github.com/Nanle-code/starforge-defi", &cfg), + TrustLevel::Unknown + ); + assert_eq!(classify_source_with_config("", &cfg), TrustLevel::Local); + } + // ── install_plugin ──────────────────────────────────────────────────────── #[test] @@ -406,6 +605,10 @@ mod tests { plugin.source, "", "missing source field should default to empty string" ); + assert!( + plugin.commands.is_empty(), + "missing commands field should default to an empty list" + ); } // ── resolve_plugin_library_path ─────────────────────────────────────────── diff --git a/src/utils/config.rs b/src/utils/config.rs index b0bd393a..1fe9d5ab 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -128,9 +128,9 @@ pub fn validate_secret_key(secret: &str) -> Result<()> { .map_err(|_| anyhow::anyhow!("Invalid KDF iteration count: must be a valid u32"))?; } if parts.len() == 6 { - parts[5] - .parse::() - .map_err(|_| anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32"))?; + parts[5].parse::().map_err(|_| { + anyhow::anyhow!("Invalid KDF parallelism factor: must be a valid u32") + })?; } return Ok(()); @@ -196,6 +196,8 @@ pub struct Config { pub wallets: Vec, #[serde(default)] pub networks: std::collections::HashMap, + #[serde(default)] + pub plugin_trust: PluginTrustConfig, pub telemetry_enabled: Option, pub wallet_encryption: Option, } @@ -213,6 +215,126 @@ pub struct NetworkConfig { pub passphrase: Option, } +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct PluginTrustConfig { + /// Trusted plugin source allowlist entries. Entries may be domains + /// (`plugins.example.com`) or URL prefixes (`https://plugins.example.com/releases/`). + #[serde(default = "default_trusted_plugin_sources")] + pub trusted_sources: Vec, +} + +impl Default for PluginTrustConfig { + fn default() -> Self { + Self { + trusted_sources: default_trusted_plugin_sources(), + } + } +} + +pub fn default_trusted_plugin_sources() -> Vec { + vec![ + "https://github.com/Nanle-code/starforge-*".to_string(), + "https://github.com/StarForge-Labs/*".to_string(), + "https://crates.io/crates/starforge-plugin-*".to_string(), + ] +} + +pub fn validate_plugin_trust_source(source: &str) -> Result<()> { + let source = source.trim(); + if source.is_empty() { + anyhow::bail!("Trusted plugin source cannot be empty"); + } + if source.chars().any(char::is_whitespace) { + anyhow::bail!("Trusted plugin source cannot contain whitespace"); + } + + let wildcard_count = source.matches('*').count(); + if wildcard_count > 1 || (wildcard_count == 1 && !source.ends_with('*')) { + anyhow::bail!("Trusted plugin source may only use '*' as a trailing wildcard"); + } + + let without_wildcard = source.strip_suffix('*').unwrap_or(source); + if without_wildcard.contains("://") { + let scheme = without_wildcard + .split_once("://") + .map(|(scheme, _)| scheme.to_ascii_lowercase()) + .unwrap_or_default(); + if !matches!(scheme.as_str(), "http" | "https" | "git+https") { + anyhow::bail!("Trusted plugin source URL must use http, https, or git+https scheme"); + } + let after_scheme = without_wildcard + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(""); + let host = after_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or("") + .rsplit('@') + .next() + .unwrap_or("") + .split(':') + .next() + .unwrap_or(""); + if host.is_empty() || host.starts_with('.') || host.ends_with('.') { + anyhow::bail!("Trusted plugin source URL must include a valid host"); + } + return Ok(()); + } + + let domain = without_wildcard.trim_start_matches("*."); + if domain.contains('/') + || domain.contains(':') + || domain.starts_with('.') + || domain.ends_with('.') + { + anyhow::bail!("Trusted plugin domain must be a domain name, not a path or URL fragment"); + } + if domain.is_empty() || !domain.contains('.') { + anyhow::bail!("Trusted plugin domain must include a dot, such as plugins.example.com"); + } + if !domain + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.') + { + anyhow::bail!("Trusted plugin domain contains invalid characters"); + } + + Ok(()) +} + +pub fn add_trusted_plugin_source(config: &mut Config, source: String) -> Result { + validate_plugin_trust_source(&source)?; + let source = source.trim().to_string(); + if config + .plugin_trust + .trusted_sources + .iter() + .any(|existing| existing.eq_ignore_ascii_case(&source)) + { + return Ok(false); + } + config.plugin_trust.trusted_sources.push(source); + config + .plugin_trust + .trusted_sources + .sort_by_key(|entry| entry.to_ascii_lowercase()); + Ok(true) +} + +pub fn remove_trusted_plugin_source(config: &mut Config, source: &str) -> bool { + let before = config.plugin_trust.trusted_sources.len(); + config + .plugin_trust + .trusted_sources + .retain(|existing| !existing.eq_ignore_ascii_case(source.trim())); + before != config.plugin_trust.trusted_sources.len() +} + +pub fn reset_trusted_plugin_sources(config: &mut Config) { + config.plugin_trust.trusted_sources = default_trusted_plugin_sources(); +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct WalletEntry { pub name: String, @@ -273,6 +395,7 @@ impl Default for Config { network: "testnet".to_string(), wallets: vec![], networks, + plugin_trust: PluginTrustConfig::default(), telemetry_enabled: Some(true), wallet_encryption: None, } @@ -511,6 +634,75 @@ mod tests { assert!(validate_secret_key("S123").is_err()); assert!(validate_secret_key("bad:bundle").is_err()); } + + #[test] + fn default_config_includes_plugin_trust_sources() { + let cfg = Config::default(); + assert_eq!( + cfg.plugin_trust.trusted_sources, + default_trusted_plugin_sources() + ); + } + + #[test] + fn config_without_plugin_trust_deserializes_with_defaults() { + let toml = r#" +version = "1" +network = "testnet" +wallets = [] +telemetry_enabled = true +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert_eq!( + cfg.plugin_trust.trusted_sources, + default_trusted_plugin_sources() + ); + } + + #[test] + fn trusted_plugin_source_management_deduplicates_and_resets() { + let mut cfg = Config::default(); + assert!(add_trusted_plugin_source(&mut cfg, "plugins.example.com".to_string()).unwrap()); + assert!(!add_trusted_plugin_source(&mut cfg, "PLUGINS.EXAMPLE.COM".to_string()).unwrap()); + assert!(cfg + .plugin_trust + .trusted_sources + .contains(&"plugins.example.com".to_string())); + + assert!(remove_trusted_plugin_source( + &mut cfg, + "plugins.example.com" + )); + assert!(!remove_trusted_plugin_source( + &mut cfg, + "plugins.example.com" + )); + + cfg.plugin_trust.trusted_sources.clear(); + reset_trusted_plugin_sources(&mut cfg); + assert_eq!( + cfg.plugin_trust.trusted_sources, + default_trusted_plugin_sources() + ); + } + + #[test] + fn invalid_trusted_plugin_sources_are_rejected() { + for source in [ + "", + "plugins example.com", + "https://", + "ftp://example.com", + "example", + "example.com/path", + "https://example.com/*/bad", + ] { + assert!( + validate_plugin_trust_source(source).is_err(), + "{source} should be invalid" + ); + } + } } /// Returns the network passphrase for transaction signing. @@ -663,10 +855,7 @@ pub fn rename_custom_network(config: &mut Config, old_name: &str, new_name: &str anyhow::bail!("Old and new network names are the same"); } - let net_cfg = config - .networks - .remove(old_name) - .expect("network exists"); + let net_cfg = config.networks.remove(old_name).expect("network exists"); config.networks.insert(new_name.to_string(), net_cfg); if config.network == old_name { diff --git a/src/utils/confirmation.rs b/src/utils/confirmation.rs index 631ccb05..83429c48 100644 --- a/src/utils/confirmation.rs +++ b/src/utils/confirmation.rs @@ -99,18 +99,18 @@ impl OperationSummary { pub fn display(&self) { p::header(&self.title); p::separator(); - + // Display risk level p::kv("Risk Level", &self.risk_level.display().to_string()); p::kv("Network", &self.network); - + println!(); - + // Display all items for (key, value) in &self.items { p::kv(key, value); } - + p::separator(); } } @@ -119,10 +119,10 @@ impl OperationSummary { pub fn confirm_operation(summary: &OperationSummary, config: &ConfirmationConfig) -> Result { // Display mainnet warning if applicable display_mainnet_warning(&config.network); - + // Display operation summary summary.display(); - + // If dry-run, show preview message and return true if config.dry_run { println!(); @@ -130,7 +130,7 @@ pub fn confirm_operation(summary: &OperationSummary, config: &ConfirmationConfig println!(); return Ok(true); } - + // Skip confirmation if requested if config.skip_confirm { println!(); @@ -138,23 +138,26 @@ pub fn confirm_operation(summary: &OperationSummary, config: &ConfirmationConfig println!(); return Ok(true); } - + // Request confirmation println!(); - - let prompt = config.prompt.as_deref().unwrap_or("Proceed with this operation?"); - + + let prompt = config + .prompt + .as_deref() + .unwrap_or("Proceed with this operation?"); + if config.require_type_confirmation || summary.risk_level == RiskLevel::High { // Require typing "yes" for high-risk operations print!(" {} [type 'yes' to confirm]: ", prompt.bright_white()); std::io::stdout().flush()?; - + let line = std::io::stdin() .lock() .lines() .next() .unwrap_or(Ok(String::new()))?; - + if line.trim().to_lowercase() != "yes" { println!(); p::info("Operation cancelled."); @@ -164,20 +167,20 @@ pub fn confirm_operation(summary: &OperationSummary, config: &ConfirmationConfig // Simple y/N confirmation print!(" {} [y/N]: ", prompt.bright_white()); std::io::stdout().flush()?; - + let line = std::io::stdin() .lock() .lines() .next() .unwrap_or(Ok(String::new()))?; - + if !matches!(line.trim().to_lowercase().as_str(), "y" | "yes") { println!(); p::info("Operation cancelled."); return Ok(false); } } - + println!(); Ok(true) } @@ -189,11 +192,11 @@ pub fn display_preview(summary: &OperationSummary) { p::kv("Risk Level", &summary.risk_level.display().to_string()); p::kv("Network", &summary.network); println!(); - + for (key, value) in &summary.items { p::kv(key, value); } - + p::separator(); println!(); p::info("This is a preview. Use --execute to perform this operation."); @@ -229,10 +232,11 @@ mod tests { #[test] fn test_operation_summary_builder() { - let summary = OperationSummary::new("Test".to_string(), "testnet".to_string(), RiskLevel::Low) - .add("Key1", "Value1") - .add("Key2", "Value2"); - + let summary = + OperationSummary::new("Test".to_string(), "testnet".to_string(), RiskLevel::Low) + .add("Key1", "Value1") + .add("Key2", "Value2"); + assert_eq!(summary.items.len(), 2); assert_eq!(summary.items[0].0, "Key1"); assert_eq!(summary.items[1].0, "Key2"); diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 1224f3ff..1bb0901b 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -497,8 +497,7 @@ mod tests { #[test] fn detects_passphrase_reusing_wallet_context() { let report = - check_passphrase_strength_with_inputs("alice-stronger-passphrase", &["alice"]) - .unwrap(); + check_passphrase_strength_with_inputs("alice-stronger-passphrase", &["alice"]).unwrap(); assert!(report.reused_context); } @@ -553,7 +552,11 @@ mod tests { let encrypted = encrypt_secret(password, secret, Some(&kdf)).unwrap(); let parts: Vec<&str> = encrypted.split(':').collect(); - assert_eq!(parts.len(), 6, "expected mem/iterations/parallelism in bundle"); + assert_eq!( + parts.len(), + 6, + "expected mem/iterations/parallelism in bundle" + ); let decrypted = decrypt_secret(password, &encrypted).unwrap(); assert_eq!(secret, decrypted); diff --git a/src/utils/horizon.rs b/src/utils/horizon.rs index 8088e2b4..770c6291 100644 --- a/src/utils/horizon.rs +++ b/src/utils/horizon.rs @@ -565,6 +565,7 @@ mod tests { version: "1".to_string(), networks, wallets: Vec::new(), + plugin_trust: Default::default(), telemetry_enabled: Some(false), wallet_encryption: None, }) @@ -593,7 +594,7 @@ mod tests { #[test] fn fetch_account_returns_mocked_account() { - let mut server = Server::new(); + let server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let public_key = "GACCOUNT123"; @@ -619,7 +620,7 @@ mod tests { #[test] fn fetch_account_reports_parse_error_for_invalid_json() { - let mut server = Server::new(); + let server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let _mock = server @@ -635,7 +636,7 @@ mod tests { #[test] fn fund_account_reports_friendbot_error_path() { - let mut server = Server::new(); + let server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), Some(server.url())); let _mock = server @@ -650,7 +651,7 @@ mod tests { #[test] fn build_transaction_query_url_includes_pagination_params() { - let mut server = Server::new(); + let server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let filter = TxFilter { @@ -671,7 +672,7 @@ mod tests { #[test] fn fetch_transactions_filtered_uses_cursor_and_filters_records() { - let mut server = Server::new(); + let server = Server::new(); let _guard = TestConfigGuard::new(&server.url(), None); let _mock = server diff --git a/src/utils/templates.rs b/src/utils/templates.rs index bbfd86d5..f1efdce6 100644 --- a/src/utils/templates.rs +++ b/src/utils/templates.rs @@ -113,7 +113,11 @@ pub struct TemplateEntry { pub license: Option, /// URL of the template's source repository (e.g. GitHub link). #[serde(default)] - pub repository_url: Option, + pub repository: Option, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub documentation: Option, } /// Outcome of a template-vs-CLI compatibility check. @@ -358,9 +362,7 @@ pub fn extract_zip_archive(archive: &Path, dest: &Path) -> Result<()> { let mut archive = ZipArchive::new(file) .with_context(|| format!("Failed to read ZIP archive {}", archive.display()))?; - let dest_canon = dest - .canonicalize() - .unwrap_or_else(|_| dest.to_path_buf()); + let dest_canon = dest.canonicalize().unwrap_or_else(|_| dest.to_path_buf()); for i in 0..archive.len() { let mut entry = archive.by_index(i)?; @@ -418,7 +420,8 @@ pub fn normalize_template_root(path: &Path) -> Result { /// Resolve a template path: directories are used as-is; ZIP archives are extracted to a temp dir. pub fn resolve_template_source(path: &Path) -> Result<(PathBuf, Option)> { if is_archive_path(path) { - let temp = tempfile::tempdir().context("Failed to create temp dir for archive extraction")?; + let temp = + tempfile::tempdir().context("Failed to create temp dir for archive extraction")?; extract_zip_archive(path, temp.path())?; let root = normalize_template_root(temp.path())?; Ok((root, Some(temp))) @@ -1023,8 +1026,10 @@ pub fn publish_template_versioned( cli_version_max, documented: source_root.join("README.md").exists(), maintenance: MaintenanceStatus::Active, - license: None, - repository_url: None, + license, + repository, + homepage, + documentation, }; add_template(entry)?; @@ -1039,7 +1044,15 @@ pub fn validate_template_structure( author: &str, version: &str, ) -> Result<()> { - validate_template_structure_with_constraints(path, name, description, author, version, None, None) + validate_template_structure_with_constraints( + path, + name, + description, + author, + version, + None, + None, + ) } /// Full validation including optional CLI version constraint format checks. @@ -1110,7 +1123,8 @@ pub fn validate_template_structure_with_constraints( anyhow::bail!( "cli_version_min '{}' is greater than cli_version_max '{}'. \ Fix the version bounds so that min <= max.", - min, max + min, + max ); } } @@ -1253,7 +1267,9 @@ fn install_from_git_url( documented: dest.join("README.md").exists(), maintenance: MaintenanceStatus::Unknown, license: None, - repository_url: Some(url.to_string()), + repository: Some(url.to_string()), + homepage: None, + documentation: None, }; registry.templates.retain(|t| t.name != name); @@ -1318,7 +1334,9 @@ fn install_from_local_path( documented: dest.join("README.md").exists(), maintenance: MaintenanceStatus::Unknown, license: None, - repository_url: None, + repository: None, + homepage: None, + documentation: None, }; registry.templates.retain(|t| t.name != name); @@ -1438,7 +1456,9 @@ mod tests { documented: false, maintenance: MaintenanceStatus::Unknown, license: None, - repository_url: None, + repository: None, + homepage: None, + documentation: None, } } @@ -1474,8 +1494,7 @@ mod tests { let rel = entry.strip_prefix(&tpl_dir).unwrap(); let name = rel.to_string_lossy().replace('\\', "/"); if entry.is_dir() { - zip.add_directory(format!("{}/", name), options) - .unwrap(); + zip.add_directory(format!("{}/", name), options).unwrap(); } else { zip.start_file(name, options).unwrap(); let mut f = fs::File::open(entry).unwrap(); @@ -1487,9 +1506,7 @@ mod tests { let extract_dir = tmp.path().join("out"); extract_zip_archive(&zip_path, &extract_dir).unwrap(); let root = normalize_template_root(&extract_dir).unwrap(); - assert!( - validate_template_structure(&root, "zip-tpl", "desc", "author", "1.0.0").is_ok() - ); + assert!(validate_template_structure(&root, "zip-tpl", "desc", "author", "1.0.0").is_ok()); } fn walkdir_flat(dir: &Path) -> Vec { @@ -1602,8 +1619,7 @@ mod tests { fn validate_rejects_bad_version_semver() { let tmp = tempdir().unwrap(); make_valid_template(tmp.path()); - let err = - validate_template_structure(tmp.path(), "n", "d", "a", "not-semver").unwrap_err(); + let err = validate_template_structure(tmp.path(), "n", "d", "a", "not-semver").unwrap_err(); assert!(err.to_string().contains("semver") || err.to_string().contains("not-semver")); } @@ -1670,7 +1686,9 @@ mod tests { documented: true, maintenance: MaintenanceStatus::Active, license: None, - repository_url: None, + repository: None, + homepage: None, + documentation: None, }); // Test name search @@ -1717,7 +1735,9 @@ mod tests { documented: false, maintenance: MaintenanceStatus::Unknown, license: None, - repository_url: None, + repository: None, + homepage: None, + documentation: None, }; let dest = tmp.path().join(&entry.name); @@ -1766,7 +1786,9 @@ mod tests { documented: false, maintenance: MaintenanceStatus::Unknown, license: None, - repository_url: None, + repository: None, + homepage: None, + documentation: None, } } @@ -2015,7 +2037,10 @@ mod tests { #[test] fn parse_semver_rejects_extra_dots() { - assert!(parse_semver("1.2.3.4").is_err(), "four components should fail"); + assert!( + parse_semver("1.2.3.4").is_err(), + "four components should fail" + ); } #[test] @@ -2135,8 +2160,14 @@ mod tests { let err = assert_template_compatible(&entry).unwrap_err(); let msg = err.to_string(); assert!(msg.contains(&min), "error should contain required_min"); - assert!(msg.contains(CLI_VERSION), "error should contain running version"); - assert!(msg.contains("future-tpl"), "error should contain template name"); + assert!( + msg.contains(CLI_VERSION), + "error should contain running version" + ); + assert!( + msg.contains("future-tpl"), + "error should contain template name" + ); } #[test] @@ -2148,8 +2179,14 @@ mod tests { let err = assert_template_compatible(&entry).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("0.0.0"), "error should contain required_max"); - assert!(msg.contains(CLI_VERSION), "error should contain running version"); - assert!(msg.contains("old-tpl"), "error should contain template name"); + assert!( + msg.contains(CLI_VERSION), + "error should contain running version" + ); + assert!( + msg.contains("old-tpl"), + "error should contain template name" + ); } } @@ -2159,7 +2196,10 @@ mod tests { entry.cli_version_min = Some("bad-version".to_string()); let err = assert_template_compatible(&entry).unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("broken-tpl"), "error should contain template name"); + assert!( + msg.contains("broken-tpl"), + "error should contain template name" + ); assert!( msg.contains("malformed") || msg.contains("bad-version"), "error should describe the problem" diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index df9b759a..9946fa78 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -103,7 +103,13 @@ fn deploy_help_documents_flags() { #[test] fn network_add_custom_succeeds() { let home = isolated_home(); - let net_name = format!("smoke-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros()); + let net_name = format!( + "smoke-net-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() + ); let output = starforge(home.path()) .args([ "network", @@ -176,7 +182,13 @@ fn network_switch_unknown_network_fails() { #[test] fn network_remove_custom_succeeds() { let home = isolated_home(); - let net_name = format!("remove-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros()); + let net_name = format!( + "remove-net-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() + ); starforge(home.path()) .args([ "network", @@ -220,8 +232,21 @@ fn network_remove_reserved_fails() { #[test] fn network_rename_custom_succeeds() { let home = isolated_home(); - let old_name = format!("old-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros()); - let new_name = format!("new-net-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() + 1); + let old_name = format!( + "old-net-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() + ); + let new_name = format!( + "new-net-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() + + 1 + ); starforge(home.path()) .args([ "network", @@ -275,7 +300,7 @@ fn network_add_reserved_name_fails() { #[test] fn config_subcommand_sets_and_shows_telemetry() { let home = isolated_home(); - + // Disable telemetry via config set let output2 = starforge(home.path()) .args(["config", "set", "telemetry.enabled", "false"]) @@ -343,7 +368,7 @@ fn telemetry_subcommand_toggles_status() { .output() .expect("spawn telemetry enable"); assert_success(&output4, "starforge telemetry enable"); - + // Check enabled status let output5 = starforge(home.path()) .args(["telemetry", "status"]) @@ -364,9 +389,8 @@ fn telemetry_respects_env_override() { cmd.env("STARFORGE_TELEMETRY", "false"); let output = cmd.output().expect("spawn telemetry status"); assert_success(&output, "starforge telemetry status with env override"); - + let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Environment Override")); assert!(stdout.contains("false")); } -