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("invalid SSH port: 0 (use 1..=65535)"));
91        return Err(e);
92    }
93    Ok(())
94}
95
96/// Validates host tags (length + cardinality).
97pub fn validate_tags(tags: &[String]) -> Result<(), validator::ValidationError> {
98    if tags.len() > MAX_TAGS {
99        let mut e = validator::ValidationError::new("tags_count");
100        e.message = Some(std::borrow::Cow::from(format!(
101            "at most {MAX_TAGS} tags allowed"
102        )));
103        return Err(e);
104    }
105    for t in tags {
106        let t = t.trim();
107        if t.is_empty() || t.len() > MAX_TAG_LEN {
108            let mut e = validator::ValidationError::new("tag");
109            e.message = Some(std::borrow::Cow::from(format!(
110                "each tag must be 1..={MAX_TAG_LEN} chars"
111            )));
112            return Err(e);
113        }
114        if t.chars().any(|c| c.is_control() || c == '/' || c == '\\') {
115            let mut e = validator::ValidationError::new("tag_charset");
116            e.message = Some(std::borrow::Cow::from(
117                "tag must not contain control chars or path separators",
118            ));
119            return Err(e);
120        }
121    }
122    Ok(())
123}
124
125/// Deserialize TOML with path-aware errors (G-SERDE-08).
126pub fn from_toml_str<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
127    let de = toml::Deserializer::new(s);
128    serde_path_to_error::deserialize(de).map_err(|e| {
129        tracing::warn!(
130            error_class = "parse",
131            path = %e.path(),
132            "TOML deserialize failed"
133        );
134        SshCliError::Config(format!("TOML at `{}`: {}", e.path(), e.inner()))
135    })
136}
137
138/// Deserialize JSON with path-aware errors (G-SERDE-08).
139pub fn from_json_str<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
140    let mut de = serde_json::Deserializer::from_str(s);
141    serde_path_to_error::deserialize(&mut de).map_err(|e| {
142        tracing::warn!(
143            error_class = "parse",
144            path = %e.path(),
145            "JSON deserialize failed"
146        );
147        SshCliError::InvalidArgument(format!(
148            "JSON at `{}`: {}",
149            e.path(),
150            e.inner()
151        ))
152    })
153}
154
155/// JSON deserialize that **warns** on unknown fields (Must-Ignore + G-SERDE-14).
156pub fn from_json_str_warn_unused<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
157    let mut unused = Vec::new();
158    let mut de = serde_json::Deserializer::from_str(s);
159    let value: T = serde_ignored::deserialize(&mut de, |path| {
160        unused.push(path.to_string());
161    })
162    .map_err(|e| {
163        // Fall back to path_to_error for better location when structure fails hard.
164        let _ = e;
165        // Re-parse with path_to_error for the real error message.
166        from_json_str::<T>(s).err().unwrap_or_else(|| {
167            SshCliError::Json(serde_json::Error::io(std::io::Error::new(
168                std::io::ErrorKind::InvalidData,
169                "JSON deserialize failed",
170            )))
171        })
172    })?;
173    for path in unused {
174        tracing::warn!(
175            error_class = "validation",
176            %path,
177            "ignored unknown JSON import field (Must-Ignore)"
178        );
179    }
180    Ok(value)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use validator::Validate;
187
188    #[derive(Debug, Validate)]
189    struct Sample {
190        #[validate(custom(function = "validate_nonempty_trimmed"))]
191        name: String,
192        #[validate(custom(function = "validate_port_nonzero"))]
193        port: u16,
194    }
195
196    #[test]
197    fn nonempty_and_port() {
198        assert!(Sample {
199            name: "x".into(),
200            port: 22
201        }
202        .validate()
203        .is_ok());
204        assert!(Sample {
205            name: "  ".into(),
206            port: 22
207        }
208        .validate()
209        .is_err());
210        assert!(Sample {
211            name: "x".into(),
212            port: 0
213        }
214        .validate()
215        .is_err());
216    }
217
218    #[test]
219    fn tags_limits() {
220        assert!(validate_tags(&["prod".into()]).is_ok());
221        assert!(validate_tags(&["".into()]).is_err());
222        assert!(validate_tags(&["a/b".into()]).is_err());
223        let many: Vec<_> = (0..MAX_TAGS + 1).map(|i| format!("t{i}")).collect();
224        assert!(validate_tags(&many).is_err());
225    }
226}