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