reinhardt_utils/utils_core/
input_validation.rs1#[derive(Debug, thiserror::Error)]
9pub enum IdentifierError {
10 #[error("Identifier is empty")]
12 Empty,
13 #[error("Identifier exceeds maximum length of {max_length} characters")]
15 TooLong {
16 max_length: usize,
18 },
19 #[error("Identifier contains invalid character: '{ch}'")]
21 InvalidCharacter {
22 ch: char,
24 },
25 #[error("Identifier must start with alphanumeric or underscore, got: '{ch}'")]
27 InvalidStartCharacter {
28 ch: char,
30 },
31}
32
33pub fn validate_redirect_url(url: &str) -> bool {
58 let trimmed = url.trim();
59
60 if trimmed.is_empty() {
61 return false;
62 }
63
64 if trimmed.starts_with("../") || trimmed.contains("/../") || trimmed.ends_with("/..") {
66 return false;
67 }
68
69 if trimmed.starts_with('#') {
71 return true;
72 }
73
74 if trimmed.starts_with("./") {
76 return true;
77 }
78
79 if trimmed.starts_with('/') {
82 return !trimmed.starts_with("//");
83 }
84
85 let lower = trimmed.to_lowercase();
86
87 let dangerous_protocols = ["javascript:", "data:", "vbscript:"];
89 for proto in &dangerous_protocols {
90 if lower.starts_with(proto) {
91 return false;
92 }
93 }
94
95 if lower.starts_with("http://") || lower.starts_with("https://") {
97 let after_scheme = if lower.starts_with("https://") {
99 &trimmed[8..]
100 } else {
101 &trimmed[7..]
102 };
103
104 if let Some(path_start) = after_scheme.find('/') {
106 let authority = &after_scheme[..path_start];
107 if authority.contains('@') {
108 return false;
109 }
110 } else if after_scheme.contains('@') {
111 return false;
112 }
113
114 return true;
115 }
116
117 false
119}
120
121pub fn sanitize_log_input(input: &str, max_length: usize) -> String {
137 let mut result = String::with_capacity(input.len().min(max_length));
138
139 for (char_count, ch) in input.chars().enumerate() {
140 if char_count >= max_length {
141 break;
142 }
143
144 match ch {
145 '\n' | '\r' => result.push(' '),
147 '\t' => result.push(' '),
149 c if c.is_control() => result.push('\u{FFFD}'),
151 c => result.push(c),
153 }
154 }
155
156 result
157}
158
159pub fn validate_identifier(input: &str, max_length: usize) -> Result<(), IdentifierError> {
181 if input.is_empty() {
182 return Err(IdentifierError::Empty);
183 }
184
185 if input.len() > max_length {
186 return Err(IdentifierError::TooLong { max_length });
187 }
188
189 let first = input.chars().next().expect("non-empty string");
191 if !first.is_ascii_alphanumeric() && first != '_' {
192 return Err(IdentifierError::InvalidStartCharacter { ch: first });
193 }
194
195 for ch in input.chars() {
197 if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
198 return Err(IdentifierError::InvalidCharacter { ch });
199 }
200 }
201
202 Ok(())
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use rstest::rstest;
209
210 #[rstest]
215 #[case("/dashboard", true)]
216 #[case("/path/to/page", true)]
217 #[case("./relative", true)]
218 #[case("#section", true)]
219 #[case("#", true)]
220 #[case("https://example.com", true)]
221 #[case("http://example.com/page", true)]
222 #[case("https://example.com/path?q=1", true)]
223 fn test_validate_redirect_url_allows_safe_urls(#[case] url: &str, #[case] expected: bool) {
224 let result = validate_redirect_url(url);
226
227 assert_eq!(result, expected, "URL {:?} should be allowed", url);
229 }
230
231 #[rstest]
232 #[case("javascript:alert(1)", false)]
233 #[case("JAVASCRIPT:alert(1)", false)]
234 #[case("data:text/html,<script>", false)]
235 #[case("vbscript:msgbox", false)]
236 #[case("../secret", false)]
237 #[case("/path/../secret", false)]
238 #[case("/path/..", false)]
239 #[case("//evil.com", false)]
240 #[case("", false)]
241 #[case(" ", false)]
242 #[case("ftp://files.example.com", false)]
243 #[case("http://user:pass@host.com", false)]
244 #[case("https://admin:secret@host.com/path", false)]
245 fn test_validate_redirect_url_rejects_unsafe_urls(#[case] url: &str, #[case] expected: bool) {
246 let result = validate_redirect_url(url);
248
249 assert_eq!(result, expected, "URL {:?} should be rejected", url);
251 }
252
253 #[rstest]
254 fn test_validate_redirect_url_trims_whitespace() {
255 let url = " /dashboard ";
257
258 let result = validate_redirect_url(url);
260
261 assert!(result);
263 }
264
265 #[rstest]
270 fn test_sanitize_log_input_replaces_newlines() {
271 let input = "line1\nline2\rline3\r\nline4";
273
274 let result = sanitize_log_input(input, 100);
276
277 assert_eq!(result, "line1 line2 line3 line4");
279 }
280
281 #[rstest]
282 fn test_sanitize_log_input_replaces_tabs() {
283 let input = "col1\tcol2\tcol3";
285
286 let result = sanitize_log_input(input, 100);
288
289 assert_eq!(result, "col1 col2 col3");
291 }
292
293 #[rstest]
294 fn test_sanitize_log_input_replaces_control_characters() {
295 let input = "before\x00\x01\x07after";
297
298 let result = sanitize_log_input(input, 100);
300
301 assert_eq!(result, "before\u{FFFD}\u{FFFD}\u{FFFD}after");
303 }
304
305 #[rstest]
306 fn test_sanitize_log_input_truncates_to_max_length() {
307 let input = "a".repeat(200);
309
310 let result = sanitize_log_input(&input, 50);
312
313 assert_eq!(result.len(), 50);
315 }
316
317 #[rstest]
318 fn test_sanitize_log_input_preserves_normal_text() {
319 let input = "Hello, World! 123 @#$";
321
322 let result = sanitize_log_input(input, 100);
324
325 assert_eq!(result, input);
327 }
328
329 #[rstest]
330 fn test_sanitize_log_input_empty_input() {
331 let result = sanitize_log_input("", 100);
333
334 assert_eq!(result, "");
336 }
337
338 #[rstest]
339 fn test_sanitize_log_input_zero_max_length() {
340 let result = sanitize_log_input("some text", 0);
342
343 assert_eq!(result, "");
345 }
346
347 #[rstest]
352 #[case("my-plugin", 64)]
353 #[case("MyPlugin", 64)]
354 #[case("plugin_v2", 64)]
355 #[case("_internal", 64)]
356 #[case("a", 64)]
357 #[case("A123-test_name", 64)]
358 fn test_validate_identifier_accepts_valid(#[case] input: &str, #[case] max_len: usize) {
359 let result = validate_identifier(input, max_len);
361
362 assert!(result.is_ok(), "Identifier {:?} should be valid", input);
364 }
365
366 #[rstest]
367 fn test_validate_identifier_rejects_empty() {
368 let result = validate_identifier("", 64);
370
371 assert!(matches!(result, Err(IdentifierError::Empty)));
373 }
374
375 #[rstest]
376 fn test_validate_identifier_rejects_too_long() {
377 let input = "a".repeat(65);
379
380 let result = validate_identifier(&input, 64);
382
383 assert!(matches!(
385 result,
386 Err(IdentifierError::TooLong { max_length: 64 })
387 ));
388 }
389
390 #[rstest]
391 #[case("-starts-with-hyphen")]
392 fn test_validate_identifier_rejects_invalid_start(#[case] input: &str) {
393 let result = validate_identifier(input, 64);
395
396 assert!(matches!(
398 result,
399 Err(IdentifierError::InvalidStartCharacter { .. })
400 ));
401 }
402
403 #[rstest]
404 #[case("has space", ' ')]
405 #[case("has.dot", '.')]
406 #[case("has/slash", '/')]
407 #[case("has@at", '@')]
408 fn test_validate_identifier_rejects_invalid_characters(
409 #[case] input: &str,
410 #[case] expected_ch: char,
411 ) {
412 let result = validate_identifier(input, 64);
414
415 match result {
417 Err(IdentifierError::InvalidCharacter { ch }) => {
418 assert_eq!(ch, expected_ch);
419 }
420 other => panic!("Expected InvalidCharacter, got {:?}", other),
421 }
422 }
423
424 #[rstest]
429 fn test_sanitize_log_input_multibyte_truncation_does_not_panic() {
430 let input = "あいうえおかきくけこ"; let result = sanitize_log_input(input, 5);
436
437 assert_eq!(result.chars().count(), 5);
439 assert_eq!(result, "あいうえお");
440 }
441
442 #[rstest]
443 fn test_sanitize_log_input_mixed_multibyte_truncation() {
444 let input = "aあbいcうdえeお";
446
447 let result = sanitize_log_input(input, 6);
449
450 assert_eq!(result.chars().count(), 6);
452 assert_eq!(result, "aあbいcう");
453 }
454
455 #[rstest]
456 fn test_identifier_error_display_messages() {
457 assert_eq!(IdentifierError::Empty.to_string(), "Identifier is empty");
459 assert_eq!(
460 IdentifierError::TooLong { max_length: 32 }.to_string(),
461 "Identifier exceeds maximum length of 32 characters"
462 );
463 assert_eq!(
464 IdentifierError::InvalidCharacter { ch: '@' }.to_string(),
465 "Identifier contains invalid character: '@'"
466 );
467 assert_eq!(
468 IdentifierError::InvalidStartCharacter { ch: '-' }.to_string(),
469 "Identifier must start with alphanumeric or underscore, got: '-'"
470 );
471 }
472}