1#![forbid(unsafe_code)]
7
8use crate::errors::{SshCliError, SshCliResult};
9use validator::ValidationErrors;
10
11pub const MAX_TIMEOUT_MS: u64 = 3_600_000;
13
14pub const MAX_CHAR_LIMIT: usize = 10_000_000;
16
17pub const MAX_TAG_LEN: usize = 64;
19
20pub const MAX_TAGS: usize = 32;
22
23pub const MAX_FIELD_LEN: usize = 255;
25
26#[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 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
64pub 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
71pub fn validate_or_err<T: validator::Validate>(value: &T) -> SshCliResult<()> {
73 value.validate().map_err(validation_to_error)
74}
75
76pub 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
86pub 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
98pub 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
127pub fn from_toml_str<'de, T: serde::Deserialize<'de>>(s: &'de str) -> SshCliResult<T> {
129 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
146pub 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
159pub 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 let _ = e;
169 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}