diff --git a/Cargo.lock b/Cargo.lock index 934e637..79207ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,6 +540,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1204,6 +1225,16 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1850,6 +1881,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.11.1" @@ -2183,6 +2225,7 @@ dependencies = [ "bytes", "chrono", "crossterm 0.27.0", + "dirs-next", "flate2", "futures", "futures-util", @@ -2194,6 +2237,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "url", diff --git a/Cargo.toml b/Cargo.toml index fa97049..c3aad7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ axum = { version = "0.7", features = ["ws"] } prost = "0.13" prost-types = "0.13" bytes = "1" +dirs-next = "2" [profile.release] # Favor smaller, simpler binaries with good runtime perf diff --git a/README.md b/README.md index e65ff55..e34a2e4 100644 --- a/README.md +++ b/README.md @@ -94,31 +94,18 @@ cargo build --release ./target/release/socktop ws://REMOTE_HOST:3000/ws ``` -Tip: Add ?token=... if you enable auth (see Security). +### Quick demo (no agent setup) -TLS quick start (optional, recommended on untrusted networks): - -- Start the agent with TLS enabled (default TLS port 8443). On first run it will generate a self‑signed certificate and key under your config directory. +Spin up a temporary local agent on port 3231 and connect automatically: ```bash -./target/release/socktop_agent --enableSSL --port 8443 # or: -p 8443 -# First run prints the cert and key paths, e.g.: -# socktop_agent: generated self-signed TLS certificate at /home/you/.config/socktop_agent/tls/cert.pem -# socktop_agent: private key at /home/you/.config/socktop_agent/tls/key.pem +socktop --demo ``` -- Copy the certificate file to the client machine (keep the key private on the server): +Or just run `socktop` with no arguments and pick the built‑in `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent: -```bash -scp /home/you/.config/socktop_agent/tls/cert.pem you@client:/tmp/socktop-agent-ca.pem -``` - -- Connect with the TUI, pinning the server cert: - -```bash -./target/release/socktop --tls-ca /tmp/socktop-agent-ca.pem wss://REMOTE_HOST:8443/ws -# Note: if you pass --tls-ca but use ws://, the client auto-upgrades to wss:// -``` +- Runs locally (`ws://127.0.0.1:3231/ws`) +- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C --- @@ -186,6 +173,84 @@ The agent stays idle unless queried. When queried, it collects just what’s nee --- +## Connection Profiles (Named) + +You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later. + +Config file location: + +- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json` +- Fallback (when XDG not set): `~/.config/socktop/profiles.json` + +### Creating a profile + +First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically: + +```bash +socktop --profile prod ws://prod-host:3000/ws +# With TLS pinning: +socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws +``` + +If a profile already exists you will be prompted before overwriting: + +``` +$ socktop --profile prod ws://new-host:3000/ws +Overwrite existing profile 'prod'? [y/N]: y +``` + +To overwrite without an interactive prompt pass `--save`: + +```bash +socktop --profile prod --save ws://new-host:3000/ws +``` + +### Using a saved profile + +Just pass the profile name (no URL needed): + +```bash +socktop --profile prod +socktop -P prod-tls # short flag +``` + +The stored URL (and TLS CA path, if any) will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL. + +### Interactive selection (no args) + +If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from: + +``` +$ socktop +Select profile: + 1. prod + 2. prod-tls +Enter number (or blank to abort): 2 +``` + +Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting. + +### JSON format + +An example `profiles.json` (pretty‑printed): + +```json +{ + "profiles": { + "prod": { "url": "ws://prod-host:3000/ws" }, + "prod-tls": { "url": "wss://prod-host:8443/ws", "tls_ca": "/home/user/certs/prod-cert.pem" } + }, + "version": 0 +} +``` + +Notes: +- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`. +- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command). +- Profiles are client-side convenience only; they do not affect the agent. + +--- + ## Updating Update the agent (systemd): diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 526001a..1fe44fb 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -19,6 +19,7 @@ crossterm = { workspace = true } chrono = { workspace = true } anyhow = { workspace = true } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +dirs-next = { workspace = true } rustls = "0.23" rustls-pemfile = "2.1" prost = { workspace = true } @@ -26,6 +27,7 @@ bytes = { workspace = true } [dev-dependencies] assert_cmd = "2.0" +tempfile = "3" [build-dependencies] prost-build = "0.13" diff --git a/socktop/src/main.rs b/socktop/src/main.rs index e1caaa7..c9204b2 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -2,29 +2,55 @@ mod app; mod history; +mod profiles; mod types; mod ui; mod ws; use app::App; +use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, ResolveProfile}; use std::env; +use std::io::{self, Write}; -fn parse_args>(args: I) -> Result<(String, Option), String> { +pub(crate) struct ParsedArgs { + url: Option, + tls_ca: Option, + profile: Option, + save: bool, + demo: bool, + dry_run: bool, // hidden test helper: skip connecting +} + +pub(crate) fn parse_args>(args: I) -> Result { let mut it = args.into_iter(); let prog = it.next().unwrap_or_else(|| "socktop".into()); let mut url: Option = None; let mut tls_ca: Option = None; - + let mut profile: Option = None; + let mut save = false; + let mut demo = false; + let mut dry_run = false; while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { - return Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws" - )); + return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]\n")); } "--tls-ca" | "-t" => { tls_ca = it.next(); } + "--profile" | "-P" => { + profile = it.next(); + } + "--save" => { + save = true; + } + "--demo" => { + demo = true; + } + "--dry-run" => { + // intentionally undocumented + dry_run = true; + } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -32,37 +58,228 @@ fn parse_args>(args: I) -> Result<(String, Option } } } + _ if arg.starts_with("--profile=") => { + if let Some((_, v)) = arg.split_once('=') { + if !v.is_empty() { + profile = Some(v.to_string()); + } + } + } _ => { if url.is_none() { url = Some(arg); } else { - return Err(format!( - "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws" - )); + return Err(format!("Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]")); } } } } - - match url { - Some(u) => Ok((u, tls_ca)), - None => Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws" - )), - } + Ok(ParsedArgs { + url, + tls_ca, + profile, + save, + demo, + dry_run, + }) } #[tokio::main] async fn main() -> Result<(), Box> { - // Reuse the same parsing logic for testability - let (url, tls_ca) = match parse_args(env::args()) { + let parsed = match parse_args(env::args()) { Ok(v) => v, Err(msg) => { eprintln!("{msg}"); return Ok(()); } }; - + if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { + return run_demo_mode(parsed.tls_ca.as_deref()).await; + } + let profiles_file = load_profiles(); + let req = ProfileRequest { + profile_name: parsed.profile.clone(), + url: parsed.url.clone(), + tls_ca: parsed.tls_ca.clone(), + }; + let resolved = req.resolve(&profiles_file); + let mut profiles_mut = profiles_file.clone(); + let (url, tls_ca): (String, Option) = match resolved { + ResolveProfile::Direct(u, t) => { + if let Some(name) = parsed.profile.as_ref() { + let existing = profiles_mut.profiles.get(name); + match existing { + None => { + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, + ); + let _ = save_profiles(&profiles_mut); + } + Some(entry) => { + let changed = entry.url != u || entry.tls_ca != t; + if changed { + let overwrite = if parsed.save { + true + } else { + prompt_yes_no(&format!( + "Overwrite existing profile '{name}'? [y/N]: " + )) + }; + if overwrite { + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, + ); + let _ = save_profiles(&profiles_mut); + } + } + } + } + } + (u, t) + } + ResolveProfile::Loaded(u, t) => (u, t), + ResolveProfile::PromptSelect(mut names) => { + if !names.iter().any(|n| n == "demo") { + names.push("demo".into()); + } + eprintln!("Select profile:"); + for (i, n) in names.iter().enumerate() { + eprintln!(" {}. {}", i + 1, n); + } + eprint!("Enter number (or blank to abort): "); + let _ = io::stderr().flush(); + let mut line = String::new(); + if io::stdin().read_line(&mut line).is_ok() { + if let Ok(idx) = line.trim().parse::() { + if idx >= 1 && idx <= names.len() { + let name = &names[idx - 1]; + if name == "demo" { + return run_demo_mode(parsed.tls_ca.as_deref()).await; + } + if let Some(entry) = profiles_mut.profiles.get(name) { + (entry.url.clone(), entry.tls_ca.clone()) + } else { + return Ok(()); + } + } else { + return Ok(()); + } + } else { + return Ok(()); + } + } else { + return Ok(()); + } + } + ResolveProfile::PromptCreate(name) => { + eprintln!("Profile '{name}' does not exist yet."); + let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?; + if url.trim().is_empty() { + return Ok(()); + } + let ca = prompt_string("Enter TLS CA path (or leave blank): ")?; + let ca_opt = if ca.trim().is_empty() { + None + } else { + Some(ca.trim().to_string()) + }; + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: url.trim().to_string(), + tls_ca: ca_opt.clone(), + }, + ); + let _ = save_profiles(&profiles_mut); + (url.trim().to_string(), ca_opt) + } + ResolveProfile::None => { + eprintln!("No URL provided and no profiles to select."); + return Ok(()); + } + }; let mut app = App::new(); + if parsed.dry_run { + return Ok(()); + } app.run(&url, tls_ca.as_deref()).await } + +fn prompt_yes_no(prompt: &str) -> bool { + eprint!("{prompt}"); + let _ = io::stderr().flush(); + let mut line = String::new(); + if io::stdin().read_line(&mut line).is_ok() { + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") + } else { + false + } +} +fn prompt_string(prompt: &str) -> io::Result { + eprint!("{prompt}"); + let _ = io::stderr().flush(); + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + Ok(line) +} + +// Demo mode implementation +async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box> { + let port = 3231; + let url = format!("ws://127.0.0.1:{port}/ws"); + let child = spawn_demo_agent(port)?; + let mut app = App::new(); + tokio::select! { res=app.run(&url,None)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } } +} +struct DemoGuard { + port: u16, + child: std::sync::Arc>>, +} +impl Drop for DemoGuard { + fn drop(&mut self) { + if let Some(mut ch) = self.child.lock().unwrap().take() { + let _ = ch.kill(); + } + eprintln!("Stopped demo agent on port {}", self.port); + } +} +fn spawn_demo_agent(port: u16) -> Result> { + let candidate = find_agent_executable(); + let mut cmd = std::process::Command::new(candidate); + cmd.arg("--port").arg(port.to_string()); + cmd.env("SOCKTOP_ENABLE_SSL", "0"); + + //JW: do not disable GPU and TEMP in demo mode + //cmd.env("SOCKTOP_AGENT_GPU", "0"); + //cmd.env("SOCKTOP_AGENT_TEMP", "0"); + + let child = cmd.spawn()?; + std::thread::sleep(std::time::Duration::from_millis(300)); + Ok(DemoGuard { + port, + child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))), + }) +} +fn find_agent_executable() -> std::path::PathBuf { + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + #[cfg(windows)] + let name = "socktop_agent.exe"; + #[cfg(not(windows))] + let name = "socktop_agent"; + let candidate = parent.join(name); + if candidate.exists() { + return candidate; + } + } + } + std::path::PathBuf::from("socktop_agent") +} diff --git a/socktop/src/profiles.rs b/socktop/src/profiles.rs new file mode 100644 index 0000000..8dcc682 --- /dev/null +++ b/socktop/src/profiles.rs @@ -0,0 +1,98 @@ +//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca } +//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json) + +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, fs, path::PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfileEntry { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls_ca: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfilesFile { + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default)] + pub version: u32, +} + +pub fn config_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("socktop") + } else { + dirs_next::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("socktop") + } +} + +pub fn profiles_path() -> PathBuf { + config_dir().join("profiles.json") +} + +pub fn load_profiles() -> ProfilesFile { + let path = profiles_path(); + match fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => ProfilesFile::default(), + } +} + +pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> { + let path = profiles_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let data = serde_json::to_vec_pretty(p).expect("serialize profiles"); + fs::write(path, data) +} + +pub enum ResolveProfile { + /// Use the provided runtime inputs (not persisted). (url, tls_ca) + Direct(String, Option), + /// Loaded from existing profile entry (url, tls_ca) + Loaded(String, Option), + /// Should prompt user to select among profile names + PromptSelect(Vec), + /// Should prompt user to create a new profile (name) + PromptCreate(String), + /// No profile could be resolved (e.g., missing arguments) + None, +} + +pub struct ProfileRequest { + pub profile_name: Option, + pub url: Option, + pub tls_ca: Option, +} + +impl ProfileRequest { + pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile { + // Case: only profile name given -> try load + if self.url.is_none() && self.profile_name.is_some() { + let name = self.profile_name.unwrap(); + if let Some(entry) = pf.profiles.get(&name) { + return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone()); + } else { + return ResolveProfile::PromptCreate(name); + } + } + // Both provided -> direct (maybe later saved by caller) + if let Some(u) = self.url { + return ResolveProfile::Direct(u, self.tls_ca); + } + // Nothing provided -> maybe prompt select if profiles exist + if self.url.is_none() && self.profile_name.is_none() { + if pf.profiles.is_empty() { + ResolveProfile::None + } else { + ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect()) + } + } else { + ResolveProfile::None + } + } +} diff --git a/socktop/tests/cli_args.rs b/socktop/tests/cli_args.rs index 53433e1..debaa57 100644 --- a/socktop/tests/cli_args.rs +++ b/socktop/tests/cli_args.rs @@ -17,8 +17,11 @@ fn test_help_mentions_short_and_long_flags() { String::from_utf8_lossy(&output.stderr) ); assert!( - text.contains("--tls-ca") && text.contains("-t"), - "help text missing --tls-ca/-t\n{text}" + text.contains("--tls-ca") + && text.contains("-t") + && text.contains("--profile") + && text.contains("-P"), + "help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}" ); } @@ -53,4 +56,20 @@ fn test_tlc_ca_arg_long_and_short_parsed() { String::from_utf8_lossy(&out2.stderr) ); assert!(text2.contains("Usage:")); + + // Profile flags with help (should not error) + let out3 = Command::new(exe) + .args(["--profile", "dev", "--help"]) + .output() + .expect("run socktop"); + assert!( + out3.status.success(), + "socktop --profile dev --help did not succeed" + ); + let text3 = format!( + "{}{}", + String::from_utf8_lossy(&out3.stdout), + String::from_utf8_lossy(&out3.stderr) + ); + assert!(text3.contains("Usage:")); } diff --git a/socktop/tests/profiles.rs b/socktop/tests/profiles.rs new file mode 100644 index 0000000..c1aa446 --- /dev/null +++ b/socktop/tests/profiles.rs @@ -0,0 +1,118 @@ +//! Tests for profile load/save and resolution logic (non-interactive paths only) +use std::fs; +use std::sync::Mutex; + +// Global lock to serialize tests that mutate process-wide environment variables. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +#[allow(dead_code)] // touch crate +fn touch() { + let _ = socktop::types::Metrics { + cpu_total: 0.0, + cpu_per_core: vec![], + mem_total: 0, + mem_used: 0, + swap_total: 0, + swap_used: 0, + process_count: None, + hostname: String::new(), + cpu_temp_c: None, + disks: vec![], + networks: vec![], + top_processes: vec![], + gpus: None, + }; +} + +// We re-import internal modules by copying minimal logic here because profiles.rs isn't public. +// Instead of exposing internals, we simulate profile saving through CLI invocations. + +use std::process::Command; + +fn run_socktop(args: &[&str]) -> (bool, String) { + let exe = env!("CARGO_BIN_EXE_socktop"); + let output = Command::new(exe).args(args).output().expect("run socktop"); + let ok = output.status.success(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + (ok, text) +} + +fn config_dir() -> std::path::PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + std::path::PathBuf::from(xdg).join("socktop") + } else { + dirs_next::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("socktop") + } +} + +fn profiles_path() -> std::path::PathBuf { + config_dir().join("profiles.json") +} + +#[test] +fn test_profile_created_on_first_use() { + let _guard = ENV_LOCK.lock().unwrap(); + // Isolate config in a temp dir + let td = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", td.path()); + // Ensure directory exists fresh + std::fs::create_dir_all(td.path().join("socktop")).unwrap(); + let _ = fs::remove_file(profiles_path()); + // Provide profile + url => should create profiles.json + let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]); + // We pass --help to exit early after parsing (no network attempt) + let data = fs::read_to_string(profiles_path()).expect("profiles.json created"); + assert!( + data.contains("unittest"), + "profiles.json missing profile entry: {data}" + ); +} + +#[test] +fn test_profile_overwrite_only_when_changed() { + let _guard = ENV_LOCK.lock().unwrap(); + let td = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", td.path()); + std::fs::create_dir_all(td.path().join("socktop")).unwrap(); + let _ = fs::remove_file(profiles_path()); + // Initial create + let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create + let first = fs::read_to_string(profiles_path()).unwrap(); + // Re-run identical (should not duplicate or corrupt) + let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical + let second = fs::read_to_string(profiles_path()).unwrap(); + assert_eq!( + first, second, + "Profile file changed despite identical input" + ); + // Overwrite with different URL using --save (no prompt path) + let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]); + let third = fs::read_to_string(profiles_path()).unwrap(); + assert!(third.contains("two"), "Updated URL not written: {third}"); +} + +#[test] +fn test_profile_tls_ca_persisted() { + let _guard = ENV_LOCK.lock().unwrap(); + let td = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", td.path()); + std::fs::create_dir_all(td.path().join("socktop")).unwrap(); + let _ = fs::remove_file(profiles_path()); + let (_ok, _out) = run_socktop(&[ + "--profile", + "secureX", + "--tls-ca", + "/tmp/cert.pem", + "wss://host/ws", + "--dry-run", + ]); + let data = fs::read_to_string(profiles_path()).unwrap(); + assert!(data.contains("secureX")); + assert!(data.contains("cert.pem")); +}