Skip to main content

ssh_cli/vps/
model.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-UNSAFE-01 / G-SECDEV-05: pure module — no `unsafe`.
3#![forbid(unsafe_code)]
4//! Data model for `VpsRecord` (schema v3) — domain newtypes (G-TYPE-*).
5//!
6//! Passwords use `SecretString` for automatic zeroize via `Drop`. On-disk TOML is
7//! plaintext (mode 0o600) or encrypted (`sshcli-enc:v1:`) when a primary key exists.
8//! `Debug` is customized to NEVER expose sensitive values.
9//!
10//! Schema v3: **English wire keys** on serialize (`name`, `port`, `username`, …).
11//! Deserialize accepts both EN and legacy Portuguese aliases (`nome`, `porta`, …).
12//! Field invariants are encoded in [`crate::domain`] newtypes (parse, don't validate).
13
14use crate::domain::{
15    secret_nonempty, try_tags, CharLimit, HostTag, KeyPath, Rfc3339Utc, SshHost, SshPort, SshUser,
16    TimeoutMs, VpsName,
17};
18use crate::validation::MAX_TAGS;
19use secrecy::{ExposeSecret, SecretString};
20use serde::{Deserialize, Serialize};
21
22/// Current schema version of the `config.toml` file.
23pub const CURRENT_SCHEMA_VERSION: u32 = 3;
24
25/// Default timeout in milliseconds (60s).
26pub const DEFAULT_TIMEOUT_MS: u64 = 60_000;
27
28/// Default character limit for the **command** (one-shot maxChars).
29pub const DEFAULT_MAX_COMMAND_CHARS: usize = 1_000;
30
31/// Default character limit for captured **output**.
32pub const DEFAULT_MAX_OUTPUT_CHARS: usize = 100_000;
33
34// Compile-time invariants for schema defaults (const/static rules).
35const _: () = assert!(CURRENT_SCHEMA_VERSION >= 1);
36const _: () = assert!(DEFAULT_TIMEOUT_MS > 0);
37const _: () = assert!(DEFAULT_MAX_COMMAND_CHARS > 0);
38const _: () = assert!(DEFAULT_MAX_OUTPUT_CHARS >= DEFAULT_MAX_COMMAND_CHARS);
39
40/// VPS host record in the configuration file.
41///
42/// Wire format (serialize): English field names. Legacy Portuguese keys remain
43/// readable via `serde(alias = …)`.
44///
45/// Field types are domain newtypes — invalid host/port/user/name cannot be
46/// constructed after a successful deserialize or [`Self::try_new`].
47#[derive(Clone, Serialize, Deserialize)]
48#[serde(deny_unknown_fields)]
49pub struct VpsRecord {
50    /// Logical unique VPS name.
51    #[serde(alias = "nome")]
52    pub name: VpsName,
53    /// Server hostname or IP.
54    pub host: SshHost,
55    /// SSH port.
56    #[serde(alias = "porta")]
57    pub port: SshPort,
58    /// SSH username.
59    #[serde(alias = "usuario")]
60    pub username: SshUser,
61    /// SSH password (empty when key-only auth).
62    #[serde(default, alias = "senha", with = "secret_string_serde")]
63    pub password: SecretString,
64    /// Absolute or expandable OpenSSH private key path.
65    #[serde(default)]
66    pub key_path: Option<KeyPath>,
67    /// Private key passphrase (optional).
68    #[serde(default, with = "opcao_secret_string_serde")]
69    pub key_passphrase: Option<SecretString>,
70    /// Use ssh-agent for publickey auth (G-SSH-04). Socket from `agent_socket` / CLI.
71    #[serde(default)]
72    pub use_agent: bool,
73    /// Agent Unix socket or Windows named pipe path (XDG; never env-as-store).
74    #[serde(default)]
75    pub agent_socket: Option<String>,
76    /// Timeout in milliseconds.
77    #[serde(default = "default_timeout_ms")]
78    pub timeout_ms: TimeoutMs,
79    /// Command character limit (input). `0` = unlimited at runtime.
80    #[serde(default = "default_max_command_chars")]
81    pub max_command_chars: CharLimit,
82    /// Stdout/stderr character limit. Accepts legacy alias `max_chars`.
83    #[serde(default = "default_max_output_chars", alias = "max_chars")]
84    pub max_output_chars: CharLimit,
85    /// Password for `sudo` (optional).
86    #[serde(default, alias = "senha_sudo", with = "opcao_secret_string_serde")]
87    pub sudo_password: Option<SecretString>,
88    /// Password for `su -` (optional).
89    #[serde(default, alias = "senha_su", with = "opcao_secret_string_serde")]
90    pub su_password: Option<SecretString>,
91    /// If true, `sudo-exec` and `su-exec` are rejected for this host.
92    #[serde(default)]
93    pub disable_sudo: bool,
94    /// Schema version for this record.
95    #[serde(default = "default_schema_version")]
96    pub schema_version: u32,
97    /// RFC 3339 inclusion timestamp (`DateTime<Utc>` newtype).
98    #[serde(default = "default_added_at", alias = "adicionado_em")]
99    pub added_at: Rfc3339Utc,
100    /// Optional host tags for fleet selection (G-O2). Empty on legacy records.
101    #[serde(default)]
102    pub tags: Vec<HostTag>,
103    /// When true, wrap SSH in TLS (SSH-over-TLS via rustls) before the SSH handshake.
104    #[serde(default)]
105    pub tls: bool,
106    /// SNI / certificate name for TLS (defaults to `host` when empty/`None`).
107    #[serde(default)]
108    pub tls_sni: Option<String>,
109    /// Client certificate PEM path for mTLS (optional).
110    #[serde(default)]
111    pub tls_client_cert: Option<String>,
112    /// Client private key PEM path for mTLS (optional; required with cert).
113    #[serde(default)]
114    pub tls_client_key: Option<String>,
115}
116
117fn default_max_command_chars() -> CharLimit {
118    CharLimit::try_new(DEFAULT_MAX_COMMAND_CHARS).expect("default command limit in range")
119}
120
121fn default_max_output_chars() -> CharLimit {
122    CharLimit::try_new(DEFAULT_MAX_OUTPUT_CHARS).expect("default output limit in range")
123}
124
125fn default_timeout_ms() -> TimeoutMs {
126    TimeoutMs::try_new(DEFAULT_TIMEOUT_MS).expect("default timeout in range")
127}
128
129fn default_schema_version() -> u32 {
130    CURRENT_SCHEMA_VERSION
131}
132
133fn default_added_at() -> Rfc3339Utc {
134    Rfc3339Utc::now()
135}
136
137impl std::fmt::Debug for VpsRecord {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("VpsRecord")
140            .field("name", &self.name.as_str())
141            .field("host", &self.host.as_str())
142            .field("port", &self.port.get())
143            .field("username", &self.username.as_str())
144            .field("password", &"<redacted>")
145            .field("key_path", &self.key_path.as_ref().map(|k| k.as_path()))
146            .field(
147                "key_passphrase",
148                &self.key_passphrase.as_ref().map(|_| "<redacted>"),
149            )
150            .field("timeout_ms", &self.timeout_ms.get())
151            .field("max_command_chars", &self.max_command_chars.wire())
152            .field("max_output_chars", &self.max_output_chars.wire())
153            .field(
154                "sudo_password",
155                &self.sudo_password.as_ref().map(|_| "<redacted>"),
156            )
157            .field(
158                "su_password",
159                &self.su_password.as_ref().map(|_| "<redacted>"),
160            )
161            .field("disable_sudo", &self.disable_sudo)
162            .field("schema_version", &self.schema_version)
163            .field("added_at", &self.added_at.to_rfc3339())
164            .field("tags", &self.tags)
165            .field("tls", &self.tls)
166            .field("use_agent", &self.use_agent)
167            .field("agent_socket", &self.agent_socket)
168            .field("tls_sni", &self.tls_sni)
169            .field("tls_client_cert", &self.tls_client_cert)
170            .field(
171                "tls_client_key",
172                &self.tls_client_key.as_ref().map(|_| "<redacted-path>"),
173            )
174            .finish()
175    }
176}
177
178impl VpsRecord {
179    /// Creates a new record applying defaults (validated — G-TYPE-06).
180    ///
181    /// # Errors
182    /// Returns a message when any field fails domain parse.
183    // B3: kept deliberately. These parameters are the record's own fields, so a
184    // params struct would be a second, structurally identical copy of
185    // `VpsRecord` whose only job is to be converted into it. Each argument is
186    // already a distinct refined domain type, which is what makes a transposition
187    // a compile error rather than a runtime surprise.
188    #[allow(clippy::too_many_arguments)]
189    pub fn try_new(
190        name: impl AsRef<str>,
191        host: impl AsRef<str>,
192        port: u16,
193        username: impl AsRef<str>,
194        password: SecretString,
195        key_path: Option<impl AsRef<str>>,
196        key_passphrase: Option<SecretString>,
197        timeout_ms: Option<u64>,
198        max_command_chars: Option<usize>,
199        max_output_chars: Option<usize>,
200        sudo_password: Option<SecretString>,
201        su_password: Option<SecretString>,
202        disable_sudo: bool,
203    ) -> Result<Self, String> {
204        let key_path = KeyPath::try_from_optional(key_path).map_err(|e| e.to_string())?;
205        Ok(Self {
206            name: VpsName::try_new(name).map_err(|e| e.to_string())?,
207            host: SshHost::try_new(host).map_err(|e| e.to_string())?,
208            port: SshPort::try_new(port).map_err(|e| e.to_string())?,
209            username: SshUser::try_new(username).map_err(|e| e.to_string())?,
210            password,
211            key_path,
212            key_passphrase,
213            use_agent: false,
214            agent_socket: None,
215            timeout_ms: TimeoutMs::try_new(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
216                .map_err(|e| e.to_string())?,
217            max_command_chars: CharLimit::try_new(
218                max_command_chars.unwrap_or(DEFAULT_MAX_COMMAND_CHARS),
219            )
220            .map_err(|e| e.to_string())?,
221            max_output_chars: CharLimit::try_new(
222                max_output_chars.unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
223            )
224            .map_err(|e| e.to_string())?,
225            sudo_password,
226            su_password,
227            disable_sudo,
228            schema_version: CURRENT_SCHEMA_VERSION,
229            added_at: Rfc3339Utc::now(),
230            tags: Vec::new(),
231            tls: false,
232            tls_sni: None,
233            tls_client_cert: None,
234            tls_client_key: None,
235        })
236    }
237
238    /// Test/helper constructor that panics on invalid input (not public API).
239    #[cfg(test)]
240    // B3: mirrors `try_new` by design — same reasoning applies.
241    #[allow(clippy::too_many_arguments)]
242    pub(crate) fn test_new(
243        name: impl AsRef<str>,
244        host: impl AsRef<str>,
245        port: u16,
246        username: impl AsRef<str>,
247        password: SecretString,
248        key_path: Option<&str>,
249        key_passphrase: Option<SecretString>,
250        timeout_ms: Option<u64>,
251        max_command_chars: Option<usize>,
252        max_output_chars: Option<usize>,
253        sudo_password: Option<SecretString>,
254        su_password: Option<SecretString>,
255        disable_sudo: bool,
256    ) -> Self {
257        Self::try_new(
258            name,
259            host,
260            port,
261            username,
262            password,
263            key_path,
264            key_passphrase,
265            timeout_ms,
266            max_command_chars,
267            max_output_chars,
268            sudo_password,
269            su_password,
270            disable_sudo,
271        )
272        .expect("test_new requires valid domain fields")
273    }
274
275    /// Returns true if this host has **any** of the requested tags (OR match, G-O2).
276    #[must_use]
277    pub fn has_any_tag(&self, wanted: &[HostTag]) -> bool {
278        if wanted.is_empty() {
279            return true;
280        }
281        wanted
282            .iter()
283            .any(|w| self.tags.iter().any(|t| t.as_str() == w.as_str()))
284    }
285
286    /// Returns true if there is a non-empty password (G-TYPE-12).
287    #[must_use]
288    pub fn has_password(&self) -> bool {
289        secret_nonempty(&self.password)
290    }
291
292    /// Returns true if there is a private key path.
293    #[must_use]
294    pub fn has_key(&self) -> bool {
295        self.key_path.is_some()
296    }
297
298    /// Validates primary authentication: **exactly one** of password / key / agent (G-AUD-07).
299    ///
300    /// # Errors
301    /// [`crate::domain::DomainError`] when zero or more than one primary method is set.
302    pub fn validate_credentials(&self) -> Result<(), crate::domain::DomainError> {
303        let n = u8::from(self.has_password()) + u8::from(self.has_key()) + u8::from(self.use_agent);
304        if n == 0 {
305            return Err(crate::domain::DomainError::new(
306                "vps_auth",
307                "must provide exactly one of --password, --key, or --use-agent",
308            ));
309        }
310        if n > 1 {
311            return Err(crate::domain::DomainError::new(
312                "vps_auth",
313                "primary auth methods are mutually exclusive: use only one of --password, --key, or --use-agent",
314            ));
315        }
316        Ok(())
317    }
318
319    /// Structural validation (G-SERDE-04 / G-TYPE): tags cardinality + field proofs already in types.
320    ///
321    /// # Errors
322    /// [`crate::domain::DomainError`] when tag cardinality exceeds the limit.
323    pub fn validate_structure(&self) -> Result<(), crate::domain::DomainError> {
324        if self.tags.len() > MAX_TAGS {
325            return Err(crate::domain::DomainError::new(
326                "tags",
327                format!("at most {MAX_TAGS} tags allowed"),
328            ));
329        }
330        // `added_at` proof lives in `Rfc3339Utc` (RFC 3339 parse on deserialize).
331        Ok(())
332    }
333
334    /// Full record validation at the write boundary (add/edit/import).
335    ///
336    /// # Errors
337    /// Propagates [`crate::domain::DomainError`] from structure or credentials checks.
338    pub fn validate(&self) -> Result<(), crate::domain::DomainError> {
339        self.validate_structure()?;
340        self.validate_credentials()
341    }
342
343    /// Normalizes schema after deserialization (v1 → v2 migration).
344    pub fn normalize_schema(&mut self) {
345        if self.schema_version < CURRENT_SCHEMA_VERSION {
346            self.schema_version = CURRENT_SCHEMA_VERSION;
347        }
348    }
349
350    /// Sets tags from raw strings (validated).
351    pub fn set_tags_from_raw(
352        &mut self,
353        raw: impl IntoIterator<Item = impl AsRef<str>>,
354    ) -> Result<(), crate::domain::DomainError> {
355        self.tags = try_tags(raw)?;
356        Ok(())
357    }
358}
359
360/// Parses a limit string (`"none"`, `"0"`, or a number) into wire `usize`.
361///
362/// `0`/`none` → `0` (unlimited at runtime).
363#[must_use]
364pub fn parse_char_limit(s: &str) -> usize {
365    let t = s.trim();
366    if t.eq_ignore_ascii_case("none") || t == "0" {
367        0
368    } else {
369        t.parse().unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
370    }
371}
372
373/// Converts a config wire limit into the effective value for truncation/validation.
374///
375/// `0` = unlimited (`usize::MAX` for comparison).
376#[must_use]
377pub fn effective_limit(configured: usize) -> usize {
378    CharLimit::try_new(configured)
379        .map(|c| c.effective())
380        .unwrap_or(usize::MAX)
381}
382
383/// Effective limit from a [`CharLimit`].
384#[must_use]
385pub fn effective_char_limit(limit: CharLimit) -> usize {
386    limit.effective()
387}
388
389mod secret_string_serde {
390    use super::{ExposeSecret, SecretString};
391    use serde::{Deserialize, Deserializer, Serializer};
392
393    pub fn serialize<S: Serializer>(value: &SecretString, s: S) -> Result<S::Ok, S::Error> {
394        let plain = value.expose_secret();
395        let out = crate::secrets::serialize_secret(plain).map_err(serde::ser::Error::custom)?;
396        s.serialize_str(&out)
397    }
398
399    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
400        let s = String::deserialize(d)?;
401        let plain = crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
402        Ok(SecretString::from(plain))
403    }
404}
405
406mod opcao_secret_string_serde {
407    use super::{ExposeSecret, SecretString};
408    use serde::{Deserialize, Deserializer, Serializer};
409
410    pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
411        match value {
412            Some(v) => {
413                let out = crate::secrets::serialize_secret(v.expose_secret())
414                    .map_err(serde::ser::Error::custom)?;
415                s.serialize_some(&out)
416            }
417            None => s.serialize_none(),
418        }
419    }
420
421    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
422        let opt = Option::<String>::deserialize(d)?;
423        match opt {
424            None => Ok(None),
425            Some(s) => {
426                let plain =
427                    crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
428                Ok(Some(SecretString::from(plain)))
429            }
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn try_new_applies_defaults() {
440        let r = VpsRecord::test_new(
441            "teste",
442            "1.2.3.4",
443            22,
444            "root",
445            SecretString::from("senha".to_string()),
446            None,
447            None,
448            None,
449            None,
450            None,
451            None,
452            None,
453            false,
454        );
455        assert_eq!(r.timeout_ms.get(), DEFAULT_TIMEOUT_MS);
456        assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
457        assert_eq!(r.max_output_chars.wire(), DEFAULT_MAX_OUTPUT_CHARS);
458        assert_eq!(r.schema_version, CURRENT_SCHEMA_VERSION);
459        assert!(!r.added_at.to_rfc3339().is_empty());
460    }
461
462    #[test]
463    fn try_new_rejects_port_zero() {
464        let err = VpsRecord::try_new(
465            "t",
466            "h",
467            0,
468            "u",
469            SecretString::from("p".to_string()),
470            None::<&str>,
471            None,
472            None,
473            None,
474            None,
475            None,
476            None,
477            false,
478        )
479        .unwrap_err();
480        assert!(err.contains("port") || err.contains("0"), "{err}");
481    }
482
483    #[test]
484    fn debug_does_not_show_password() {
485        let r = VpsRecord::test_new(
486            "t",
487            "h",
488            22,
489            "u",
490            SecretString::from("senha-super-secreta".to_string()),
491            None,
492            None,
493            None,
494            None,
495            None,
496            None,
497            None,
498            false,
499        );
500        let dbg = format!("{r:?}");
501        assert!(!dbg.contains("senha-super-secreta"));
502        assert!(dbg.contains("redacted"));
503    }
504
505    #[test]
506    #[serial_test::serial]
507    fn round_trip_toml_preserves_data() {
508        let tmp = tempfile::TempDir::new().unwrap();
509        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
510        // G-UNSAFE-02: plaintext opt-out is CLI/runtime flag only (no env store).
511        crate::secrets::set_runtime_flags(true, None, false);
512        let r = VpsRecord::test_new(
513            "producao",
514            "srv.exemplo.com",
515            2222,
516            "admin",
517            SecretString::from("senha-do-admin-longa".to_string()),
518            Some("/home/u/.ssh/id_ed25519"),
519            None,
520            Some(5000),
521            Some(500),
522            Some(50_000),
523            Some(SecretString::from("sudopass".to_string())),
524            None,
525            false,
526        );
527        let toml_str = toml::to_string(&r).expect("serialize");
528        let r2: VpsRecord = toml::from_str(&toml_str).expect("deserialize");
529        assert_eq!(r2.name.as_str(), "producao");
530        assert_eq!(r2.port.get(), 2222);
531        assert_eq!(r2.password.expose_secret(), "senha-do-admin-longa");
532        assert_eq!(
533            r2.key_path.as_ref().map(|k| k.to_string_lossy_owned()),
534            Some("/home/u/.ssh/id_ed25519".into())
535        );
536        assert_eq!(r2.max_command_chars.wire(), 500);
537        assert_eq!(r2.max_output_chars.wire(), 50_000);
538        assert_eq!(
539            r2.sudo_password
540                .as_ref()
541                .map(|s| s.expose_secret().to_string()),
542            Some("sudopass".to_string())
543        );
544        assert!(r2.su_password.is_none());
545        crate::secrets::set_runtime_flags(false, None, false);
546        crate::secrets::set_config_dir(None);
547    }
548
549    #[test]
550    fn migrates_legacy_max_chars() {
551        let legacy = r#"
552nome = "x"
553host = "h"
554porta = 22
555usuario = "u"
556senha = "s"
557timeout_ms = 30000
558max_chars = 4242
559schema_version = 1
560adicionado_em = "2020-01-01T00:00:00Z"
561"#;
562        let r: VpsRecord = toml::from_str(legacy).expect("deserialize legacy PT wire");
563        assert_eq!(r.max_output_chars.wire(), 4242);
564        assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
565        assert_eq!(r.name.as_str(), "x");
566        assert_eq!(r.port.get(), 22);
567        assert_eq!(r.username.as_str(), "u");
568    }
569
570    #[test]
571    fn deserializes_english_wire_keys() {
572        let en = r#"
573name = "prod"
574host = "h.example"
575port = 2222
576username = "admin"
577password = "secret"
578timeout_ms = 5000
579schema_version = 3
580"#;
581        let r: VpsRecord = toml::from_str(en).expect("deserialize EN wire");
582        assert_eq!(r.name.as_str(), "prod");
583        assert_eq!(r.port.get(), 2222);
584        assert_eq!(r.username.as_str(), "admin");
585        assert!(!r.added_at.to_rfc3339().is_empty());
586    }
587
588    #[test]
589    #[serial_test::serial]
590    fn serializes_english_wire_keys() {
591        let tmp = tempfile::TempDir::new().unwrap();
592        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
593        crate::secrets::set_runtime_flags(true, None, false);
594        let r = VpsRecord::test_new(
595            "prod",
596            "h",
597            22,
598            "u",
599            SecretString::from("p".to_string()),
600            None,
601            None,
602            None,
603            None,
604            None,
605            None,
606            None,
607            false,
608        );
609        let s = toml::to_string(&r).expect("serialize");
610        assert!(s.contains("name ="), "expected EN key name: {s}");
611        assert!(s.contains("port ="), "expected EN key port: {s}");
612        assert!(s.contains("username ="), "expected EN key username: {s}");
613        assert!(s.contains("password ="), "expected EN key password: {s}");
614        assert!(s.contains("added_at ="), "expected EN key added_at: {s}");
615        assert!(!s.contains("nome ="), "must not write PT key nome: {s}");
616        assert!(!s.contains("porta ="), "must not write PT key porta: {s}");
617        assert!(
618            !s.contains("adicionado_em ="),
619            "must not write PT adicionado_em: {s}"
620        );
621        crate::secrets::set_runtime_flags(false, None, false);
622        crate::secrets::set_config_dir(None);
623    }
624
625    #[test]
626    fn deserializes_without_added_at() {
627        let bare = r#"
628nome = "x"
629host = "h"
630porta = 22
631usuario = "u"
632senha = "s"
633schema_version = 2
634"#;
635        let r: VpsRecord = toml::from_str(bare).expect("default added_at");
636        assert!(!r.added_at.to_rfc3339().is_empty());
637    }
638
639    #[test]
640    fn validate_credentials_requires_password_or_key() {
641        let mut r = VpsRecord::test_new(
642            "t",
643            "h",
644            22,
645            "u",
646            SecretString::from(String::new()),
647            None,
648            None,
649            None,
650            None,
651            None,
652            None,
653            None,
654            false,
655        );
656        assert!(r.validate_credentials().is_err());
657        r.key_path = Some(KeyPath::try_new("/tmp/k").unwrap());
658        assert!(r.validate_credentials().is_ok());
659    }
660
661    #[test]
662    fn deny_unknown_fields_on_vps_record() {
663        let bad = r#"
664name = "x"
665host = "1.2.3.4"
666port = 22
667username = "root"
668password = "p"
669timeuot_ms = 1
670"#;
671        let err = toml::from_str::<VpsRecord>(bad).unwrap_err();
672        let s = err.to_string();
673        assert!(
674            s.contains("unknown") || s.contains("timeuot") || s.contains("did not expect"),
675            "expected deny_unknown, got: {s}"
676        );
677    }
678
679    #[test]
680    fn deserialize_rejects_empty_host() {
681        let bad = r#"
682name = "n"
683host = "  "
684port = 22
685username = "root"
686password = "p"
687"#;
688        assert!(toml::from_str::<VpsRecord>(bad).is_err());
689    }
690
691    #[test]
692    fn parse_limit_none_and_zero() {
693        assert_eq!(parse_char_limit("none"), 0);
694        assert_eq!(parse_char_limit("0"), 0);
695        assert_eq!(parse_char_limit("1000"), 1000);
696        assert_eq!(effective_limit(0), usize::MAX);
697        assert_eq!(effective_limit(10), 10);
698    }
699}