Skip to main content

ssh_cli/vps/
config_io.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-UNSAFE-10: config path/load/save/permissions extracted from monólito vps/mod (SRP).
3#![forbid(unsafe_code)]
4//! Atomic TOML config I/O under XDG (load/save/flock/0o600).
5
6use super::model::{self, VpsRecord};
7use crate::errors::{SshCliError, SshCliResult};
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeMap;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13/// Full configuration file.
14#[derive(Debug, Default, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct ConfigFile {
17    /// File schema version.
18    #[serde(default)]
19    pub schema_version: u32,
20    /// Host map keyed by VPS name.
21    #[serde(default)]
22    pub hosts: BTreeMap<String, VpsRecord>,
23}
24
25/// Resolves the config file path from an optional override.
26///
27/// Takes `Option<&Path>` (not `Option<PathBuf>`) so callers can share one
28/// override without cloning — ownership of the path stays with the caller.
29pub fn resolve_config_path(override_path: Option<&Path>) -> SshCliResult<PathBuf> {
30    match override_path {
31        Some(p) => {
32            if p.is_dir() {
33                return Ok(p.join(crate::constants::CONFIG_FILE_NAME));
34            }
35            if p.extension().and_then(|e| e.to_str()) == Some("toml") {
36                return Ok(p.to_path_buf());
37            }
38            Ok(p.join(crate::constants::CONFIG_FILE_NAME))
39        }
40        None => default_config_path(),
41    }
42}
43
44/// Returns the config file path under XDG (`--config-dir` wins at call sites).
45///
46/// G-AUD-12: no `SSH_CLI_HOME` env store — use `--config-dir` for overrides.
47pub fn default_config_path() -> SshCliResult<PathBuf> {
48    Ok(crate::paths::xdg_config_dir()?.join(crate::constants::CONFIG_FILE_NAME))
49}
50
51/// Winning configuration layer (doctor).
52#[derive(Debug, Clone)]
53pub struct ConfigLayer {
54    /// Layer name.
55    pub name: &'static str,
56    /// Resolved path.
57    pub path: PathBuf,
58}
59
60/// Resolves and describes the winning config layer.
61pub fn winning_layer(override_path: Option<&Path>) -> SshCliResult<ConfigLayer> {
62    if override_path.is_some() {
63        return Ok(ConfigLayer {
64            name: "--config-dir",
65            path: resolve_config_path(override_path)?,
66        });
67    }
68    Ok(ConfigLayer {
69        name: "XDG ProjectDirs",
70        path: default_config_path()?,
71    })
72}
73
74/// Loads the configuration file (returns empty if missing).
75pub fn load(path: &Path) -> SshCliResult<ConfigFile> {
76    if !path.exists() {
77        return Ok(ConfigFile {
78            schema_version: model::CURRENT_SCHEMA_VERSION,
79            hosts: BTreeMap::new(),
80        });
81    }
82    let content = crate::paths::read_text_capped(path, crate::paths::MAX_CONFIG_TOML_BYTES)?;
83    // G-SERDE-02/08: parse → path-aware serde → structure validate (no auth required).
84    let mut file: ConfigFile = crate::validation::from_toml_str(&content)?;
85    // Sequential: in-memory schema normalize per record (CPU µs; no SSH).
86    for (name, reg) in file.hosts.iter_mut() {
87        reg.normalize_schema();
88        reg.validate_structure().map_err(|e| {
89            crate::errors::SshCliError::InvalidArgument(format!(
90                "invalid host {name} in config: {e}"
91            ))
92        })?;
93    }
94    if file.schema_version < model::CURRENT_SCHEMA_VERSION {
95        file.schema_version = model::CURRENT_SCHEMA_VERSION;
96    }
97    Ok(file)
98}
99
100/// Writes bytes to `path` atomically (tempfile + fsync + rename + 0o600).
101///
102/// Used by `save` and `export -o` (atomwrite rule).
103pub fn write_atomic(path: &Path, bytes: &[u8]) -> SshCliResult<()> {
104    if let Some(parent_dir) = path.parent() {
105        std::fs::create_dir_all(parent_dir)?;
106    }
107    let parent_dir = path
108        .parent()
109        .map(Path::to_path_buf)
110        .unwrap_or_else(|| PathBuf::from("."));
111    let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
112    tmp.write_all(bytes)?;
113    tmp.as_file().sync_data()?;
114    tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
115    apply_permissions_600(path)?;
116    #[cfg(unix)]
117    {
118        if let Ok(dir) = std::fs::File::open(&parent_dir) {
119            let _ = dir.sync_all();
120        }
121    }
122    Ok(())
123}
124
125/// Saves the configuration file atomically with flock and 0o600.
126///
127/// # Errors
128/// Returns an error if serialization, atomic write, or permission hardening fails.
129pub fn save(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
130    if let Some(parent_dir) = path.parent() {
131        std::fs::create_dir_all(parent_dir)?;
132    }
133    let text = toml::to_string_pretty(file)
134        .map_err(|e| SshCliError::Config(format!("failed to serialize TOML: {e}")))?;
135
136    // Sibling lock file to serialize concurrent mutations (N one-shots).
137    let lock_path = path.with_extension("toml.lock");
138    let lock_file = std::fs::OpenOptions::new()
139        .create(true)
140        .truncate(false)
141        .read(true)
142        .write(true)
143        .open(&lock_path)?;
144    // GAP-SSH-PERM-001: lock with 0o600 (not umask 0644).
145    apply_permissions_600(&lock_path)?;
146    fs2::FileExt::lock_exclusive(&lock_file)?;
147
148    write_atomic(path, text.as_bytes())?;
149
150    let _ = fs2::FileExt::unlock(&lock_file);
151    Ok(())
152}
153
154/// Expands leading `~` in a path (user home).
155pub(crate) fn expand_tilde(path: &str) -> PathBuf {
156    let home = std::env::var_os("HOME")
157        .or_else(|| std::env::var_os("USERPROFILE"))
158        .map(PathBuf::from);
159    if let Some(rest) = path.strip_prefix("~/") {
160        if let Some(home) = home {
161            return home.join(rest);
162        }
163    }
164    if path == "~" {
165        if let Some(home) = home {
166            return home;
167        }
168    }
169    PathBuf::from(path)
170}
171
172/// Validates that `key_path` points to an existing local file (VAL-003)
173/// and, with `ssh-real`, that the content is a parseable OpenSSH key (VAL-004).
174pub(crate) fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
175    validate_key_path_exists_with_passphrase(key_path, None)
176}
177
178/// Like [`validate_key_path_exists`], with optional passphrase from add/edit.
179pub(crate) fn validate_key_path_exists_with_passphrase(
180    key_path: &str,
181    passphrase: Option<&str>,
182) -> Result<(), SshCliError> {
183    let p = expand_tilde(key_path);
184    if !p.is_file() {
185        return Err(SshCliError::FileNotFound(format!(
186            "private key not found: {}",
187            p.display()
188        )));
189    }
190    #[cfg(feature = "ssh-real")]
191    {
192        match russh::keys::load_secret_key(&p, passphrase) {
193            Ok(_) => Ok(()),
194            Err(e) => {
195                let msg = e.to_string().to_lowercase();
196                // Valid encrypted key without passphrase on the write-path.
197                if msg.contains("password")
198                    || msg.contains("passphrase")
199                    || msg.contains("encrypted")
200                    || msg.contains("decrypt")
201                {
202                    return Ok(());
203                }
204                Err(SshCliError::InvalidArgument(format!(
205                    "invalid OpenSSH private key at {}: {e}",
206                    p.display()
207                )))
208            }
209        }
210    }
211    #[cfg(not(feature = "ssh-real"))]
212    {
213        let _ = passphrase;
214        Ok(())
215    }
216}
217
218fn apply_permissions_600(path: &Path) -> SshCliResult<()> {
219    crate::fs_perm::set_secret_file_mode(path)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use secrecy::{ExposeSecret, SecretString};
226    use tempfile::TempDir;
227
228    fn reg_min() -> VpsRecord {
229        VpsRecord::test_new(
230            "srv",
231            "host.example.com",
232            2222,
233            "admin",
234            SecretString::from("pass".to_string()),
235            None,
236            None,
237            Some(60_000),
238            Some(1_000),
239            Some(50_000),
240            None,
241            None,
242            false,
243        )
244    }
245
246    #[test]
247    fn empty_file_serializes_with_schema() {
248        let cfg_file = ConfigFile {
249            schema_version: model::CURRENT_SCHEMA_VERSION,
250            hosts: BTreeMap::new(),
251        };
252        let text = toml::to_string(&cfg_file).unwrap();
253        assert!(text.contains("schema_version = 3"));
254    }
255
256    #[test]
257    #[serial_test::serial]
258    fn atomic_save_roundtrip() {
259        let tmp = TempDir::new().unwrap();
260        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
261        crate::secrets::set_runtime_flags(true, None, false);
262        let path = tmp.path().join("config.toml");
263        let mut cfg_file = ConfigFile {
264            schema_version: 2,
265            hosts: BTreeMap::new(),
266        };
267        cfg_file.hosts.insert("a".into(), reg_min());
268        save(&path, &cfg_file).unwrap();
269        let loaded = load(&path).unwrap();
270        assert_eq!(loaded.hosts.len(), 1);
271        assert_eq!(loaded.hosts["a"].password.expose_secret(), "pass");
272        #[cfg(unix)]
273        {
274            use std::os::unix::fs::PermissionsExt;
275            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
276            assert_eq!(mode, 0o600);
277            let lock = path.with_extension("toml.lock");
278            if lock.exists() {
279                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
280                assert_eq!(lm, 0o600);
281            }
282        }
283        crate::secrets::set_runtime_flags(false, None, false);
284        crate::secrets::set_config_dir(None);
285    }
286
287    #[test]
288    fn resolve_config_path_with_dir_override() {
289        let result = resolve_config_path(Some(Path::new("/tmp/test-dir")));
290        assert_eq!(
291            result.unwrap(),
292            PathBuf::from("/tmp/test-dir/config.toml")
293        );
294    }
295
296    #[test]
297    fn resolve_config_path_toml_file_override_keeps_path() {
298        let p = Path::new("/tmp/custom-hosts.toml");
299        let result = resolve_config_path(Some(p)).unwrap();
300        assert_eq!(result, PathBuf::from("/tmp/custom-hosts.toml"));
301    }
302
303    #[test]
304    fn config_override_shared_without_clone() {
305        let owned = PathBuf::from("/tmp/share-me");
306        let a = resolve_config_path(Some(owned.as_path())).unwrap();
307        let b = winning_layer(Some(owned.as_path())).unwrap();
308        assert_eq!(a, PathBuf::from("/tmp/share-me/config.toml"));
309        assert_eq!(b.name, "--config-dir");
310        assert_eq!(b.path, a);
311        assert_eq!(owned, PathBuf::from("/tmp/share-me"));
312    }
313}