Skip to main content

max / makenotwork

tagtree: simplify common_ancestor guard, add validate_with proptests Drop the tautological min_len guard in common_ancestor: min_len always equals one of the two lengths, so the check was a no-op. The surrounding block restated is_ancestor_of by hand, so it collapses to a call. Add tests/validate_proptest.rs covering validate_with over random strings and random configs: no panics on arbitrary input, Ok implies every invariant the error set names, InvalidChar is accurate, well-formed tags pass a fitting config, and tightening each bound rejects so no check is dead. Input is weighted toward tag-shaped strings since pure .* almost never validates. Closes the two Run-17 audit items carried since Run 18. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-20 17:52 UTC
Commit: 2fc5764e4bd21520411506b9e9ec7489bfe80296
Parent: 17bb0a8
5 files changed, +346 insertions, -10 deletions
@@ -30,6 +30,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
30 30 checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
31 31
32 32 [[package]]
33 + name = "bit-set"
34 + version = "0.8.0"
35 + source = "registry+https://github.com/rust-lang/crates.io-index"
36 + checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
37 + dependencies = [
38 + "bit-vec",
39 + ]
40 +
41 + [[package]]
42 + name = "bit-vec"
43 + version = "0.8.0"
44 + source = "registry+https://github.com/rust-lang/crates.io-index"
45 + checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
46 +
47 + [[package]]
48 + name = "bitflags"
49 + version = "2.13.1"
50 + source = "registry+https://github.com/rust-lang/crates.io-index"
51 + checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
52 +
53 + [[package]]
33 54 name = "bumpalo"
34 55 version = "3.20.2"
35 56 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -173,6 +194,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
173 194 checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
174 195
175 196 [[package]]
197 + name = "errno"
198 + version = "0.3.14"
199 + source = "registry+https://github.com/rust-lang/crates.io-index"
200 + checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
201 + dependencies = [
202 + "libc",
203 + "windows-sys",
204 + ]
205 +
206 + [[package]]
207 + name = "fastrand"
208 + version = "2.5.0"
209 + source = "registry+https://github.com/rust-lang/crates.io-index"
210 + checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
211 +
212 + [[package]]
213 + name = "fnv"
214 + version = "1.0.7"
215 + source = "registry+https://github.com/rust-lang/crates.io-index"
216 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
217 +
218 + [[package]]
219 + name = "getrandom"
220 + version = "0.3.4"
221 + source = "registry+https://github.com/rust-lang/crates.io-index"
222 + checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
223 + dependencies = [
224 + "cfg-if",
225 + "libc",
226 + "r-efi 5.3.0",
227 + "wasip2",
228 + ]
229 +
230 + [[package]]
231 + name = "getrandom"
232 + version = "0.4.3"
233 + source = "registry+https://github.com/rust-lang/crates.io-index"
234 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
235 + dependencies = [
236 + "cfg-if",
237 + "libc",
238 + "r-efi 6.0.0",
239 + ]
240 +
241 + [[package]]
176 242 name = "half"
177 243 version = "2.7.1"
178 244 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -232,6 +298,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
232 298 checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
233 299
234 300 [[package]]
301 + name = "linux-raw-sys"
302 + version = "0.12.1"
303 + source = "registry+https://github.com/rust-lang/crates.io-index"
304 + checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
305 +
306 + [[package]]
235 307 name = "memchr"
236 308 version = "2.8.0"
237 309 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -287,6 +359,15 @@ dependencies = [
287 359 ]
288 360
289 361 [[package]]
362 + name = "ppv-lite86"
363 + version = "0.2.21"
364 + source = "registry+https://github.com/rust-lang/crates.io-index"
365 + checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
366 + dependencies = [
367 + "zerocopy",
368 + ]
369 +
370 + [[package]]
290 371 name = "proc-macro2"
291 372 version = "1.0.106"
292 373 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -296,6 +377,31 @@ dependencies = [
296 377 ]
297 378
298 379 [[package]]
380 + name = "proptest"
381 + version = "1.11.0"
382 + source = "registry+https://github.com/rust-lang/crates.io-index"
383 + checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
384 + dependencies = [
385 + "bit-set",
386 + "bit-vec",
387 + "bitflags",
388 + "num-traits",
389 + "rand",
390 + "rand_chacha",
391 + "rand_xorshift",
392 + "regex-syntax",
393 + "rusty-fork",
394 + "tempfile",
395 + "unarray",
396 + ]
397 +
398 + [[package]]
399 + name = "quick-error"
400 + version = "1.2.3"
401 + source = "registry+https://github.com/rust-lang/crates.io-index"
402 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
403 +
404 + [[package]]
299 405 name = "quote"
300 406 version = "1.0.45"
301 407 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -305,6 +411,56 @@ dependencies = [
305 411 ]
306 412
307 413 [[package]]
414 + name = "r-efi"
415 + version = "5.3.0"
416 + source = "registry+https://github.com/rust-lang/crates.io-index"
417 + checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
418 +
419 + [[package]]
420 + name = "r-efi"
421 + version = "6.0.0"
422 + source = "registry+https://github.com/rust-lang/crates.io-index"
423 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
424 +
425 + [[package]]
426 + name = "rand"
427 + version = "0.9.5"
428 + source = "registry+https://github.com/rust-lang/crates.io-index"
429 + checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
430 + dependencies = [
431 + "rand_chacha",
432 + "rand_core",
433 + ]
434 +
435 + [[package]]
436 + name = "rand_chacha"
437 + version = "0.9.0"
438 + source = "registry+https://github.com/rust-lang/crates.io-index"
439 + checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
440 + dependencies = [
441 + "ppv-lite86",
442 + "rand_core",
443 + ]
444 +
445 + [[package]]
446 + name = "rand_core"
447 + version = "0.9.5"
448 + source = "registry+https://github.com/rust-lang/crates.io-index"
449 + checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
450 + dependencies = [
451 + "getrandom 0.3.4",
452 + ]
453 +
454 + [[package]]
455 + name = "rand_xorshift"
456 + version = "0.4.0"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
459 + dependencies = [
460 + "rand_core",
461 + ]
462 +
463 + [[package]]
308 464 name = "rayon"
309 465 version = "1.11.0"
310 466 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -354,12 +510,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
354 510 checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
355 511
356 512 [[package]]
513 + name = "rustix"
514 + version = "1.1.4"
515 + source = "registry+https://github.com/rust-lang/crates.io-index"
516 + checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
517 + dependencies = [
518 + "bitflags",
519 + "errno",
520 + "libc",
521 + "linux-raw-sys",
522 + "windows-sys",
523 + ]
524 +
525 + [[package]]
357 526 name = "rustversion"
358 527 version = "1.0.22"
359 528 source = "registry+https://github.com/rust-lang/crates.io-index"
360 529 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
361 530
362 531 [[package]]
532 + name = "rusty-fork"
533 + version = "0.3.1"
534 + source = "registry+https://github.com/rust-lang/crates.io-index"
535 + checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
536 + dependencies = [
537 + "fnv",
538 + "quick-error",
539 + "tempfile",
540 + "wait-timeout",
541 + ]
542 +
543 + [[package]]
363 544 name = "same-file"
364 545 version = "1.0.6"
365 546 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -427,6 +608,20 @@ name = "tagtree"
427 608 version = "0.4.0"
428 609 dependencies = [
429 610 "criterion",
611 + "proptest",
612 + ]
613 +
614 + [[package]]
615 + name = "tempfile"
616 + version = "3.27.0"
617 + source = "registry+https://github.com/rust-lang/crates.io-index"
618 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
619 + dependencies = [
620 + "fastrand",
621 + "getrandom 0.4.3",
622 + "once_cell",
623 + "rustix",
624 + "windows-sys",
430 625 ]
431 626
432 627 [[package]]
@@ -440,12 +635,27 @@ dependencies = [
440 635 ]
441 636
442 637 [[package]]
638 + name = "unarray"
639 + version = "0.1.4"
640 + source = "registry+https://github.com/rust-lang/crates.io-index"
641 + checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
642 +
643 + [[package]]
443 644 name = "unicode-ident"
444 645 version = "1.0.24"
445 646 source = "registry+https://github.com/rust-lang/crates.io-index"
446 647 checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
447 648
448 649 [[package]]
650 + name = "wait-timeout"
651 + version = "0.2.1"
652 + source = "registry+https://github.com/rust-lang/crates.io-index"
653 + checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
654 + dependencies = [
655 + "libc",
656 + ]
657 +
658 + [[package]]
449 659 name = "walkdir"
450 660 version = "2.5.0"
451 661 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -456,6 +666,15 @@ dependencies = [
456 666 ]
457 667
458 668 [[package]]
669 + name = "wasip2"
670 + version = "1.0.4+wasi-0.2.12"
671 + source = "registry+https://github.com/rust-lang/crates.io-index"
672 + checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
673 + dependencies = [
674 + "wit-bindgen",
675 + ]
676 +
677 + [[package]]
459 678 name = "wasm-bindgen"
460 679 version = "0.2.114"
461 680 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -535,6 +754,12 @@ dependencies = [
535 754 ]
536 755
537 756 [[package]]
757 + name = "wit-bindgen"
758 + version = "0.57.1"
759 + source = "registry+https://github.com/rust-lang/crates.io-index"
760 + checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
761 +
762 + [[package]]
538 763 name = "zerocopy"
539 764 version = "0.8.47"
540 765 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -11,6 +11,7 @@ categories = ["data-structures", "text-processing"]
11 11
12 12 [dev-dependencies]
13 13 criterion = { version = "0.5", features = ["html_reports"] }
14 + proptest = "1"
14 15
15 16 [[bench]]
16 17 name = "tagtree_bench"
@@ -92,7 +92,7 @@ assert!(results.contains(&"genre.electronic.house"));
92 92
93 93 ## Dependencies
94 94
95 - Zero runtime dependencies. `std`-only. Criterion is a dev-dependency for benchmarks.
95 + Zero runtime dependencies. `std`-only. Criterion (benchmarks) and proptest (property tests) are dev-dependencies.
96 96
97 97 ## License
98 98
@@ -333,15 +333,9 @@ pub fn common_ancestor<'a>(a: &'a str, b: &str) -> Option<&'a str> {
333 333 }
334 334 // If one is a prefix of the other and ends at a separator boundary,
335 335 // the shorter one is the common ancestor.
336 - let min_len = a.len().min(b.len());
337 - if a.len() != b.len()
338 - && a.as_bytes()[..min_len] == b.as_bytes()[..min_len]
339 - && (min_len == a.len() || min_len == b.len())
340 - {
341 - let longer = if a.len() > b.len() { a } else { b };
342 - if longer.as_bytes()[min_len] == SEPARATOR_BYTE {
343 - return Some(&a[..min_len]);
344 - }
336 + let (shorter, longer) = if a.len() <= b.len() { (a, b) } else { (b, a) };
337 + if is_ancestor_of(shorter, longer) {
338 + return Some(&a[..shorter.len()]);
345 339 }
346 340 last_sep.map(|pos| &a[..pos])
347 341 }
@@ -0,0 +1,116 @@
1 + //! Property tests for `validate_with` over random strings and random configs.
2 + //!
3 + //! The unit tests in `src/lib.rs` cover the named cases. These cover the space
4 + //! between them: arbitrary input must never panic, and an `Ok` verdict must
5 + //! imply every invariant the error set claims to enforce.
6 +
7 + use proptest::prelude::*;
8 + use tagtree::{TagConfig, TagError, depth, validate, validate_with};
9 +
10 + /// Configs across the range apps actually use, plus the degenerate ends.
11 + fn any_config() -> impl Strategy<Value = TagConfig> {
12 + (0usize..=8, 0usize..=64, 0usize..=3).prop_map(|(max_depth, max_length, semantic_depth)| {
13 + TagConfig { max_depth, max_length, semantic_depth }
14 + })
15 + }
16 +
17 + /// Well-formed tags: 1-5 segments drawn from the allowed charset.
18 + fn valid_tag() -> impl Strategy<Value = String> {
19 + proptest::collection::vec("[a-z0-9-]{1,6}", 1..=5).prop_map(|segs| segs.join("."))
20 + }
21 +
22 + /// Tag-shaped input, weighted so that `Ok` verdicts stay common enough to test.
23 + /// Pure `.*` almost never validates, which starves the `Ok`-implies properties.
24 + fn tagish() -> impl Strategy<Value = String> {
25 + prop_oneof![
26 + 4 => valid_tag(),
27 + 2 => "[a-z0-9.\\-]{0,24}",
28 + 1 => ".*",
29 + ]
30 + }
31 +
32 + fn is_allowed_char(ch: char) -> bool {
33 + ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '.'
34 + }
35 +
36 + proptest! {
37 + /// Arbitrary bytes-as-text must not panic, however malformed.
38 + #[test]
39 + fn never_panics_on_arbitrary_input(tag in ".*", config in any_config()) {
40 + let _ = validate_with(&tag, &config);
41 + }
42 +
43 + /// `Ok` is a promise: every invariant the error set names must hold.
44 + #[test]
45 + fn ok_implies_all_invariants(tag in tagish(), config in any_config()) {
46 + prop_assume!(validate_with(&tag, &config).is_ok());
47 +
48 + prop_assert!(!tag.is_empty());
49 + prop_assert!(tag.chars().count() <= config.max_length);
50 + prop_assert!(!tag.starts_with('.') && !tag.ends_with('.'));
51 + prop_assert!(!tag.contains(".."));
52 + prop_assert!(tag.chars().all(is_allowed_char));
53 +
54 + let d = depth(&tag);
55 + prop_assert!(d <= config.max_depth);
56 + if config.semantic_depth > 0 {
57 + prop_assert!(d > config.semantic_depth);
58 + }
59 +
60 + // No empty segments, and the segments reconstruct the tag.
61 + let segs: Vec<&str> = tag.split('.').collect();
62 + prop_assert_eq!(segs.len(), d);
63 + prop_assert!(segs.iter().all(|s| !s.is_empty()));
64 + prop_assert_eq!(segs.join("."), tag);
65 + }
66 +
67 + /// `InvalidChar` must name a character that is really present and really illegal.
68 + #[test]
69 + fn invalid_char_is_accurate(tag in tagish(), config in any_config()) {
70 + if let Err(TagError::InvalidChar(ch)) = validate_with(&tag, &config) {
71 + prop_assert!(tag.contains(ch));
72 + prop_assert!(!is_allowed_char(ch));
73 + }
74 + }
75 +
76 + /// A well-formed tag validates under any config sized to admit it.
77 + #[test]
78 + fn well_formed_tags_pass_a_fitting_config(tag in valid_tag()) {
79 + let d = depth(&tag);
80 + for semantic_depth in 0..d {
81 + let config = TagConfig {
82 + max_depth: d,
83 + max_length: tag.chars().count(),
84 + semantic_depth,
85 + };
86 + prop_assert_eq!(validate_with(&tag, &config), Ok(()));
87 + }
88 + }
89 +
90 + /// Tightening one bound at a time rejects, so each check is load-bearing.
91 + #[test]
92 + fn tightening_a_bound_rejects(tag in valid_tag()) {
93 + let d = depth(&tag);
94 + let len = tag.chars().count();
95 +
96 + prop_assert_eq!(
97 + validate_with(&tag, &TagConfig { max_depth: d, max_length: len - 1, semantic_depth: 0 }),
98 + Err(TagError::TooLong { max: len - 1 })
99 + );
100 + prop_assert_eq!(
101 + validate_with(&tag, &TagConfig { max_depth: d - 1, max_length: len, semantic_depth: 0 }),
102 + Err(TagError::TooDeep { levels: d, max: d - 1 })
103 + );
104 + prop_assert_eq!(
105 + validate_with(&tag, &TagConfig { max_depth: d, max_length: len, semantic_depth: d }),
106 + Err(TagError::InsufficientDepth { min_segments: d + 1, semantic_depth: d })
107 + );
108 + }
109 +
110 + /// `validate` is exactly `validate_with` against the documented defaults.
111 + #[test]
112 + fn validate_matches_its_documented_defaults(tag in ".*") {
113 + const DEFAULT: TagConfig = TagConfig { max_depth: 5, max_length: 100, semantic_depth: 0 };
114 + prop_assert_eq!(validate(&tag), validate_with(&tag, &DEFAULT));
115 + }
116 + }