ssh-cli 0.5.5

Native Rust CLI that gives LLMs (Claude Code, Cursor, Windsurf) the ability to operate remote servers via SSH over stdin/stdout
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
// G-UNSAFE-10: config path/load/save/permissions extracted from monolith vps/mod (SRP).
#![forbid(unsafe_code)]
//! Atomic TOML config I/O under XDG (load/save/flock/0o600).

use super::model::{self, VpsRecord};
use crate::errors::{SshCliError, SshCliResult};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};

/// Full configuration file.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
    /// File schema version.
    #[serde(default)]
    pub schema_version: u32,
    /// Host map keyed by VPS name.
    #[serde(default)]
    pub hosts: BTreeMap<String, VpsRecord>,
}

/// Resolves the config file path from an optional override.
///
/// Takes `Option<&Path>` (not `Option<PathBuf>`) so callers can share one
/// override without cloning — ownership of the path stays with the caller.
pub fn resolve_config_path(override_path: Option<&Path>) -> SshCliResult<PathBuf> {
    match override_path {
        Some(p) => {
            if p.is_dir() {
                return Ok(p.join(crate::constants::CONFIG_FILE_NAME));
            }
            if p.extension().and_then(|e| e.to_str()) == Some("toml") {
                return Ok(p.to_path_buf());
            }
            Ok(p.join(crate::constants::CONFIG_FILE_NAME))
        }
        None => default_config_path(),
    }
}

/// Returns the config file path under XDG (`--config-dir` wins at call sites).
///
/// G-AUD-12: no `SSH_CLI_HOME` env store — use `--config-dir` for overrides.
pub fn default_config_path() -> SshCliResult<PathBuf> {
    Ok(crate::paths::xdg_config_dir()?.join(crate::constants::CONFIG_FILE_NAME))
}

/// Winning configuration layer (doctor).
#[derive(Debug, Clone)]
pub struct ConfigLayer {
    /// Layer name.
    pub name: &'static str,
    /// Resolved path.
    pub path: PathBuf,
}

/// Resolves and describes the winning config layer.
pub fn winning_layer(override_path: Option<&Path>) -> SshCliResult<ConfigLayer> {
    if override_path.is_some() {
        return Ok(ConfigLayer {
            name: "--config-dir",
            path: resolve_config_path(override_path)?,
        });
    }
    Ok(ConfigLayer {
        name: "XDG ProjectDirs",
        path: default_config_path()?,
    })
}

/// Loads the configuration file (returns empty if missing).
pub fn load(path: &Path) -> SshCliResult<ConfigFile> {
    if !path.exists() {
        return Ok(ConfigFile {
            schema_version: model::CURRENT_SCHEMA_VERSION,
            hosts: BTreeMap::new(),
        });
    }
    let content = crate::paths::read_text_capped(path, crate::paths::MAX_CONFIG_TOML_BYTES)?;
    // G-SERDE-02/08: parse → path-aware serde → structure validate (no auth required).
    let mut file: ConfigFile = crate::validation::from_toml_str(&content)?;
    // Sequential: in-memory schema normalize per record (CPU µs; no SSH).
    for (name, reg) in file.hosts.iter_mut() {
        reg.normalize_schema();
        reg.validate_structure().map_err(|e| {
            crate::errors::SshCliError::InvalidArgument(format!(
                "invalid host {name} in config: {e}"
            ))
        })?;
    }
    if file.schema_version < model::CURRENT_SCHEMA_VERSION {
        file.schema_version = model::CURRENT_SCHEMA_VERSION;
    }
    Ok(file)
}

