Skip to main content

max / makenotwork

s3-storage, tagtree: close the mutation runs' remaining survivors s3-storage, 207 mutants: 195 caught, 2 missed, 10 unviable. Both misses are handled here. The `&&` guarding "truncated with neither marker" could become `||`, which stops the listing on the first page naming only NextKeyMarker -- the ordinary case, since S3 returns NextUploadIdMarker only when a page splits one key's uploads. A short list from that function is parts the orphan reaper never sees and the account keeps paying for. Killed by a two-page test where page one names one marker. `while bytes_read < part_size` -> `<=` is excluded: at equality the extra iteration reads into an empty slice, which returns Ok(0), which is the loop's own break arm. tagtree, 384 mutants: 335 caught, 47 missed, 2 timeouts. Ten of the eleven in the parsing module are equivalent and the file says why each is; the one real gap is the character-length bound. The two-stage length check reads bytes as a gate on characters, so every ASCII test has the byte gate already false at the boundary and cannot see the character comparison at all. Ten Greek letters are twenty bytes and ten characters, which opens the gate and makes the character bound decide. The largest equivalence is worth stating on its own: TagIndex::segments has one reader, that reader has one caller, and every condition in the guard it feeds is re-checked inside the loop it guards. The segment index decides how much work runs and never what comes back, so six mutants over it are unobservable through the public API -- including both `if changed { rebuild }` guards, since a rebuild after a no-op edit only prunes orphaned segments nobody can read. Both exclusion files now carry line anchors where a description alone would also match a mutant being caught. Measured: the loose patterns swallowed four caught mutants in tagtree before the anchors went in.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01DwpiantpUgohzML4xr6KeQ
Author: Max Johnson <me@maxj.phd> · 2026-08-31 15:35 UTC
Signed with PGP, not checked
Commit: ddd53ad9f84650cd6ab8287d9c4cdfafb167f613
Parent: 9413fd7
4 files changed, +189 insertions, -2 deletions
@@ -13,8 +13,16 @@
13 13 # equivalent at a glance and were not. So: try to kill it first, and add an
14 14 # entry only with the reason written out.
15 15 #
16 - # The patterns are regexes, so `+`, `*` and `|` need escaping. A bare `||` is an
17 - # empty alternation and silently excludes every mutant in the crate.
16 + # The patterns are regexes matched against the WHOLE listing line, `file:line:col`
17 + # included, so `+`, `*` and `|` need escaping. A bare `||` is an empty
18 + # alternation and silently excludes every mutant in the crate.
19 + #
20 + # An entry carries a line number when the description alone would also match a
21 + # mutant that is being CAUGHT -- cargo-mutants names a mutant by its operator and
22 + # its enclosing function and nothing else, so one function with two similar
23 + # operators needs the position to tell them apart. A stale anchor stops matching
24 + # and the mutant returns as a visible survivor, which is the safe direction.
25 + # Re-derive by diffing `cargo mutants --list` with and without this file.
18 26
19 27 exclude_re = [
20 28 # The two oracle wrappers in `mod oracle`. An oracle asserts what must hold
@@ -28,4 +36,11 @@
28 36 # were caught, and they were caught AT THESE ASSERTIONS.
29 37 "replace oracle::check_plan with \\(\\)",
30 38 "replace oracle::check_auto with \\(\\)",
39 +
40 + # `while bytes_read < part_size` -> `<=` in the part-filling loop. At
41 + # equality the extra iteration reads into `&mut buf[part_size..]`, an empty
42 + # slice, and a read into an empty buffer returns `Ok(0)` without touching
43 + # the file -- which is the loop's own break arm. One wasted call to `read`
44 + # and an identical buffer, so no observation distinguishes it.
45 + "src/lib.rs:1072:30: replace < with <= in S3Client::run_multipart_upload",
31 46 ]
@@ -1621,6 +1621,57 @@
1621 1621 );
1622 1622 }
1623 1623
1624 + /// A page may name only ONE of the two markers, and that is still a page to
1625 + /// follow. The guard reads "neither marker", so it is an `&&`; an `||` there
1626 + /// stops on the first page whose key marker happens to be the only one set,
1627 + /// silently returning a short list to the orphan reaper -- which then leaves
1628 + /// the parts it did not see billing forever.
1629 + ///
1630 + /// `ListMultipartUploads` returns `NextUploadIdMarker` only when the page
1631 + /// splits a key's uploads, so a page ending on a key boundary carries the
1632 + /// key marker alone. That is the ordinary case, not a corner.
1633 + #[tokio::test]
1634 + async fn a_page_naming_only_the_key_marker_is_still_followed() {
1635 + let (client, replay) = replay_client(vec![
1636 + xml_ok(
1637 + r#"<?xml version="1.0" encoding="UTF-8"?>
1638 + <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1639 + <Bucket>test-bucket</Bucket>
1640 + <IsTruncated>true</IsTruncated>
1641 + <NextKeyMarker>staging/abc</NextKeyMarker>
1642 + <Upload>
1643 + <Key>staging/abc</Key>
1644 + <UploadId>upload-one</UploadId>
1645 + <Initiated>2026-08-31T00:00:00.000Z</Initiated>
1646 + </Upload>
1647 + </ListMultipartUploadsResult>"#,
1648 + ),
1649 + xml_ok(
1650 + r#"<?xml version="1.0" encoding="UTF-8"?>
1651 + <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
1652 + <Bucket>test-bucket</Bucket>
1653 + <IsTruncated>false</IsTruncated>
1654 + <Upload>
1655 + <Key>staging/abc</Key>
1656 + <UploadId>upload-two</UploadId>
1657 + <Initiated>2026-08-31T00:00:00.000Z</Initiated>
1658 + </Upload>
1659 + </ListMultipartUploadsResult>"#,
1660 + ),
1661 + ]);
1662 +
1663 + let ids = client
1664 + .list_multipart_uploads_for_key("staging/abc")
1665 + .await
1666 + .expect("one marker is enough to continue");
1667 +
1668 + assert_eq!(
1669 + ids,
1670 + vec!["upload-one".to_string(), "upload-two".to_string()]
1671 + );
1672 + assert_eq!(replay.actual_requests().count(), 2);
1673 + }
1674 +
1624 1675 /// infra `a536db81`. `configure_cors` returns `()` and the crate exposed no
1625 1676 /// readback, so replacing its whole body with `()` was invisible to every
1626 1677 /// test that could be written against it. The request it sends is the
@@ -2098,4 +2098,43 @@
2098 2098 "abc should fuzzy-match cab.x via segment 'cab' (distance 2); got {results:?}"
2099 2099 );
2100 2100 }
2101 +
2102 + /// The two-stage length check reads bytes first because byte length is an
2103 + /// exact upper bound on character count, so the cheap test gates the O(n)
2104 + /// one. That makes the CHARACTER comparison the one that decides, and
2105 + /// mutation found it could become `>=` unnoticed: every existing test uses
2106 + /// ASCII, where the byte gate is already false at the boundary and masks it.
2107 + ///
2108 + /// A multi-byte tag is what separates them. Ten Greek letters are twenty
2109 + /// bytes and ten characters, so the byte gate opens and the character gate
2110 + /// decides. `>=` there rejects a tag of exactly `max_length` characters as
2111 + /// too long; the crate rejects it for its charset instead, which is the
2112 + /// error a caller has to act on.
2113 + #[test]
2114 + fn a_tag_of_exactly_max_length_characters_is_not_too_long() {
2115 + const CFG: TagConfig = TagConfig {
2116 + max_depth: 5,
2117 + max_length: 10,
2118 + semantic_depth: 0,
2119 + };
2120 +
2121 + assert_eq!(
2122 + validate_with("abcdefghij", &CFG),
2123 + Ok(()),
2124 + "10 ASCII, 10 bytes"
2125 + );
2126 + assert_eq!(
2127 + validate_with("abcdefghijk", &CFG),
2128 + Err(TagError::TooLong { max: 10 }),
2129 + "11 characters is over"
2130 + );
2131 +
2132 + // 10 characters, 20 bytes: over the byte bound, exactly on the
2133 + // character bound. The charset is what rejects it, not the length.
2134 + assert_eq!(
2135 + validate_with("αβγδεζηθικ", &CFG),
2136 + Err(TagError::InvalidChar('α')),
2137 + "the character count is the bound, and 10 is not over 10"
2138 + );
2139 + }
2101 2140 }
@@ -1,0 +1,82 @@
1 + # Mutants that NO test can kill, with the reason each one is unreachable by any
2 + # test that could be written.
3 + #
4 + # THE BAR IS IMPOSSIBILITY, NOT COST. A mutant that a test could kill, where
5 + # nobody has written that test, does not belong here however unappealing the
6 + # test looks. It stays a visible survivor with a GoingsOn task against it. The
7 + # two classes are easy to conflate and the difference is the whole value of the
8 + # number: an exclusion list that also absorbs "not worth it" reports zero while
9 + # real coverage gaps sit underneath it.
10 + #
11 + # The patterns are regexes matched against the WHOLE listing line, `file:line:col`
12 + # included, so `+`, `*`, `|`, `(` and `.` need escaping. A bare `||` is an empty
13 + # alternation and silently excludes every mutant in the crate.
14 + #
15 + # WHY MOST OF THESE CARRY A LINE NUMBER. cargo-mutants names a mutant by its
16 + # operator and its enclosing function, and nothing else: three different `>` in
17 + # `validate_with` all render as "replace > with >= in validate_with", and one of
18 + # the three is a real coverage gap that `a_tag_of_exactly_max_length_characters_
19 + # is_not_too_long` kills. A pattern written on the description alone excluded
20 + # that one too, which is precisely the failure this file's own bar warns about.
21 + # Measured the same way for `edit_distance` and `suggest`, where the loose
22 + # patterns swallowed four mutants that were being CAUGHT.
23 + #
24 + # Line numbers go stale when the file moves, and that is the safe direction: a
25 + # stale anchor stops matching, and the mutant comes back as a visible survivor
26 + # rather than silently staying excluded. Re-derive them by diffing
27 + # `cargo mutants --list` with and without this file.
28 +
29 + exclude_re = [
30 + # --- The segment index is an optimisation and nothing observes it -----
31 + #
32 + # `TagIndex::segments` has exactly one reader, `any_segment_starts_with`,
33 + # and that has exactly one caller: the guard on tier 2 of `suggest`. Every
34 + # condition in that guard is re-checked inside the loop it guards -- the
35 + # limit at the top of each iteration, and `seg.starts_with(input)` per
36 + # segment -- so the guard decides how much work runs and never what comes
37 + # back. That makes six mutants unobservable through the public API, and no
38 + # test can observe "the same answer, reached the slow way".
39 + #
40 + # The two `rebuild` guards are the same fact one step removed. `TagIndex::
41 + # remove` deliberately does not prune orphaned segments, so a rebuild after
42 + # a no-op edit genuinely changes `segments` -- and changes nothing anyone
43 + # can read.
44 + "replace TagIndex::any_segment_starts_with -> bool with true",
45 + "src/lib.rs:891:26: replace < with <= in TagIndex::suggest",
46 + "src/lib.rs:892:13: replace && with \\|\\| in TagIndex::suggest",
47 + "src/lib.rs:893:13: replace && with \\|\\| in TagIndex::suggest",
48 + "src/lib.rs:616:14: replace > with >= in rename_prefix_bulk",
49 + "src/lib.rs:645:16: replace > with >= in remove_subtree",
50 +
51 + # --- Bounds that cannot be reached ------------------------------------
52 + #
53 + # `tag.len() > max_length && tag.chars().count() > max_length`. The byte
54 + # test is a gate on the O(n) character test because byte length is an exact
55 + # upper bound on character count. `>=` on the BYTE side differs only when
56 + # `len == max && chars > max`, and `chars <= len` makes that impossible.
57 + # (The character side is a different matter and is killed by
58 + # `a_tag_of_exactly_max_length_characters_is_not_too_long`.)
59 + "src/lib.rs:166:18: replace > with >= in validate_with",
60 +
61 + # `config.semantic_depth > 0` guarding the minimum-depth check. `>= 0` is
62 + # always true for a usize, so the mutant runs the check with
63 + # `semantic_depth == 0` and asks `d < 1`. `validate_with` rejects the empty
64 + # tag on its first line, and `depth` returns 0 only for the empty string, so
65 + # `d >= 1` always holds by the time this line runs and the extra check
66 + # cannot fire.
67 + "src/lib.rs:189:30: replace > with >= in validate_with",
68 +
69 + # `tag.starts_with(prefix) && tag.len() > prefix.len() && ...` in `subtree`.
70 + # `>=` differs only at equal lengths, and a tag that starts with the prefix
71 + # and has its length IS the prefix -- which the preceding `*tag == prefix`
72 + # arm has already returned true for, so the third arm is never evaluated
73 + # there. (Without that short-circuit the mutant would index one past the
74 + # end, which is a different bug.)
75 + "src/lib.rs:490:34: replace > with >= in subtree",
76 +
77 + # The operand swap in `edit_distance`, which puts the shorter string first
78 + # so the DP row is the smaller of the two. `>=` swaps at equal lengths as
79 + # well, and Levenshtein distance is symmetric: same row length, same
80 + # `b.len() - a.len()` of zero for the short-circuit, same distance.
81 + "src/lib.rs:561:29: replace > with >= in edit_distance",
82 + ]