diff --git a/src/config.rs b/src/config.rs index 8976c61..b36dfb8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,8 @@ pub struct Config { pub(crate) url: String, pub(crate) debug: i8, pub(crate) token: String, + #[serde(default)] + pub(crate) default_agent: Option, } impl Config { @@ -38,6 +40,7 @@ impl Config { url: "https://www.corgea.app".to_string(), debug: 0, token: "".to_string(), + default_agent: None, }; let toml = toml::to_string(&config).expect("Failed to serialize config"); @@ -104,4 +107,19 @@ impl Config { return self.debug; } + + pub fn set_default_agent(&mut self, agent: String) -> io::Result<()> { + self.default_agent = Some(agent); + self.save() + } + + pub fn get_default_agent(&self) -> Option { + if let Ok(agent) = env::var("CORGEA_DEFAULT_AGENT") { + if !agent.trim().is_empty() { + return Some(agent); + } + } + + self.default_agent.clone() + } } diff --git a/src/main.rs b/src/main.rs index 5da00f9..1d563d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,28 +1,29 @@ +mod authorize; +mod cicd; mod config; -mod scan; -mod wait; -mod list; mod inspect; -mod cicd; +mod list; mod log; +mod scan; mod setup_hooks; -mod authorize; +mod skill; +mod wait; mod scanners { - pub mod fortify; pub mod blast; + pub mod fortify; pub mod parsers; } mod utils { - pub mod terminal; - pub mod generic; pub mod api; + pub mod generic; + pub mod terminal; } mod targets; -use std::str::FromStr; -use clap::{Parser, Subcommand, CommandFactory}; +use clap::{CommandFactory, Parser, Subcommand}; use config::Config; use scanners::fortify::parse as fortify_parse; +use std::str::FromStr; #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] @@ -32,20 +33,26 @@ struct Cli { command: Option, #[arg(required = false)] - args: Vec, + args: Vec, } #[derive(Subcommand, Debug)] enum Commands { /// Authenticate to Corgea - Login { + Login { #[arg(help = "API token (if not provided, will use OAuth flow)")] token: Option, - #[arg(long, help = "The url of the corgea instance to use. defaults to https://www.corgea.app")] + #[arg( + long, + help = "The url of the corgea instance to use. defaults to https://www.corgea.app" + )] url: Option, - #[arg(long, help = "Scope to use for custom domain (e.g., 'ikea' for ikea.corgea.app). Only used with OAuth flow")] + #[arg( + long, + help = "Scope to use for custom domain (e.g., 'ikea' for ikea.corgea.app). Only used with OAuth flow" + )] scope: Option, }, /// Upload a scan report to Corgea via STDIN or a file @@ -65,13 +72,20 @@ enum Commands { #[arg(default_value = "blast")] scanner: Scanner, - #[arg(long, help = "Fail on (exits with error code 1) a specific severity level . Valid options are CR, HI, ME, LO.")] + #[arg( + long, + help = "Fail on (exits with error code 1) a specific severity level . Valid options are CR, HI, ME, LO." + )] fail_on: Option, #[arg(long, help = "Only scan uncommitted changes.")] only_uncommitted: bool, - #[arg(short, long, help = "Fail on (exits with error code 1) based on blocking rules defined in the web app.")] + #[arg( + short, + long, + help = "Fail on (exits with error code 1) based on blocking rules defined in the web app." + )] fail: bool, #[arg( @@ -88,10 +102,17 @@ enum Commands { )] scan_type: Option, - #[arg(long, help = "Output the result to a file in a specific format. Valid options are json, html, sarif, markdown.")] + #[arg( + long, + help = "Output the result to a file in a specific format. Valid options are json, html, sarif, markdown." + )] out_format: Option, - #[arg(short, long, help = "Output the result to a file. you can use the out_format option to specify the format of the output file.")] + #[arg( + short, + long, + help = "Output the result to a file. you can use the out_format option to specify the format of the output file." + )] out_file: Option, #[arg( @@ -107,16 +128,18 @@ enum Commands { project_name: Option, }, /// Wait for the latest in progress scan - Wait { - scan_id: Option, - }, + Wait { scan_id: Option }, /// List something, by default it lists the scans #[command(alias = "ls")] List { #[arg(short, long, help = "List issues instead of scans")] issues: bool, - #[arg(long, short = 'c', help = "List SCA (Software Composition Analysis) issues instead of regular issues")] + #[arg( + long, + short = 'c', + help = "List SCA (Software Composition Analysis) issues instead of regular issues" + )] sca_issues: bool, #[arg(short, long, help = "Specify the scan id to list issues for.")] @@ -129,7 +152,7 @@ enum Commands { json: bool, #[arg(long, value_parser = clap::value_parser!(u16), help = "Number of items per page")] - page_size: Option + page_size: Option, }, /// Inspect something, by default it will inspect a scan Inspect { @@ -140,22 +163,82 @@ enum Commands { #[arg(long, help = "Output the result in JSON format.")] json: bool, - #[arg(long, short, help = "Display a summary only of the issue in the output (only if --issue is true).")] + #[arg( + long, + short, + help = "Display a summary only of the issue in the output (only if --issue is true)." + )] summary: bool, - #[arg(long, short, help = "Display the fix explanations only in the output (only if --issue is true).")] + #[arg( + long, + short, + help = "Display the fix explanations only in the output (only if --issue is true)." + )] fix: bool, - #[arg(long, short, help = "Display the diff of the fix only in the output (only if --issue is true).")] + #[arg( + long, + short, + help = "Display the diff of the fix only in the output (only if --issue is true)." + )] diff: bool, id: String, }, /// Setup a git hook, currently only pre-commit is supported SetupHooks { - #[arg(long, short, help = "Include default config (scan types are pii, secrets and fail on levels are CR, HI, ME, LO).")] + #[arg( + long, + short, + help = "Include default config (scan types are pii, secrets and fail on levels are CR, HI, ME, LO)." + )] default_config: bool, }, + /// Manage agent skills from the Corgea registry + Skill { + #[command(subcommand)] + command: SkillCommands, + }, +} + +#[derive(Subcommand, Debug)] +enum SkillCommands { + /// Install an approved skill into your agent's skills directory + Install { + #[arg(help = "Skill name, optionally with a version: name or name@version")] + name: String, + + #[arg( + long, + help = "Agent to install for (e.g. cursor, claude-code, codex). Defaults to the configured default agent." + )] + agent: Option, + + #[arg( + long, + default_value = "project", + help = "Installation scope: project or user." + )] + scope: String, + + #[arg( + long, + help = "Install to a custom directory (overrides --agent and --scope)." + )] + dir: Option, + + #[arg( + long, + help = "Persist the provided --agent as the default for future installs." + )] + set_default: bool, + }, + /// Configure the default agent used when --agent is not provided + SetDefaultAgent { + #[arg(help = "Agent id (e.g. cursor, claude-code, codex).")] + agent: String, + }, } #[derive(Subcommand, Debug, Clone, PartialEq)] @@ -181,7 +264,7 @@ impl FromStr for Scanner { fn main() { let cli = Cli::parse(); let mut corgea_config = Config::load().expect("Failed to load config"); - fn verify_token_and_exit_when_fail (config: &Config) { + fn verify_token_and_exit_when_fail(config: &Config) { if config.get_token().is_empty() { eprintln!("No token set.\nPlease run 'corgea login' to authenticate.\nFor more info checkout our docs at Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli"); std::process::exit(1); @@ -194,7 +277,7 @@ fn main() { Ok(false) => { println!("Invalid token provided.\nPlease run 'corgea login' to authenticate.\nFor more info checkout our docs at Check out our docs at https://docs.corgea.app/install_cli#login-with-the-cli"); std::process::exit(1); - }, + } Err(e) => { eprintln!("Error occurred: {}", e); std::process::exit(1); @@ -203,19 +286,34 @@ fn main() { } match &cli.command { Some(Commands::Login { token, url, scope }) => { - let effective_token = token.clone().or_else(|| utils::generic::get_env_var_if_exists("CORGEA_TOKEN")); - + let effective_token = token + .clone() + .or_else(|| utils::generic::get_env_var_if_exists("CORGEA_TOKEN")); + match effective_token { Some(token_value) => { - let token_source = if token.is_some() { "parameter" } else { "CORGEA_TOKEN environment variable" }; + let token_source = if token.is_some() { + "parameter" + } else { + "CORGEA_TOKEN environment variable" + }; utils::api::set_auth_token(&token_value); - match utils::api::verify_token(url.as_deref().unwrap_or(corgea_config.get_url().as_str())) { + match utils::api::verify_token( + url.as_deref().unwrap_or(corgea_config.get_url().as_str()), + ) { Ok(true) => { - corgea_config.set_token(token_value.clone()).expect("Failed to set token"); + corgea_config + .set_token(token_value.clone()) + .expect("Failed to set token"); if let Some(url) = url { - corgea_config.set_url(url.clone()).expect("Failed to set url"); + corgea_config + .set_url(url.clone()) + .expect("Failed to set url"); } - println!("Successfully authenticated to Corgea using token from {}.", token_source) + println!( + "Successfully authenticated to Corgea using token from {}.", + token_source + ) } Ok(false) => println!("Invalid token provided from {}.", token_source), Err(e) => { @@ -225,7 +323,7 @@ fn main() { } eprintln!("Error occurred: {}", e); std::process::exit(1); - }, + } } } // No token available - use OAuth flow @@ -233,9 +331,9 @@ fn main() { if url.is_some() && scope.is_some() { eprintln!("Warning: --url option is ignored when using OAuth flow with --scope. The scope determines the domain."); } - + match authorize::run(scope.clone(), url.clone()) { - Ok(()) => {}, + Ok(()) => {} Err(e) => { eprintln!("Authorization failed: {}", e); std::process::exit(1); @@ -244,7 +342,10 @@ fn main() { } } } - Some(Commands::Upload { report, project_name }) => { + Some(Commands::Upload { + report, + project_name, + }) => { verify_token_and_exit_when_fail(&corgea_config); match report { Some(report) => { @@ -259,7 +360,18 @@ fn main() { } } } - Some(Commands::Scan { scanner , fail_on, fail, only_uncommitted, scan_type, policy, out_format, out_file, target, project_name }) => { + Some(Commands::Scan { + scanner, + fail_on, + fail, + only_uncommitted, + scan_type, + policy, + out_format, + out_file, + target, + project_name, + }) => { verify_token_and_exit_when_fail(&corgea_config); if let Some(level) = fail_on { if *scanner != Scanner::Blast { @@ -292,7 +404,9 @@ fn main() { std::process::exit(1); } - if out_file.is_some() && !out_format.is_some() || !out_file.is_some() && out_format.is_some() { + if out_file.is_some() && !out_format.is_some() + || !out_file.is_some() && out_format.is_some() + { eprintln!("out_file and out_format must be used together."); std::process::exit(1); } @@ -342,14 +456,32 @@ fn main() { match scanner { Scanner::Snyk => scan::run_snyk(&corgea_config, project_name.clone()), Scanner::Semgrep => scan::run_semgrep(&corgea_config, project_name.clone()), - Scanner::Blast => scanners::blast::run(&corgea_config, fail_on.clone(), fail, only_uncommitted, scan_type.clone(), policy.clone(), out_format.clone(), out_file.clone(), target.clone(), project_name.clone()) + Scanner::Blast => scanners::blast::run( + &corgea_config, + fail_on.clone(), + fail, + only_uncommitted, + scan_type.clone(), + policy.clone(), + out_format.clone(), + out_file.clone(), + target.clone(), + project_name.clone(), + ), } } Some(Commands::Wait { scan_id }) => { verify_token_and_exit_when_fail(&corgea_config); wait::run(&corgea_config, scan_id.clone(), None); } - Some(Commands::List { issues , json, page, page_size, scan_id, sca_issues}) => { + Some(Commands::List { + issues, + json, + page, + page_size, + scan_id, + sca_issues, + }) => { verify_token_and_exit_when_fail(&corgea_config); if *issues && *sca_issues { eprintln!("Cannot use both --issues and --sca-issues at the same time."); @@ -359,15 +491,52 @@ fn main() { println!("scan_id option is only supported for issues list command."); std::process::exit(1); } - list::run(&corgea_config, issues, sca_issues, json, page, page_size, scan_id); + list::run( + &corgea_config, + issues, + sca_issues, + json, + page, + page_size, + scan_id, + ); } - Some(Commands::Inspect { issue, json, id, summary, fix, diff }) => { + Some(Commands::Inspect { + issue, + json, + id, + summary, + fix, + diff, + }) => { verify_token_and_exit_when_fail(&corgea_config); inspect::run(&corgea_config, issue, json, summary, fix, diff, id) } Some(Commands::SetupHooks { default_config }) => { setup_hooks::setup_pre_commit_hook(*default_config); } + Some(Commands::Skill { command }) => match command { + SkillCommands::Install { + name, + agent, + scope, + dir, + set_default, + } => { + verify_token_and_exit_when_fail(&corgea_config); + skill::run_install( + &mut corgea_config, + name, + agent.clone(), + scope, + dir.clone(), + *set_default, + ); + } + SkillCommands::SetDefaultAgent { agent } => { + skill::run_set_default_agent(&mut corgea_config, agent); + } + }, None => { utils::terminal::show_welcome_message(); let _ = Cli::command().print_help(); diff --git a/src/skill.rs b/src/skill.rs new file mode 100644 index 0000000..d4e5139 --- /dev/null +++ b/src/skill.rs @@ -0,0 +1,367 @@ +use crate::config::Config; +use crate::utils; +use crate::utils::terminal::{set_text_color, TerminalColor}; +use std::path::PathBuf; + +/// Supported agents and where their skills are installed. +/// +/// Tuple layout: `(agent_id, project_relative_dir, user_relative_dir)`. +/// `project_relative_dir` is resolved against the current working directory; +/// `user_relative_dir` is resolved against the user's home directory. +pub const SUPPORTED_AGENTS: &[(&str, &str, &str)] = &[ + ("cursor", ".cursor/skills", ".cursor/skills"), + ("claude-code", ".claude/skills", ".claude/skills"), + ("codex", ".codex/skills", ".codex/skills"), + ( + "github-copilot", + ".github/skills", + ".config/github-copilot/skills", + ), + ("gemini-cli", ".gemini/skills", ".gemini/skills"), + ("windsurf", ".windsurf/skills", ".codeium/windsurf/skills"), + ("opencode", ".opencode/skills", ".config/opencode/skills"), + ("universal", ".ai/skills", ".ai/skills"), +]; + +fn supported_agent_ids() -> String { + SUPPORTED_AGENTS + .iter() + .map(|(id, _, _)| *id) + .collect::>() + .join(", ") +} + +fn is_supported_agent(agent: &str) -> bool { + SUPPORTED_AGENTS.iter().any(|(id, _, _)| *id == agent) +} + +/// Parse a `name[@version]` argument into `(name, Option)`. +pub fn parse_skill_arg(arg: &str) -> (String, Option) { + match arg.split_once('@') { + Some((name, version)) if !version.is_empty() => { + (name.to_string(), Some(version.to_string())) + } + _ => (arg.to_string(), None), + } +} + +/// Resolve the directory that will contain the skill's `SKILL.md`. +/// +/// When `dir` is provided it overrides `agent`/`scope` and is used as the base +/// skills directory. Otherwise the agent's directory for the given scope is +/// used. The skill is always placed in a `` subfolder. +pub fn resolve_skill_dir( + skill_name: &str, + agent: Option<&str>, + scope: &str, + dir: Option<&str>, + cwd: &PathBuf, + home: &PathBuf, +) -> Result { + let base = if let Some(custom) = dir { + PathBuf::from(custom) + } else { + let agent = agent.ok_or_else(|| { + format!( + "No agent specified. Pass --agent , set a default with \ + 'corgea skill set-default-agent ', or use --dir. \ + Supported agents: {}", + supported_agent_ids() + ) + })?; + let entry = SUPPORTED_AGENTS + .iter() + .find(|(id, _, _)| *id == agent) + .ok_or_else(|| { + format!( + "Unsupported agent '{}'. Supported agents: {}", + agent, + supported_agent_ids() + ) + })?; + match scope { + "project" => cwd.join(entry.1), + "user" => home.join(entry.2), + other => { + return Err(format!( + "Invalid scope '{}'. Expected 'project' or 'user'.", + other + )) + } + } + }; + + Ok(base.join(skill_name)) +} + +/// `corgea skill set-default-agent ` +pub fn run_set_default_agent(config: &mut Config, agent: &str) { + if !is_supported_agent(agent) { + eprintln!( + "Unsupported agent '{}'. Supported agents: {}", + agent, + supported_agent_ids() + ); + std::process::exit(1); + } + match config.set_default_agent(agent.to_string()) { + Ok(()) => println!( + "{}", + set_text_color( + &format!("Default agent set to '{}'.", agent), + TerminalColor::Green + ) + ), + Err(e) => { + eprintln!("Failed to save default agent: {}", e); + std::process::exit(1); + } + } +} + +/// `corgea skill install ` +pub fn run_install( + config: &mut Config, + name_arg: &str, + agent: Option, + scope: &str, + dir: Option, + set_default: bool, +) { + let (skill_name, version) = parse_skill_arg(name_arg); + + if !["project", "user"].contains(&scope) { + eprintln!("Invalid scope '{}'. Expected 'project' or 'user'.", scope); + std::process::exit(1); + } + + // Resolve the agent (flag > configured default) unless a custom dir is set. + let resolved_agent = agent.clone().or_else(|| config.get_default_agent()); + if dir.is_none() && resolved_agent.is_none() { + eprintln!( + "No agent specified. Pass --agent , set a default with \ + 'corgea skill set-default-agent ', or use --dir.\nSupported agents: {}", + supported_agent_ids() + ); + std::process::exit(1); + } + if dir.is_none() { + if let Some(ref a) = resolved_agent { + if !is_supported_agent(a) { + eprintln!( + "Unsupported agent '{}'. Supported agents: {}", + a, + supported_agent_ids() + ); + std::process::exit(1); + } + } + } + + let result = utils::api::get_skill(config.get_url().as_str(), &skill_name, version.as_deref()); + + let response = match result { + Ok(Some(resp)) => resp, + Ok(None) => { + eprintln!( + "{}", + set_text_color( + &format!("No skill named '{}' was found.", skill_name), + TerminalColor::Red + ) + ); + std::process::exit(1); + } + Err(e) => { + eprintln!("Error: {}", e); + std::process::exit(1); + } + }; + + let version_info = match response.version { + Some(v) => v, + None => { + eprintln!( + "{}", + set_text_color( + &format!("Skill '{}' has no versions to install.", skill_name), + TerminalColor::Yellow + ) + ); + std::process::exit(1); + } + }; + + if !version_info.is_installable || version_info.content.is_none() { + match version_info.status.as_str() { + "pending_review" => { + println!( + "{}", + set_text_color( + &format!( + "Skill '{}' (v{}) is pending security review and is not yet installable.", + skill_name, version_info.version + ), + TerminalColor::Yellow + ) + ); + } + "rejected" => { + println!( + "{}", + set_text_color( + &format!( + "Skill '{}' (v{}) was rejected during security review and cannot be installed.", + skill_name, version_info.version + ), + TerminalColor::Red + ) + ); + if !version_info.security_concerns.is_empty() { + println!("Reason: {}", version_info.security_concerns); + } + } + other => { + println!( + "Skill '{}' (v{}) is not installable (status: {}).", + skill_name, version_info.version, other + ); + } + } + std::process::exit(1); + } + + let content = version_info.content.unwrap_or_default(); + + let cwd = match std::env::current_dir() { + Ok(p) => p, + Err(e) => { + eprintln!("Failed to determine current directory: {}", e); + std::process::exit(1); + } + }; + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + + let skill_dir = match resolve_skill_dir( + &skill_name, + resolved_agent.as_deref(), + scope, + dir.as_deref(), + &cwd, + &home, + ) { + Ok(p) => p, + Err(e) => { + eprintln!("{}", e); + std::process::exit(1); + } + }; + + if let Err(e) = utils::generic::create_path_if_not_exists(&skill_dir) { + eprintln!("Failed to create skill directory {:?}: {}", skill_dir, e); + std::process::exit(1); + } + + let skill_file = skill_dir.join("SKILL.md"); + if let Err(e) = std::fs::write(&skill_file, content) { + eprintln!("Failed to write skill file {:?}: {}", skill_file, e); + std::process::exit(1); + } + + println!( + "{}", + set_text_color( + &format!( + "Installed skill '{}' (v{}) to {}", + skill_name, + version_info.version, + skill_file.display() + ), + TerminalColor::Green + ) + ); + + if set_default { + if let Some(a) = resolved_agent { + if let Err(e) = config.set_default_agent(a.clone()) { + eprintln!("Warning: failed to save default agent: {}", e); + } else { + println!("Default agent set to '{}'.", a); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_skill_arg_with_version() { + let (name, version) = parse_skill_arg("my-skill@1.2.3"); + assert_eq!(name, "my-skill"); + assert_eq!(version, Some("1.2.3".to_string())); + } + + #[test] + fn test_parse_skill_arg_without_version() { + let (name, version) = parse_skill_arg("my-skill"); + assert_eq!(name, "my-skill"); + assert_eq!(version, None); + } + + #[test] + fn test_parse_skill_arg_trailing_at() { + let (name, version) = parse_skill_arg("my-skill@"); + assert_eq!(name, "my-skill@"); + assert_eq!(version, None); + } + + #[test] + fn test_resolve_project_scope() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let dir = resolve_skill_dir("foo", Some("cursor"), "project", None, &cwd, &home).unwrap(); + assert_eq!(dir, PathBuf::from("/work/project/.cursor/skills/foo")); + } + + #[test] + fn test_resolve_user_scope() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let dir = resolve_skill_dir("foo", Some("claude-code"), "user", None, &cwd, &home).unwrap(); + assert_eq!(dir, PathBuf::from("/home/user/.claude/skills/foo")); + } + + #[test] + fn test_resolve_custom_dir_overrides_agent() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let dir = resolve_skill_dir( + "foo", + Some("cursor"), + "project", + Some("/custom/place"), + &cwd, + &home, + ) + .unwrap(); + assert_eq!(dir, PathBuf::from("/custom/place/foo")); + } + + #[test] + fn test_resolve_unsupported_agent_errors() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let result = resolve_skill_dir("foo", Some("notreal"), "project", None, &cwd, &home); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_missing_agent_errors() { + let cwd = PathBuf::from("/work/project"); + let home = PathBuf::from("/home/user"); + let result = resolve_skill_dir("foo", None, "project", None, &cwd, &home); + assert!(result.is_err()); + } +} diff --git a/src/utils/api.rs b/src/utils/api.rs index d008ad3..f13ce2b 100644 --- a/src/utils/api.rs +++ b/src/utils/api.rs @@ -1,16 +1,19 @@ +use crate::log::debug; use crate::utils; -use serde_json::json; -use std::collections::HashMap; use reqwest::header::HeaderMap; -use serde::{Deserialize, Serialize}; use reqwest::StatusCode; -use std::fs::File; +use reqwest::{ + blocking::multipart, + blocking::multipart::{Form, Part}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use serde_json::Value; +use std::collections::HashMap; use std::error::Error; +use std::fs::File; use std::io::Read; use std::path::Path; -use reqwest::{blocking::multipart, blocking::multipart::{Form, Part}}; -use serde_json::Value; -use crate::log::debug; const CHUNK_SIZE: usize = 50 * 1024 * 1024; // 50 MB const API_BASE: &str = "/api/v1"; @@ -77,15 +80,24 @@ pub struct DebugRequestBuilder { impl HttpClient { pub fn get(&self, url: U) -> DebugRequestBuilder { - DebugRequestBuilder { client: self.inner.clone(), inner: self.inner.get(url) } + DebugRequestBuilder { + client: self.inner.clone(), + inner: self.inner.get(url), + } } pub fn post(&self, url: U) -> DebugRequestBuilder { - DebugRequestBuilder { client: self.inner.clone(), inner: self.inner.post(url) } + DebugRequestBuilder { + client: self.inner.clone(), + inner: self.inner.post(url), + } } pub fn patch(&self, url: U) -> DebugRequestBuilder { - DebugRequestBuilder { client: self.inner.clone(), inner: self.inner.patch(url) } + DebugRequestBuilder { + client: self.inner.clone(), + inner: self.inner.patch(url), + } } } @@ -97,19 +109,31 @@ impl DebugRequestBuilder { reqwest::header::HeaderValue: TryFrom, >::Error: Into, { - Self { inner: self.inner.header(key, value), client: self.client } + Self { + inner: self.inner.header(key, value), + client: self.client, + } } pub fn query(self, query: &T) -> Self { - Self { inner: self.inner.query(query), client: self.client } + Self { + inner: self.inner.query(query), + client: self.client, + } } pub fn multipart(self, form: reqwest::blocking::multipart::Form) -> Self { - Self { inner: self.inner.multipart(form), client: self.client } + Self { + inner: self.inner.multipart(form), + client: self.client, + } } pub fn body>(self, body: T) -> Self { - Self { inner: self.inner.body(body), client: self.client } + Self { + inner: self.inner.body(body), + client: self.client, + } } pub fn send(self) -> reqwest::Result { @@ -127,7 +151,10 @@ impl DebugRequestBuilder { debug(&format!("→ {} {}", request.method(), request.url())); debug(&format!(" Request headers: {:?}", request.headers())); match COOKIE_JAR.cookies(request.url()) { - Some(cookies) => debug(&format!(" Cookie: {}", cookies.to_str().unwrap_or(""))), + Some(cookies) => debug(&format!( + " Cookie: {}", + cookies.to_str().unwrap_or("") + )), None => debug(" Cookie: (none in jar for this URL)"), } @@ -141,7 +168,9 @@ impl DebugRequestBuilder { } pub fn http_client() -> HttpClient { - HttpClient { inner: SHARED_CLIENT.clone() } + HttpClient { + inner: SHARED_CLIENT.clone(), + } } #[cfg(not(test))] @@ -162,7 +191,11 @@ where let delay = RETRY_BACKOFF_SECS[attempt]; eprintln!( "Network error during {}: {}. Retrying in {}s... ({}/{})", - operation, e, delay, attempt + 1, RETRY_BACKOFF_SECS.len() + operation, + e, + delay, + attempt + 1, + RETRY_BACKOFF_SECS.len() ); std::thread::sleep(std::time::Duration::from_secs(delay)); attempt += 1; @@ -199,68 +232,81 @@ pub fn upload_zip( project_name: &str, repo_info: Option, scan_type: Option, - policy: Option + policy: Option, ) -> Result> { let client = http_client(); let file_size = std::fs::metadata(file_path)?.len(); - let file_name = Path::new(file_path) - .file_name() - .unwrap() - .to_str() - .unwrap(); + let file_name = Path::new(file_path).file_name().unwrap().to_str().unwrap(); let json_object = json!({ "file_name": file_name, "file_size": file_size }); let form = reqwest::blocking::multipart::Form::new() - .part("files", reqwest::blocking::multipart::Part::bytes(Vec::new()) - .file_name(file_name.to_string())) + .part( + "files", + reqwest::blocking::multipart::Part::bytes(Vec::new()).file_name(file_name.to_string()), + ) .text("json", json_object.to_string()); let response_object = client .post(format!("{}{}/start-scan", url, API_BASE)) - .query(&[ - ("scan_type", "blast"), - ]) + .query(&[("scan_type", "blast")]) .multipart(form) .send(); let response_object = match response_object { Ok(response) => { check_for_warnings(response.headers(), response.status()); response - }, - Err(err) => return Err(format!("Network error: Unable to reach the server. Please try again later. Error: {}", err).into()), + } + Err(err) => { + return Err(format!( + "Network error: Unable to reach the server. Please try again later. Error: {}", + err + ) + .into()) + } }; let response_status = response_object.status(); let response_text = response_object.text()?; - + if response_status != StatusCode::OK { - debug(&format!("Initial scan request failed with status: {}. Response body: {}", response_status, response_text)); - + debug(&format!( + "Initial scan request failed with status: {}. Response body: {}", + response_status, response_text + )); + if response_status == StatusCode::BAD_REQUEST { - if let Ok(error_response) = serde_json::from_str::>(&response_text) { + if let Ok(error_response) = + serde_json::from_str::>(&response_text) + { if let Some(message) = error_response.get("message").and_then(Value::as_str) { return Err(format!("Request failed: {}", message).into()); } } return Err(format!("Request failed (400): {}", response_text).into()); } - + return Err("Error getting server response, Please try again later.".into()); } - + let response: HashMap = match serde_json::from_str(&response_text) { Ok(json) => json, Err(_) => { - debug(&format!("Failed to parse initial scan response as JSON. Response body: {}", response_text)); + debug(&format!( + "Failed to parse initial scan response as JSON. Response body: {}", + response_text + )); return Err("Error getting server response, Please try again later.".into()); - }, + } }; let transfer_id = match response["transfer_id"].as_str() { Some(transfer_id) => transfer_id, - None => return Err("Failed to retrieve transfer ID. Please check the request parameters and try again.".into()), + None => return Err( + "Failed to retrieve transfer ID. Please check the request parameters and try again." + .into(), + ), }; let mut file = File::open(file_path)?; let mut buffer = vec![0; CHUNK_SIZE]; @@ -275,14 +321,17 @@ pub fn upload_zip( let chunk = &buffer[..bytes_read]; let mut form = Form::new() - .part( - "chunk_data", - Part::bytes(chunk.to_vec()) - .file_name(file_name.to_string()) - .mime_str("application/octet-stream")?, - ) - .part("project_name", multipart::Part::text(project_name.to_string())) - .part("file_size", multipart::Part::text(file_size.to_string())); + .part( + "chunk_data", + Part::bytes(chunk.to_vec()) + .file_name(file_name.to_string()) + .mime_str("application/octet-stream")?, + ) + .part( + "project_name", + multipart::Part::text(project_name.to_string()), + ) + .part("file_size", multipart::Part::text(file_size.to_string())); if let Some(ref info) = repo_info { if let Some(branch) = &info.branch { form = form.part("branch", multipart::Part::text(branch.to_string())); @@ -307,43 +356,50 @@ pub fn upload_zip( } let response = match client - .patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id)) - .header("Upload-Offset", offset.to_string()) - .header("Upload-Length", file_size.to_string()) - .header("Upload-Name", file_name) - .query(&[ - ("scan_type", "blast") - ]) - .multipart(form) - .send() { + .patch(format!("{}{}/start-scan/{}/", url, API_BASE, transfer_id)) + .header("Upload-Offset", offset.to_string()) + .header("Upload-Length", file_size.to_string()) + .header("Upload-Name", file_name) + .query(&[("scan_type", "blast")]) + .multipart(form) + .send() + { Ok(response) => { check_for_warnings(response.headers(), response.status()); response - }, + } Err(e) => { return Err(format!("Failed to send request: {}", e).into()); } }; if !response.status().is_success() { let status_code = response.status(); - let response_text = response.text().unwrap_or_else(|_| "Unable to read response body".to_string()); - debug(&format!("Chunk upload failed with status: {}. Response body: {}", status_code, response_text)); - + let response_text = response + .text() + .unwrap_or_else(|_| "Unable to read response body".to_string()); + debug(&format!( + "Chunk upload failed with status: {}. Response body: {}", + status_code, response_text + )); + if status_code.is_client_error() && response_text.contains("Invalid policy ids") { - return Err("Invalid policy ids passed. Please check the policy ids and try again.".into()); + return Err( + "Invalid policy ids passed. Please check the policy ids and try again.".into(), + ); } - + if status_code == StatusCode::BAD_REQUEST { - if let Ok(error_response) = serde_json::from_str::>(&response_text) { + if let Ok(error_response) = + serde_json::from_str::>(&response_text) + { if let Some(message) = error_response.get("message").and_then(Value::as_str) { return Err(format!("Upload failed: {}", message).into()); } } return Err(format!("Upload failed (400): {}", response_text).into()); } - - return Err(format!("Failed to upload file: {}", status_code).into()); + return Err(format!("Failed to upload file: {}", status_code).into()); } utils::terminal::show_progress_bar(offset as f32 / file_size as f32); offset += bytes_read as u64; @@ -355,10 +411,14 @@ pub fn upload_zip( if let Some(scan_id_value) = body.get("scan_id") { let scan_id = scan_id_value.as_str().unwrap().to_string(); let project_id = body.get("project_id").and_then(|v| { - v.as_str().map(|s| s.to_string()) + v.as_str() + .map(|s| s.to_string()) .or_else(|| v.as_i64().map(|n| n.to_string())) }); - return Ok(UploadZipResult { scan_id, project_id }); + return Ok(UploadZipResult { + scan_id, + project_id, + }); } else { return Err("Failed to get scan_id from response".into()); } @@ -368,14 +428,24 @@ pub fn upload_zip( Err("Failed to upload file".into()) } -pub fn get_all_issues(url: &str, project: &str, scan_id: Option) -> Result, Box> { +pub fn get_all_issues( + url: &str, + project: &str, + scan_id: Option, +) -> Result, Box> { let mut all_issues = Vec::new(); let mut current_page: u32 = 1; loop { - let response = match get_scan_issues(url, project, Some(current_page as u16), Some(30), scan_id.clone()) { + let response = match get_scan_issues( + url, + project, + Some(current_page as u16), + Some(30), + scan_id.clone(), + ) { Ok(response) => response, - Err(e) => return Err(format!("Failed to get scan issues: {}", e).into()) + Err(e) => return Err(format!("Failed to get scan issues: {}", e).into()), }; if let Some(mut issues) = response.issues { @@ -402,19 +472,14 @@ pub fn get_scan_issues( project: &str, page: Option, page_size: Option, - scan_id: Option -) -> Result> { + scan_id: Option, +) -> Result> { let mut seperator = "?"; let mut url = match scan_id { Some(scan_id) => format!("{}{}/scan/{}/issues", url, API_BASE, scan_id), None => { seperator = "&"; - format!( - "{}{}/issues?project={}", - url, - API_BASE, - project - ) + format!("{}{}/issues?project={}", url, API_BASE, project) } }; if let Some(p) = page { @@ -433,14 +498,18 @@ pub fn get_scan_issues( Ok(res) => { check_for_warnings(res.headers(), res.status()); res - }, + } Err(e) => return Err(format!("Failed to send request: {}", e).into()), }; let response_text = response.text()?; - let project_issues_response: ProjectIssuesResponse = serde_json::from_str(&response_text).map_err(|e| { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - format!("Failed to parse response: {}", e) - })?; + let project_issues_response: ProjectIssuesResponse = serde_json::from_str(&response_text) + .map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; if project_issues_response.status == "ok" { Ok(project_issues_response) @@ -451,7 +520,7 @@ pub fn get_scan_issues( } } -pub fn get_scan(url: &str, scan_id: &str) -> Result> { +pub fn get_scan(url: &str, scan_id: &str) -> Result> { let url = format!("{}{}/scan/{}", url, API_BASE, scan_id); let client = http_client(); @@ -466,16 +535,27 @@ pub fn get_scan(url: &str, scan_id: &str) -> Result) -> Result> { +pub fn get_scan_report( + url: &str, + scan_id: &str, + format: Option<&str>, +) -> Result> { let url = if let Some(fmt) = format { format!("{}{}/scan/{}/report?format={}", url, API_BASE, scan_id, fmt) } else { @@ -496,43 +576,129 @@ pub fn get_scan_report(url: &str, scan_id: &str, format: Option<&str>) -> Result if response.status().is_success() { Ok(response.text()?) } else { - Err(format!("Error: Unable to fetch scan report. Status code: {}", response.status()).into()) + Err(format!( + "Error: Unable to fetch scan report. Status code: {}", + response.status() + ) + .into()) } } pub fn get_issue(url: &str, issue: &str) -> Result> { - let url = format!( - "{}{}/issue/{}", - url, - API_BASE, - issue, - ); + let url = format!("{}{}/issue/{}", url, API_BASE, issue,); let client = http_client(); debug(&format!("Sending request to URL: {}", url)); let response = match client.get(&url).send() { Ok(res) => { check_for_warnings(res.headers(), res.status()); res - }, + } Err(e) => return Err(format!("Failed to send request: {}", e).into()), }; let response_text = response.text()?; return match serde_json::from_str::(&response_text) { Ok(body) => Ok(body), Err(e) => { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); Err(format!("Failed to parse response: {}", e).into()) - }, + } }; } +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct SkillInfo { + pub name: String, + pub slug: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub is_installable: bool, + #[serde(default)] + pub latest_approved_version: Option, +} + +#[derive(Deserialize, Debug)] +pub struct SkillVersionInfo { + pub version: String, + pub status: String, + #[serde(default)] + pub is_installable: bool, + #[serde(default)] + pub security_concerns: String, + #[serde(default)] + pub content: Option, +} +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct SkillResponse { + #[serde(default)] + pub status: String, + pub skill: SkillInfo, + #[serde(default)] + pub version: Option, +} + +/// Fetch a single skill (optionally a specific version) for installation. +/// +/// Returns `Ok(None)` when no skill/version matches (HTTP 404), `Ok(Some(..))` +/// on success, and `Err(..)` for auth or other failures. +pub fn get_skill( + url: &str, + slug: &str, + version: Option<&str>, +) -> Result, Box> { + let mut request_url = format!("{}{}/skills/{}", url, API_BASE, slug); + if let Some(v) = version { + request_url = format!("{}?version={}", request_url, v); + } + + let client = http_client(); + debug(&format!("Sending request to URL: {}", request_url)); + + let response = client + .get(&request_url) + .send() + .map_err(|e| format!("Failed to send request: {}", e))?; + + check_for_warnings(response.headers(), response.status()); + + let status = response.status(); + if status == StatusCode::NOT_FOUND { + return Ok(None); + } + if status == StatusCode::UNAUTHORIZED { + return Err("Authentication failed. Please run 'corgea login'.".into()); + } + if status == StatusCode::FORBIDDEN { + return Err("Permission denied: you do not have access to skills.".into()); + } + if !status.is_success() { + return Err(format!("Unable to fetch skill. Status code: {}", status).into()); + } + + let response_text = response.text()?; + let skill_response: SkillResponse = serde_json::from_str(&response_text).map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; + Ok(Some(skill_response)) +} pub fn query_scan_list( url: &str, project: Option<&str>, page: Option, - page_size: Option + page_size: Option, ) -> Result> { let url = format!("{}{}/scans", url, API_BASE); let page = page.unwrap_or(1); @@ -546,60 +712,57 @@ pub fn query_scan_list( query_params.push(("project", project.to_string())); } - let client = http_client(); debug(&format!("Sending request to URL: {}", url)); - let response = match client - .get(url) - .query(&query_params) - .send() { - Ok(res) => { - check_for_warnings(res.headers(), res.status()); - res - }, - Err(e) => return Err(format!("API request failed: {}", e).into()), - }; - if response.status().is_success() { - let response_text = response.text()?; - let api_response: ScansResponse = serde_json::from_str(&response_text).map_err(|e| { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - format!("Failed to parse response: {}", e) - })?; - Ok(api_response) - } else { - Err(format!( - "API request failed with status: {}", - response.status() - ).into()) + let response = match client.get(url).query(&query_params).send() { + Ok(res) => { + check_for_warnings(res.headers(), res.status()); + res } + Err(e) => return Err(format!("API request failed: {}", e).into()), + }; + if response.status().is_success() { + let response_text = response.text()?; + let api_response: ScansResponse = serde_json::from_str(&response_text).map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; + Ok(api_response) + } else { + Err(format!("API request failed with status: {}", response.status()).into()) + } } - pub fn exchange_code_for_token( base_url: &str, code: &str, ) -> Result> { let client = reqwest::blocking::Client::new(); let exchange_url = format!("{}{}/authorize", base_url, API_BASE); - + let response = client .get(&exchange_url) .header("CORGEA-SOURCE", get_source()) .query(&[("code", code)]) .send()?; - + if response.status().is_success() { let response_json: HashMap = response.json()?; - + if let Some(user_token) = response_json.get("user_token") { if let Some(user_token_str) = user_token.as_str() { return Ok(user_token_str.to_string()); } } - + Err("User token not found in response".into()) } else { - let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string()); + let error_text = response + .text() + .unwrap_or_else(|_| "Unknown error".to_string()); Err(format!("Failed to exchange code for user token: {}", error_text).into()) } } @@ -609,9 +772,7 @@ pub fn verify_token(corgea_url: &str) -> Result> { let client = http_client(); debug(&format!("Sending request to URL: {}", url)); - let response = client - .get(&url) - .send()?; + let response = client.get(&url).send()?; check_for_warnings(response.headers(), response.status()); @@ -620,7 +781,10 @@ pub fn verify_token(corgea_url: &str) -> Result> { let body: HashMap = match serde_json::from_str(&body_text) { Ok(json) => json, Err(e) => { - debug(&format!("Failed to parse response as JSON: {}. Response body: {}", e, body_text)); + debug(&format!( + "Failed to parse response as JSON: {}. Response body: {}", + e, body_text + )); return Err(format!("Failed to parse response").into()); } }; @@ -634,9 +798,12 @@ pub fn verify_token(corgea_url: &str) -> Result> { pub fn check_blocking_rules( url: &str, sast_scan_id: &str, - page: Option + page: Option, ) -> Result> { - let url = format!("{}{}/scan/{}/check_blocking_rules", url, API_BASE, sast_scan_id); + let url = format!( + "{}{}/scan/{}/check_blocking_rules", + url, API_BASE, sast_scan_id + ); let page = page.unwrap_or(1); let query_params = vec![("page", page.to_string())]; @@ -644,43 +811,40 @@ pub fn check_blocking_rules( debug(&format!("Sending request to URL: {}", url)); debug(&format!("Query params: {:?}", query_params)); - let response = match client - .get(url) - .query(&query_params) - .send() { - Ok(res) => { - check_for_warnings(res.headers(), res.status()); - debug(&format!("Response status: {}", res.status())); - debug(&format!("Response headers: {:?}", res.headers())); - res - }, - Err(e) => return Err(format!("API request failed: {}", e).into()), - }; + let response = match client.get(url).query(&query_params).send() { + Ok(res) => { + check_for_warnings(res.headers(), res.status()); + debug(&format!("Response status: {}", res.status())); + debug(&format!("Response headers: {:?}", res.headers())); + res + } + Err(e) => return Err(format!("API request failed: {}", e).into()), + }; if response.status().is_success() { let response_text = response.text()?; - let api_response: BlockingRuleResponse = serde_json::from_str(&response_text).map_err(|e| { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - format!("Failed to parse response: {}", e) - })?; + let api_response: BlockingRuleResponse = + serde_json::from_str(&response_text).map_err(|e| { + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + format!("Failed to parse response: {}", e) + })?; Ok(api_response) } else { let status = response.status(); let response_text = response.text()?; debug(&format!("Response body: {}", response_text)); - Err(format!( - "API request failed with status: {}", - status - ).into()) + Err(format!("API request failed with status: {}", status).into()) } } - pub fn get_sca_issues( url: &str, page: Option, page_size: Option, - scan_id: Option + scan_id: Option, ) -> Result> { let client = http_client(); let mut query_params = vec![]; @@ -700,10 +864,7 @@ pub fn get_sca_issues( debug(&format!("Sending request to URL: {}", endpoint)); debug(&format!("Query params: {:?}", query_params)); - let response = client - .get(&endpoint) - .query(&query_params) - .send(); + let response = client.get(&endpoint).query(&query_params).send(); let response = match response { Ok(response) => { @@ -711,14 +872,23 @@ pub fn get_sca_issues( debug(&format!("Response status: {}", response.status())); debug(&format!("Response headers: {:?}", response.headers())); response - }, - Err(err) => return Err(format!("Network error: Unable to reach the server. Please try again later. Error: {}", err).into()), + } + Err(err) => { + return Err(format!( + "Network error: Unable to reach the server. Please try again later. Error: {}", + err + ) + .into()) + } }; let status = response.status(); if !status.is_success() { if status == StatusCode::NOT_FOUND { - return Err("SCA issues not found. Please check the scan ID or ensure the scan has SCA issues.".into()); + return Err( + "SCA issues not found. Please check the scan ID or ensure the scan has SCA issues." + .into(), + ); } return Err(format!("Request failed with status: {}", status).into()); } @@ -727,9 +897,12 @@ pub fn get_sca_issues( let response_data: SCAIssuesResponse = match serde_json::from_str(&response_text) { Ok(json) => json, Err(e) => { - debug(&format!("Failed to parse response: {}. Response body: {}", e, response_text)); - return Err("Error parsing server response. Please try again later.".into()) - }, + debug(&format!( + "Failed to parse response: {}. Response body: {}", + e, response_text + )); + return Err("Error parsing server response. Please try again later.".into()); + } }; Ok(response_data) @@ -738,16 +911,17 @@ pub fn get_sca_issues( pub fn get_all_sca_issues( url: &str, _project: &str, - scan_id: Option + scan_id: Option, ) -> Result, Box> { let mut all_issues = Vec::new(); let mut current_page: u32 = 1; loop { - let response = match get_sca_issues(url, Some(current_page as u16), Some(30), scan_id.clone()) { - Ok(response) => response, - Err(e) => return Err(format!("Failed to get SCA issues: {}", e).into()) - }; + let response = + match get_sca_issues(url, Some(current_page as u16), Some(30), scan_id.clone()) { + Ok(response) => response, + Err(e) => return Err(format!("Failed to get SCA issues: {}", e).into()), + }; if response.issues.is_empty() { break; @@ -765,7 +939,7 @@ pub fn get_all_sca_issues( } #[derive(Deserialize, Serialize, Debug)] -pub struct ScanResponse { +pub struct ScanResponse { pub id: String, pub project: String, pub repo: Option, @@ -781,10 +955,9 @@ pub struct ProjectIssuesResponse { pub issues: Option>, pub page: Option, pub total_pages: Option, - pub total_issues: Option + pub total_issues: Option, } - #[derive(Serialize, Deserialize, Debug)] pub struct ScansResponse { pub status: String, @@ -793,7 +966,6 @@ pub struct ScansResponse { pub scans: Option>, } - #[derive(Serialize, Deserialize, Debug)] pub struct FullIssueResponse { pub status: String, @@ -830,7 +1002,6 @@ pub struct IssueWithBlockingRules { pub blocking_rules: Option>, } - #[derive(Serialize, Deserialize, Debug, Clone)] pub struct Classification { pub id: String, @@ -905,7 +1076,7 @@ pub struct BlockingRuleResponse { #[derive(Deserialize, Debug, Clone)] pub struct BlockingIssue { pub id: String, - pub triggered_by_rules: Vec + pub triggered_by_rules: Vec, } #[derive(Deserialize, Serialize, Debug)] @@ -952,7 +1123,10 @@ mod tests { fn connection_refused_error() -> reqwest::Error { let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind ephemeral port"); - let port = listener.local_addr().expect("failed to get listener addr").port(); + let port = listener + .local_addr() + .expect("failed to get listener addr") + .port(); drop(listener); reqwest::blocking::Client::builder() @@ -966,7 +1140,10 @@ mod tests { fn timeout_error() -> reqwest::Error { let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind ephemeral port"); - let port = listener.local_addr().expect("failed to get listener addr").port(); + let port = listener + .local_addr() + .expect("failed to get listener addr") + .port(); thread::spawn(move || { if let Ok((_, _)) = listener.accept() {