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 if let Ok(dir) = std::fs::File::open(&parent_dir) {
119 let _ = dir.sync_all();
120 }
121 }
122 Ok(())
123}
124
125pub 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 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 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
154pub(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
172pub(crate) fn validate_key_path_exists(key_path: &str) -> Result<(), SshCliError> {
175 validate_key_path_exists_with_passphrase(key_path, None)
176}
177
178pub(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 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}