Skip to main content

ssh_cli/
validation.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Shared validation pipeline for external input (G-SERDE-07).
3//!
4//! Canonical order: **parse → serde → validator → domain**.
5//! No product telemetry (local `tracing` only).
6#![forbid(unsafe_code)]
7
8use crate::errors::{SshCliError, SshCliResult};
9use validator::ValidationErrors;
10
11/// Hard ceiling for `timeout_ms` (1 hour) — G-SERDE-11.
12pub const MAX_TIMEOUT_MS: u64 = 3_600_000;
13
14/// Hard ceiling for command/output char limits (0 = unlimited still allowed) — G-SERDE-11.
15pub const MAX_CHAR_LIMIT: usize = 10_000_000;
16
17/// Max length for a single host tag.
18pub const MAX_TAG_LEN: usize = 64;
19
20/// Max number of tags per host.
21pub const MAX_TAGS: usize = 32;
22
23/// Max length for VPS name / host / username string fields.
24pub const MAX_FIELD_LEN: usize = 255;
25
26/// Formats `ValidationErrors` into a single agent-readable message (no secrets).
27#[must_use]
28pub fn format_validation_errors(errs: &ValidationErrors) -> String {
29    let mut parts = Vec::new();
30    for (field, field_errs) in errs.field_errors() {
31        for e in field_errs {
32            let code = e.code.as_ref();
33            let msg = e
34                .message
35                .as_ref()
36                .map(|m| m.to_string())
37                .unwrap_or_else(|| code.to_string());
38            parts.push(format!("{field}: {msg}"));
39        }
40    }
41    // Nested / schema errors
42    for (field, nested) in errs.errors() {
43        if let validator::ValidationErrorsKind::Struct(inner) = nested {
44            parts.push(format!(
45                "{field}: {}",
46                format_validation_errors(inner.as_ref())
47            ));
48        } else if let validator::ValidationErrorsKind::List(map) = nested {
49            for (i, inner) in map {
50                parts.push(format!(
51                    "{field}[{i}]: {}",
52                    format_validation_errors(inner.as_ref())
53                ));
54            }
55        }
56    }
57    if parts.is_empty() {
58        "validation failed".into()
59    } else {
60        parts.join("; ")
61    }
62}
63
64/// Maps validation failures to [`SshCliError::InvalidArgument`] and logs locally.
65pub fn validation_to_error(errs: ValidationErrors) -> SshCliError {
66    let msg = format_validation_errors(&errs);
67    tracing::warn!(error_class = "validation", %msg, "input validation failed");
68    SshCliError::InvalidArgument(msg)
69}
70
71/// Convenience: run `Validate` and map errors.
72pub fn validate_or_err<T: validator::Validate>(value: &T) -> SshCliResult<()> {
73    value.validate().map_err(validation_to_error)
74}
75
76/// Custom validator: non-empty after trim.
77pub fn validate_nonempty_trimmed(s: &str) -> Result<(), validator::ValidationError> {
78    if s.trim().is_empty() {
79        let mut e = validator::ValidationError::new("nonempty");
80        e.message = Some(std::borrow::Cow::from("must not be empty"));
81        return Err(e);
82    }
83    Ok(())
84}
85
86/// Custom validator: SSH port must be 1..=65535 (u16 already caps 65535).
87pub fn validate_port_nonzero(port: u16) -> Result<(), validator::ValidationError> {
88    if port == 0 {
89        let mut e = validator::ValidationError::new("port");
90        e.message = Some(std::borrow::Cow::from(
91            "invalid SSH port: 0 (use 1..=65535)",
92        ));
93        return Err(e);
94    }
95    Ok(())
96}
97
98/// Validates host tags (length + cardinality).
99pub fn validate_tags(tags: &[String]) -> Result<(), validator::ValidationError> {
100    if tags.len() > MAX_TAGS {
101        let mut e = validator::ValidationError::new("tags_count");
102        e.message = Some(std::borrow::Cow::from(format!(
103            "at most {MAX_TAGS} tags allowed"
104        )));
105        return Err(e);
106    }
107    for t in tags {
108        let t = t.trim();
109        if t.is_empty() || t.len() > MAX_TAG_LEN {
110            let mut e = validator::ValidationError::new("tag");
111            e.message = Some(std::borrow::Cow::from(format!(
112                "each tag must be 1..={MAX_TAG_LEN} chars"
113            )));
114            return Err(e);
115        }
116        if t.chars().any(|c| c.is_control() || c == '/' || c == '\\') {
117            let mut e = validator::ValidationError::new("tag_charset");
118            e.message = Some(std::borrow::Cow::from(
119                "tag must not contain control chars or path separators",
120            ));
121            return Err(e);
122        }
123    }
124    Ok(())
125}
126
127/// Deserialize TOML with path-aware errors (G-SERDE-08).
128pub fn from_toml_str<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
129    // A3: `toml` 1.x replaced the infallible `Deserializer::new` with `parse`, which
130    // surfaces lexer-level failures before any field is visited. Those have no serde
131    // path to report, so they are mapped without one rather than blamed on a field.
132    let de = toml::Deserializer::parse(s).map_err(|e| {
133        tracing::warn!(error_class = "parse", "TOML parse failed before field walk");
134        SshCliError::Config(format!("TOML: {e}"))
135    })?;
136    serde_path_to_error::deserialize(de).map_err(|e| {
137        tracing::warn!(
138            error_class = "parse",
139            path = %e.path(),
140            "TOML deserialize failed"
141        );
142        SshCliError::Config(format!("TOML at `{}`: {}", e.path(), e.inner()))
143    })
144}
145
146/// Deserialize JSON with path-aware errors (G-SERDE-08).
147pub fn from_json_str<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
148    let mut de = serde_json::Deserializer::from_str(s);
149    serde_path_to_error::deserialize(&mut de).map_err(|e| {
150        tracing::warn!(
151            error_class = "parse",
152            path = %e.path(),
153            "JSON deserialize failed"
154        );
155        SshCliError::InvalidArgument(format!("JSON at `{}`: {}", e.path(), e.inner()))
156    })
157}
158
159/// JSON deserialize that **warns** on unknown fields (Must-Ignore + G-SERDE-14).
160pub fn from_json_str_warn_unused<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
161    let mut unused = Vec::new();
162    let mut de = serde_json::Deserializer::from_str(s);
163    let value: T = serde_ignored::deserialize(&mut de, |path| {
164        unused.push(path.to_string());
165    })
166    .map_err(|e| {
167        // Fall back to path_to_error for better location when structure fails hard.
168        let _ = e;
169        // Re-parse with path_to_error for the real error message.
170        from_json_str::<T>(s).err().unwrap_or_else(|| {
171            SshCliError::Json(serde_json::Error::io(std::io::Error::new(
172                std::io::ErrorKind::InvalidData,
173                "JSON deserialize failed",
174            )))
175        })
176    })?;
177    for path in unused {
178        tracing::warn!(
179            error_class = "validation",
180            %path,
181            "ignored unknown JSON import field (Must-Ignore)"
182        );
183    }
184    Ok(value)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use validator::Validate;
191
192    #[derive(Debug, Validate)]
193    struct Sample {
194        #[validate(custom(function = "validate_nonempty_trimmed"))]
195        name: String,
196        #[validate(custom(function = "validate_port_nonzero"))]
197        port: u16,
198    }
199
200    #[test]
201    fn nonempty_and_port() {
202        assert!(Sample {
203            name: "x".into(),
204            port: 22
205        }
206        .validate()
207        .is_ok());
208        assert!(Sample {
209            name: "  ".into(),
210            port: 22
211        }
212        .validate()
213        .is_err());
214        assert!(Sample {
215            name: "x".into(),
216            port: 0
217        }
218        .validate()
219        .is_err());
220    }
221
222    #[test]
223    fn tags_limits() {
224        assert!(validate_tags(&["prod".into()]).is_ok());
225        assert!(validate_tags(&["".into()]).is_err());
226        assert!(validate_tags(&["a/b".into()]).is_err());
227        let many: Vec<_> = (0..MAX_TAGS + 1).map(|i| format!("t{i}")).collect();
228        assert!(validate_tags(&many).is_err());
229    }
230}