From be72df55c81487423ad9f9e458db11ceb6becfad Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Tue, 3 Mar 2026 22:24:09 -0500 Subject: [PATCH] Add `arc setup` interactive setup wizard Automates first-time setup: generates server.toml, Ed25519 JWT keypair, mTLS CA+server certificates, session secret, and .env file with proper permissions. Includes pre-flight system dependency checks shared with doctor, LLM provider API key collection, and optional doctor verification. Also renames config file from arc.toml to server.toml across the codebase, and loads ~/.arc/.env before CWD .env for centralized secret management. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 3 + apps/arc-web/app/lib/config.server.ts | 2 +- crates/arc-api/src/server_config.rs | 4 +- crates/arc-cli/Cargo.toml | 3 + crates/arc-cli/src/doctor.rs | 16 +- crates/arc-cli/src/main.rs | 10 + crates/arc-cli/src/setup.rs | 677 ++++++++++++++++++++++++++ 7 files changed, 704 insertions(+), 11 deletions(-) create mode 100644 crates/arc-cli/src/setup.rs diff --git a/Cargo.lock b/Cargo.lock index b70a49f7d..177b19857 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,12 +186,14 @@ dependencies = [ "chrono", "clap", "daytona-sdk", + "dialoguer", "dirs", "dotenvy", "futures", "httpmock", "jsonwebtoken", "predicates", + "rand 0.8.5", "regex", "reqwest 0.12.28", "rustls-pemfile", @@ -199,6 +201,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "toml", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/apps/arc-web/app/lib/config.server.ts b/apps/arc-web/app/lib/config.server.ts index 26a3204e5..7be9747c3 100644 --- a/apps/arc-web/app/lib/config.server.ts +++ b/apps/arc-web/app/lib/config.server.ts @@ -51,7 +51,7 @@ const GIT_DEFAULTS: GitConfig = { client_id: null, }; -export const ARC_CONFIG_PATH = join(homedir(), ".arc", "arc.toml"); +export const ARC_CONFIG_PATH = join(homedir(), ".arc", "server.toml"); function loadAppConfig(): AppConfig { const configPath = ARC_CONFIG_PATH; diff --git a/crates/arc-api/src/server_config.rs b/crates/arc-api/src/server_config.rs index e768191bc..d0e667f25 100644 --- a/crates/arc-api/src/server_config.rs +++ b/crates/arc-api/src/server_config.rs @@ -105,12 +105,12 @@ pub struct ServerConfig { pub run_defaults: RunDefaults, } -/// Load server config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist. +/// Load server config from `~/.arc/server.toml`, returning defaults if the file doesn't exist. pub fn load_server_config() -> anyhow::Result { let Some(home) = dirs::home_dir() else { return Ok(ServerConfig::default()); }; - let path = home.join(".arc").join("arc.toml"); + let path = home.join(".arc").join("server.toml"); match std::fs::read_to_string(&path) { Ok(contents) => Ok(toml::from_str(&contents)?), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ServerConfig::default()), diff --git a/crates/arc-cli/Cargo.toml b/crates/arc-cli/Cargo.toml index cfd2759a7..10c588fe1 100644 --- a/crates/arc-cli/Cargo.toml +++ b/crates/arc-cli/Cargo.toml @@ -34,6 +34,8 @@ jsonwebtoken.workspace = true base64.workspace = true rustls-pemfile = "2" x509-parser = "0.16" +rand.workspace = true +dialoguer.workspace = true [dev-dependencies] assert_cmd = "2" @@ -41,3 +43,4 @@ predicates = "3" tempfile = "3" serde_json.workspace = true httpmock = "0.8" +toml.workspace = true diff --git a/crates/arc-cli/src/doctor.rs b/crates/arc-cli/src/doctor.rs index 0454ff7f4..54e5c2ad9 100644 --- a/crates/arc-cli/src/doctor.rs +++ b/crates/arc-cli/src/doctor.rs @@ -175,7 +175,7 @@ fn parse_version(re: &Regex, output: &str) -> Option { )) } -const DEP_SPECS: &[DepSpec] = &[ +pub const DEP_SPECS: &[DepSpec] = &[ DepSpec { name: "openssl", command: &["openssl", "version"], required: true, min_version: Version::new(3, 0, 0), pattern: &OPENSSL_RE }, DepSpec { name: "node", command: &["node", "--version"], required: true, min_version: Version::new(20, 0, 0), pattern: &NODE_RE }, DepSpec { name: "gh", command: &["gh", "--version"], required: false, min_version: Version::new(2, 0, 0), pattern: &GH_RE }, @@ -301,9 +301,9 @@ pub fn check_config(path: Option) -> CheckResult { status: CheckStatus::Warning, summary: "no config file found".to_string(), details: vec![CheckDetail { - text: "Create ~/.arc/arc.toml to configure Arc".to_string(), + text: "Create ~/.arc/server.toml to configure Arc".to_string(), }], - remediation: Some("Create ~/.arc/arc.toml".to_string()), + remediation: Some("Create ~/.arc/server.toml".to_string()), }, } } @@ -571,7 +571,7 @@ pub fn check_github_app(status: &GithubAppStatus) -> CheckResult { summary: "not configured".to_string(), details, remediation: Some( - "Configure GitHub App in arc.toml and set env vars to enable GitHub integration" + "Configure GitHub App in server.toml and set env vars to enable GitHub integration" .to_string(), ), } @@ -867,7 +867,7 @@ pub fn check_crypto(input: &CryptoInput) -> CheckResult { text: "No authentication strategies or keys configured".to_string(), }], remediation: Some( - "Configure authentication_strategies in [api] section of arc.toml".to_string(), + "Configure authentication_strategies in [api] section of server.toml".to_string(), ), }; } @@ -978,7 +978,7 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { let styles = Styles::detect_stdout(); // Gather state - let config_path = dirs::home_dir().map(|h| h.join(".arc").join("arc.toml")); + let config_path = dirs::home_dir().map(|h| h.join(".arc").join("server.toml")); let config_exists = config_path .as_ref() .is_some_and(|p| p.exists()); @@ -1298,9 +1298,9 @@ mod tests { #[test] fn check_config_pass_with_path() { - let result = check_config(Some(PathBuf::from("/home/user/.arc/arc.toml"))); + let result = check_config(Some(PathBuf::from("/home/user/.arc/server.toml"))); assert_eq!(result.status, CheckStatus::Pass); - assert!(result.summary.contains(".arc/arc.toml")); + assert!(result.summary.contains(".arc/server.toml")); } #[test] diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index 7ee7f4e78..327b6481f 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -1,5 +1,6 @@ mod doctor; mod logging; +mod setup; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -53,6 +54,8 @@ enum Command { #[arg(short, long)] live: bool, }, + /// Interactive setup wizard for Arc + Setup, } #[derive(Subcommand)] @@ -77,6 +80,9 @@ enum LlmCommand { async fn main() -> Result<()> { let cli = Cli::parse(); if !cli.no_dotenv { + if let Some(home) = dirs::home_dir() { + let _ = dotenvy::from_path(home.join(".arc").join(".env")); + } dotenvy::dotenv().ok(); } @@ -92,6 +98,7 @@ async fn main() -> Result<()> { Command::Models { .. } => "models", Command::Serve(_) => "serve", Command::Doctor { .. } => "doctor", + Command::Setup => "setup", }; debug!(command = %command_name, "CLI command started"); @@ -130,6 +137,9 @@ async fn main() -> Result<()> { let exit_code = doctor::run_doctor(verbose, live).await; std::process::exit(exit_code); } + Command::Setup => { + setup::run_setup().await?; + } } Ok(()) diff --git a/crates/arc-cli/src/setup.rs b/crates/arc-cli/src/setup.rs new file mode 100644 index 000000000..a128b74fe --- /dev/null +++ b/crates/arc-cli/src/setup.rs @@ -0,0 +1,677 @@ +use std::io::Write as _; +use std::path::Path; +use std::process::{Command, Stdio}; + +use anyhow::{bail, Context, Result}; +use arc_llm::provider::Provider; +use dialoguer::{Confirm, Input, MultiSelect}; +use rand::Rng; + +use crate::doctor; + +// --------------------------------------------------------------------------- +// Session secret +// --------------------------------------------------------------------------- + +fn generate_session_secret() -> String { + let mut rng = rand::thread_rng(); + let bytes: [u8; 32] = rng.gen(); + hex::encode(&bytes) +} + +// --------------------------------------------------------------------------- +// JWT keypair generation +// --------------------------------------------------------------------------- + +fn generate_jwt_keypair() -> Result<(String, String)> { + let private_output = Command::new("openssl") + .args(["genpkey", "-algorithm", "Ed25519"]) + .output() + .context("failed to run openssl genpkey")?; + if !private_output.status.success() { + bail!( + "openssl genpkey failed: {}", + String::from_utf8_lossy(&private_output.stderr) + ); + } + let private_pem = private_output.stdout; + + let mut child = Command::new("openssl") + .args(["pkey", "-pubout"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn openssl pkey")?; + child + .stdin + .take() + .unwrap() + .write_all(&private_pem) + .context("failed to write private key to openssl stdin")?; + let public_output = child + .wait_with_output() + .context("failed to read openssl pkey output")?; + if !public_output.status.success() { + bail!( + "openssl pkey -pubout failed: {}", + String::from_utf8_lossy(&public_output.stderr) + ); + } + + let private_pem_str = + String::from_utf8(private_pem).context("private key is not valid UTF-8")?; + let public_pem_str = + String::from_utf8(public_output.stdout).context("public key is not valid UTF-8")?; + Ok((private_pem_str, public_pem_str)) +} + +// --------------------------------------------------------------------------- +// mTLS certificate generation +// --------------------------------------------------------------------------- + +fn generate_mtls_certs(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir).context("failed to create certs directory")?; + + // 1. CA key + let ca_key_output = Command::new("openssl") + .args(["genpkey", "-algorithm", "Ed25519"]) + .output() + .context("failed to generate CA key")?; + if !ca_key_output.status.success() { + bail!("openssl genpkey (CA) failed"); + } + let ca_key_path = dir.join("ca.key"); + std::fs::write(&ca_key_path, &ca_key_output.stdout)?; + + // 2. CA self-signed cert + let ca_cert_output = Command::new("openssl") + .args([ + "req", "-new", "-x509", "-key", + ca_key_path.to_str().unwrap(), + "-days", "3650", + "-subj", "/CN=Arc CA", + ]) + .output() + .context("failed to generate CA cert")?; + if !ca_cert_output.status.success() { + bail!( + "openssl req (CA cert) failed: {}", + String::from_utf8_lossy(&ca_cert_output.stderr) + ); + } + let ca_cert_path = dir.join("ca.crt"); + std::fs::write(&ca_cert_path, &ca_cert_output.stdout)?; + + // 3. Server key + let server_key_output = Command::new("openssl") + .args(["genpkey", "-algorithm", "Ed25519"]) + .output() + .context("failed to generate server key")?; + if !server_key_output.status.success() { + bail!("openssl genpkey (server) failed"); + } + let server_key_path = dir.join("server.key"); + std::fs::write(&server_key_path, &server_key_output.stdout)?; + + // 4. Server CSR + let mut csr_child = Command::new("openssl") + .args(["req", "-new", "-key", "/dev/stdin", "-subj", "/CN=localhost"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .context("failed to spawn openssl req (CSR)")?; + csr_child + .stdin + .take() + .unwrap() + .write_all(&server_key_output.stdout)?; + let csr_output = csr_child.wait_with_output()?; + if !csr_output.status.success() { + bail!( + "openssl req (CSR) failed: {}", + String::from_utf8_lossy(&csr_output.stderr) + ); + } + + // 5. Sign server cert with CA + let csr_path = dir.join("server.csr"); + std::fs::write(&csr_path, &csr_output.stdout)?; + let server_cert_output = Command::new("openssl") + .args([ + "x509", "-req", + "-in", csr_path.to_str().unwrap(), + "-CA", ca_cert_path.to_str().unwrap(), + "-CAkey", ca_key_path.to_str().unwrap(), + "-CAcreateserial", + "-days", "3650", + ]) + .output() + .context("failed to sign server cert")?; + if !server_cert_output.status.success() { + bail!( + "openssl x509 (sign) failed: {}", + String::from_utf8_lossy(&server_cert_output.stderr) + ); + } + let server_cert_path = dir.join("server.crt"); + std::fs::write(&server_cert_path, &server_cert_output.stdout)?; + + // Clean up CSR and serial file + let _ = std::fs::remove_file(&csr_path); + let _ = std::fs::remove_file(dir.join("ca.srl")); + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Config TOML generation +// --------------------------------------------------------------------------- + +fn format_config_toml(username: &str) -> String { + format!( + r#"[web] +url = "http://localhost:5173" + +[web.auth] +provider = "github" +allowed_usernames = ["{username}"] + +[api] +base_url = "https://localhost:3000" +authentication_strategies = ["jwt", "mtls"] + +[api.tls] +cert = "~/.arc/certs/server.crt" +key = "~/.arc/certs/server.key" +ca = "~/.arc/certs/ca.crt" +"# + ) +} + +// --------------------------------------------------------------------------- +// .env merge +// --------------------------------------------------------------------------- + +fn merge_env(existing: &str, new_vars: &[(&str, &str)]) -> String { + let mut result_lines: Vec = Vec::new(); + let mut handled_keys: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + for line in existing.lines() { + if let Some(eq_pos) = line.find('=') { + let key = line[..eq_pos].trim(); + if !key.is_empty() && !key.starts_with('#') { + if let Some((_, new_val)) = new_vars.iter().find(|(k, _)| *k == key) { + result_lines.push(format!("{key}={new_val}")); + handled_keys.insert(key); + continue; + } + } + } + result_lines.push(line.to_string()); + } + + for (key, val) in new_vars { + if !handled_keys.contains(*key) { + result_lines.push(format!("{key}={val}")); + } + } + + let mut result = result_lines.join("\n"); + if !result.ends_with('\n') { + result.push('\n'); + } + result +} + +// --------------------------------------------------------------------------- +// Provider key URLs +// --------------------------------------------------------------------------- + +fn provider_key_url(provider: Provider) -> &'static str { + match provider { + Provider::Anthropic => "https://console.anthropic.com/settings/keys", + Provider::OpenAi => "https://platform.openai.com/api-keys", + Provider::Gemini => "https://aistudio.google.com/apikey", + Provider::Kimi => "https://platform.moonshot.cn/console/api-keys", + Provider::Zai => "https://open.bigmodel.cn/usercenter/apikeys", + Provider::Minimax => "https://platform.minimaxi.com/user-center/basic-information/interface-key", + Provider::Inception => "https://console.inceptionlabs.ai/api-keys", + } +} + +fn provider_display_name(provider: Provider) -> &'static str { + match provider { + Provider::Anthropic => "Anthropic", + Provider::OpenAi => "OpenAI", + Provider::Gemini => "Gemini", + Provider::Kimi => "Kimi", + Provider::Zai => "Zai", + Provider::Minimax => "Minimax", + Provider::Inception => "Inception", + } +} + +// --------------------------------------------------------------------------- +// Interactive setup +// --------------------------------------------------------------------------- + +fn prompt_confirm(prompt: &str, default: bool) -> Result { + Ok(Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt) + .default(default) + .interact_on(&dialoguer::console::Term::stderr())?) +} + +fn prompt_input(prompt: &str) -> Result { + Ok(Input::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt) + .interact_on(&dialoguer::console::Term::stderr())?) +} + +fn prompt_multiselect(prompt: &str, items: &[String]) -> Result> { + Ok( + MultiSelect::with_theme(&dialoguer::theme::ColorfulTheme::default()) + .with_prompt(prompt) + .items(items) + .interact_on(&dialoguer::console::Term::stderr())?, + ) +} + +pub async fn run_setup() -> Result<()> { + eprintln!("Arc Setup"); + eprintln!("========="); + eprintln!(); + + let arc_dir = dirs::home_dir() + .context("could not determine home directory")? + .join(".arc"); + std::fs::create_dir_all(&arc_dir)?; + + // Step 0: Pre-flight checks + eprintln!("[Step 0/7] Pre-flight checks"); + let dep_outcomes = doctor::probe_system_deps(); + let dep_check = doctor::check_system_deps(doctor::DEP_SPECS, &dep_outcomes); + + if dep_check.status == doctor::CheckStatus::Error { + eprintln!(" Missing required system dependencies:"); + for detail in &dep_check.details { + eprintln!(" {}", detail.text); + } + bail!("Install missing required tools before running setup"); + } + + // Check if dot is missing and offer to install + let dot_idx = doctor::DEP_SPECS.iter().position(|s| s.name == "dot"); + if let Some(idx) = dot_idx { + if matches!(dep_outcomes[idx], doctor::ProbeOutcome::NotFound) { + let install = tokio::task::spawn_blocking(|| { + prompt_confirm("Graphviz (dot) not found. Install via Homebrew?", true) + }) + .await??; + + if install { + let status = Command::new("brew") + .args(["install", "graphviz"]) + .status() + .context("failed to run brew install graphviz")?; + if !status.success() { + eprintln!(" Warning: brew install graphviz failed"); + } + } + } + } + + for detail in &dep_check.details { + eprintln!(" {}", detail.text); + } + eprintln!(); + + // Step 1: Configuration + eprintln!("[Step 1/7] Configuration"); + let config_path = arc_dir.join("server.toml"); + let write_config = if config_path.exists() { + tokio::task::spawn_blocking(|| { + prompt_confirm("~/.arc/server.toml already exists. Overwrite?", false) + }) + .await?? + } else { + true + }; + + if write_config { + let username: String = tokio::task::spawn_blocking(|| { + prompt_input("GitHub username for allowed access") + }) + .await??; + + let toml_content = format_config_toml(&username); + std::fs::write(&config_path, &toml_content)?; + eprintln!(" Wrote {}", config_path.display()); + } else { + eprintln!(" Keeping existing server.toml"); + } + eprintln!(); + + // Step 2: Generating secrets and certificates + eprintln!("[Step 2/7] Generating secrets and certificates"); + + let session_secret = generate_session_secret(); + eprintln!(" [ok] Session secret generated"); + + let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?; + eprintln!(" [ok] Ed25519 JWT keypair generated"); + + let certs_dir = arc_dir.join("certs"); + generate_mtls_certs(&certs_dir)?; + eprintln!(" [ok] mTLS CA + server certificates generated"); + eprintln!(); + + // Step 3: LLM providers + eprintln!("[Step 3/7] LLM providers"); + let provider_labels: Vec = Provider::ALL + .iter() + .map(|p| { + let env_vars = p.api_key_env_vars().join(" / "); + format!("{} ({})", provider_display_name(*p), env_vars) + }) + .collect(); + + let selected_indices: Vec = tokio::task::spawn_blocking({ + let labels = provider_labels.clone(); + move || prompt_multiselect("Which LLM providers do you want to configure?", &labels) + }) + .await??; + + let mut api_keys: Vec<(Provider, String, String)> = Vec::new(); + for idx in selected_indices { + let provider = Provider::ALL[idx]; + let env_var = provider.api_key_env_vars()[0]; + let url = provider_key_url(provider); + eprintln!(" Get your API key at: {url}"); + + let prompt = env_var.to_string(); + let key: String = + tokio::task::spawn_blocking(move || prompt_input(&prompt)).await??; + + api_keys.push((provider, env_var.to_string(), key)); + } + eprintln!(); + + // Step 4: Writing ~/.arc/.env + eprintln!("[Step 4/7] Writing ~/.arc/.env"); + let env_path = arc_dir.join(".env"); + + let jwt_private_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + jwt_private_pem.as_bytes(), + ); + let jwt_public_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + jwt_public_pem.as_bytes(), + ); + + let api_key_entries: Vec<(String, String)> = api_keys + .iter() + .map(|(_, env_var, key)| (env_var.clone(), key.clone())) + .collect(); + + let mut env_pairs: Vec<(String, String)> = api_key_entries; + env_pairs.push(("ARC_JWT_PRIVATE_KEY".to_string(), jwt_private_b64)); + env_pairs.push(("ARC_JWT_PUBLIC_KEY".to_string(), jwt_public_b64)); + env_pairs.push(("SESSION_SECRET".to_string(), session_secret)); + + let existing_env = std::fs::read_to_string(&env_path).unwrap_or_default(); + let env_refs: Vec<(&str, &str)> = env_pairs + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let merged = merge_env(&existing_env, &env_refs); + std::fs::write(&env_path, &merged)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600))?; + } + + eprintln!( + " Wrote {} ({} variables)", + env_path.display(), + env_pairs.len() + ); + eprintln!(); + + // Step 5: Start servers + eprintln!("[Step 5/7] Start servers"); + eprintln!(" To start Arc, run these commands:"); + eprintln!(); + eprintln!(" arc serve"); + eprintln!(" cd apps/arc-web && npx react-router dev"); + eprintln!(); + + // Step 6: Verify setup + eprintln!("[Step 6/7] Verify setup"); + let run_doctor = + tokio::task::spawn_blocking(|| prompt_confirm("Run arc doctor to verify?", true)) + .await??; + + if run_doctor { + eprintln!(); + doctor::run_doctor(true, false).await; + } + + eprintln!(); + eprintln!("Setup complete!"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Hex encoding (avoid adding a dep just for this) +// --------------------------------------------------------------------------- + +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + // -- Session secret -- + + #[test] + fn session_secret_length() { + let secret = generate_session_secret(); + assert_eq!(secret.len(), 64); + } + + #[test] + fn session_secret_is_hex() { + let secret = generate_session_secret(); + assert!(secret.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn session_secret_is_lowercase() { + let secret = generate_session_secret(); + assert!(secret.chars().all(|c| !c.is_ascii_uppercase())); + } + + // -- JWT keypair -- + + #[test] + fn jwt_keypair_private_pem_header() { + let (private, _) = generate_jwt_keypair().unwrap(); + assert!( + private.starts_with("-----BEGIN PRIVATE KEY-----"), + "private PEM: {private}" + ); + } + + #[test] + fn jwt_keypair_public_pem_header() { + let (_, public) = generate_jwt_keypair().unwrap(); + assert!( + public.starts_with("-----BEGIN PUBLIC KEY-----"), + "public PEM: {public}" + ); + } + + #[test] + fn jwt_keypair_public_parses() { + let (_, public) = generate_jwt_keypair().unwrap(); + jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()) + .expect("public key should parse"); + } + + // -- mTLS cert generation -- + + #[test] + fn mtls_certs_creates_files() { + let dir = tempfile::tempdir().unwrap(); + let certs_dir = dir.path().join("certs"); + generate_mtls_certs(&certs_dir).unwrap(); + + assert!(certs_dir.join("ca.key").exists()); + assert!(certs_dir.join("ca.crt").exists()); + assert!(certs_dir.join("server.key").exists()); + assert!(certs_dir.join("server.crt").exists()); + } + + #[test] + fn mtls_ca_cert_is_pem() { + let dir = tempfile::tempdir().unwrap(); + let certs_dir = dir.path().join("certs"); + generate_mtls_certs(&certs_dir).unwrap(); + + let ca_crt = std::fs::read_to_string(certs_dir.join("ca.crt")).unwrap(); + assert!( + ca_crt.starts_with("-----BEGIN CERTIFICATE-----"), + "ca.crt: {ca_crt}" + ); + } + + #[test] + fn mtls_server_cert_is_pem() { + let dir = tempfile::tempdir().unwrap(); + let certs_dir = dir.path().join("certs"); + generate_mtls_certs(&certs_dir).unwrap(); + + let server_crt = std::fs::read_to_string(certs_dir.join("server.crt")).unwrap(); + assert!( + server_crt.starts_with("-----BEGIN CERTIFICATE-----"), + "server.crt: {server_crt}" + ); + } + + #[test] + fn mtls_certs_parse_via_rustls() { + let dir = tempfile::tempdir().unwrap(); + let certs_dir = dir.path().join("certs"); + generate_mtls_certs(&certs_dir).unwrap(); + + let ca_pem = std::fs::read(certs_dir.join("ca.crt")).unwrap(); + let mut reader = std::io::Cursor::new(&ca_pem); + let ca_certs: Vec<_> = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .unwrap(); + assert_eq!(ca_certs.len(), 1); + + let server_pem = std::fs::read(certs_dir.join("server.crt")).unwrap(); + let mut reader = std::io::Cursor::new(&server_pem); + let server_certs: Vec<_> = rustls_pemfile::certs(&mut reader) + .collect::, _>>() + .unwrap(); + assert_eq!(server_certs.len(), 1); + } + + // -- Config TOML generation -- + + #[test] + fn config_toml_roundtrips() { + let toml_str = format_config_toml("brynary"); + let config: arc_api::server_config::ServerConfig = + toml::from_str(&toml_str).expect("config should parse"); + assert_eq!(config.web.auth.allowed_usernames, vec!["brynary"]); + } + + #[test] + fn config_toml_has_auth_strategies() { + let toml_str = format_config_toml("alice"); + let config: arc_api::server_config::ServerConfig = toml::from_str(&toml_str).unwrap(); + assert_eq!( + config.api.authentication_strategies, + vec![ + arc_api::server_config::ApiAuthStrategy::Jwt, + arc_api::server_config::ApiAuthStrategy::Mtls, + ] + ); + } + + #[test] + fn config_toml_has_tls_paths() { + let toml_str = format_config_toml("bob"); + let config: arc_api::server_config::ServerConfig = toml::from_str(&toml_str).unwrap(); + let tls = config.api.tls.expect("tls should be set"); + assert_eq!(tls.cert, PathBuf::from("~/.arc/certs/server.crt")); + assert_eq!(tls.key, PathBuf::from("~/.arc/certs/server.key")); + assert_eq!(tls.ca, PathBuf::from("~/.arc/certs/ca.crt")); + } + + // -- .env merge -- + + #[test] + fn merge_env_replaces_existing() { + let result = merge_env("FOO=old\nBAR=keep\n", &[("FOO", "new"), ("BAZ", "added")]); + assert!(result.contains("FOO=new")); + assert!(result.contains("BAR=keep")); + assert!(result.contains("BAZ=added")); + } + + #[test] + fn merge_env_empty_existing() { + let result = merge_env("", &[("FOO", "bar"), ("BAZ", "qux")]); + assert!(result.contains("FOO=bar")); + assert!(result.contains("BAZ=qux")); + } + + #[test] + fn merge_env_preserves_comments_and_blanks() { + let existing = "# A comment\n\nFOO=old\n# Another\nBAR=keep\n"; + let result = merge_env(existing, &[("FOO", "new")]); + assert!(result.contains("# A comment")); + assert!(result.contains("# Another")); + assert!(result.contains("FOO=new")); + assert!(result.contains("BAR=keep")); + } + + #[test] + fn merge_env_full_scenario() { + let result = merge_env( + "FOO=old\nBAR=keep", + &[("FOO", "new"), ("BAZ", "added")], + ); + assert_eq!(result, "FOO=new\nBAR=keep\nBAZ=added\n"); + } + + // -- Provider key URLs -- + + #[test] + fn every_provider_has_key_url() { + for provider in Provider::ALL { + let url = provider_key_url(*provider); + assert!(!url.is_empty(), "{provider:?} has empty URL"); + assert!(url.starts_with("https://"), "{provider:?} URL: {url}"); + } + } +}