Skip to main content

ssh_cli/vps/
model.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Data model for `VpsRecord` (schema v3).
3//!
4//! Passwords use `SecretString` for automatic zeroize via `Drop`. On-disk TOML is
5//! plaintext (mode 0o600) or encrypted (`sshcli-enc:v1:`) when a primary key exists.
6//! `Debug` is customized to NEVER expose sensitive values.
7//!
8//! Schema v3: **English wire keys** on serialize (`name`, `port`, `username`, …).
9//! Deserialize accepts both EN and legacy Portuguese aliases (`nome`, `porta`, …).
10//! Schema v2: password **or** key auth, max_command/max_output duality, `disable_sudo`.
11
12use secrecy::{ExposeSecret, SecretString};
13use serde::{Deserialize, Serialize};
14
15/// Current schema version of the `config.toml` file.
16pub const CURRENT_SCHEMA_VERSION: u32 = 3;
17
18/// Default timeout in milliseconds (60s).
19pub const DEFAULT_TIMEOUT_MS: u64 = 60_000;
20
21/// Default character limit for the **command** (one-shot maxChars).
22pub const DEFAULT_MAX_COMMAND_CHARS: usize = 1_000;
23
24/// Default character limit for captured **output**.
25pub const DEFAULT_MAX_OUTPUT_CHARS: usize = 100_000;
26
27/// VPS host record in the configuration file.
28///
29/// Wire format (serialize): English field names. Legacy Portuguese keys remain
30/// readable via `serde(alias = …)` (GAP-AUD-20260717-001/002/021).
31#[derive(Clone, Serialize, Deserialize)]
32pub struct VpsRecord {
33    /// Logical unique VPS name.
34    #[serde(alias = "nome")]
35    pub name: String,
36    /// Server hostname or IP.
37    pub host: String,
38    /// SSH port.
39    #[serde(alias = "porta")]
40    pub port: u16,
41    /// SSH username.
42    #[serde(alias = "usuario")]
43    pub username: String,
44    /// SSH password (empty when key-only auth).
45    #[serde(default, alias = "senha", with = "secret_string_serde")]
46    pub password: SecretString,
47    /// Absolute or expandable OpenSSH private key path.
48    #[serde(default)]
49    pub key_path: Option<String>,
50    /// Private key passphrase (optional).
51    #[serde(default, with = "opcao_secret_string_serde")]
52    pub key_passphrase: Option<SecretString>,
53    /// Timeout in milliseconds.
54    #[serde(default = "default_timeout_ms")]
55    pub timeout_ms: u64,
56    /// Command character limit (input). `0` = unlimited at runtime.
57    #[serde(default = "default_max_command_chars")]
58    pub max_command_chars: usize,
59    /// Stdout/stderr character limit. Accepts legacy alias `max_chars`.
60    #[serde(default = "default_max_output_chars", alias = "max_chars")]
61    pub max_output_chars: usize,
62    /// Password for `sudo` (optional).
63    #[serde(default, alias = "senha_sudo", with = "opcao_secret_string_serde")]
64    pub sudo_password: Option<SecretString>,
65    /// Password for `su -` (optional).
66    #[serde(default, alias = "senha_su", with = "opcao_secret_string_serde")]
67    pub su_password: Option<SecretString>,
68    /// If true, `sudo-exec` and `su-exec` are rejected for this host.
69    #[serde(default)]
70    pub disable_sudo: bool,
71    /// Schema version for this record.
72    #[serde(default = "default_schema_version")]
73    pub schema_version: u32,
74    /// RFC 3339 inclusion timestamp.
75    #[serde(default = "default_added_at", alias = "adicionado_em")]
76    pub added_at: String,
77}
78
79fn default_max_command_chars() -> usize {
80    DEFAULT_MAX_COMMAND_CHARS
81}
82
83fn default_max_output_chars() -> usize {
84    DEFAULT_MAX_OUTPUT_CHARS
85}
86
87fn default_timeout_ms() -> u64 {
88    DEFAULT_TIMEOUT_MS
89}
90
91fn default_schema_version() -> u32 {
92    CURRENT_SCHEMA_VERSION
93}
94
95fn default_added_at() -> String {
96    chrono::Utc::now().to_rfc3339()
97}
98
99impl std::fmt::Debug for VpsRecord {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("VpsRecord")
102            .field("name", &self.name)
103            .field("host", &self.host)
104            .field("port", &self.port)
105            .field("username", &self.username)
106            .field("password", &"<redacted>")
107            .field("key_path", &self.key_path)
108            .field(
109                "key_passphrase",
110                &self.key_passphrase.as_ref().map(|_| "<redacted>"),
111            )
112            .field("timeout_ms", &self.timeout_ms)
113            .field("max_command_chars", &self.max_command_chars)
114            .field("max_output_chars", &self.max_output_chars)
115            .field(
116                "sudo_password",
117                &self.sudo_password.as_ref().map(|_| "<redacted>"),
118            )
119            .field("su_password", &self.su_password.as_ref().map(|_| "<redacted>"))
120            .field("disable_sudo", &self.disable_sudo)
121            .field("schema_version", &self.schema_version)
122            .field("added_at", &self.added_at)
123            .finish()
124    }
125}
126
127impl VpsRecord {
128    /// Creates a new record applying defaults.
129    #[must_use]
130    #[allow(clippy::too_many_arguments)]
131    pub fn new(
132        name: String,
133        host: String,
134        port: u16,
135        username: String,
136        password: SecretString,
137        key_path: Option<String>,
138        key_passphrase: Option<SecretString>,
139        timeout_ms: Option<u64>,
140        max_command_chars: Option<usize>,
141        max_output_chars: Option<usize>,
142        sudo_password: Option<SecretString>,
143        su_password: Option<SecretString>,
144        disable_sudo: bool,
145    ) -> Self {
146        Self {
147            name,
148            host,
149            port,
150            username,
151            password,
152            key_path,
153            key_passphrase,
154            timeout_ms: timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS),
155            max_command_chars: max_command_chars.unwrap_or(DEFAULT_MAX_COMMAND_CHARS),
156            max_output_chars: max_output_chars.unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
157            sudo_password,
158            su_password,
159            disable_sudo,
160            schema_version: CURRENT_SCHEMA_VERSION,
161            added_at: chrono::Utc::now().to_rfc3339(),
162        }
163    }
164
165    /// Returns true if there is a non-empty password.
166    #[must_use]
167    pub fn has_password(&self) -> bool {
168        !self.password.expose_secret().is_empty()
169    }
170
171    /// Returns true if there is a private key path.
172    #[must_use]
173    pub fn has_key(&self) -> bool {
174        self.key_path.as_ref().is_some_and(|p| !p.trim().is_empty())
175    }
176
177    /// Validates that at least one authentication method exists.
178    pub fn validate_credentials(&self) -> Result<(), String> {
179        if !self.has_password() && !self.has_key() {
180            return Err(
181                "must provide --password or --key (password or private key auth)"
182                    .to_string(),
183            );
184        }
185        Ok(())
186    }
187
188    /// Full record validation at the write boundary (add/edit/import).
189    ///
190    /// Ensures port ∈ 1..=65535, non-empty host/user, and credentials present.
191    /// Does not check that `key_path` exists on the filesystem (dispatcher does).
192    pub fn validate(&self) -> Result<(), String> {
193        if self.port == 0 {
194            return Err("invalid SSH port: 0 (use 1..=65535)".to_string());
195        }
196        if self.host.trim().is_empty() {
197            return Err("host cannot be empty".to_string());
198        }
199        if self.username.trim().is_empty() {
200            return Err("SSH username cannot be empty".to_string());
201        }
202        self.validate_credentials()
203    }
204
205    /// Normalizes schema after deserialization (v1 → v2 migration).
206    pub fn normalize_schema(&mut self) {
207        if self.schema_version < CURRENT_SCHEMA_VERSION {
208            self.schema_version = CURRENT_SCHEMA_VERSION;
209        }
210        if self.max_command_chars == 0 && self.max_output_chars == 0 {
211            // nothing: 0 means unlimited at runtime validation
212        }
213    }
214}
215
216/// Parses a limit string (`"none"`, `"0"`, or a number).
217///
218/// `0`/`none` → `0` (unlimited at runtime).
219#[must_use]
220pub fn parse_char_limit(s: &str) -> usize {
221    let t = s.trim();
222    if t.eq_ignore_ascii_case("none") || t == "0" {
223        0
224    } else {
225        t.parse().unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
226    }
227}
228
229/// Converts a config limit into the effective value for truncation/validation.
230///
231/// `0` = unlimited (`usize::MAX` for comparison).
232#[must_use]
233pub fn effective_limit(configured: usize) -> usize {
234    if configured == 0 {
235        usize::MAX
236    } else {
237        configured
238    }
239}
240
241mod secret_string_serde {
242    use super::{ExposeSecret, SecretString};
243    use serde::{Deserialize, Deserializer, Serializer};
244
245    pub fn serialize<S: Serializer>(value: &SecretString, s: S) -> Result<S::Ok, S::Error> {
246        let plain = value.expose_secret();
247        let out = crate::secrets::serialize_secret(plain).map_err(serde::ser::Error::custom)?;
248        s.serialize_str(&out)
249    }
250
251    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
252        let s = String::deserialize(d)?;
253        let plain = crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
254        Ok(SecretString::from(plain))
255    }
256}
257
258mod opcao_secret_string_serde {
259    use super::{ExposeSecret, SecretString};
260    use serde::{Deserialize, Deserializer, Serializer};
261
262    pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
263        match value {
264            Some(v) => {
265                let out = crate::secrets::serialize_secret(v.expose_secret())
266                    .map_err(serde::ser::Error::custom)?;
267                s.serialize_some(&out)
268            }
269            None => s.serialize_none(),
270        }
271    }
272
273    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
274        let opt = Option::<String>::deserialize(d)?;
275        match opt {
276            None => Ok(None),
277            Some(s) => {
278                let plain =
279                    crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
280                Ok(Some(SecretString::from(plain)))
281            }
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn new_record_applies_defaults() {
292        let r = VpsRecord::new(
293            "teste".into(),
294            "1.2.3.4".into(),
295            22,
296            "root".into(),
297            SecretString::from("senha".to_string()),
298            None,
299            None,
300            None,
301            None,
302            None,
303            None,
304            None,
305            false,
306        );
307        assert_eq!(r.timeout_ms, DEFAULT_TIMEOUT_MS);
308        assert_eq!(r.max_command_chars, DEFAULT_MAX_COMMAND_CHARS);
309        assert_eq!(r.max_output_chars, DEFAULT_MAX_OUTPUT_CHARS);
310        assert_eq!(r.schema_version, CURRENT_SCHEMA_VERSION);
311        assert!(!r.added_at.is_empty());
312    }
313
314    #[test]
315    fn debug_does_not_show_password() {
316        let r = VpsRecord::new(
317            "t".into(),
318            "h".into(),
319            22,
320            "u".into(),
321            SecretString::from("senha-super-secreta".to_string()),
322            None,
323            None,
324            None,
325            None,
326            None,
327            None,
328            None,
329            false,
330        );
331        let dbg = format!("{r:?}");
332        assert!(!dbg.contains("senha-super-secreta"));
333        assert!(dbg.contains("redacted"));
334    }
335
336    #[test]
337    #[serial_test::serial]
338    fn round_trip_toml_preserves_data() {
339        // Isolates at-rest encryption from other tests (global primary-key).
340        let tmp = tempfile::TempDir::new().unwrap();
341        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
342        // SAFETY:
343        // 1. Contract: temporary mutation of process environment for a serial test/setup path.
344        // 2. Invariant: no concurrent threads in this process mutate the same env keys.
345        // 3. Caller guarantees serial_test::serial (or single-threaded test) around this block.
346        // 4. See std::env::set_var / remove_var safety notes for multi-threaded processes.
347        unsafe {
348
349            std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
350        }
351        let r = VpsRecord::new(
352            "producao".into(),
353            "srv.exemplo.com".into(),
354            2222,
355            "admin".into(),
356            SecretString::from("senha-do-admin-longa".to_string()),
357            Some("/home/u/.ssh/id_ed25519".into()),
358            None,
359            Some(5000),
360            Some(500),
361            Some(50_000),
362            Some(SecretString::from("sudopass".to_string())),
363            None,
364            false,
365        );
366        let toml_str = toml::to_string(&r).expect("serializar");
367        let r2: VpsRecord = toml::from_str(&toml_str).expect("deserializar");
368        assert_eq!(r2.name, "producao");
369        assert_eq!(r2.port, 2222);
370        assert_eq!(r2.password.expose_secret(), "senha-do-admin-longa");
371        assert_eq!(r2.key_path.as_deref(), Some("/home/u/.ssh/id_ed25519"));
372        assert_eq!(r2.max_command_chars, 500);
373        assert_eq!(r2.max_output_chars, 50_000);
374        assert_eq!(
375            r2.sudo_password
376                .as_ref()
377                .map(|s| s.expose_secret().to_string()),
378            Some("sudopass".to_string())
379        );
380        assert!(r2.su_password.is_none());
381        // SAFETY:
382
383        // 1. Contract: temporary mutation of process environment for a serial test/setup path.
384
385        // 2. Invariant: no concurrent threads in this process mutate the same env keys.
386
387        // 3. Caller guarantees serial_test::serial (or single-threaded test) around this block.
388
389        // 4. See std::env::set_var / remove_var safety notes for multi-threaded processes.
390
391        unsafe {
392            std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
393        }
394        crate::secrets::set_config_dir(None);
395    }
396
397    #[test]
398    fn migrates_legacy_max_chars() {
399        let legacy = r#"
400nome = "x"
401host = "h"
402porta = 22
403usuario = "u"
404senha = "s"
405timeout_ms = 30000
406max_chars = 4242
407schema_version = 1
408adicionado_em = "2020-01-01T00:00:00Z"
409"#;
410        let r: VpsRecord = toml::from_str(legacy).expect("deserialize legacy PT wire");
411        assert_eq!(r.max_output_chars, 4242);
412        assert_eq!(r.max_command_chars, DEFAULT_MAX_COMMAND_CHARS);
413        assert_eq!(r.name, "x");
414        assert_eq!(r.port, 22);
415        assert_eq!(r.username, "u");
416    }
417
418    #[test]
419    fn deserializes_english_wire_keys() {
420        let en = r#"
421name = "prod"
422host = "h.example"
423port = 2222
424username = "admin"
425password = "secret"
426timeout_ms = 5000
427schema_version = 3
428"#;
429        let r: VpsRecord = toml::from_str(en).expect("deserialize EN wire");
430        assert_eq!(r.name, "prod");
431        assert_eq!(r.port, 2222);
432        assert_eq!(r.username, "admin");
433        assert!(!r.added_at.is_empty());
434    }
435
436    #[test]
437    fn serializes_english_wire_keys() {
438        let r = VpsRecord::new(
439            "prod".into(),
440            "h".into(),
441            22,
442            "u".into(),
443            SecretString::from("p".to_string()),
444            None,
445            None,
446            None,
447            None,
448            None,
449            None,
450            None,
451            false,
452        );
453        let s = toml::to_string(&r).expect("serialize");
454        assert!(s.contains("name ="), "expected EN key name: {s}");
455        assert!(s.contains("port ="), "expected EN key port: {s}");
456        assert!(s.contains("username ="), "expected EN key username: {s}");
457        assert!(s.contains("password ="), "expected EN key password: {s}");
458        assert!(s.contains("added_at ="), "expected EN key added_at: {s}");
459        assert!(!s.contains("nome ="), "must not write PT key nome: {s}");
460        assert!(!s.contains("porta ="), "must not write PT key porta: {s}");
461        assert!(!s.contains("adicionado_em ="), "must not write PT adicionado_em: {s}");
462    }
463
464    #[test]
465    fn deserializes_without_added_at() {
466        let bare = r#"
467nome = "x"
468host = "h"
469porta = 22
470usuario = "u"
471senha = "s"
472schema_version = 2
473"#;
474        let r: VpsRecord = toml::from_str(bare).expect("default added_at");
475        assert!(!r.added_at.is_empty());
476    }
477
478    #[test]
479    fn validate_credentials_requires_password_or_key() {
480        let mut r = VpsRecord::new(
481            "t".into(),
482            "h".into(),
483            22,
484            "u".into(),
485            SecretString::from(String::new()),
486            None,
487            None,
488            None,
489            None,
490            None,
491            None,
492            None,
493            false,
494        );
495        assert!(r.validate_credentials().is_err());
496        r.key_path = Some("/tmp/k".into());
497        assert!(r.validate_credentials().is_ok());
498    }
499
500    #[test]
501    fn parse_limit_none_and_zero() {
502        assert_eq!(parse_char_limit("none"), 0);
503        assert_eq!(parse_char_limit("0"), 0);
504        assert_eq!(parse_char_limit("1000"), 1000);
505        assert_eq!(effective_limit(0), usize::MAX);
506        assert_eq!(effective_limit(10), 10);
507    }
508}