From 0089c340f7a1befca28b0bbf267225dfcb724381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= Date: Sat, 23 May 2026 18:18:07 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=A5=87=20export=20from=20upstream=20(0a94?= =?UTF-8?q?b11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 - crates/sandcage/src/config.rs | 66 ++- crates/sandcage/src/docker.rs | 95 +++- crates/sandcage/src/lib.rs | 1 + crates/sandcage/src/main.rs | 12 +- crates/sandcage/src/setup.rs | 788 +++++++++++++++++++++++++++++- crates/sandcage/src/ssh_config.rs | 449 +++++++++++++++++ 7 files changed, 1399 insertions(+), 29 deletions(-) create mode 100644 crates/sandcage/src/ssh_config.rs diff --git a/README.md b/README.md index 35c4b18..278e50f 100644 --- a/README.md +++ b/README.md @@ -10,23 +10,6 @@ --- -> [!WARNING] -> **⚠️ Unreleased Software ⚠️** -> -> 🚧 This project is under active development and **not yet released**. APIs, configuration formats, and behavior may change without notice. Please **do not use without contacting the author** about the current state of the project. 🚧 - -> [!CAUTION] -> **⚠️ Development Tool Only ⚠️** -> -> 🚧 Sandcage is designed for **local development use**. Do **not** use it in CI pipelines or production environments — container isolation is not yet hardened for those contexts. 🚧 - -### Planned Features - -- **Full encapsulation hardening** — for worker and CI environments, ensuring complete sandboxing of file system, network, and credentials -- **ACP integration** via [`dirigate`](https://github.com/dirigence/dirigate) — Agent Communication Protocol support for structured agent orchestration - ---- - ## Why Sandcage? AI coding agents need broad access to do their work: shell, filesystem, network. Letting them run directly on your machine means they share your credentials, your session history, and your entire environment. diff --git a/crates/sandcage/src/config.rs b/crates/sandcage/src/config.rs index 059d219..fb94136 100644 --- a/crates/sandcage/src/config.rs +++ b/crates/sandcage/src/config.rs @@ -29,6 +29,16 @@ pub enum ConfigError { pub type Result = std::result::Result; +// --------------------------------------------------------------------------- +// Sub-structs +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct SshKeyEntry { + pub host: String, + pub identity_file: String, +} + // --------------------------------------------------------------------------- // Raw deserialization type — captures unknown keys // --------------------------------------------------------------------------- @@ -56,6 +66,10 @@ struct RawConfig { container_workspace: Option, #[serde(default)] agent_args: Option>>, + #[serde(default)] + ssh_mode: Option, + #[serde(default)] + ssh_keys: Option>, /// Absorb everything else so we can warn about unknown fields. #[serde(flatten)] @@ -89,13 +103,17 @@ pub struct SandcageConfig { pub container_workspace: Option, #[serde(skip_serializing_if = "Option::is_none")] pub agent_args: Option>>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ssh_keys: Option>, } // --------------------------------------------------------------------------- // Known keys — used to filter the flattened `extra` map // --------------------------------------------------------------------------- -const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args"]; +const KNOWN_KEYS: &[&str] = &["env", "packages", "toolchains", "mounts", "shell", "justfile", "dockerfiles", "trusted_projects", "container_workspace", "agent_args", "ssh_mode", "ssh_keys"]; // --------------------------------------------------------------------------- // Validation helpers @@ -148,6 +166,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig { trusted_projects: raw.trusted_projects, container_workspace: raw.container_workspace, agent_args: raw.agent_args, + ssh_mode: raw.ssh_mode, + ssh_keys: raw.ssh_keys, } } @@ -570,4 +590,48 @@ agent_args: let codex_args = agent_args.get("codex").expect("codex args"); assert_eq!(codex_args, &vec!["--full-auto"]); } + + #[test] + fn parse_ssh_mode() { + let yaml = "ssh_mode: volume\n"; + let cfg = load_from_str(yaml).expect("parse ssh_mode"); + assert_eq!(cfg.ssh_mode.as_deref(), Some("volume")); + } + + #[test] + fn parse_ssh_keys() { + let yaml = r#" +ssh_mode: volume +ssh_keys: + - host: github.com + identity_file: ~/.ssh/id_ed25519 + - host: gitea + identity_file: ~/.ssh/work_gitea +"#; + let cfg = load_from_str(yaml).expect("parse ssh_keys"); + let keys = cfg.ssh_keys.expect("ssh_keys should be Some"); + assert_eq!(keys.len(), 2); + assert_eq!(keys[0].host, "github.com"); + assert_eq!(keys[0].identity_file, "~/.ssh/id_ed25519"); + assert_eq!(keys[1].host, "gitea"); + } + + #[test] + fn ssh_mode_defaults_to_none() { + let cfg = load_from_str("").expect("parse empty"); + assert!(cfg.ssh_mode.is_none()); + assert!(cfg.ssh_keys.is_none()); + } + + #[test] + fn resolve_ssh_mode_project_overrides_global() { + let global_toml = r#"ssh_mode = "bind""#; + let project_yaml = "ssh_mode: volume\n"; + let mut global_tmp = NamedTempFile::new().expect("create global tmpfile"); + write!(global_tmp, "{global_toml}").expect("write global tmpfile"); + let mut project_tmp = NamedTempFile::new().expect("create project tmpfile"); + write!(project_tmp, "{project_yaml}").expect("write project tmpfile"); + let cfg = resolve_config(Some(global_tmp.path()), Some(project_tmp.path())).expect("resolve"); + assert_eq!(cfg.ssh_mode.as_deref(), Some("volume")); + } } diff --git a/crates/sandcage/src/docker.rs b/crates/sandcage/src/docker.rs index fe445b6..6c88a7d 100644 --- a/crates/sandcage/src/docker.rs +++ b/crates/sandcage/src/docker.rs @@ -253,6 +253,20 @@ pub fn build_run_args( } } + // SSH mode-aware mount + match config.ssh_mode.as_deref() { + Some("volume") => { + args.push("--mount".to_string()); + args.push("type=volume,source=sandcage-ssh,target=/home/agent/.ssh,readonly".to_string()); + } + Some("bind") => { + let mount = expand_mount_path("~/.ssh:/home/agent/.ssh:ro"); + args.push("-v".to_string()); + args.push(mount); + } + _ => {} // "none" or absent: no SSH mount + } + if shell_override { args.push("--entrypoint".to_string()); args.push("/bin/zsh".to_string()); @@ -260,13 +274,12 @@ pub fn build_run_args( args.push(service.to_string()); - if !shell_override { - if let Some(ref all_agent_args) = config.agent_args - && let Some(default_args) = all_agent_args.get(service) - { - for arg in default_args { - args.push(arg.clone()); - } + if !shell_override + && let Some(ref all_agent_args) = config.agent_args + && let Some(default_args) = all_agent_args.get(service) + { + for arg in default_args { + args.push(arg.clone()); } } @@ -290,6 +303,17 @@ pub fn run_service( let image = image_for_service(service); require_image(&docker, image)?; + if config.ssh_mode.as_deref() == Some("volume") { + let vol_check = Command::new(&docker) + .args(["volume", "inspect", "sandcage-ssh"]) + .output(); + if !vol_check.map(|o| o.status.success()).unwrap_or(false) { + eprintln!( + "sandcage: SSH volume not found — run 'sandcage setup ssh --refresh' to populate it" + ); + } + } + let compose_file = write_compose_tempfile()?; let compose_path = compose_file.path().to_string_lossy().into_owned(); @@ -984,6 +1008,63 @@ mod tests { assert!(resume_pos > service_pos); } + #[test] + fn build_run_args_ssh_mode_volume() { + let config = SandcageConfig { + ssh_mode: Some("volume".to_string()), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]); + assert!(args.contains(&"--mount".to_string())); + let mount_idx = args.iter().position(|a| a == "--mount").unwrap(); + assert_eq!( + args[mount_idx + 1], + "type=volume,source=sandcage-ssh,target=/home/agent/.ssh,readonly" + ); + } + + #[test] + fn build_run_args_ssh_mode_bind() { + let config = SandcageConfig { + ssh_mode: Some("bind".to_string()), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]); + assert!(args.contains(&"-v".to_string())); + // Find the -v flag and check the mount string contains .ssh + let v_positions: Vec<_> = args + .iter() + .enumerate() + .filter(|(_, a)| *a == "-v") + .map(|(i, _)| i) + .collect(); + let has_ssh_mount = v_positions.iter().any(|&i| { + args.get(i + 1) + .map(|a| a.contains(".ssh") && a.ends_with(":ro")) + .unwrap_or(false) + }); + assert!(has_ssh_mount, "should have .ssh bind mount, args: {:?}", args); + } + + #[test] + fn build_run_args_ssh_mode_none() { + let config = SandcageConfig { + ssh_mode: Some("none".to_string()), + ..Default::default() + }; + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]); + assert!(!args.contains(&"--mount".to_string())); + assert!(!args.iter().any(|a| a.contains(".ssh"))); + } + + #[test] + fn build_run_args_ssh_mode_absent() { + let config = SandcageConfig::default(); + let args = build_run_args("claude", "/tmp/compose.yml", &config, false, &[]); + assert!(!args.contains(&"--mount".to_string())); + // May have .ssh in other mounts, but no SSH-specific mount + } + #[test] fn build_compose_env_container_dir_root_fallback() { let workspace = PathBuf::from("/"); diff --git a/crates/sandcage/src/lib.rs b/crates/sandcage/src/lib.rs index 9e49be3..d778be2 100644 --- a/crates/sandcage/src/lib.rs +++ b/crates/sandcage/src/lib.rs @@ -2,4 +2,5 @@ pub mod config; pub mod docker; pub mod init; pub mod setup; +pub mod ssh_config; pub mod workspace; diff --git a/crates/sandcage/src/main.rs b/crates/sandcage/src/main.rs index 6fe6401..de66fc7 100644 --- a/crates/sandcage/src/main.rs +++ b/crates/sandcage/src/main.rs @@ -79,6 +79,14 @@ enum SetupAction { /// Skip confirmation prompt #[arg(long)] yes: bool, + + /// Re-populate the SSH volume using saved selection + #[arg(long)] + refresh: bool, + + /// Use legacy full bind mount instead of volume copy + #[arg(long)] + bind: bool, }, } @@ -144,8 +152,8 @@ fn main() -> miette::Result<()> { init::scaffold_project(&workspace)?; } Commands::Setup { action } => match action { - SetupAction::Ssh { global, yes } => { - setup::run_ssh_setup(global, yes)?; + SetupAction::Ssh { global, yes, refresh, bind } => { + setup::run_ssh_setup(global, yes, refresh, bind)?; } }, } diff --git a/crates/sandcage/src/setup.rs b/crates/sandcage/src/setup.rs index 55212c0..3e73078 100644 --- a/crates/sandcage/src/setup.rs +++ b/crates/sandcage/src/setup.rs @@ -1,9 +1,13 @@ +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use miette::Diagnostic; use thiserror::Error; -use crate::config::SandcageConfig; +use figment::providers::Format as _; + +use crate::config::{SandcageConfig, SshKeyEntry}; +use crate::ssh_config::{self, SshHostBlock}; #[derive(Debug, Error, Diagnostic)] pub enum SetupError { @@ -34,6 +38,10 @@ pub enum SetupError { #[error("Cannot determine home directory")] #[diagnostic(code(sandcage::setup::no_home_dir))] NoHomeDir, + + #[error("Docker command failed: {0}")] + #[diagnostic(code(sandcage::setup::docker_failed))] + DockerFailed(String), } pub type Result = std::result::Result; @@ -229,6 +237,77 @@ pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> { Ok(()) } +pub fn extract_git_remote_hosts(git_remote_output: &str) -> Vec { + use std::collections::BTreeSet; + + let mut hosts: BTreeSet = BTreeSet::new(); + + for line in git_remote_output.lines() { + // Each line: "\t (fetch)" or "\t (push)" + let url = match line.split('\t').nth(1) { + Some(u) => u, + None => continue, + }; + // Strip trailing " (fetch)" / " (push)" + let url = url + .trim_end_matches(" (push)") + .trim_end_matches(" (fetch)") + .trim(); + + if url.starts_with("https://") || url.starts_with("http://") { + continue; + } + + if let Some(after_scheme) = url.strip_prefix("ssh://") { + // ssh://[user@]host[:port]/path + // Drop optional user@ + let host_part = match after_scheme.find('@') { + Some(at) => &after_scheme[at + 1..], + None => after_scheme, + }; + // host is everything before the first '/' or ':' + let host = host_part + .split(&['/', ':'][..]) + .next() + .unwrap_or("") + .trim(); + if !host.is_empty() { + hosts.insert(host.to_string()); + } + } else if url.contains('@') && url.contains(':') { + // git@host:path format + let after_at = match url.find('@') { + Some(at) => &url[at + 1..], + None => continue, + }; + let host = match after_at.find(':') { + Some(colon) => &after_at[..colon], + None => continue, + }; + let host = host.trim(); + if !host.is_empty() { + hosts.insert(host.to_string()); + } + } + } + + hosts.into_iter().collect() +} + +pub fn discover_git_remote_hosts() -> Vec { + let output = std::process::Command::new("git") + .args(["remote", "-v"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let text = String::from_utf8_lossy(&out.stdout); + extract_git_remote_hosts(&text) + } + _ => Vec::new(), + } +} + fn display_scan_result(result: &SshScanResult) { if !result.keys.is_empty() { eprintln!(" Keys: {}", result.keys.join(", ")); @@ -250,7 +329,380 @@ fn display_scan_result(result: &SshScanResult) { eprintln!("This will mount ~/.ssh into containers as read-only."); } -pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> { +pub fn has_legacy_ssh_mount(config: &SandcageConfig) -> bool { + config.ssh_mode.is_none() && check_existing_mount(config, SSH_MOUNT) +} + +pub fn write_ssh_config_to_yaml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { + let content = std::fs::read_to_string(path) + .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?; + + let mut config: SandcageConfig = if content.trim().is_empty() { + SandcageConfig::default() + } else { + serde_yaml::from_str(&content) + .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))? + }; + + config.ssh_mode = Some("volume".to_string()); + config.ssh_keys = Some( + entries + .iter() + .map(|e| SshKeyEntry { + host: e + .ssh_host_alias + .clone() + .unwrap_or_else(|| e.display_host.clone()), + identity_file: e.identity_file.clone().unwrap_or_default(), + }) + .collect(), + ); + + // Remove old bind mount if present + if let Some(ref mut mounts) = config.mounts { + mounts.retain(|m| m != SSH_MOUNT); + if mounts.is_empty() { + config.mounts = None; + } + } + + let yaml = serde_yaml::to_string(&config) + .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?; + + let output = rehydrate_yaml_comments(&content, &yaml); + + std::fs::write(path, output) + .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; + + Ok(()) +} + +pub fn write_ssh_config_to_toml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { + let content = if path.exists() { + std::fs::read_to_string(path) + .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))? + } else { + String::new() + }; + + let mut doc: toml_edit::DocumentMut = content.parse().map_err(|e: toml_edit::TomlError| { + SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()) + })?; + + doc["ssh_mode"] = toml_edit::value("volume"); + + // Remove old bind mount if present + if let Some(mounts) = doc.get_mut("mounts").and_then(|m| m.as_array_mut()) { + mounts.retain(|v| v.as_str() != Some(SSH_MOUNT)); + if mounts.is_empty() { + doc.remove("mounts"); + } + } + + // Build ssh_keys array of tables + let mut arr = toml_edit::ArrayOfTables::new(); + for entry in entries { + let mut table = toml_edit::Table::new(); + table["host"] = toml_edit::value( + entry + .ssh_host_alias + .clone() + .unwrap_or_else(|| entry.display_host.clone()), + ); + table["identity_file"] = + toml_edit::value(entry.identity_file.clone().unwrap_or_default()); + arr.push(table); + } + doc.insert("ssh_keys", toml_edit::Item::ArrayOfTables(arr)); + + std::fs::write(path, doc.to_string()) + .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; + + Ok(()) +} + +pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Result<()> { + if bind { + return run_ssh_setup_bind(global, yes); + } + + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + let ssh_dir = home.join(".ssh"); + + if refresh { + return run_ssh_refresh(global, &ssh_dir); + } + + // Check for existing bind mount (migration path) + let config_path_for_check = if global { + home.join(".sandcage").join("config.toml") + } else { + let cwd = std::env::current_dir() + .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + cwd.join(".sandcage.yml") + }; + + if config_path_for_check.exists() { + let existing_cfg = if global { + let figment = figment::Figment::from( + figment::providers::Serialized::defaults(SandcageConfig::default()), + ) + .merge(figment::providers::Toml::file(&config_path_for_check)); + figment.extract::().unwrap_or_default() + } else { + crate::config::load(&config_path_for_check).unwrap_or_default() + }; + + if has_legacy_ssh_mount(&existing_cfg) { + eprintln!("sandcage: existing SSH bind mount detected in config"); + if !yes { + let migrate = dialoguer::Confirm::new() + .with_prompt("Migrate to volume-based SSH? (bind mount will be removed)") + .default(true) + .interact() + .unwrap_or(false); + if !migrate { + // Set explicit bind mode so we don't ask again + if global { + let mut doc: toml_edit::DocumentMut = + std::fs::read_to_string(&config_path_for_check) + .unwrap_or_default() + .parse() + .unwrap_or_default(); + doc["ssh_mode"] = toml_edit::value("bind"); + std::fs::write(&config_path_for_check, doc.to_string()).map_err( + |e| SetupError::ConfigWriteFailed(config_path_for_check, e), + )?; + } else { + let content = std::fs::read_to_string(&config_path_for_check) + .map_err(|e| { + SetupError::ConfigReadFailed(config_path_for_check.clone(), e) + })?; + let mut cfg: SandcageConfig = + serde_yaml::from_str(&content).unwrap_or_default(); + cfg.ssh_mode = Some("bind".to_string()); + let yaml = serde_yaml::to_string(&cfg).unwrap_or_default(); + let output = rehydrate_yaml_comments(&content, &yaml); + std::fs::write(&config_path_for_check, output).map_err(|e| { + SetupError::ConfigWriteFailed(config_path_for_check, e) + })?; + } + eprintln!("sandcage: keeping bind mount, set ssh_mode: bind"); + return Ok(()); + } + } + // Continue with volume mode setup — the write_ssh_config_to_* functions + // will remove the old mount entry + } + } + + // Discover candidates + let ssh_config_path = ssh_dir.join("config"); + let ssh_blocks = if ssh_config_path.exists() { + let content = std::fs::read_to_string(&ssh_config_path) + .map_err(|e| SetupError::ConfigReadFailed(ssh_config_path.clone(), e))?; + let ssh_dir_clone = ssh_dir.clone(); + let read_include = move |path: &str| { + let include_path = if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + ssh_dir_clone.join(path) + }; + std::fs::read_to_string(include_path).ok() + }; + ssh_config::parse_ssh_config_with_includes(&content, &read_include) + } else { + Vec::new() + }; + + let git_remote_hosts = discover_git_remote_hosts(); + let entries = build_ssh_discovery(&ssh_blocks, &git_remote_hosts, &ssh_dir); + + if entries.is_empty() { + eprintln!("sandcage: no git SSH hosts found in remotes or ~/.ssh/config"); + return Ok(()); + } + + // Display discoveries + eprintln!("sandcage: scanning git remotes and SSH config...\n"); + for entry in &entries { + let source_label = match entry.source { + SshDiscoverySource::Both => "git remote + ssh config", + SshDiscoverySource::GitRemote => "git remote", + SshDiscoverySource::SshConfig => "ssh config (User git)", + }; + let key_label = entry.identity_file.as_deref().unwrap_or("(default key)"); + let alias_label = entry + .ssh_host_alias + .as_deref() + .filter(|a| *a != entry.display_host) + .map(|a| format!(" (alias: {a})")) + .unwrap_or_default(); + eprintln!( + " {}{alias_label} → {key_label} [{source_label}]", + entry.display_host + ); + } + eprintln!(); + + // Interactive selection + let (selected_entries, include_known_hosts) = if yes { + (entries.clone(), true) + } else { + let items: Vec = entries + .iter() + .map(|e| { + let key = e.identity_file.as_deref().unwrap_or("(default)"); + format!("{} → {key}", e.display_host) + }) + .collect(); + let defaults: Vec = vec![true; items.len()]; + let selected_indices = dialoguer::MultiSelect::new() + .with_prompt("Select SSH keys to copy") + .items(&items) + .defaults(&defaults) + .interact() + .unwrap_or_else(|_| (0..items.len()).collect()); + let selected: Vec = selected_indices + .iter() + .map(|&i| entries[i].clone()) + .collect(); + let include_kh = dialoguer::Confirm::new() + .with_prompt("Include known_hosts? (recommended)") + .default(true) + .interact() + .unwrap_or(true); + (selected, include_kh) + }; + + if selected_entries.is_empty() { + eprintln!("sandcage: no keys selected, nothing to do"); + return Ok(()); + } + + // Determine config target + let (config_path, is_global) = if global { + (home.join(".sandcage").join("config.toml"), true) + } else { + let cwd = std::env::current_dir() + .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + let project_config = cwd.join(".sandcage.yml"); + if !project_config.exists() { + return Err(SetupError::NoProjectConfig); + } + (project_config, false) + }; + + // Write config + if is_global { + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SetupError::ConfigWriteFailed(config_path.clone(), e))?; + } + write_ssh_config_to_toml(&config_path, &selected_entries)?; + } else { + write_ssh_config_to_yaml(&config_path, &selected_entries)?; + } + + // Populate volume + eprintln!("sandcage: populating SSH volume..."); + populate_ssh_volume(&selected_entries, &ssh_blocks, include_known_hosts)?; + + let config_label = if is_global { + "~/.sandcage/config.toml" + } else { + ".sandcage.yml" + }; + eprintln!("sandcage: SSH keys copied to volume 'sandcage-ssh'"); + eprintln!("sandcage: configuration saved to {config_label}"); + + Ok(()) +} + +fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + + let config_path = if global { + home.join(".sandcage").join("config.toml") + } else { + let cwd = std::env::current_dir() + .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; + cwd.join(".sandcage.yml") + }; + + if !config_path.exists() { + return Err(SetupError::ConfigReadFailed( + config_path, + std::io::Error::new(std::io::ErrorKind::NotFound, "config file not found"), + )); + } + + let cfg = if global { + let figment = figment::Figment::from( + figment::providers::Serialized::defaults(SandcageConfig::default()), + ) + .merge(figment::providers::Toml::file(&config_path)); + figment + .extract::() + .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? + } else { + crate::config::load(&config_path) + .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? + }; + + let ssh_keys = cfg.ssh_keys.unwrap_or_default(); + if ssh_keys.is_empty() { + eprintln!("sandcage: no ssh_keys in config — run `sandcage setup ssh` first"); + return Ok(()); + } + + // Rebuild entries from saved config + let entries: Vec = ssh_keys + .iter() + .map(|k| SshDiscoveryEntry { + display_host: k.host.clone(), + ssh_host_alias: Some(k.host.clone()), + identity_file: Some(k.identity_file.clone()), + host_block: None, + source: SshDiscoverySource::SshConfig, + }) + .collect(); + + // Re-read SSH config to get full Host blocks for config synthesis + let ssh_config_path = ssh_dir.join("config"); + let ssh_blocks = if ssh_config_path.exists() { + let content = std::fs::read_to_string(&ssh_config_path) + .map_err(|e| SetupError::ConfigReadFailed(ssh_config_path.clone(), e))?; + ssh_config::parse_ssh_config(&content) + } else { + Vec::new() + }; + + // Rebuild entries with host blocks from current SSH config + let entries_with_blocks: Vec = entries + .into_iter() + .map(|mut e| { + e.host_block = ssh_blocks + .iter() + .find(|b| { + b.aliases + .first() + .map(|a| a.as_str()) + == Some(&e.display_host) + }) + .cloned(); + e + }) + .collect(); + + eprintln!("sandcage: refreshing SSH volume..."); + populate_ssh_volume(&entries_with_blocks, &ssh_blocks, true)?; + eprintln!("sandcage: SSH volume refreshed"); + + Ok(()) +} + +fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> { let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; let ssh_dir = home.join(".ssh"); @@ -331,6 +783,242 @@ pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> { Ok(()) } +// ── Task 7: SSH host discovery ────────────────────────────────────────────── + +#[derive(Debug, Clone, PartialEq)] +pub enum SshDiscoverySource { + GitRemote, + SshConfig, + Both, +} + +#[derive(Debug, Clone)] +pub struct SshDiscoveryEntry { + pub display_host: String, + pub ssh_host_alias: Option, + pub identity_file: Option, + pub host_block: Option, + pub source: SshDiscoverySource, +} + +/// Find a default SSH private key in `ssh_dir` by checking common names. +fn find_default_key(ssh_dir: &Path) -> Option { + let candidates = ["id_ed25519", "id_ecdsa", "id_rsa"]; + for name in candidates { + if ssh_dir.join(name).exists() { + return Some(format!("~/.ssh/{name}")); + } + } + None +} + +/// Merge SSH host candidates from SSH config blocks and git remote hosts. +/// +/// Returns a deduplicated, sorted list of [`SshDiscoveryEntry`] values. +pub fn build_ssh_discovery( + ssh_blocks: &[SshHostBlock], + git_remote_hosts: &[String], + ssh_dir: &Path, +) -> Vec { + // Keyed by display_host for deduplication and sorted output. + let mut map: BTreeMap = BTreeMap::new(); + + // 1. Add SSH config blocks where user is git. + for block in ssh_blocks { + if !block.is_git_user() { + continue; + } + // display_host = HostName directive, or first alias as fallback. + let display_host = block + .hostname() + .unwrap_or_else(|| block.aliases.first().map(|s| s.as_str()).unwrap_or("")) + .to_string(); + if display_host.is_empty() { + continue; + } + let ssh_host_alias = block.aliases.first().cloned(); + let identity_file = block.identity_file().map(|s| s.to_string()); + map.entry(display_host.clone()).or_insert_with(|| SshDiscoveryEntry { + display_host, + ssh_host_alias, + identity_file, + host_block: Some(block.clone()), + source: SshDiscoverySource::SshConfig, + }); + } + + // 2. Process git remote hosts. + for git_host in git_remote_hosts { + if let Some(entry) = map.get_mut(git_host.as_str()) { + // Hostname already present via SSH config — upgrade source. + entry.source = SshDiscoverySource::Both; + } else { + // Check if any SSH block has this git_host as an alias. + let alias_match = ssh_blocks.iter().find(|b| { + b.is_git_user() && b.aliases.iter().any(|a| a == git_host) + }); + + if let Some(block) = alias_match { + // The display_host should already be in the map via step 1, but just in case + // the block wasn't added (e.g., HostName was set and we keyed on HostName), + // upgrade source on whichever entry owns this block's alias. + let display = block + .hostname() + .unwrap_or_else(|| block.aliases.first().map(|s| s.as_str()).unwrap_or("")) + .to_string(); + if let Some(existing) = map.get_mut(&display) { + existing.source = SshDiscoverySource::Both; + } else { + // Shouldn't normally happen, but handle gracefully. + map.insert(display.clone(), SshDiscoveryEntry { + display_host: display, + ssh_host_alias: block.aliases.first().cloned(), + identity_file: block.identity_file().map(|s| s.to_string()), + host_block: Some(block.clone()), + source: SshDiscoverySource::Both, + }); + } + } else { + // No SSH config match — pure git remote entry. + let identity_file = find_default_key(ssh_dir); + map.insert(git_host.clone(), SshDiscoveryEntry { + display_host: git_host.clone(), + ssh_host_alias: None, + identity_file, + host_block: None, + source: SshDiscoverySource::GitRemote, + }); + } + } + } + + map.into_values().collect() +} + +// ── Task 8: Docker volume population ─────────────────────────────────────── + +/// Copy selected SSH identity files and (optionally) `known_hosts` into the +/// `sandcage-ssh` Docker volume via a temporary container. +pub fn populate_ssh_volume( + entries: &[SshDiscoveryEntry], + _ssh_blocks: &[SshHostBlock], + include_known_hosts: bool, +) -> Result<()> { + let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + let ssh_dir = home.join(".ssh"); + + // Build synthesized SSH config from selected entries' host blocks. + let selected_blocks: Vec = entries + .iter() + .filter_map(|e| e.host_block.clone()) + .collect(); + let synthesized_config = ssh_config::synthesize_ssh_config(&selected_blocks); + + // Collect key file names to copy. + let mut key_files: Vec = Vec::new(); + for entry in entries { + if let Some(ref id_file) = entry.identity_file { + let resolved = ssh_config::resolve_identity_file(id_file, &ssh_dir); + if let Some(file_name) = resolved.file_name() { + let name = file_name.to_string_lossy().into_owned(); + if !key_files.contains(&name) { + key_files.push(name.clone()); + } + // Include matching .pub key if it exists. + let pub_name = format!("{name}.pub"); + let pub_path = ssh_dir.join(&pub_name); + if pub_path.exists() && !key_files.contains(&pub_name) { + key_files.push(pub_name); + } + } + } + } + + // Optionally include known_hosts. + if include_known_hosts && ssh_dir.join("known_hosts").exists() { + let kh = "known_hosts".to_string(); + if !key_files.contains(&kh) { + key_files.push(kh); + } + } + + // Normalize path for Docker (Windows: backslash → forward slash). + let ssh_dir_str = ssh_dir.to_string_lossy().replace('\\', "/"); + + // Remove old volume. + let rm_status = std::process::Command::new("docker") + .args(["volume", "rm", "-f", "sandcage-ssh"]) + .status() + .map_err(|e| SetupError::DockerFailed(format!("docker volume rm: {e}")))?; + if !rm_status.success() { + return Err(SetupError::DockerFailed( + format!("docker volume rm exited with {rm_status}"), + )); + } + + // Create volume. + let create_status = std::process::Command::new("docker") + .args(["volume", "create", "sandcage-ssh"]) + .status() + .map_err(|e| SetupError::DockerFailed(format!("docker volume create: {e}")))?; + if !create_status.success() { + return Err(SetupError::DockerFailed( + format!("docker volume create exited with {create_status}"), + )); + } + + // Build in-container shell script. + let mut script = String::new(); + + // Ensure target directory exists. + script.push_str("mkdir -p /home/agent/.ssh && "); + + // Write synthesized config. + // Escape single quotes in the config for sh -c '...' embedding. + let escaped_config = synthesized_config.replace('\'', "'\\''"); + script.push_str(&format!( + "printf '%s' '{escaped_config}' > /home/agent/.ssh/config && chmod 644 /home/agent/.ssh/config" + )); + + // Copy key files. + for name in &key_files { + let is_pub_or_kh = name.ends_with(".pub") || name == "known_hosts"; + let mode = if is_pub_or_kh { "644" } else { "600" }; + script.push_str(&format!( + " && cp /host-ssh/{name} /home/agent/.ssh/{name} && chmod {mode} /home/agent/.ssh/{name}" + )); + } + + // Fix ownership. + script.push_str(" && chown -R agent:agent /home/agent/.ssh"); + + // Run the container. + let run_status = std::process::Command::new("docker") + .args([ + "run", + "--rm", + &format!("-v{ssh_dir_str}:/host-ssh:ro"), + "--mount", + "type=volume,source=sandcage-ssh,target=/home/agent/.ssh", + "--user", + "root", + "sandcage:latest", + "sh", + "-c", + &script, + ]) + .status() + .map_err(|e| SetupError::DockerFailed(format!("docker run: {e}")))?; + + if !run_status.success() { + return Err(SetupError::DockerFailed( + format!("docker run exited with {run_status}"), + )); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -537,4 +1225,100 @@ mod tests { assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); assert!(content.contains("/data:/mnt")); } + + // -- extract_git_remote_hosts tests -- + + #[test] + fn extract_hostnames_from_git_remote_output() { + let output = "\ +origin\tgit@github.com:user/repo.git (fetch) +origin\tgit@github.com:user/repo.git (push) +upstream\tgit@gitea.example.com:org/repo.git (fetch) +upstream\tgit@gitea.example.com:org/repo.git (push) +https\thttps://gitlab.com/user/repo.git (fetch) +https\thttps://gitlab.com/user/repo.git (push) +"; + let hosts = extract_git_remote_hosts(output); + assert_eq!(hosts, vec!["gitea.example.com", "github.com"]); + } + + #[test] + fn extract_hostnames_ssh_url_with_port() { + let output = "origin\tssh://git@git.corp.com:2222/repo.git (fetch)\n"; + let hosts = extract_git_remote_hosts(output); + assert_eq!(hosts, vec!["git.corp.com"]); + } + + #[test] + fn extract_hostnames_empty_output() { + let hosts = extract_git_remote_hosts(""); + assert!(hosts.is_empty()); + } + + // -- Task 7: build_ssh_discovery tests -- + + #[test] + fn discover_ssh_entries_merges_sources() { + use crate::ssh_config::parse_ssh_config; + + let ssh_config_text = "\ +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 + +Host gitea + User git + HostName gitea.example.com + IdentityFile ~/.ssh/work_gitea + +Host myserver + User admin + HostName server.example.com +"; + let blocks = parse_ssh_config(ssh_config_text); + let git_remote_hosts = vec!["github.com".to_string(), "gitlab.com".to_string()]; + let ssh_dir = Path::new("/nonexistent/.ssh"); // default key won't be found + + let entries = build_ssh_discovery(&blocks, &git_remote_hosts, ssh_dir); + + // github.com: from both (git remote + SSH config User git) + // gitea.example.com: from SSH config only (User git, HostName resolves) + // gitlab.com: from git remote only, no SSH config match + // myserver: NOT included (User admin, not in git remotes) + assert_eq!(entries.len(), 3); + + let github = entries.iter().find(|e| e.display_host == "github.com").unwrap(); + assert_eq!(github.source, SshDiscoverySource::Both); + assert_eq!(github.ssh_host_alias.as_deref(), Some("github.com")); + + let gitea = entries.iter().find(|e| e.display_host == "gitea.example.com").unwrap(); + assert_eq!(gitea.source, SshDiscoverySource::SshConfig); + assert_eq!(gitea.ssh_host_alias.as_deref(), Some("gitea")); + + let gitlab = entries.iter().find(|e| e.display_host == "gitlab.com").unwrap(); + assert_eq!(gitlab.source, SshDiscoverySource::GitRemote); + assert!(gitlab.ssh_host_alias.is_none()); + } + + // -- has_legacy_ssh_mount tests -- + + #[test] + fn detect_existing_bind_mount_in_config() { + let config = SandcageConfig { + mounts: Some(vec![SSH_MOUNT.to_string()]), + ssh_mode: None, + ..Default::default() + }; + assert!(has_legacy_ssh_mount(&config)); + } + + #[test] + fn no_legacy_mount_when_ssh_mode_set() { + let config = SandcageConfig { + mounts: Some(vec![SSH_MOUNT.to_string()]), + ssh_mode: Some("bind".to_string()), + ..Default::default() + }; + assert!(!has_legacy_ssh_mount(&config)); + } } diff --git a/crates/sandcage/src/ssh_config.rs b/crates/sandcage/src/ssh_config.rs new file mode 100644 index 0000000..9be5f93 --- /dev/null +++ b/crates/sandcage/src/ssh_config.rs @@ -0,0 +1,449 @@ +use std::path::{Path, PathBuf}; + +/// A parsed SSH config `Host` block. +#[derive(Debug, Clone, PartialEq)] +pub struct SshHostBlock { + /// The patterns listed on the `Host` line (e.g. `["github.com", "gh"]`). + pub aliases: Vec, + /// Key-value directives in the order they appear in the config. + pub directives: Vec<(String, String)>, +} + +impl SshHostBlock { + /// Return the value of the first directive matching `key` (case-insensitive). + pub fn get(&self, key: &str) -> Option<&str> { + let key_lower = key.to_lowercase(); + self.directives + .iter() + .find(|(k, _)| k.to_lowercase() == key_lower) + .map(|(_, v)| v.as_str()) + } + + /// Return the `User` directive value, if present. + pub fn user(&self) -> Option<&str> { + self.get("User") + } + + /// Return the `IdentityFile` directive value, if present. + pub fn identity_file(&self) -> Option<&str> { + self.get("IdentityFile") + } + + /// Return the `HostName` directive value, if present. + pub fn hostname(&self) -> Option<&str> { + self.get("HostName") + } + + /// Returns `true` if this block's `User` is `git` (common for GitHub/GitLab host entries). + pub fn is_git_user(&self) -> bool { + self.user().map(|u| u == "git").unwrap_or(false) + } +} + +/// Resolve an SSH `IdentityFile` path to an absolute [`PathBuf`]. +/// +/// - `~/...` paths expand `~` via [`dirs::home_dir`], falling back to `ssh_dir.parent()`. +/// - Relative paths are joined onto `ssh_dir`. +/// - Absolute paths are returned as-is. +pub fn resolve_identity_file(identity_file: &str, ssh_dir: &Path) -> PathBuf { + if let Some(suffix) = identity_file.strip_prefix("~/") { + let home = dirs::home_dir() + .unwrap_or_else(|| ssh_dir.parent().unwrap_or(ssh_dir).to_path_buf()); + home.join(suffix) + } else if Path::new(identity_file).is_absolute() { + PathBuf::from(identity_file) + } else { + ssh_dir.join(identity_file) + } +} + +/// Produce a valid SSH config file string from a slice of [`SshHostBlock`]s. +/// +/// Blocks are separated by blank lines; directives are indented with 4 spaces. +pub fn synthesize_ssh_config(blocks: &[SshHostBlock]) -> String { + let mut out = String::new(); + for (i, block) in blocks.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + out.push_str("Host "); + out.push_str(&block.aliases.join(" ")); + out.push('\n'); + for (key, value) in &block.directives { + out.push_str(" "); + out.push_str(key); + out.push(' '); + out.push_str(value); + out.push('\n'); + } + } + out +} + +/// Parse SSH config text into a list of [`SshHostBlock`]s, supporting `Include` directives. +/// +/// When an `Include ` line is encountered: +/// - The current block is flushed. +/// - `read_include(path)` is called; if it returns `Some(content)`, that content is parsed +/// recursively and its blocks are appended. +/// - Parsing of the main content then continues. +/// +/// Pass `&|_| None` to disable Include handling (identical to [`parse_ssh_config`]). +pub fn parse_ssh_config_with_includes( + content: &str, + read_include: &dyn Fn(&str) -> Option, +) -> Vec { + let mut blocks: Vec = Vec::new(); + // None → no active block yet; Some(true) → Host block; Some(false) → Match block (skip) + let mut current_aliases: Option> = None; + let mut current_directives: Vec<(String, String)> = Vec::new(); + let mut in_host_block = false; + + let flush = |blocks: &mut Vec, + aliases: &mut Option>, + directives: &mut Vec<(String, String)>, + in_host: &mut bool| { + if *in_host { + if let Some(a) = aliases.take() { + blocks.push(SshHostBlock { + aliases: a, + directives: std::mem::take(directives), + }); + } + } else { + aliases.take(); + directives.clear(); + } + *in_host = false; + }; + + for line in content.lines() { + let trimmed = line.trim(); + + // Skip blank lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Split keyword from the rest on first whitespace or '=' + let (keyword, rest) = split_keyword(trimmed); + + match keyword.to_lowercase().as_str() { + "host" => { + // Flush previous block + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + let aliases: Vec = rest + .split_whitespace() + .map(|s| s.to_string()) + .collect(); + current_aliases = Some(aliases); + in_host_block = true; + } + "match" => { + // Flush previous block, then enter skip mode + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + current_aliases = Some(Vec::new()); // placeholder so flush knows there's a block + in_host_block = false; + } + "include" => { + // Flush the current block before processing the include + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + if let Some(included_content) = read_include(rest.trim()) { + let included_blocks = + parse_ssh_config_with_includes(&included_content, read_include); + blocks.extend(included_blocks); + } + } + _ => { + if in_host_block && current_aliases.is_some() { + current_directives.push((keyword.to_string(), rest.to_string())); + } + // In Match block or before any block → ignore + } + } + } + + // Flush last block + flush( + &mut blocks, + &mut current_aliases, + &mut current_directives, + &mut in_host_block, + ); + + blocks +} + +/// Parse SSH config text into a list of [`SshHostBlock`]s. +/// +/// - Only `Host` blocks are returned; `Match` blocks are skipped entirely. +/// - Comments (`#`) and blank lines are ignored. +/// - Directive lines are split on the first run of whitespace or `=`. +/// - Multiple aliases on a single `Host` line are all captured. +/// - `Include` directives are ignored (use [`parse_ssh_config_with_includes`] to handle them). +pub fn parse_ssh_config(content: &str) -> Vec { + parse_ssh_config_with_includes(content, &|_| None) +} + +/// Split a trimmed SSH config line into (keyword, value). +/// +/// The separator is the first `=` (with optional surrounding whitespace) or the first +/// run of whitespace — whichever comes first. +fn split_keyword(line: &str) -> (String, String) { + // Find position of first '=' or first whitespace character + let eq_pos = line.find('='); + let ws_pos = line.find(|c: char| c.is_ascii_whitespace()); + + let sep_pos = match (eq_pos, ws_pos) { + (Some(e), Some(w)) => Some(e.min(w)), + (Some(e), None) => Some(e), + (None, Some(w)) => Some(w), + (None, None) => None, + }; + + match sep_pos { + None => (line.to_string(), String::new()), + Some(pos) => { + let keyword = line[..pos].to_string(); + // Skip separator characters (whitespace and '=') + let rest = line[pos..].trim_start_matches(|c: char| c == '=' || c.is_ascii_whitespace()); + (keyword, rest.to_string()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_single_host_block() { + let config = "\ +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 + HostName github.com +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + let b = &blocks[0]; + assert_eq!(b.aliases, vec!["github.com"]); + assert_eq!(b.user(), Some("git")); + assert_eq!(b.identity_file(), Some("~/.ssh/id_ed25519")); + assert_eq!(b.hostname(), Some("github.com")); + } + + #[test] + fn parse_multiple_blocks_filter_git_user() { + let config = "\ +Host work-gitlab + HostName gitlab.mycompany.com + User git + IdentityFile ~/.ssh/id_work + +Host personal + HostName github.com + User git + IdentityFile ~/.ssh/id_personal + +Host bastion + HostName bastion.mycompany.com + User admin + IdentityFile ~/.ssh/id_bastion +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 3); + + let git_blocks: Vec<_> = blocks.iter().filter(|b| b.is_git_user()).collect(); + assert_eq!(git_blocks.len(), 2); + let git_aliases: Vec<&str> = git_blocks.iter().map(|b| b.aliases[0].as_str()).collect(); + assert!(git_aliases.contains(&"work-gitlab")); + assert!(git_aliases.contains(&"personal")); + + let non_git: Vec<_> = blocks.iter().filter(|b| !b.is_git_user()).collect(); + assert_eq!(non_git.len(), 1); + assert_eq!(non_git[0].aliases[0], "bastion"); + } + + #[test] + fn parse_host_with_multiple_aliases() { + let config = "\ +Host github.com gh + User git + IdentityFile ~/.ssh/id_github +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].aliases, vec!["github.com", "gh"]); + } + + #[test] + fn skip_match_blocks() { + let config = "\ +Host first + User alice + +Match host internal.example.com + ProxyJump bastion + +Host second + User bob +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].aliases, vec!["first"]); + assert_eq!(blocks[0].user(), Some("alice")); + assert_eq!(blocks[1].aliases, vec!["second"]); + assert_eq!(blocks[1].user(), Some("bob")); + } + + #[test] + fn handle_tabs_and_varied_indentation() { + let config = "Host tabbed\n\tUser git\n\tIdentityFile\t~/.ssh/id_tab\n"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].user(), Some("git")); + assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_tab")); + } + + #[test] + fn ignore_comments_and_blank_lines() { + let config = "\ +# Global settings comment + +# Host section +Host example.com + # inline comment + User git + + IdentityFile ~/.ssh/id_example +"; + let blocks = parse_ssh_config(config); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].aliases, vec!["example.com"]); + assert_eq!(blocks[0].user(), Some("git")); + assert_eq!(blocks[0].identity_file(), Some("~/.ssh/id_example")); + // The inline comment line should not appear as a directive + assert!(!blocks[0].directives.iter().any(|(k, _)| k.starts_with('#'))); + } + + #[test] + fn empty_config() { + let blocks = parse_ssh_config(""); + assert!(blocks.is_empty()); + } + + #[test] + fn comments_only() { + let config = "\ +# This is a comment +# Another comment + +# Yet another +"; + let blocks = parse_ssh_config(config); + assert!(blocks.is_empty()); + } + + // --- Task 2: identity file resolution --- + + #[test] + fn resolve_identity_file_tilde() { + let ssh_dir = Path::new("/home/user/.ssh"); + let path = resolve_identity_file("~/.ssh/id_ed25519", ssh_dir); + let home = dirs::home_dir().unwrap(); + assert_eq!(path, home.join(".ssh/id_ed25519")); + } + + #[test] + fn resolve_identity_file_relative() { + let path = resolve_identity_file("id_ed25519", Path::new("/home/user/.ssh")); + assert_eq!(path, PathBuf::from("/home/user/.ssh/id_ed25519")); + } + + #[test] + fn resolve_identity_file_absolute() { + let path = resolve_identity_file("/etc/ssh/key", Path::new("/home/user/.ssh")); + assert_eq!(path, PathBuf::from("/etc/ssh/key")); + } + + #[test] + fn synthesize_config_from_blocks() { + let blocks = vec![ + SshHostBlock { + aliases: vec!["github.com".into()], + directives: vec![ + ("User".into(), "git".into()), + ("IdentityFile".into(), "~/.ssh/id_ed25519".into()), + ("HostName".into(), "github.com".into()), + ], + }, + SshHostBlock { + aliases: vec!["gitea".into()], + directives: vec![ + ("User".into(), "git".into()), + ("HostName".into(), "gitea.example.com".into()), + ("IdentityFile".into(), "~/.ssh/work_gitea".into()), + ("Port".into(), "2222".into()), + ], + }, + ]; + let config = synthesize_ssh_config(&blocks); + let expected = "\ +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 + HostName github.com + +Host gitea + User git + HostName gitea.example.com + IdentityFile ~/.ssh/work_gitea + Port 2222 +"; + assert_eq!(config, expected); + } + + // --- Task 3: Include directive support --- + + #[test] + fn parse_with_include_callback() { + let main_config = "\ +Include extra_config + +Host github.com + User git + IdentityFile ~/.ssh/id_ed25519 +"; + let extra_config = "\ +Host gitea.local + User git + IdentityFile ~/.ssh/gitea_key +"; + let blocks = parse_ssh_config_with_includes(main_config, &|path: &str| { + if path == "extra_config" { + Some(extra_config.to_string()) + } else { + None + } + }); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].aliases, vec!["gitea.local"]); + assert_eq!(blocks[1].aliases, vec!["github.com"]); + } +}