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    #[allow(clippy::too_many_arguments)]
184    pub fn try_new(
185        name: impl AsRef<str>,
186        host: impl AsRef<str>,
187        port: u16,
188        username: impl AsRef<str>,
189        password: SecretString,
190        key_path: Option<impl AsRef<str>>,
191        key_passphrase: Option<SecretString>,
192        timeout_ms: Option<u64>,
193        max_command_chars: Option<usize>,
194        max_output_chars: Option<usize>,
195        sudo_password: Option<SecretString>,
196        su_password: Option<SecretString>,
197        disable_sudo: bool,
198    ) -> Result<Self, String> {
199        let key_path = KeyPath::try_from_optional(key_path).map_err(|e| e.to_string())?;
200        Ok(Self {
201            name: VpsName::try_new(name).map_err(|e| e.to_string())?,
202            host: SshHost::try_new(host).map_err(|e| e.to_string())?,
203            port: SshPort::try_new(port).map_err(|e| e.to_string())?,
204            username: SshUser::try_new(username).map_err(|e| e.to_string())?,
205            password,
206            key_path,
207            key_passphrase,
208            use_agent: false,
209            agent_socket: None,
210            timeout_ms: TimeoutMs::try_new(timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
211                .map_err(|e| e.to_string())?,
212            max_command_chars: CharLimit::try_new(
213                max_command_chars.unwrap_or(DEFAULT_MAX_COMMAND_CHARS),
214            )
215            .map_err(|e| e.to_string())?,
216            max_output_chars: CharLimit::try_new(
217                max_output_chars.unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
218            )
219            .map_err(|e| e.to_string())?,
220            sudo_password,
221            su_password,
222            disable_sudo,
223            schema_version: CURRENT_SCHEMA_VERSION,
224            added_at: Rfc3339Utc::now(),
225            tags: Vec::new(),
226            tls: false,
227            tls_sni: None,
228            tls_client_cert: None,
229            tls_client_key: None,
230        })
231    }
232
233    /// Test/helper constructor that panics on invalid input (not public API).
234    #[cfg(test)]
235    #[allow(clippy::too_many_arguments)]
236    pub(crate) fn test_new(
237        name: impl AsRef<str>,
238        host: impl AsRef<str>,
239        port: u16,
240        username: impl AsRef<str>,
241        password: SecretString,
242        key_path: Option<&str>,
243        key_passphrase: Option<SecretString>,
244        timeout_ms: Option<u64>,
245        max_command_chars: Option<usize>,
246        max_output_chars: Option<usize>,
247        sudo_password: Option<SecretString>,
248        su_password: Option<SecretString>,
249        disable_sudo: bool,
250    ) -> Self {
251        Self::try_new(
252            name,
253            host,
254            port,
255            username,
256            password,
257            key_path,
258            key_passphrase,
259            timeout_ms,
260            max_command_chars,
261            max_output_chars,
262            sudo_password,
263            su_password,
264            disable_sudo,
265        )
266        .expect("test_new requires valid domain fields")
267    }
268
269    /// Returns true if this host has **any** of the requested tags (OR match, G-O2).
270    #[must_use]
271    pub fn has_any_tag(&self, wanted: &[HostTag]) -> bool {
272        if wanted.is_empty() {
273            return true;
274        }
275        wanted
276            .iter()
277            .any(|w| self.tags.iter().any(|t| t.as_str() == w.as_str()))
278    }
279
280    /// Returns true if there is a non-empty password (G-TYPE-12).
281    #[must_use]
282    pub fn has_password(&self) -> bool {
283        secret_nonempty(&self.password)
284    }
285
286    /// Returns true if there is a private key path.
287    #[must_use]
288    pub fn has_key(&self) -> bool {
289        self.key_path.is_some()
290    }
291
292    /// Validates primary authentication: **exactly one** of password / key / agent (G-AUD-07).
293    ///
294    /// # Errors
295    /// [`DomainError`] when zero or more than one primary method is set.
296    pub fn validate_credentials(&self) -> Result<(), crate::domain::DomainError> {
297        let n = u8::from(self.has_password()) + u8::from(self.has_key()) + u8::from(self.use_agent);
298        if n == 0 {
299            return Err(crate::domain::DomainError::new(
300                "vps_auth",
301                "must provide exactly one of --password, --key, or --use-agent",
302            ));
303        }
304        if n > 1 {
305            return Err(crate::domain::DomainError::new(
306                "vps_auth",
307                "primary auth methods are mutually exclusive: use only one of --password, --key, or --use-agent",
308            ));
309        }
310        Ok(())
311    }
312
313    /// Structural validation (G-SERDE-04 / G-TYPE): tags cardinality + field proofs already in types.
314    ///
315    /// # Errors
316    /// [`DomainError`] when tag cardinality exceeds the limit.
317    pub fn validate_structure(&self) -> Result<(), crate::domain::DomainError> {
318        if self.tags.len() > MAX_TAGS {
319            return Err(crate::domain::DomainError::new(
320                "tags",
321                format!("at most {MAX_TAGS} tags allowed"),
322            ));
323        }
324        // `added_at` proof lives in `Rfc3339Utc` (RFC 3339 parse on deserialize).
325        Ok(())
326    }
327
328    /// Full record validation at the write boundary (add/edit/import).
329    ///
330    /// # Errors
331    /// Propagates [`DomainError`] from structure or credentials checks.
332    pub fn validate(&self) -> Result<(), crate::domain::DomainError> {
333        self.validate_structure()?;
334        self.validate_credentials()
335    }
336
337    /// Normalizes schema after deserialization (v1 → v2 migration).
338    pub fn normalize_schema(&mut self) {
339        if self.schema_version < CURRENT_SCHEMA_VERSION {
340            self.schema_version = CURRENT_SCHEMA_VERSION;
341        }
342    }
343
344    /// Sets tags from raw strings (validated).
345    pub fn set_tags_from_raw(
346        &mut self,
347        raw: impl IntoIterator<Item = impl AsRef<str>>,
348    ) -> Result<(), crate::domain::DomainError> {
349        self.tags = try_tags(raw)?;
350        Ok(())
351    }
352}
353
354/// Parses a limit string (`"none"`, `"0"`, or a number) into wire `usize`.
355///
356/// `0`/`none` → `0` (unlimited at runtime).
357#[must_use]
358pub fn parse_char_limit(s: &str) -> usize {
359    let t = s.trim();
360    if t.eq_ignore_ascii_case("none") || t == "0" {
361        0
362    } else {
363        t.parse().unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
364    }
365}
366
367/// Converts a config wire limit into the effective value for truncation/validation.
368///
369/// `0` = unlimited (`usize::MAX` for comparison).
370#[must_use]
371pub fn effective_limit(configured: usize) -> usize {
372    CharLimit::try_new(configured)
373        .map(|c| c.effective())
374        .unwrap_or(usize::MAX)
375}
376
377/// Effective limit from a [`CharLimit`].
378#[must_use]
379pub fn effective_char_limit(limit: CharLimit) -> usize {
380    limit.effective()
381}
382
383mod secret_string_serde {
384    use super::{ExposeSecret, SecretString};
385    use serde::{Deserialize, Deserializer, Serializer};
386
387    pub fn serialize<S: Serializer>(value: &SecretString, s: S) -> Result<S::Ok, S::Error> {
388        let plain = value.expose_secret();
389        let out = crate::secrets::serialize_secret(plain).map_err(serde::ser::Error::custom)?;
390        s.serialize_str(&out)
391    }
392
393    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<SecretString, D::Error> {
394        let s = String::deserialize(d)?;
395        let plain = crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
396        Ok(SecretString::from(plain))
397    }
398}
399
400mod opcao_secret_string_serde {
401    use super::{ExposeSecret, SecretString};
402    use serde::{Deserialize, Deserializer, Serializer};
403
404    pub fn serialize<S: Serializer>(value: &Option<SecretString>, s: S) -> Result<S::Ok, S::Error> {
405        match value {
406            Some(v) => {
407                let out = crate::secrets::serialize_secret(v.expose_secret())
408                    .map_err(serde::ser::Error::custom)?;
409                s.serialize_some(&out)
410            }
411            None => s.serialize_none(),
412        }
413    }
414
415    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<SecretString>, D::Error> {
416        let opt = Option::<String>::deserialize(d)?;
417        match opt {
418            None => Ok(None),
419            Some(s) => {
420                let plain =
421                    crate::secrets::deserialize_secret(&s).map_err(serde::de::Error::custom)?;
422                Ok(Some(SecretString::from(plain)))
423            }
424        }
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn try_new_applies_defaults() {
434        let r = VpsRecord::test_new(
435            "teste",
436            "1.2.3.4",
437            22,
438            "root",
439            SecretString::from("senha".to_string()),
440            None,
441            None,
442            None,
443            None,
444            None,
445            None,
446            None,
447            false,
448        );
449        assert_eq!(r.timeout_ms.get(), DEFAULT_TIMEOUT_MS);
450        assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
451        assert_eq!(r.max_output_chars.wire(), DEFAULT_MAX_OUTPUT_CHARS);
452        assert_eq!(r.schema_version, CURRENT_SCHEMA_VERSION);
453        assert!(!r.added_at.to_rfc3339().is_empty());
454    }
455
456    #[test]
457    fn try_new_rejects_port_zero() {
458        let err = VpsRecord::try_new(
459            "t",
460            "h",
461            0,
462            "u",
463            SecretString::from("p".to_string()),
464            None::<&str>,
465            None,
466            None,
467            None,
468            None,
469            None,
470            None,
471            false,
472        )
473        .unwrap_err();
474        assert!(err.contains("port") || err.contains("0"), "{err}");
475    }
476
477    #[test]
478    fn debug_does_not_show_password() {
479        let r = VpsRecord::test_new(
480            "t",
481            "h",
482            22,
483            "u",
484            SecretString::from("senha-super-secreta".to_string()),
485            None,
486            None,
487            None,
488            None,
489            None,
490            None,
491            None,
492            false,
493        );
494        let dbg = format!("{r:?}");
495        assert!(!dbg.contains("senha-super-secreta"));
496        assert!(dbg.contains("redacted"));
497    }
498
499    #[test]
500    #[serial_test::serial]
501    fn round_trip_toml_preserves_data() {
502        let tmp = tempfile::TempDir::new().unwrap();
503        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
504        // G-UNSAFE-02: plaintext opt-out is CLI/runtime flag only (no env store).
505        crate::secrets::set_runtime_flags(true, None, false);
506        let r = VpsRecord::test_new(
507            "producao",
508            "srv.exemplo.com",
509            2222,
510            "admin",
511            SecretString::from("senha-do-admin-longa".to_string()),
512            Some("/home/u/.ssh/id_ed25519"),
513            None,
514            Some(5000),
515            Some(500),
516            Some(50_000),
517            Some(SecretString::from("sudopass".to_string())),
518            None,
519            false,
520        );
521        let toml_str = toml::to_string(&r).expect("serialize");
522        let r2: VpsRecord = toml::from_str(&toml_str).expect("deserialize");
523        assert_eq!(r2.name.as_str(), "producao");
524        assert_eq!(r2.port.get(), 2222);
525        assert_eq!(r2.password.expose_secret(), "senha-do-admin-longa");
526        assert_eq!(
527            r2.key_path.as_ref().map(|k| k.to_string_lossy_owned()),
528            Some("/home/u/.ssh/id_ed25519".into())
529        );
530        assert_eq!(r2.max_command_chars.wire(), 500);
531        assert_eq!(r2.max_output_chars.wire(), 50_000);
532        assert_eq!(
533            r2.sudo_password
534                .as_ref()
535                .map(|s| s.expose_secret().to_string()),
536            Some("sudopass".to_string())
537        );
538        assert!(r2.su_password.is_none());
539        crate::secrets::set_runtime_flags(false, None, false);
540        crate::secrets::set_config_dir(None);
541    }
542
543    #[test]
544    fn migrates_legacy_max_chars() {
545        let legacy = r#"
546nome = "x"
547host = "h"
548porta = 22
549usuario = "u"
550senha = "s"
551timeout_ms = 30000
552max_chars = 4242
553schema_version = 1
554adicionado_em = "2020-01-01T00:00:00Z"
555"#;
556        let r: VpsRecord = toml::from_str(legacy).expect("deserialize legacy PT wire");
557        assert_eq!(r.max_output_chars.wire(), 4242);
558        assert_eq!(r.max_command_chars.wire(), DEFAULT_MAX_COMMAND_CHARS);
559        assert_eq!(r.name.as_str(), "x");
560        assert_eq!(r.port.get(), 22);
561        assert_eq!(r.username.as_str(), "u");
562    }
563
564    #[test]
565    fn deserializes_english_wire_keys() {
566        let en = r#"
567name = "prod"
568host = "h.example"
569port = 2222
570username = "admin"
571password = "secret"
572timeout_ms = 5000
573schema_version = 3
574"#;
575        let r: VpsRecord = toml::from_str(en).expect("deserialize EN wire");
576        assert_eq!(r.name.as_str(), "prod");
577        assert_eq!(r.port.get(), 2222);
578        assert_eq!(r.username.as_str(), "admin");
579        assert!(!r.added_at.to_rfc3339().is_empty());
580    }
581
582    #[test]
583    #[serial_test::serial]
584    fn serializes_english_wire_keys() {
585        let tmp = tempfile::TempDir::new().unwrap();
586        crate::secrets::set_config_dir(Some(tmp.path().to_path_buf()));
587        crate::secrets::set_runtime_flags(true, None, false);
588        let r = VpsRecord::test_new(
589            "prod",
590            "h",
591            22,
592            "u",
593            SecretString::from("p".to_string()),
594            None,
595            None,
596            None,
597            None,
598            None,
599            None,
600            None,
601            false,
602        );
603        let s = toml::to_string(&r).expect("serialize");
604        assert!(s.contains("name ="), "expected EN key name: {s}");
605        assert!(s.contains("port ="), "expected EN key port: {s}");
606        assert!(s.contains("username ="), "expected EN key username: {s}");
607        assert!(s.contains("password ="), "expected EN key password: {s}");
608        assert!(s.contains("added_at ="), "expected EN key added_at: {s}");
609        assert!(!s.contains("nome ="), "must not write PT key nome: {s}");
610        assert!(!s.contains("porta ="), "must not write PT key porta: {s}");
611        assert!(
612            !s.contains("adicionado_em ="),
613            "must not write PT adicionado_em: {s}"
614        );
615        crate::secrets::set_runtime_flags(false, None, false);
616        crate::secrets::set_config_dir(None);
617    }
618
619    #[test]
620    fn deserializes_without_added_at() {
621        let bare = r#"
622nome = "x"
623host = "h"
624porta = 22
625usuario = "u"
626senha = "s"
627schema_version = 2
628"#;
629        let r: VpsRecord = toml::from_str(bare).expect("default added_at");
630        assert!(!r.added_at.to_rfc3339().is_empty());
631    }
632
633    #[test]
634    fn validate_credentials_requires_password_or_key() {
635        let mut r = VpsRecord::test_new(
636            "t",
637            "h",
638            22,
639            "u",
640            SecretString::from(String::new()),
641            None,
642            None,
643            None,
644            None,
645            None,
646            None,
647            None,
648            false,
649        );
650        assert!(r.validate_credentials().is_err());
651        r.key_path = Some(KeyPath::try_new("/tmp/k").unwrap());
652        assert!(r.validate_credentials().is_ok());
653    }
654
655    #[test]
656    fn deny_unknown_fields_on_vps_record() {
657        let bad = r#"
658name = "x"
659host = "1.2.3.4"
660port = 22
661username = "root"
662password = "p"
663timeuot_ms = 1
664"#;
665        let err = toml::from_str::<VpsRecord>(bad).unwrap_err();
666        let s = err.to_string();
667        assert!(
668            s.contains("unknown") || s.contains("timeuot") || s.contains("did not expect"),
669            "expected deny_unknown, got: {s}"
670        );
671    }
672
673    #[test]
674    fn deserialize_rejects_empty_host() {
675        let bad = r#"
676name = "n"
677host = "  "
678port = 22
679username = "root"
680password = "p"
681"#;
682        assert!(toml::from_str::<VpsRecord>(bad).is_err());
683    }
684
685    #[test]
686    fn parse_limit_none_and_zero() {
687        assert_eq!(parse_char_limit("none"), 0);
688        assert_eq!(parse_char_limit("0"), 0);
689        assert_eq!(parse_char_limit("1000"), 1000);
690        assert_eq!(effective_limit(0), usize::MAX);
691        assert_eq!(effective_limit(10), 10);
692    }
693}