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    let de = toml::Deserializer::new(s);
130    serde_path_to_error::deserialize(de).map_err(|e| {
131        tracing::warn!(
132            error_class = "parse",
133            path = %e.path(),
134            "TOML deserialize failed"
135        );
136        SshCliError::Config(format!("TOML at `{}`: {}", e.path(), e.inner()))
137    })
138}
139
140/// Deserialize JSON with path-aware errors (G-SERDE-08).
141pub fn from_json_str<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
142    let mut de = serde_json::Deserializer::from_str(s);
143    serde_path_to_error::deserialize(&mut de).map_err(|e| {
144        tracing::warn!(
145            error_class = "parse",
146            path = %e.path(),
147            "JSON deserialize failed"
148        );
149        SshCliError::InvalidArgument(format!("JSON at `{}`: {}", e.path(), e.inner()))
150    })
151}
152
153/// JSON deserialize that **warns** on unknown fields (Must-Ignore + G-SERDE-14).
154pub fn from_json_str_warn_unused<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
155    let mut unused = Vec::new();
156    let mut de = serde_json::Deserializer::from_str(s);
157    let value: T = serde_ignored::deserialize(&mut de, |path| {
158        unused.push(path.to_string());
159    })
160    .map_err(|e| {
161        // Fall back to path_to_error for better location when structure fails hard.
162        let _ = e;
163        // Re-parse with path_to_error for the real error message.
164        from_json_str::<T>(s).err().unwrap_or_else(|| {
165            SshCliError::Json(serde_json::Error::io(std::io::Error::new(
166                std::io::ErrorKind::InvalidData,
167                "JSON deserialize failed",
168            )))
169        })
170    })?;
171    for path in unused {
172        tracing::warn!(
173            error_class = "validation",
174            %path,
175            "ignored unknown JSON import field (Must-Ignore)"
176        );
177    }
178    Ok(value)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use validator::Validate;
185
186    #[derive(Debug, Validate)]
187    struct Sample {
188        #[validate(custom(function = "validate_nonempty_trimmed"))]
189        name: String,
190        #[validate(custom(function = "validate_port_nonzero"))]
191        port: u16,
192    }
193
194    #[test]
195    fn nonempty_and_port() {
196        assert!(Sample {
197            name: "x".into(),
198            port: 22
199        }
200        .validate()
201        .is_ok());
202        assert!(Sample {
203            name: "  ".into(),
204            port: 22
205        }
206        .validate()
207        .is_err());
208        assert!(Sample {
209            name: "x".into(),
210            port: 0
211        }
212        .validate()
213        .is_err());
214    }
215
216    #[test]
217    fn tags_limits() {
218        assert!(validate_tags(&["prod".into()]).is_ok());
219        assert!(validate_tags(&["".into()]).is_err());
220        assert!(validate_tags(&["a/b".into()]).is_err());
221        let many: Vec<_> = (0..MAX_TAGS + 1).map(|i| format!("t{i}")).collect();
222        assert!(validate_tags(&many).is_err());
223    }
224}