/// Writes bytes to `path` atomically (tempfile + fsync + rename + 0o600).
///
/// Used by `save` and `export -o` (atomwrite rule).
pub fn write_atomic(path: &Path, bytes: &[u8]) -> SshCliResult<()> {
    if let Some(parent_dir) = path.parent() {
        std::fs::create_dir_all(parent_dir)?;
    }
    let parent_dir = path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("."));
    let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
    tmp.write_all(bytes)?;
    tmp.as_file().sync_data()?;
    tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
    apply_permissions_600(path)?;
    #[cfg(unix)]
    {
        // G-SCP-R02 (same class): the registry rename is atomic, but the directory
        // entry is not durable until the parent is flushed. Kept best-effort — several
        // filesystems reject `sync_all` on a directory handle — yet no longer silent,
        // so a lost `vps add` after a power cut leaves a trace instead of a mystery.
        match std::fs::File::open(&parent_dir).and_then(|dir| dir.sync_all()) {
            Ok(()) => {}
            Err(e) => tracing::warn!(
                err = %e,
                dir = %parent_dir.display(),
                "config parent dir fsync failed; registry write may not survive a crash"
            ),
        }
    }
    Ok(())
}

/// Exclusive hold on the config file for a whole read-modify-write cycle.
///
/// `flock` around the write alone was not enough: two one-shot invocations could both
/// [`load`], mutate their own copy and write in turn, so the second one silently
/// dropped the first one's host. Callers that mutate must take this guard **before**
/// `load` and keep it until [`ConfigGuard::save`] returns.
///
/// The guard must never be held across network I/O — `flock` is process-wide and a
/// blocked peer would wait for an SSH round trip. Drop it (or scope it) before any
/// connect. Re-locking while holding it would deadlock: the second `flock` on a new
/// file descriptor blocks on the first.
#[derive(Debug)]
pub struct ConfigGuard {
    /// Sibling lock file; `flock` is released on drop with the descriptor.
    lock_file: std::fs::File,
}

impl ConfigGuard {
    /// Writes the file while still holding the lock (see [`save`] for the standalone form).
    ///
    /// # Errors
    /// Returns an error if serialization, atomic write, or permission hardening fails.
    pub fn save(&self, path: &Path, file: &ConfigFile) -> SshCliResult<()> {
        save_locked(path, file)
    }
}

impl Drop for ConfigGuard {
    fn drop(&mut self) {
        let _ = fs2::FileExt::unlock(&self.lock_file);
    }
}

/// Takes the exclusive config lock for a read-modify-write cycle.
///
/// # Errors
/// Returns an error if the sibling lock file cannot be created, hardened or locked.
pub fn lock_config(path: &Path) -> SshCliResult<ConfigGuard> {
    if let Some(parent_dir) = path.parent() {
        std::fs::create_dir_all(parent_dir)?;
    }
    // Sibling lock file to serialize concurrent mutations (N one-shots).
    let lock_path = path.with_extension("toml.lock");
    let lock_file = std::fs::OpenOptions::new()
        .create(true)
        .truncate(false)
        .read(true)
        .write(true)
        .open(&lock_path)?;
    // GAP-SSH-PERM-001: lock with 0o600 (not umask 0644).
    apply_permissions_600(&lock_path)?;
    fs2::FileExt::lock_exclusive(&lock_file)?;
    Ok(ConfigGuard { lock_file })
}

/// Serializes and writes atomically. Caller must already hold the config lock.
fn save_locked(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
    if let Some(parent_dir) = path.parent() {
        std::fs::create_dir_all(parent_dir)?;
    }
    let text = toml::to_string_pretty(file)
        .map_err(|e| SshCliError::Config(format!("failed to serialize TOML: {e}")))?;
    write_atomic(path, text.as_bytes())
}

/// Saves the configuration file atomically with flock and 0o600.
///
/// Takes the lock for the write only. Callers that read, mutate and write back must
/// use `lock_config` instead, or they race with a concurrent invocation.
///
/// # Errors
/// Returns an error if serialization, atomic write, or permission hardening fails.
pub fn save(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
    let guard = lock_config(path)?;
    guard.save(path, file)
}

/// Expands leading `~` in a path (user home).
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
    let home = std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from);
    if let Some(rest) = path.strip_prefix("~/") {
        if let Some(home) = home {
            return home.join(rest);
        }
    }
    if path == "~" {
        if let Some(home) = home {
            return home;
        }
    }
    PathBuf::from(path)
}

/// Validates that `key_path` points to an existing local file (VAL-003)
/// and, with `ssh-real`, that the content is a parseable OpenSSH key (VAL-004).
pub(crate) fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
    validate_key_path_exists_with_passphrase(key_path, None)
}

