Skip to content

fix(daemon): make the credential scan provably terminating - #84

Merged
acamarata merged 1 commit into
mainfrom
fix/sanitize-termination
Sep 16, 2026
Merged

acamarata merged 1 commit into
mainfrom
fix/sanitize-termination

Conversation

@acamarata

Copy link
Copy Markdown
Contributor

The mutation gate on main has been failing — and not because a mutant survived. Three mutants in sanitize_tool_input hung the job outright, so the run produced no kill rate at all.

guard.rs:91  replace += with *= in sanitize_tool_input   (j *= 1)
guard.rs:93  replace >= with <  in sanitize_tool_input
guard.rs:98  replace += with *= in sanitize_tool_input   (i *= 1)

Each stops the scan making progress:

mutation why it never terminates
j *= 1 freezes the inner cursor, so the run-counting loop never reaches the end of the input
run < 40 sends a zero-length run down the redact branch, which advances by i += run — i.e. not at all
i *= 1 freezes the outer cursor directly

cargo-mutants times each out after 58s of a 63s build, the shard exits 3, and the workflow prints "Raise timeout_multiplier in .cargo/mutants.toml, or the mutant caused a genuine hang."

It is the second of those. Raising timeout_multiplier would only make the job take longer before failing, and would weaken the gate for every other mutant in the suite. So the fix is to remove the hang.

One cause, three symptoms

All three share a root: a hand-written cursor walked by arithmetic inside while i < chars.len(), with three separate expressions responsible for making progress and nothing enforcing that any of them does.

Grouping the input into maximal runs of same-class characters removes the cursor entirely. Progress becomes structural — the loop is over groups — so no arithmetic slip can turn the scan into an infinite loop. It is also less code: 28 lines replacing 175.

Behaviour is unchanged, and that is tested

A differential test reproduces the original cursor walk verbatim and asserts the two agree across fourteen inputs covering every branch of both: runs at the threshold and one either side, adjacent runs, runs at each end of the string, punctuation-only input, and non-ASCII text.

The magic numbers also gain names — MIN_REDACTED_RUN for the 40-character threshold and is_base64_char for the alphabet test — which the tests assert against rather than restating.

Mutants

All 12 mutants of the rewritten code were hand-applied and the suite confirmed to fail:

12 killed, 0 survived, 0 hung.

That includes the >= MIN_REDACTED_RUN< mutation that used to hang; it is now an ordinary caught mutant.

Two kills are worth naming, because both are real leak paths:

  • A run of 40+ characters that are not base64 (punctuation, say) must not be redacted — this pins the class check sitting next to the length check.
  • + and / must keep counting. If they stopped, a real base64 key would split into runs of 17, 14 and 7 — none long enough to redact — and the whole key would be written to the audit log in clear.

cargo fmt --check, cargo clippy --all-targets -- -D warnings and the full 881-test lib suite are clean.

The mutation gate on main has been failing, and not because a mutant
survived. Three mutants in sanitize_tool_input hung the job outright:

  guard.rs:91  replace += with *= in sanitize_tool_input   (j *= 1)
  guard.rs:93  replace >= with <  in sanitize_tool_input
  guard.rs:98  replace += with *= in sanitize_tool_input   (i *= 1)

Each one stops the scan making progress. `j *= 1` freezes the inner
cursor, so the run-counting loop never reaches the end of the input.
`run < 40` sends a zero-length run down the redact branch, which then
advances by `i += run`, i.e. not at all. `i *= 1` freezes the outer
cursor directly. cargo-mutants times each out after 58s of a 63s build,
the shard exits 3, and the workflow reports "Raise timeout_multiplier in
.cargo/mutants.toml, or the mutant caused a genuine hang."

It is the second of those. Raising timeout_multiplier would only make
the job take longer before failing, and would weaken the gate for every
other mutant in the suite, so the fix is to remove the hang.

All three share one cause: a hand-written cursor walked by arithmetic
inside `while i < chars.len()`, with three separate expressions
responsible for making progress and nothing enforcing that any of them
does. Grouping the input into maximal runs of same-class characters
removes the cursor entirely, so progress is structural and no
arithmetic slip can turn the scan into an infinite loop. It is also
less code: 28 lines replacing 175.

Behaviour is unchanged. A differential test reproduces the original
cursor walk verbatim and asserts the two agree across fourteen inputs
covering every branch of both, including runs at the threshold and one
either side of it, adjacent runs, runs at each end of the string, and
non-ASCII text.

The magic numbers gain names: the 40-character threshold is
MIN_REDACTED_RUN and the alphabet test is is_base64_char, which the
tests now assert against rather than restating.

All 12 mutants of the rewritten code were hand-applied and the suite
confirmed to fail: 12 killed, 0 survived, 0 hung. That includes the
`>= MIN_REDACTED_RUN` -> `<` mutation that used to hang, which is now
an ordinary caught mutant.

Two of the kills are worth naming. A run of 40+ characters that are NOT
base64 (punctuation, say) must not be redacted, which pins the class
check next to the length check. And `+` and `/` must keep counting: if
they stopped, a real base64 key would split into runs of 17, 14 and 7,
none long enough to redact, and the whole key would be written to the
audit log in clear.
@acamarata
acamarata merged commit 55589c2 into main Sep 16, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant