Tiebreak equal-length redactions on name
When two declared secrets resolve to the same value, sort_by_key on
length alone left the choice of replacement name to HashMap iteration
order — i.e. random across runs. Add a secondary sort on name so the
output is reproducible (matters for snapshot tests, log diffing, and
the operator's sanity).
Assisted-by: Claude Opus 4.7 (1M context)
diff --git a/src/ci/redact.rs b/src/ci/redact.rs
index 21cee12..a93e17c 100644
--- a/src/ci/redact.rs
+++ b/src/ci/redact.rs
@@ -88,14 +88,16 @@ impl SecretRegistry {
/// Return revealed (name, value) pairs sorted by value length
/// descending so longest matches are replaced first (prevents
- /// partial replacement of overlapping secrets).
+ /// partial replacement of overlapping secrets). Equal-length
+ /// values tiebreak on name, so two names that map to the same
+ /// value redact deterministically.
fn entries(&self) -> Vec<(&str, &str)> {
let mut entries: Vec<_> = self
.revealed
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
- entries.sort_by_key(|b| std::cmp::Reverse(b.1.len()));
+ entries.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
entries
}
@@ -251,4 +253,16 @@ mod tests {
let input = "contains ghp_long_secret_value but not resolved";
assert_eq!(redact(input, ®), input);
}
+
+ #[test]
+ fn redact_tiebreaks_equal_length_by_name() {
+ // Two names with the same revealed value: alphabetical name wins.
+ let mut reg = SecretRegistry::new(plain_secrets(&[
+ ("zzz_late", "samevalue"),
+ ("aaa_early", "samevalue"),
+ ]));
+ reg.resolve("zzz_late").unwrap();
+ reg.resolve("aaa_early").unwrap();
+ assert_eq!(redact("samevalue", ®), "{{ aaa_early }}");
+ }
}