Skip to main content

perfgate_validation/
lib.rs

1//! Validation functions for benchmark names and configuration.
2//!
3//! This crate provides validation logic for validating benchmark names
4//! according to a strict set of rules.
5//!
6//! Part of the [perfgate](https://github.com/EffortlessMetrics/perfgate) workspace.
7//!
8//! # Example
9//!
10//! ```
11//! use perfgate_validation::{validate_bench_name, ValidationError};
12//!
13//! // Valid names
14//! assert!(validate_bench_name("my-bench").is_ok());
15//! assert!(validate_bench_name("bench_v2").is_ok());
16//! assert!(validate_bench_name("path/to/bench").is_ok());
17//! assert!(validate_bench_name("bench.v1").is_ok());
18//!
19//! // Invalid names
20//! assert!(validate_bench_name("").is_err());
21//! assert!(validate_bench_name("MyBench").is_err());  // uppercase
22//! assert!(validate_bench_name("../bench").is_err()); // path traversal
23//! assert!(validate_bench_name("bench/").is_err());   // trailing slash
24//! ```
25
26/// Maximum allowed length (in bytes) for a benchmark name.
27///
28/// # Examples
29///
30/// ```
31/// use perfgate_validation::{BENCH_NAME_MAX_LEN, validate_bench_name};
32///
33/// // Exactly at the limit – accepted
34/// let name = "a".repeat(BENCH_NAME_MAX_LEN);
35/// assert!(validate_bench_name(&name).is_ok());
36///
37/// // One byte over – rejected
38/// let too_long = "a".repeat(BENCH_NAME_MAX_LEN + 1);
39/// assert!(validate_bench_name(&too_long).is_err());
40/// ```
41pub use perfgate_error::BENCH_NAME_MAX_LEN;
42
43/// Regex pattern describing the set of valid benchmark-name characters.
44///
45/// The pattern allows lowercase ASCII letters, digits, underscores,
46/// dots, hyphens, and forward slashes.
47///
48/// # Examples
49///
50/// ```
51/// use perfgate_validation::BENCH_NAME_PATTERN;
52///
53/// assert_eq!(BENCH_NAME_PATTERN, r"^[a-z0-9_.\-/]+$");
54/// assert!(BENCH_NAME_PATTERN.starts_with('^'));
55/// assert!(BENCH_NAME_PATTERN.ends_with('$'));
56/// ```
57pub use perfgate_error::BENCH_NAME_PATTERN;
58
59/// Error type returned when a benchmark name fails validation.
60///
61/// # Examples
62///
63/// ```
64/// use perfgate_validation::{validate_bench_name, ValidationError};
65///
66/// // Empty name yields `ValidationError::Empty`
67/// let err = validate_bench_name("").unwrap_err();
68/// assert!(matches!(err, ValidationError::Empty));
69/// assert_eq!(err.name(), "");
70///
71/// // Uppercase letters yield `ValidationError::InvalidCharacters`
72/// let err = validate_bench_name("MyBench").unwrap_err();
73/// assert!(matches!(err, ValidationError::InvalidCharacters { .. }));
74/// assert_eq!(err.name(), "MyBench");
75///
76/// // Path traversal yields `ValidationError::PathTraversal`
77/// let err = validate_bench_name("../escape").unwrap_err();
78/// assert!(matches!(err, ValidationError::PathTraversal { .. }));
79///
80/// // Trailing slash yields `ValidationError::EmptySegment`
81/// let err = validate_bench_name("bench/").unwrap_err();
82/// assert!(matches!(err, ValidationError::EmptySegment { .. }));
83/// ```
84pub use perfgate_error::ValidationError;
85
86/// Validate a benchmark name against the naming rules.
87///
88/// Returns `Ok(())` when the name is valid, or a [`ValidationError`]
89/// describing why the name was rejected.
90///
91/// # Rules
92///
93/// 1. Must not be empty.
94/// 2. Must not exceed [`BENCH_NAME_MAX_LEN`] bytes.
95/// 3. Only lowercase ASCII, digits, `_`, `.`, `-`, and `/` are allowed.
96/// 4. No empty path segments (leading, trailing, or consecutive `/`).
97/// 5. No `.` or `..` path segments (path traversal).
98///
99/// # Examples
100///
101/// ```
102/// use perfgate_validation::{validate_bench_name, ValidationError};
103///
104/// // ── Valid names ──────────────────────────────────────
105/// assert!(validate_bench_name("my-bench").is_ok());
106/// assert!(validate_bench_name("bench_v2").is_ok());
107/// assert!(validate_bench_name("path/to/bench").is_ok());
108/// assert!(validate_bench_name("bench.v1").is_ok());
109/// assert!(validate_bench_name("123").is_ok());
110///
111/// // ── Invalid names ───────────────────────────────────
112/// // Empty
113/// assert!(matches!(
114///     validate_bench_name(""),
115///     Err(ValidationError::Empty),
116/// ));
117///
118/// // Uppercase
119/// assert!(matches!(
120///     validate_bench_name("MyBench"),
121///     Err(ValidationError::InvalidCharacters { .. }),
122/// ));
123///
124/// // Path traversal
125/// assert!(matches!(
126///     validate_bench_name("../bench"),
127///     Err(ValidationError::PathTraversal { .. }),
128/// ));
129///
130/// // Trailing slash (empty segment)
131/// assert!(matches!(
132///     validate_bench_name("bench/"),
133///     Err(ValidationError::EmptySegment { .. }),
134/// ));
135/// ```
136pub use perfgate_error::validate_bench_name;
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn valid_names_basic() {
144        assert!(validate_bench_name("my-bench").is_ok());
145        assert!(validate_bench_name("bench_a").is_ok());
146        assert!(validate_bench_name("path/to/bench").is_ok());
147        assert!(validate_bench_name("bench.v2").is_ok());
148        assert!(validate_bench_name("a").is_ok());
149        assert!(validate_bench_name("123").is_ok());
150    }
151
152    #[test]
153    fn valid_names_with_dots() {
154        assert!(validate_bench_name("bench.v1").is_ok());
155        assert!(validate_bench_name("v1.2.3").is_ok());
156        assert!(validate_bench_name("bench.test.final").is_ok());
157    }
158
159    #[test]
160    fn valid_names_with_hyphens() {
161        assert!(validate_bench_name("my-bench-name").is_ok());
162        assert!(validate_bench_name("bench-v1-final").is_ok());
163    }
164
165    #[test]
166    fn valid_names_with_underscores() {
167        assert!(validate_bench_name("bench_name").is_ok());
168        assert!(validate_bench_name("my_bench_v2").is_ok());
169    }
170
171    #[test]
172    fn valid_names_with_slashes() {
173        assert!(validate_bench_name("path/to/bench").is_ok());
174        assert!(validate_bench_name("a/b/c").is_ok());
175        assert!(validate_bench_name("category/subcategory/bench").is_ok());
176    }
177
178    #[test]
179    fn valid_names_mixed_chars() {
180        assert!(validate_bench_name("my_bench-v1.2").is_ok());
181        assert!(validate_bench_name("path/to-bench_v2").is_ok());
182        assert!(validate_bench_name("a1-b2_c3.d4/e5").is_ok());
183    }
184
185    #[test]
186    fn valid_names_single_char() {
187        assert!(validate_bench_name("a").is_ok());
188        assert!(validate_bench_name("z").is_ok());
189        assert!(validate_bench_name("0").is_ok());
190        assert!(validate_bench_name("9").is_ok());
191    }
192
193    #[test]
194    fn valid_names_all_digits() {
195        assert!(validate_bench_name("12345").is_ok());
196        assert!(validate_bench_name("0").is_ok());
197    }
198
199    #[test]
200    fn invalid_empty() {
201        assert!(matches!(
202            validate_bench_name(""),
203            Err(ValidationError::Empty)
204        ));
205    }
206
207    #[test]
208    fn invalid_uppercase() {
209        assert!(matches!(
210            validate_bench_name("MyBench"),
211            Err(ValidationError::InvalidCharacters { .. })
212        ));
213        assert!(matches!(
214            validate_bench_name("BENCH"),
215            Err(ValidationError::InvalidCharacters { .. })
216        ));
217        assert!(matches!(
218            validate_bench_name("benchA"),
219            Err(ValidationError::InvalidCharacters { .. })
220        ));
221        assert!(matches!(
222            validate_bench_name("Bench"),
223            Err(ValidationError::InvalidCharacters { .. })
224        ));
225    }
226
227    #[test]
228    fn invalid_special_characters() {
229        assert!(matches!(
230            validate_bench_name("bench|name"),
231            Err(ValidationError::InvalidCharacters { .. })
232        ));
233        assert!(matches!(
234            validate_bench_name("bench name"),
235            Err(ValidationError::InvalidCharacters { .. })
236        ));
237        assert!(matches!(
238            validate_bench_name("bench@name"),
239            Err(ValidationError::InvalidCharacters { .. })
240        ));
241        assert!(matches!(
242            validate_bench_name("bench#name"),
243            Err(ValidationError::InvalidCharacters { .. })
244        ));
245        assert!(matches!(
246            validate_bench_name("bench$name"),
247            Err(ValidationError::InvalidCharacters { .. })
248        ));
249        assert!(matches!(
250            validate_bench_name("bench%name"),
251            Err(ValidationError::InvalidCharacters { .. })
252        ));
253        assert!(matches!(
254            validate_bench_name("bench!name"),
255            Err(ValidationError::InvalidCharacters { .. })
256        ));
257    }
258
259    #[test]
260    fn invalid_path_traversal() {
261        assert!(matches!(
262            validate_bench_name("../bench"),
263            Err(ValidationError::PathTraversal { .. })
264        ));
265        assert!(matches!(
266            validate_bench_name("bench/../x"),
267            Err(ValidationError::PathTraversal { .. })
268        ));
269        assert!(matches!(
270            validate_bench_name("./bench"),
271            Err(ValidationError::PathTraversal { .. })
272        ));
273        assert!(matches!(
274            validate_bench_name("bench/."),
275            Err(ValidationError::PathTraversal { .. })
276        ));
277        assert!(matches!(
278            validate_bench_name(".."),
279            Err(ValidationError::PathTraversal { .. })
280        ));
281        assert!(matches!(
282            validate_bench_name("."),
283            Err(ValidationError::PathTraversal { .. })
284        ));
285    }
286
287    #[test]
288    fn invalid_empty_segments() {
289        assert!(matches!(
290            validate_bench_name("/bench"),
291            Err(ValidationError::EmptySegment { .. })
292        ));
293        assert!(matches!(
294            validate_bench_name("bench/"),
295            Err(ValidationError::EmptySegment { .. })
296        ));
297        assert!(matches!(
298            validate_bench_name("bench//x"),
299            Err(ValidationError::EmptySegment { .. })
300        ));
301        assert!(matches!(
302            validate_bench_name("/"),
303            Err(ValidationError::EmptySegment { .. })
304        ));
305        assert!(matches!(
306            validate_bench_name("a//b"),
307            Err(ValidationError::EmptySegment { .. })
308        ));
309        assert!(matches!(
310            validate_bench_name("//"),
311            Err(ValidationError::EmptySegment { .. })
312        ));
313    }
314
315    #[test]
316    fn invalid_too_long() {
317        let name_64 = "a".repeat(BENCH_NAME_MAX_LEN);
318        assert!(validate_bench_name(&name_64).is_ok());
319
320        let name_65 = "a".repeat(BENCH_NAME_MAX_LEN + 1);
321        let result = validate_bench_name(&name_65);
322        assert!(matches!(result, Err(ValidationError::TooLong { .. })));
323        if let Err(ValidationError::TooLong { max_len, .. }) = result {
324            assert_eq!(max_len, BENCH_NAME_MAX_LEN);
325        }
326    }
327
328    #[test]
329    fn error_name_accessor() {
330        let err = validate_bench_name("INVALID").unwrap_err();
331        assert_eq!(err.name(), "INVALID");
332
333        let err = validate_bench_name("").unwrap_err();
334        assert_eq!(err.name(), "");
335
336        let err = validate_bench_name(&"x".repeat(100)).unwrap_err();
337        assert!(err.name().starts_with('x'));
338    }
339
340    #[test]
341    fn error_display() {
342        let err = ValidationError::Empty;
343        assert!(err.to_string().contains("must not be empty"));
344
345        let err = ValidationError::TooLong {
346            name: "test".to_string(),
347            max_len: 64,
348        };
349        assert!(err.to_string().contains("exceeds maximum length"));
350
351        let err = ValidationError::InvalidCharacters {
352            name: "TEST".to_string(),
353        };
354        assert!(err.to_string().contains("invalid characters"));
355
356        let err = ValidationError::EmptySegment {
357            name: "/test".to_string(),
358        };
359        assert!(err.to_string().contains("empty path segment"));
360
361        let err = ValidationError::PathTraversal {
362            name: "../test".to_string(),
363            segment: "..".to_string(),
364        };
365        assert!(err.to_string().contains("path traversal"));
366    }
367
368    // ── Boundary value tests ──────────────────────────────────────────
369
370    #[test]
371    fn boundary_exact_max_len() {
372        let name = "a".repeat(BENCH_NAME_MAX_LEN);
373        assert!(validate_bench_name(&name).is_ok());
374    }
375
376    #[test]
377    fn boundary_one_over_max_len() {
378        let name = "a".repeat(BENCH_NAME_MAX_LEN + 1);
379        assert!(matches!(
380            validate_bench_name(&name),
381            Err(ValidationError::TooLong { max_len, .. }) if max_len == BENCH_NAME_MAX_LEN
382        ));
383    }
384
385    #[test]
386    fn boundary_one_under_max_len() {
387        let name = "a".repeat(BENCH_NAME_MAX_LEN - 1);
388        assert!(validate_bench_name(&name).is_ok());
389    }
390
391    #[test]
392    fn boundary_single_char_all_valid() {
393        for c in b'a'..=b'z' {
394            assert!(validate_bench_name(&String::from(c as char)).is_ok());
395        }
396        for c in b'0'..=b'9' {
397            assert!(validate_bench_name(&String::from(c as char)).is_ok());
398        }
399        assert!(validate_bench_name("_").is_ok());
400        assert!(validate_bench_name("-").is_ok());
401    }
402
403    #[test]
404    fn boundary_single_dot_is_path_traversal() {
405        assert!(matches!(
406            validate_bench_name("."),
407            Err(ValidationError::PathTraversal { .. })
408        ));
409    }
410
411    #[test]
412    fn boundary_double_dot_is_path_traversal() {
413        assert!(matches!(
414            validate_bench_name(".."),
415            Err(ValidationError::PathTraversal { .. })
416        ));
417    }
418
419    #[test]
420    fn boundary_single_slash_is_empty_segment() {
421        assert!(matches!(
422            validate_bench_name("/"),
423            Err(ValidationError::EmptySegment { .. })
424        ));
425    }
426
427    #[test]
428    fn boundary_max_len_with_slashes() {
429        // Build a name of exactly BENCH_NAME_MAX_LEN using segments
430        let segment = "ab";
431        let sep = "/";
432        let seg_with_sep = segment.len() + sep.len(); // 3
433        let count = BENCH_NAME_MAX_LEN / seg_with_sep; // 21 segments with slashes
434        let remainder = BENCH_NAME_MAX_LEN - (count * seg_with_sep);
435        let mut name: String = (0..count).map(|_| format!("{segment}/")).collect();
436        name.push_str(&"a".repeat(remainder));
437        assert_eq!(name.len(), BENCH_NAME_MAX_LEN);
438        assert!(validate_bench_name(&name).is_ok());
439    }
440
441    // ── Unicode handling tests ────────────────────────────────────────
442
443    #[test]
444    fn unicode_emoji_rejected() {
445        assert!(matches!(
446            validate_bench_name("bench-🚀"),
447            Err(ValidationError::InvalidCharacters { .. })
448        ));
449        assert!(matches!(
450            validate_bench_name("🔥"),
451            Err(ValidationError::InvalidCharacters { .. })
452        ));
453        assert!(matches!(
454            validate_bench_name("a😀b"),
455            Err(ValidationError::InvalidCharacters { .. })
456        ));
457    }
458
459    #[test]
460    fn unicode_cjk_rejected() {
461        assert!(matches!(
462            validate_bench_name("ベンチ"),
463            Err(ValidationError::InvalidCharacters { .. })
464        ));
465        assert!(matches!(
466            validate_bench_name("bench-测试"),
467            Err(ValidationError::InvalidCharacters { .. })
468        ));
469        assert!(matches!(
470            validate_bench_name("벤치마크"),
471            Err(ValidationError::InvalidCharacters { .. })
472        ));
473    }
474
475    #[test]
476    fn unicode_rtl_rejected() {
477        assert!(matches!(
478            validate_bench_name("مقعد"),
479            Err(ValidationError::InvalidCharacters { .. })
480        ));
481        assert!(matches!(
482            validate_bench_name("bench-בדיקה"),
483            Err(ValidationError::InvalidCharacters { .. })
484        ));
485    }
486
487    #[test]
488    fn unicode_accented_rejected() {
489        assert!(matches!(
490            validate_bench_name("café"),
491            Err(ValidationError::InvalidCharacters { .. })
492        ));
493        assert!(matches!(
494            validate_bench_name("naïve"),
495            Err(ValidationError::InvalidCharacters { .. })
496        ));
497        assert!(matches!(
498            validate_bench_name("über"),
499            Err(ValidationError::InvalidCharacters { .. })
500        ));
501    }
502
503    #[test]
504    fn unicode_zero_width_and_bom_rejected() {
505        // Zero-width space U+200B
506        assert!(matches!(
507            validate_bench_name("bench\u{200B}name"),
508            Err(ValidationError::InvalidCharacters { .. })
509        ));
510        // BOM U+FEFF
511        assert!(matches!(
512            validate_bench_name("\u{FEFF}bench"),
513            Err(ValidationError::InvalidCharacters { .. })
514        ));
515    }
516
517    #[test]
518    fn unicode_multibyte_length_check() {
519        // 16 x 4-byte emoji = 64 bytes, but invalid chars rejected first
520        let name: String = "🔥".repeat(16);
521        assert_eq!(name.len(), 64);
522        assert!(matches!(
523            validate_bench_name(&name),
524            Err(ValidationError::InvalidCharacters { .. })
525        ));
526    }
527
528    // ── Empty input tests ─────────────────────────────────────────────
529
530    #[test]
531    fn empty_string_returns_empty_error() {
532        assert!(matches!(
533            validate_bench_name(""),
534            Err(ValidationError::Empty)
535        ));
536    }
537
538    #[test]
539    fn whitespace_only_rejected() {
540        assert!(matches!(
541            validate_bench_name(" "),
542            Err(ValidationError::InvalidCharacters { .. })
543        ));
544        assert!(matches!(
545            validate_bench_name("   "),
546            Err(ValidationError::InvalidCharacters { .. })
547        ));
548        assert!(matches!(
549            validate_bench_name("\t"),
550            Err(ValidationError::InvalidCharacters { .. })
551        ));
552        assert!(matches!(
553            validate_bench_name("\n"),
554            Err(ValidationError::InvalidCharacters { .. })
555        ));
556        assert!(matches!(
557            validate_bench_name("\r\n"),
558            Err(ValidationError::InvalidCharacters { .. })
559        ));
560    }
561
562    #[test]
563    fn null_byte_rejected() {
564        assert!(matches!(
565            validate_bench_name("\0"),
566            Err(ValidationError::InvalidCharacters { .. })
567        ));
568        assert!(matches!(
569            validate_bench_name("bench\0name"),
570            Err(ValidationError::InvalidCharacters { .. })
571        ));
572    }
573
574    // ── Negative / adversarial input tests ────────────────────────────
575
576    #[test]
577    fn hyphen_prefixed_names_are_valid() {
578        // Leading hyphen is allowed since '-' is a valid character
579        assert!(validate_bench_name("-1").is_ok());
580        assert!(validate_bench_name("-bench").is_ok());
581        assert!(validate_bench_name("--double").is_ok());
582    }
583
584    #[test]
585    fn control_characters_rejected() {
586        for c in 0x00u8..=0x1F {
587            let name = format!("bench{}name", c as char);
588            assert!(
589                validate_bench_name(&name).is_err(),
590                "control char 0x{c:02x} should be rejected"
591            );
592        }
593        // DEL character (0x7F)
594        assert!(matches!(
595            validate_bench_name("bench\x7Fname"),
596            Err(ValidationError::InvalidCharacters { .. })
597        ));
598    }
599
600    #[test]
601    fn backslash_rejected() {
602        assert!(matches!(
603            validate_bench_name("bench\\name"),
604            Err(ValidationError::InvalidCharacters { .. })
605        ));
606        assert!(matches!(
607            validate_bench_name("path\\to\\bench"),
608            Err(ValidationError::InvalidCharacters { .. })
609        ));
610    }
611
612    #[test]
613    fn path_traversal_in_middle_segment() {
614        assert!(matches!(
615            validate_bench_name("a/../b"),
616            Err(ValidationError::PathTraversal { .. })
617        ));
618        assert!(matches!(
619            validate_bench_name("a/./b"),
620            Err(ValidationError::PathTraversal { .. })
621        ));
622        assert!(matches!(
623            validate_bench_name("a/b/../c"),
624            Err(ValidationError::PathTraversal { .. })
625        ));
626    }
627
628    #[test]
629    fn triple_dot_segment_is_valid() {
630        // "..." is not "." or ".." so it should be valid
631        assert!(validate_bench_name("...").is_ok());
632        assert!(validate_bench_name("a/.../b").is_ok());
633    }
634
635    // ── Large input tests ─────────────────────────────────────────────
636
637    #[test]
638    fn large_string_over_max_len() {
639        let name = "a".repeat(1000);
640        assert!(matches!(
641            validate_bench_name(&name),
642            Err(ValidationError::TooLong { .. })
643        ));
644    }
645
646    #[test]
647    fn large_string_way_over_max_len() {
648        let name = "b".repeat(100_000);
649        let result = validate_bench_name(&name);
650        assert!(
651            matches!(result, Err(ValidationError::TooLong { max_len, .. }) if max_len == BENCH_NAME_MAX_LEN)
652        );
653    }
654
655    #[test]
656    fn large_string_with_invalid_chars_over_max_len() {
657        // TooLong is checked before InvalidCharacters
658        let name = "X".repeat(BENCH_NAME_MAX_LEN + 1);
659        assert!(matches!(
660            validate_bench_name(&name),
661            Err(ValidationError::TooLong { .. })
662        ));
663    }
664
665    #[test]
666    fn large_number_of_segments() {
667        // Many valid segments within max length: "a/a/a/..."
668        let segments: Vec<&str> = (0..32).map(|_| "a").collect();
669        let name = segments.join("/");
670        if name.len() <= BENCH_NAME_MAX_LEN {
671            assert!(validate_bench_name(&name).is_ok());
672        } else {
673            assert!(matches!(
674                validate_bench_name(&name),
675                Err(ValidationError::TooLong { .. })
676            ));
677        }
678    }
679
680    #[test]
681    fn large_segment_at_boundary() {
682        // Single segment of exactly max length
683        let name = "z".repeat(BENCH_NAME_MAX_LEN);
684        assert!(validate_bench_name(&name).is_ok());
685    }
686
687    #[test]
688    fn error_preserves_name_for_large_input() {
689        let name = "x".repeat(BENCH_NAME_MAX_LEN + 10);
690        if let Err(ValidationError::TooLong {
691            name: err_name,
692            max_len,
693        }) = validate_bench_name(&name)
694        {
695            assert_eq!(err_name, name);
696            assert_eq!(max_len, BENCH_NAME_MAX_LEN);
697        } else {
698            panic!("expected TooLong error");
699        }
700    }
701
702    #[test]
703    fn bench_name_max_len_constant_is_64() {
704        assert_eq!(BENCH_NAME_MAX_LEN, 64);
705    }
706
707    #[test]
708    fn bench_name_pattern_matches_expected() {
709        assert_eq!(BENCH_NAME_PATTERN, r"^[a-z0-9_.\-/]+$");
710    }
711}
712
713#[cfg(test)]
714mod property_tests {
715    use super::*;
716    use proptest::prelude::*;
717
718    prop_compose! {
719        fn valid_bench_char()(
720            c in any::<u8>()
721                .prop_map(|b| {
722                    if b.is_ascii_lowercase() || b.is_ascii_digit() {
723                        char::from(b)
724                    } else {
725                        ['_', '-'][(b as usize) % 2]
726                    }
727                })
728        ) -> char {
729            c
730        }
731    }
732
733    prop_compose! {
734        fn valid_segment_char()(
735            c in any::<u8>()
736                .prop_map(|b| {
737                    if b.is_ascii_lowercase() || b.is_ascii_digit() {
738                        char::from(b)
739                    } else {
740                        ['_', '.', '-'][(b as usize) % 3]
741                    }
742                })
743        ) -> char {
744            c
745        }
746    }
747
748    prop_compose! {
749        fn valid_segment()(s in proptest::collection::vec(valid_segment_char(), 1..10)) -> String {
750            let seg: String = s.into_iter().collect();
751            if seg == "." || seg == ".." {
752                "a".to_string()
753            } else {
754                seg
755            }
756        }
757    }
758
759    prop_compose! {
760        fn valid_bench_name()(
761            segments in proptest::collection::vec(valid_segment(), 1..5)
762        ) -> String {
763            segments.join("/")
764        }
765    }
766
767    fn is_invalid_chars_error(result: &std::result::Result<(), ValidationError>) -> bool {
768        matches!(result, Err(ValidationError::InvalidCharacters { .. }))
769    }
770
771    fn is_too_long_error(result: &std::result::Result<(), ValidationError>) -> bool {
772        matches!(result, Err(ValidationError::TooLong { .. }))
773    }
774
775    fn is_empty_error(result: &std::result::Result<(), ValidationError>) -> bool {
776        matches!(result, Err(ValidationError::Empty))
777    }
778
779    fn is_empty_segment_error(result: &std::result::Result<(), ValidationError>) -> bool {
780        matches!(result, Err(ValidationError::EmptySegment { .. }))
781    }
782
783    fn is_path_traversal_error(result: &std::result::Result<(), ValidationError>) -> bool {
784        matches!(result, Err(ValidationError::PathTraversal { .. }))
785    }
786
787    proptest! {
788        #[test]
789        fn valid_chars_produce_ok(name in valid_bench_name()) {
790            prop_assume!(name.len() <= BENCH_NAME_MAX_LEN);
791            prop_assert!(validate_bench_name(&name).is_ok());
792        }
793
794        #[test]
795        fn uppercase_always_fails(name in "[a-z0-9_\\-]{1,30}[A-Z][a-z0-9_\\-]{1,30}") {
796            prop_assume!(name.len() <= BENCH_NAME_MAX_LEN);
797            let result = validate_bench_name(&name);
798            prop_assert!(is_invalid_chars_error(&result),
799                "Expected InvalidCharacters error for name '{}' with uppercase, got {:?}", name, result);
800        }
801
802        #[test]
803        fn length_boundary(
804            len in BENCH_NAME_MAX_LEN.saturating_sub(1)..=BENCH_NAME_MAX_LEN.saturating_add(1)
805        ) {
806            let name: String = "a".repeat(len);
807            let result = validate_bench_name(&name);
808            if len <= BENCH_NAME_MAX_LEN && len > 0 {
809                prop_assert!(result.is_ok());
810            } else if len > BENCH_NAME_MAX_LEN {
811                prop_assert!(is_too_long_error(&result));
812            } else {
813                prop_assert!(is_empty_error(&result));
814            }
815        }
816
817        #[test]
818        fn empty_string_fails(name in "") {
819            let _ = name;
820            let result = validate_bench_name("");
821            prop_assert!(is_empty_error(&result));
822        }
823
824        #[test]
825        fn double_slash_fails(prefix in valid_segment(), suffix in valid_segment()) {
826            prop_assume!(prefix != "." && prefix != "..");
827            prop_assume!(suffix != "." && suffix != "..");
828            let name = format!("{prefix}//{suffix}");
829            let result = validate_bench_name(&name);
830            prop_assert!(is_empty_segment_error(&result));
831        }
832
833        #[test]
834        fn leading_slash_fails(name in valid_bench_name()) {
835            let name_with_leading = format!("/{name}");
836            let result = validate_bench_name(&name_with_leading);
837            prop_assert!(is_empty_segment_error(&result));
838        }
839
840        #[test]
841        fn trailing_slash_fails(name in valid_bench_name()) {
842            let name_with_trailing = format!("{name}/");
843            let result = validate_bench_name(&name_with_trailing);
844            prop_assert!(is_empty_segment_error(&result));
845        }
846
847        #[test]
848        fn dot_segment_fails(suffix in "[a-z0-9_-]+") {
849            let name = format!("./{suffix}");
850            prop_assume!(!suffix.is_empty());
851            let result = validate_bench_name(&name);
852            prop_assert!(is_path_traversal_error(&result));
853        }
854
855        #[test]
856        fn double_dot_segment_fails(suffix in "[a-z0-9_-]+") {
857            let name = format!("../{suffix}");
858            prop_assume!(!suffix.is_empty());
859            let result = validate_bench_name(&name);
860            prop_assert!(is_path_traversal_error(&result));
861        }
862
863        #[test]
864        fn valid_char_roundtrip(c in valid_bench_char()) {
865            let name: String = std::iter::repeat_n(c, 10).collect();
866            prop_assume!(name.len() <= BENCH_NAME_MAX_LEN);
867            prop_assert!(validate_bench_name(&name).is_ok());
868        }
869
870        #[test]
871        fn special_invalid_chars(c in any::<char>()) {
872            prop_assume!(!c.is_ascii_lowercase());
873            prop_assume!(!c.is_ascii_digit());
874            prop_assume!(c != '_');
875            prop_assume!(c != '.');
876            prop_assume!(c != '-');
877            prop_assume!(c != '/');
878            prop_assume!(c != '\0');
879
880            let name = format!("bench{}test", c);
881            let result = validate_bench_name(&name);
882            prop_assert!(is_invalid_chars_error(&result));
883        }
884    }
885}