1#![forbid(unsafe_code)]
4use 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#[derive(Debug, Default, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct ConfigFile {
17 #[serde(default)]
19 pub schema_version: u32,
20 #[serde(default)]
22 pub hosts: BTreeMap<String, VpsRecord>,
23}
24
25pub 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
44pub fn default_config_path() -> SshCliResult<PathBuf> {
48 Ok(crate::paths::xdg_config_dir()?.join(crate::constants::CONFIG_FILE_NAME))
49}
50
51#[derive(Debug, Clone)]
53pub struct ConfigLayer {
54 pub name: &'static str,
56 pub path: PathBuf,
58}
59
60pub 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
74pub 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 let mut file: ConfigFile = crate::validation::from_toml_str(&content)?;
85 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
100pub 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 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#[derive(Debug)]
146pub struct ConfigGuard {
147 lock_file: std::fs::File,
149}
150
151impl ConfigGuard {
152 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
167pub 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 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 apply_permissions_600(&lock_path)?;
185 fs2::FileExt::lock_exclusive(&lock_file)?;
186 Ok(ConfigGuard { lock_file })
187}
188
189fn 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
199pub fn save(path: &Path, file: &ConfigFile) -> SshCliResult<()> {
207 let guard = lock_config(path)?;
208 guard.save(path, file)
209}
210
211pub(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
229pub(crate) fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
232 validate_key_path_exists_with_passphrase(key_path, None)
233}
234
235pub(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 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}