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("invalid SSH port: 0 (use 1..=65535)"));
91 return Err(e);
92 }
93 Ok(())
94}
95
96pub 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
125pub 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
138pub 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
155pub 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 let _ = e;
165 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}