/// Like [`validate_key_path_exists`], with optional passphrase from add/edit.
pub(crate) fn validate_key_path_exists_with_passphrase(
    key_path: &str,
    passphrase: Option<&str>,
) -> Result<(), SshCliError> {
    let p = expand_tilde(key_path);
    if !p.is_file() {
        return Err(SshCliError::FileNotFound(format!(
            "private key not found: {}",
            p.display()
        )));
    }
    #[cfg(feature = "ssh-real")]
    {
        match russh::keys::load_secret_key(&p, passphrase) {
            Ok(_) => Ok(()),
            Err(e) => {
                let msg = e.to_string().to_lowercase();
                // Valid encrypted key without passphrase on the write-path.
                if msg.contains("password")
                    || msg.contains("passphrase")
                    || msg.contains("encrypted")
                    || msg.contains("decrypt")
                {
                    return Ok(());
                }
                Err(SshCliError::InvalidArgument(format!(
                    "invalid OpenSSH private key at {}: {e}",
                    p.display()
                )))
            }
        }
    }
    #[cfg(not(feature = "ssh-real"))]
    {
        let _ = passphrase;
        Ok(())
    }
}

fn apply_permissions_600(path: &Path) -> SshCliResult<()> {
    crate::fs_perm::set_secret_file_mode(path)
}

#[cfg(test)]
mod tests {
    use super::*;
    use secrecy::{ExposeSecret, SecretString};
    use tempfile::TempDir;

    fn reg_min() -> VpsRecord {
        VpsRecord::test_new(
            "srv",
            "host.example.com",
            2222,
            "admin",
            SecretString::from("pass".to_string()),
            None,
            None,
            Some(60_000),
            Some(1_000),
            Some(50_000),
            None,
            None,
            false,
        )
    }

    #[test]
    fn empty_file_serializes_with_schema() {
        let cfg_file = ConfigFile {
            schema_version: model::CURRENT_SCHEMA_VERSION,
            hosts: BTreeMap::new(),
        };
        let text = toml::to_string(&cfg_file).unwrap();
        assert!(text.contains("schema_version = 3"));
    }

    #[test]
    #[serial_test::serial]
    fn atomic_save_roundtrip() {
        let tmp = TempDir::new().unwrap();
        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
        crate::secrets::set_runtime_flags(true, None, false);
        let path = tmp.path().join("config.toml");
        let mut cfg_file = ConfigFile {
            schema_version: 2,
            hosts: BTreeMap::new(),
        };
        cfg_file.hosts.insert("a".into(), reg_min());
        save(&path, &cfg_file).unwrap();
        let loaded = load(&path).unwrap();
        assert_eq!(loaded.hosts.len(), 1);
        assert_eq!(loaded.hosts["a"].password.expose_secret(), "pass");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
            assert_eq!(mode, 0o600);
            let lock = path.with_extension("toml.lock");
            if lock.exists() {
                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
                assert_eq!(lm, 0o600);
            }
        }
        crate::secrets::set_runtime_flags(false, None, false);
        crate::secrets::set_config_dir(None);
    }

    #[test]
    fn resolve_config_path_with_dir_override() {
        let result = resolve_config_path(Some(Path::new("/tmp/test-dir")));
        assert_eq!(result.unwrap(), PathBuf::from("/tmp/test-dir/config.toml"));
    }

    #[test]
    fn resolve_config_path_toml_file_override_keeps_path() {
        let p = Path::new("/tmp/custom-hosts.toml");
        let result = resolve_config_path(Some(p)).unwrap();
        assert_eq!(result, PathBuf::from("/tmp/custom-hosts.toml"));
    }

    #[test]
    fn config_override_shared_without_clone() {
        let owned = PathBuf::from("/tmp/share-me");
        let a = resolve_config_path(Some(owned.as_path())).unwrap();
        let b = winning_layer(Some(owned.as_path())).unwrap();
        assert_eq!(a, PathBuf::from("/tmp/share-me/config.toml"));
        assert_eq!(b.name, "--config-dir");
        assert_eq!(b.path, a);
        assert_eq!(owned, PathBuf::from("/tmp/share-me"));
    }
}