🥇 export from upstream (0a94b11)
This commit is contained in:
@@ -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?
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ pub enum ConfigError {
|
|||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, ConfigError>;
|
pub type Result<T> = std::result::Result<T, ConfigError>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
// Raw deserialization type — captures unknown keys
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -56,6 +66,10 @@ struct RawConfig {
|
|||||||
container_workspace: Option<String>,
|
container_workspace: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
agent_args: Option<HashMap<String, Vec<String>>>,
|
agent_args: Option<HashMap<String, Vec<String>>>,
|
||||||
|
#[serde(default)]
|
||||||
|
ssh_mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
ssh_keys: Option<Vec<SshKeyEntry>>,
|
||||||
|
|
||||||
/// Absorb everything else so we can warn about unknown fields.
|
/// Absorb everything else so we can warn about unknown fields.
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
@@ -89,13 +103,17 @@ pub struct SandcageConfig {
|
|||||||
pub container_workspace: Option<String>,
|
pub container_workspace: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub agent_args: Option<HashMap<String, Vec<String>>>,
|
pub agent_args: Option<HashMap<String, Vec<String>>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ssh_mode: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ssh_keys: Option<Vec<SshKeyEntry>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Known keys — used to filter the flattened `extra` map
|
// 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
|
// Validation helpers
|
||||||
@@ -148,6 +166,8 @@ fn from_raw(raw: RawConfig) -> SandcageConfig {
|
|||||||
trusted_projects: raw.trusted_projects,
|
trusted_projects: raw.trusted_projects,
|
||||||
container_workspace: raw.container_workspace,
|
container_workspace: raw.container_workspace,
|
||||||
agent_args: raw.agent_args,
|
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");
|
let codex_args = agent_args.get("codex").expect("codex args");
|
||||||
assert_eq!(codex_args, &vec!["--full-auto"]);
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
if shell_override {
|
||||||
args.push("--entrypoint".to_string());
|
args.push("--entrypoint".to_string());
|
||||||
args.push("/bin/zsh".to_string());
|
args.push("/bin/zsh".to_string());
|
||||||
@@ -260,15 +274,14 @@ pub fn build_run_args(
|
|||||||
|
|
||||||
args.push(service.to_string());
|
args.push(service.to_string());
|
||||||
|
|
||||||
if !shell_override {
|
if !shell_override
|
||||||
if let Some(ref all_agent_args) = config.agent_args
|
&& let Some(ref all_agent_args) = config.agent_args
|
||||||
&& let Some(default_args) = all_agent_args.get(service)
|
&& let Some(default_args) = all_agent_args.get(service)
|
||||||
{
|
{
|
||||||
for arg in default_args {
|
for arg in default_args {
|
||||||
args.push(arg.clone());
|
args.push(arg.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for arg in extra_args {
|
for arg in extra_args {
|
||||||
args.push(arg.clone());
|
args.push(arg.clone());
|
||||||
@@ -290,6 +303,17 @@ pub fn run_service(
|
|||||||
let image = image_for_service(service);
|
let image = image_for_service(service);
|
||||||
require_image(&docker, image)?;
|
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_file = write_compose_tempfile()?;
|
||||||
let compose_path = compose_file.path().to_string_lossy().into_owned();
|
let compose_path = compose_file.path().to_string_lossy().into_owned();
|
||||||
|
|
||||||
@@ -984,6 +1008,63 @@ mod tests {
|
|||||||
assert!(resume_pos > service_pos);
|
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]
|
#[test]
|
||||||
fn build_compose_env_container_dir_root_fallback() {
|
fn build_compose_env_container_dir_root_fallback() {
|
||||||
let workspace = PathBuf::from("/");
|
let workspace = PathBuf::from("/");
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ pub mod config;
|
|||||||
pub mod docker;
|
pub mod docker;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
pub mod setup;
|
pub mod setup;
|
||||||
|
pub mod ssh_config;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|||||||
@@ -79,6 +79,14 @@ enum SetupAction {
|
|||||||
/// Skip confirmation prompt
|
/// Skip confirmation prompt
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
yes: bool,
|
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)?;
|
init::scaffold_project(&workspace)?;
|
||||||
}
|
}
|
||||||
Commands::Setup { action } => match action {
|
Commands::Setup { action } => match action {
|
||||||
SetupAction::Ssh { global, yes } => {
|
SetupAction::Ssh { global, yes, refresh, bind } => {
|
||||||
setup::run_ssh_setup(global, yes)?;
|
setup::run_ssh_setup(global, yes, refresh, bind)?;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use miette::Diagnostic;
|
use miette::Diagnostic;
|
||||||
use thiserror::Error;
|
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)]
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
pub enum SetupError {
|
pub enum SetupError {
|
||||||
@@ -34,6 +38,10 @@ pub enum SetupError {
|
|||||||
#[error("Cannot determine home directory")]
|
#[error("Cannot determine home directory")]
|
||||||
#[diagnostic(code(sandcage::setup::no_home_dir))]
|
#[diagnostic(code(sandcage::setup::no_home_dir))]
|
||||||
NoHomeDir,
|
NoHomeDir,
|
||||||
|
|
||||||
|
#[error("Docker command failed: {0}")]
|
||||||
|
#[diagnostic(code(sandcage::setup::docker_failed))]
|
||||||
|
DockerFailed(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, SetupError>;
|
pub type Result<T> = std::result::Result<T, SetupError>;
|
||||||
@@ -229,6 +237,77 @@ pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn extract_git_remote_hosts(git_remote_output: &str) -> Vec<String> {
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
let mut hosts: BTreeSet<String> = BTreeSet::new();
|
||||||
|
|
||||||
|
for line in git_remote_output.lines() {
|
||||||
|
// Each line: "<name>\t<url> (fetch)" or "<name>\t<url> (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<String> {
|
||||||
|
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) {
|
fn display_scan_result(result: &SshScanResult) {
|
||||||
if !result.keys.is_empty() {
|
if !result.keys.is_empty() {
|
||||||
eprintln!(" Keys: {}", result.keys.join(", "));
|
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.");
|
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::<SandcageConfig>().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<String> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let key = e.identity_file.as_deref().unwrap_or("(default)");
|
||||||
|
format!("{} → {key}", e.display_host)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let defaults: Vec<bool> = 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<SshDiscoveryEntry> = 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::<SandcageConfig>()
|
||||||
|
.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<SshDiscoveryEntry> = 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<SshDiscoveryEntry> = 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 home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?;
|
||||||
let ssh_dir = home.join(".ssh");
|
let ssh_dir = home.join(".ssh");
|
||||||
|
|
||||||
@@ -331,6 +783,242 @@ pub fn run_ssh_setup(global: bool, yes: bool) -> Result<()> {
|
|||||||
Ok(())
|
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<String>,
|
||||||
|
pub identity_file: Option<String>,
|
||||||
|
pub host_block: Option<SshHostBlock>,
|
||||||
|
pub source: SshDiscoverySource,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a default SSH private key in `ssh_dir` by checking common names.
|
||||||
|
fn find_default_key(ssh_dir: &Path) -> Option<String> {
|
||||||
|
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<SshDiscoveryEntry> {
|
||||||
|
// Keyed by display_host for deduplication and sorted output.
|
||||||
|
let mut map: BTreeMap<String, SshDiscoveryEntry> = 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<SshHostBlock> = 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<String> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -537,4 +1225,100 @@ mod tests {
|
|||||||
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
assert!(content.contains("~/.ssh:/home/agent/.ssh:ro"));
|
||||||
assert!(content.contains("/data:/mnt"));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
/// 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 <path>` 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<String>,
|
||||||
|
) -> Vec<SshHostBlock> {
|
||||||
|
let mut blocks: Vec<SshHostBlock> = Vec::new();
|
||||||
|
// None → no active block yet; Some(true) → Host block; Some(false) → Match block (skip)
|
||||||
|
let mut current_aliases: Option<Vec<String>> = None;
|
||||||
|
let mut current_directives: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut in_host_block = false;
|
||||||
|
|
||||||
|
let flush = |blocks: &mut Vec<SshHostBlock>,
|
||||||
|
aliases: &mut Option<Vec<String>>,
|
||||||
|
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<String> = 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<SshHostBlock> {
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user