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 monolith 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        // G-SCP-R02 (same class): the registry rename is atomic, but the directory
119        // entry is not durable until the parent is flushed. Kept best-effort — several
120        // filesystems reject `sync_all` on a directory handle — yet no longer silent,
121        // so a lost `vps add` after a power cut leaves a trace instead of a mystery.
122        match std::fs::File::open(&parent_dir).and_then(|dir| dir.sync_all()) {
123            Ok(()) => {}
124            Err(e) => tracing::warn!(
125                err = %e,
126                dir = %parent_dir.display(),
127                "config parent dir fsync failed; registry write may not survive a crash"
128            ),
129        }
130    }
131    Ok(())
132}
133
134/// Exclusive hold on the config file for a whole read-modify-write cycle.
135///
136/// `flock` around the write alone was not enough: two one-shot invocations could both
137/// [`load`], mutate their own copy and write in turn, so the second one silently
138/// dropped the first one's host. Callers that mutate must take this guard **before**
139/// `load` and keep it until [`ConfigGuard::save`] returns.
140///
141/// The guard must never be held across network I/O — `flock` is process-wide and a
142/// blocked peer would wait for an SSH round trip. Drop it (or scope it) before any
143/// connect. Re-locking while holding it would deadlock: the second `flock` on a new
144/// file descriptor blocks on the first.
145#[derive(Debug)]
146pub struct ConfigGuard {
147    /// Sibling lock file; `flock` is released on drop with the descriptor.
148    lock_file: std::fs::File,
149}
150
151impl ConfigGuard {
152    /// Writes the file while still holding the lock (see [`save`] for the standalone form).
153    ///
154    /// # Errors
155    /// Returns an error if serialization, atomic write, or permission hardening fails.
156    pub fn save(&self, path: &Path, file: &ConfigFile) -> SshCliResult<()> {
157        save_locked(path, file)
158    }
159}
160
161impl Drop for ConfigGuard {
162    fn drop(&mut self) {
163        let _ = fs2::FileExt::unlock(&self.lock_file);
164    }
165}
166
167/// Takes the exclusive config lock for a read-modify-write cycle.
168///
169/// # Errors
170/// Returns an error if the sibling lock file cannot be created, hardened or locked.
171pub fn lock_config(path: &Path) -> SshCliResult<ConfigGuard> {
172    if let Some(parent_dir) = path.parent() {
173        std::fs::create_dir_all(parent_dir)?;
174    }
175    // Sibling lock file to serialize concurrent mutations (N one-shots).
176    let lock_path = path.with_extension("toml.lock");
177    let lock_file = std::fs::OpenOptions::new()
178        .create(true)
179        .truncate(false)
180        .read(true)
181        .write(true)
182        .open(&lock_path)?;
183    // GAP-SSH-PERM-001: lock with 0o600 (not umask 0644).
184    apply_permissions_600(&lock_path)?;
185    fs2::FileExt::lock_exclusive(&lock_file)?;
186    Ok(ConfigGuard { lock_file })
187}
188
189/// Serializes and writes atomically. Caller must already hold the config lock.
190fn save_locked(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
191    if let Some(parent_dir) = path.parent() {
192        std::fs::create_dir_all(parent_dir)?;
193    }
194    let text = toml::to_string_pretty(file)
195        .map_err(|e| SshCliError::Config(format!("failed to serialize TOML: {e}")))?;
196    write_atomic(path, text.as_bytes())
197}
198
199/// Saves the configuration file atomically with flock and 0o600.
200///
201/// Takes the lock for the write only. Callers that read, mutate and write back must
202/// use `lock_config` instead, or they race with a concurrent invocation.
203///
204/// # Errors
205/// Returns an error if serialization, atomic write, or permission hardening fails.
206pub fn save(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
207    let guard = lock_config(path)?;
208    guard.save(path, file)
209}
210
211/// Expands leading `~` in a path (user home).
212pub(crate) fn expand_tilde(path: &str) -> PathBuf {
213    let home = std::env::var_os("HOME")
214        .or_else(|| std::env::var_os("USERPROFILE"))
215        .map(PathBuf::from);
216    if let Some(rest) = path.strip_prefix("~/") {
217        if let Some(home) = home {
218            return home.join(rest);
219        }
220    }
221    if path == "~" {
222        if let Some(home) = home {
223            return home;
224        }
225    }
226    PathBuf::from(path)
227}
228
229/// Validates that `key_path` points to an existing local file (VAL-003)
230/// and, with `ssh-real`, that the content is a parseable OpenSSH key (VAL-004).
231pub(crate) fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
232    validate_key_path_exists_with_passphrase(key_path, None)
233}
234
235/// Like [`validate_key_path_exists`], with optional passphrase from add/edit.
236pub(crate) fn validate_key_path_exists_with_passphrase(
237    key_path: &str,
238    passphrase: Option<&str>,
239) -> Result<(), SshCliError> {
240    let p = expand_tilde(key_path);
241    if !p.is_file() {
242        return Err(SshCliError::FileNotFound(format!(
243            "private key not found: {}",
244            p.display()
245        )));
246    }
247    #[cfg(feature = "ssh-real")]
248    {
249        match russh::keys::load_secret_key(&p, passphrase) {
250            Ok(_) => Ok(()),
251            Err(e) => {
252                let msg = e.to_string().to_lowercase();
253                // Valid encrypted key without passphrase on the write-path.
254                if msg.contains("password")
255                    || msg.contains("passphrase")
256                    || msg.contains("encrypted")
257                    || msg.contains("decrypt")
258                {
259                    return Ok(());
260                }
261                Err(SshCliError::InvalidArgument(format!(
262                    "invalid OpenSSH private key at {}: {e}",
263                    p.display()
264                )))
265            }
266        }
267    }
268    #[cfg(not(feature = "ssh-real"))]
269    {
270        let _ = passphrase;
271        Ok(())
272    }
273}
274
275fn apply_permissions_600(path: &Path) -> SshCliResult<()> {
276    crate::fs_perm::set_secret_file_mode(path)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use secrecy::{ExposeSecret, SecretString};
283    use tempfile::TempDir;
284
285    fn reg_min() -> VpsRecord {
286        VpsRecord::test_new(
287            "srv",
288            "host.example.com",
289            2222,
290            "admin",
291            SecretString::from("pass".to_string()),
292            None,
293            None,
294            Some(60_000),
295            Some(1_000),
296            Some(50_000),
297            None,
298            None,
299            false,
300        )
301    }
302
303    #[test]
304    fn empty_file_serializes_with_schema() {
305        let cfg_file = ConfigFile {
306            schema_version: model::CURRENT_SCHEMA_VERSION,
307            hosts: BTreeMap::new(),
308        };
309        let text = toml::to_string(&cfg_file).unwrap();
310        assert!(text.contains("schema_version = 3"));
311    }
312
313    #[test]
314    #[serial_test::serial]
315    fn atomic_save_roundtrip() {
316        let tmp = TempDir::new().unwrap();
317        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
318        crate::secrets::set_runtime_flags(true, None, false);
319        let path = tmp.path().join("config.toml");
320        let mut cfg_file = ConfigFile {
321            schema_version: 2,
322            hosts: BTreeMap::new(),
323        };
324        cfg_file.hosts.insert("a".into(), reg_min());
325        save(&path, &cfg_file).unwrap();
326        let loaded = load(&path).unwrap();
327        assert_eq!(loaded.hosts.len(), 1);
328        assert_eq!(loaded.hosts["a"].password.expose_secret(), "pass");
329        #[cfg(unix)]
330        {
331            use std::os::unix::fs::PermissionsExt;
332            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
333            assert_eq!(mode, 0o600);
334            let lock = path.with_extension("toml.lock");
335            if lock.exists() {
336                let lm = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
337                assert_eq!(lm, 0o600);
338            }
339        }
340        crate::secrets::set_runtime_flags(false, None, false);
341        crate::secrets::set_config_dir(None);
342    }
343
344    #[test]
345    fn resolve_config_path_with_dir_override() {
346        let result = resolve_config_path(Some(Path::new("/tmp/test-dir")));
347        assert_eq!(result.unwrap(), PathBuf::from("/tmp/test-dir/config.toml"));
348    }
349
350    #[test]
351    fn resolve_config_path_toml_file_override_keeps_path() {
352        let p = Path::new("/tmp/custom-hosts.toml");
353        let result = resolve_config_path(Some(p)).unwrap();
354        assert_eq!(result, PathBuf::from("/tmp/custom-hosts.toml"));
355    }
356
357    #[test]
358    fn config_override_shared_without_clone() {
359        let owned = PathBuf::from("/tmp/share-me");
360        let a = resolve_config_path(Some(owned.as_path())).unwrap();
361        let b = winning_layer(Some(owned.as_path())).unwrap();
362        assert_eq!(a, PathBuf::from("/tmp/share-me/config.toml"));
363        assert_eq!(b.name, "--config-dir");
364        assert_eq!(b.path, a);
365        assert_eq!(owned, PathBuf::from("/tmp/share-me"));
366    }
367}