# BACKLOG - attackstr
Gemini first-pass (2026-07-13) + Claude verification/fixes. Format: number | affected files | problem | acceptance criteria.
## META
DONE 2026-07-14 _ | process | high | attackstr is in the root workspace `exclude` list (Cargo.toml:102), so the main CI sweep never compiles/tests it. UPDATE (2026-07-13): the test targets now COMPILE and RUN green (`cargo test --manifest-path libs/offensive/attackstr/Cargo.toml` → all suites pass, only unused-import warnings) — the earlier "rotted to non-compiling" state was already repaired by the round-1/round-2 fixes above. The residual risk is that CI still never runs these tests, so future rot goes undetected. | Add attackstr to a CI job that builds `--manifest-path libs/offensive/attackstr/Cargo.toml --tests`, or fold it into the workspace. | LANDED: root ./cargo_full wrapper + .github/workflows/santh-ci.yml offensive-excluded job and crate-checks matrix run attackstr with `./cargo_full test --manifest-path libs/offensive/attackstr/Cargo.toml`; verified 315 tests pass.
## FIXED (round 3: perf + Law-10 unknown-encoding fail-closed, 2026-07-13)
src/encoding.rs:276 | perf | medium | join_chars_with mapped each char to a String, collected Vec<String>, then joined -> N+1 allocations. | FIXED: build in one pre-sized String via push/push_str (first char, then separator+char). Result-identical. | status=done
src/mutate.rs:252 | perf | medium | alternate_case mapped each char to a String allocation in map/collect. | FIXED: push chars into one String::with_capacity(payload.len()); non-alpha pushed verbatim, else ascii lower/upper by (idx+offset)%2. Result-identical. | status=done
src/loader.rs:494 | silent-fallback | high | expand_category did `.iter_payloads(...).filter_map(Result::ok).collect()`, SILENTLY dropping every per-payload expansion error (Law 10). An unknown encoding transform passed load-time validation (which only checks `iter.next()`, the first/identity payload) then got dropped here -> `payloads()` returned an empty slice with no operator-visible signal (adversarial test `unknown_encoding_transform` then panicked on `payloads[0]`). | FIXED (fail closed at the boundary): validate_grammar now rejects any encoding transform that is neither a BuiltinEncoding::ALL member NOR a registered custom encoding, as an Error-level GrammarIssue -> load_toml/load_reader/load_dir return Err(GrammarValidation). Unknown encodings can no longer reach expand_category. Test rewritten to assert fail-closed load + a registered-custom-encoding positive case. | status=done
src/validate.rs:209 | coherence | low | validate_encodings warning message said unknown transforms "will pass through unchanged" — stale/false since apply_encoding fails closed. | FIXED: message now states loading fails closed (payload rejected, not passed through) unless the transform is a registered custom encoding. | status=done
src/loader.rs:494 | silent-fallback | medium | RESIDUAL after the fail-closed load fix: expand_category's `filter_map(Result::ok)` can still silently drop a per-payload ExpansionLengthExceeded (an over-length payload vanishes from the category with no operator signal). Unknown-encoding is now impossible here, but length-exceeded recall loss is still invisible. | FIXED (Law-10 loud-degrade, no API change): expand_category's `filter_map(Result::ok)` replaced with a match that emits `tracing::warn!(category, %error, "dropping payload whose expansion failed")` on Err before dropping (attackstr already depends on tracing, unlike pocgen). The over-length payload is still dropped (payloads() must return &[Payload], so it can't surface a Result), but the recall loss is now visible. Chose the loud-warn over the dropped-count-API since it needs no signature change and matches the crate's existing warn+capture_logs test pattern. Proving test loader::expand_category_warns_when_over_length_payload_dropped: grammar with a short first context (passes load's first-payload-only validation) + a 50-byte second context (dropped at expand under max_payload_length=20); asserts the surviving payload is present, none over cap, AND a WARN "dropping payload" is captured. ALSO hoisted the capture_logs tracing helper from encoding.rs to tests/unit/mod.rs as the single owner (ONE-PLACE) so encoding.rs + loader.rs share it. attackstr all targets 315/0. | status=done
tests/unit/{lib_tests.rs,validate.rs,mod.rs} | organization | low | FOUND 2026-07-14 (pre-existing, NOT from my loader/capture-hoist change - verified: the shadow warnings sit on the pre-existing `mod config/grammar/validate` decls, and the unused imports are in files I did not touch). unit_tests target emits 9 warnings: (1) "private item shadows public glob re-export" x3 - tests/unit/mod.rs does `pub use attackstr::*` (which re-exports attackstr's config/grammar/validate modules) AND declares `mod config; mod grammar; mod validate;`, so the test submodules shadow the glob'd module names; (2) 6 unused-import warnings in lib_tests.rs (PayloadConfigFile, GrammarIssue/validate, apply_encoding/BuiltinEncoding, mutate_* , Payload/PayloadConfig/PayloadDb/PayloadError) and validate.rs (`attackstr::grammar::*`). | Drop the unused `pub use` lines in lib_tests.rs/validate.rs (trivial, compiler-verified), and either stop glob-re-exporting attackstr's modules in mod.rs or `#[allow]`/rename to end the module-shadow warnings. Test-only hygiene; batch with a warnings-zero pass. | DONE 2026-07-14: renamed conflicting test modules to config_tests/grammar_tests/validate_tests (#[path]) to stop private-item-shadows-public-glob warnings; removed unused pub mod prelude from lib_tests.rs and unused `use attackstr::grammar::*` from validate.rs. `cargo +1.92.0 test -p attackstr --test unit_tests` 154 passed, 0 warnings. | status=done
## FIXED (verified: unit_tests + break_it now compile; 38 tests pass)
tests/unit/lib_tests.rs:5 | bug | high | payload_round_trips_with_serde instantiates Payload without the required target_media_type field (E0063). | Added target_media_type: None. | status=done
tests/unit/validate.rs:23 | bug | high | valid_grammar_no_issues Context literal missing target_media_type (E0063). | Added target_media_type: None. | status=done
tests/unit/validate.rs:202 | bug | high | prefix_suffix_not_flagged_as_undefined Context literal missing target_media_type (E0063). | Added target_media_type: None. | status=done
tests/unit/mutate.rs:22 | bug | high | encoding_mix_mutations_are_generated called mutate_encoding_mix (returns Result) then .is_empty()/.iter() on the Result -> compile error. | Added .unwrap(); assertions verified to hold. | status=done
tests/break_it.rs:218 | bug | high | test_apply_encoding_unknown_encoding asserted unwrap()=="test" ("graceful identity fallback") but apply_encoding fails closed with Err(UnknownTransform); .unwrap() panicked. | Rewrote to assert Err(UnknownTransform{transform=="nonexistent_encoding"}); the fail-closed behavior is correct (Law 10). | status=done
tests/break_it.rs:100 | test-gap | high | test_grammar_expansion_length_limit used a 10000-char prefix but MAX_TEMPLATE_LENGTH is 256KB, so it asserted is_err() on input the code correctly allows -> the test verified nothing. | Bumped trigger to "A".repeat(300_000) so expansion exceeds the cap and ExpansionLengthExceeded fires. | status=done
tests/break_it.rs:443 | test-gap | medium | test_grammar_template_unclosed_braces asserted the error contains "template expansion error", but an unclosed brace is caught fail-fast by validation with the specific message "unclosed '{'". | Assert contains("unclosed") - the specific, better message. | status=done
tests/break_it.rs:359 | test-gap | medium | test_toml_resource_exhaustion_100k_techniques used template "p" for all 10000 techniques; value-dedup (documented default) collapsed them to 1, but the test asserted 10000. | Made templates distinct ("p{i}") so all 10000 load without exhaustion; asserts 10000. | status=done
## FIXED (round 2: src bugs + unmasked unit_tests failures; full suite green, 152 + 38 pass)
src/grammar.rs:558 | bug | high | TemplateExpansionIter::next pushes variable values onto a LIFO Vec stack then pops from the end, so streaming expansion (iter_payloads) yields values in REVERSED order vs insertion and vs the batch expander (test iter_payloads_streams_category_payloads got b,a for vars a,b). | Push values with .iter().rev() so pop yields insertion order; verified by the streaming test. | status=done
src/grammar.rs:686 | bug | medium | depluralize("bypasses") -> "bypasse" (only ies/s handled), so singular {bypass} refs to -sses variables never resolve. | Added "-sses" arm dropping "es"; regression asserts bypasses/classes/passes -> stem and houses -> house. | status=done
src/encoding.rs:245 | bug | medium | js_concat_split emitted format!("'{c}'") so a literal ' produced the invalid '''' and a literal \ left the JS unterminated. | Escape ', \\, \n, \r, \t inside the single-quoted literals; proving test js_concat_escapes_structural_chars. | status=done
tests/unit/encoding.rs:107 | test-gap | low | charcode test asserted String.fromCharCode; code emits the strictly-more-correct String.fromCodePoint (astral-safe). | Updated test to fromCodePoint + added U+1F600 astral case fromCharCode would fail. | status=done
tests/unit/encoding.rs:75 | test-gap | low | html_entities test expected '/' unencoded but html_encode deliberately entity-encodes '/' (/) and backtick as a filter-bypass hardening measure. | Updated expected to / with a comment on the intent. | status=done
## OPEN (gemini first-pass, pending Claude verification + fix)
src/validate.rs:182 | bug | high | check_template_variables reports IssueLevel::Error for unclosed brace even when a valid escaped "{{" is present without a matching "}", blocking valid brace-escaped grammars. NOTE: genuinely-unclosed "{prefix" is correctly rejected; verify the {{-escape false-positive specifically. | FIXED (verified {{ IS an escape at grammar.rs:534): the scanner now skips "{{" escape pairs (pos += 2; continue) before the find('}')/unclosed check. Proving tests escaped_brace_is_not_unclosed (3 templates incl "{{ then {prefix}") and genuinely_unclosed_brace_still_errors (real "{prefix" / "x { y" still Error) confirm the escape is honored without masking real unclosed braces. | status=done
src/loader.rs:539 | perf | high | PayloadDb::payload_count re-ran a full Cartesian expansion for every category, never consulting self.cache. | FIXED: use self.cache.get(cat).len() when the category is already materialized, else expand. Result-identical (expand_category IS iter_payloads().filter(ok).collect(), and the cache is invalidated on every config/grammar change). | status=done
src/grammar.rs:686 | bug | medium | depluralize mishandles words ending in "sses" (bypasses -> bypasse instead of bypass), breaking variable-name matching in substitution. | DUPLICATE of the round-2 grammar.rs:686 row above (already FIXED with the "-sses"->stem arm + regression). No further work. | status=done
src/encoding.rs:244 | bug | medium | js_concat_split formats char quotes as '{c}' without escaping a literal single quote, producing invalid ''' when c == '. | DUPLICATE of the round-2 encoding.rs:245 row above (already FIXED: escapes ' \ \n \r \t; proving test js_concat_escapes_structural_chars). No further work. | status=done
src/encoding.rs:262 | perf | medium | join_chars_with maps each char to a String, collects Vec<String>, then joins -> N allocations. | Build with String::with_capacity + push. | status=done | DUPLICATE (line drift): join_chars_with is the round-3 encoding.rs:276 row, already single-alloc (String::with_capacity + push, N->1 allocation). No further work.
src/mutate.rs:252 | perf | medium | alternate_case maps chars to String allocations in map/collect. | Pre-allocate and push chars directly. | status=done | DUPLICATE (line drift): mutate.rs:252 alternate_case(payload,offset) is the round-3 row, already single-buffer push (ASCII case, non-alpha passthrough). No further work.
src/grammar.rs:360 | silent-fallback | low | GrammarExpansionIter::new uses let _ = TemplateExpansionIter::new(...) discarding a potential expansion error. | Propagate with ?. | status=done | NOT-A-DEFECT: the `?` on `let _ = TemplateExpansionIter::new(base, Arc::clone(&lookup))?` ALREADY propagates any construction/expansion Err. `let _ =` only drops the Ok(iterator) in a deliberate fail-fast pre-flight that validates every (context x technique) template parses at grammar-load time; the real iterator is built lazily later in advance_source. No error is swallowed.
src/encoding.rs:207 | silent-fallback | low | percent_hex_encode uses let _ = write! swallowing fmt errors (write! to String never fails - benign, but flagged for consistency). | Use core::fmt::Write and .expect on String sink, or ignore explicitly with a comment. | status=done | FIXED: `let _ = write!` -> `write!(...).expect("writing to a String sink is infallible")`. std::fmt::Write for String cannot return Err, so this documents infallibility instead of silently discarding (Law 10 consistency).
src/encoding.rs:222 | silent-fallback | low | unicode_escape let _ = write! for surrogate pairs. | Same as above. | status=done | FIXED: .expect("writing to a String sink is infallible").
src/encoding.rs:224 | silent-fallback | low | unicode_escape let _ = write!. | Same as above. | status=done | FIXED: .expect("writing to a String sink is infallible").
src/encoding.rs:234 | silent-fallback | low | octal_escape let _ = write!. | Same as above. | status=done | FIXED: .expect("writing to a String sink is infallible").
src/encoding.rs:298 | silent-fallback | low | css_escape let _ = write!. | Same as above. | status=done | FIXED (css_escape now at :323): .expect("writing to a String sink is infallible").
## FIXED (round 4: perf + ONE-PLACE dedup, 2026-07-13)
src/encoding.rs:263 | perf | medium | alternate_case (Unicode, "case_alternate" transform) mapped each char to `to_lowercase().to_string()` / `to_uppercase().to_string()` then collected -> a String allocation per char. | FIXED: single String::with_capacity(s.len()) buffer, out.extend(c.to_lowercase()/to_uppercase()). Keeps Unicode-aware multi-char case mapping (e.g. 'İ'), byte-identical result. | status=done
src/mutate.rs:267 | dedup | medium | ONE-PLACE violation: alternating_ascii_case(input) is byte-identical to the existing alternate_case(input, 1) (offset 1 => even idx uppercase, odd lowercase; non-alpha passthrough == ascii-case no-op). Two implementations of the same behavior. | FIXED: deleted alternating_ascii_case; the sole call site (mutate_html tag-casing, :166) now calls alternate_case(tag, 1). Proving test html_tag_case_mixing_uses_uppercase_first_alternation pins "script"->"ScRiPt", "iframe"->"IfRaMe". | status=done
## FIXED (round 4: Claude manual audit 2026-07-17 & 2026-08-07)
src/mutate.rs:130-180 | mutate | correctness | high | mutate_html's "forward slash insertion in common tags" branch (159-180) builds its variants from `payload.to_lowercase()` as the BASE, not just the matched tag. | FIXED: `replace_tag_span` replaces only the tag span case-insensitively while preserving the payload body verbatim, with tag boundary validation (`!bytes[i+span].is_ascii_alphanumeric()`). Proving tests `html_tag_mutation_preserves_payload_body_case` and `html_tag_mutation_respects_tag_boundary` verify exact body preservation and boundary isolation. | status=done
src/encoding.rs:264 + src/mutate.rs:252 | encoding,mutate | duplication | medium | Two functions named `alternate_case` with DIVERGENT semantics. | FIXED: Unified into single `pub(crate) fn alternate_case(s: &str, offset: usize)` in `encoding.rs` (Unicode-aware, single buffer). Re-pointed both `case_alternate` encoding and `mutate_case` / HTML tag casing mutation paths. Proving test `alternate_case_encoding_and_mutation_agree_on_non_ascii` locks behavior. | status=done
src/encoding.rs:157-201 + :405-430 | encoding,validate,loader | one-place | medium | Built-in encoding names maintained in multiple places. | FIXED: `BuiltinEncoding` enum's `FromStr` is the single owner for name resolution. Added `BuiltinEncoding::is_builtin` (driven by `FromStr`), re-pointed `loader.rs` and `validate.rs` to it. `all_and_dispatch_are_bidirectionally_complete` unit test enforces bidirectional completeness with `BuiltinEncoding::ALL`. | status=done