use crate::check::{Violation, ViolationKind};
fn is_attribute(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("#[") || t.starts_with("#![")
}
fn is_comment(line: &str) -> bool {
line.trim_start().starts_with("//")
}
fn is_justified(lines: &[&str], i: usize) -> bool {
if let Some((_, tail)) = lines[i].rsplit_once(']')
&& tail.contains("//")
{
return true;
}
let mut j = i;
while j > 0 && is_attribute(lines[j - 1]) {
j -= 1;
}
j > 0 && is_comment(lines[j - 1])
}
#[must_use]
pub fn scan_lossy_identity(rel_path: &str, text: &str) -> Vec<Violation> {
if !std::path::Path::new(rel_path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("rs"))
{
return Vec::new();
}
if rto_graph::is_scan_exempt(text.as_bytes()) {
return Vec::new();
}
let lines: Vec<&str> = text.lines().collect();
let mut out = Vec::new();
for (i, line) in lines.iter().enumerate() {
if !line.contains("to_string_lossy") {
continue;
}
if HASH_MARKERS.iter().any(|m| line.contains(m)) {
out.push(Violation {
kind: ViolationKind::LossyIdentity,
message: format!(
"{rel_path}:{}: a lossy string conversion reaches a hash — two \
inputs differing only in invalid UTF-8 collapse to one digest, \
and the second silently replaces the first. Hash the bytes \
(`as_os_str().as_encoded_bytes()`) rather than the lossy string, \
or reject non-UTF-8 input explicitly.",
i + 1
),
});
}
}
out
}
const HASH_MARKERS: [&str; 5] = ["sha256", "Sha256", "Hasher", "blake3", "digest"];
#[must_use]
pub fn scan_unjustified_allows(rel_path: &str, text: &str) -> Vec<Violation> {
if !std::path::Path::new(rel_path)
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("rs"))
{
return Vec::new();
}
if rto_graph::is_scan_exempt(text.as_bytes()) {
return Vec::new();
}
let lines: Vec<&str> = text.lines().collect();
lines
.iter()
.enumerate()
.filter(|(_, l)| {
let t = l.trim_start();
t.starts_with("#[allow(") || t.starts_with("#![allow(")
})
.filter(|(i, _)| !is_justified(&lines, *i))
.map(|(i, _)| Violation {
kind: ViolationKind::UnjustifiedAllow,
message: format!(
"{rel_path}:{}: `#[allow(…)]` carries no justification — AGENTS.md \
asks that an allow be justified in a comment, so a reader can tell \
a considered exception from a silenced warning",
i + 1
),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{ViolationKind, scan_lossy_identity};
#[test]
fn a_lossy_conversion_feeding_a_hash_is_reported() {
let v = scan_lossy_identity(
"src/runner.rs",
"let digest = sha256_hex(absolute.to_string_lossy().as_bytes());\n",
);
assert_eq!(v.len(), 1, "{v:?}");
assert_eq!(v[0].kind, ViolationKind::LossyIdentity);
assert!(v[0].message.contains("src/runner.rs:1"), "{}", v[0].message);
}
#[test]
fn a_lossy_conversion_in_a_message_is_not_reported() {
for line in [
"eprintln!(\"cannot read {}\", path.to_string_lossy());",
"let name = entry.file_name().to_string_lossy().into_owned();",
"anyhow::bail!(\"{}: unreadable\", p.to_string_lossy())",
] {
assert!(
scan_lossy_identity("src/x.rs", line).is_empty(),
"false positive on: {line}"
);
}
}
#[test]
fn a_conversion_near_a_hash_but_not_in_it_is_not_reported() {
let src = "found.push((\n entry.file_name().to_string_lossy().into_owned(),\n sha256_hex(&bytes),\n));\n";
assert!(
scan_lossy_identity("tests/pin.rs", src).is_empty(),
"a conversion two lines from a hash it does not feed must not fire"
);
}
#[test]
fn a_mention_in_prose_is_not_a_use() {
let md = "Call `sha256_hex(p.to_string_lossy().as_bytes())` to hash a path.\n";
assert!(scan_lossy_identity("docs/guide.md", md).is_empty());
}
use super::scan_unjustified_allows;
fn hits(text: &str) -> Vec<String> {
scan_unjustified_allows("src/x.rs", text)
.into_iter()
.map(|v| v.message)
.collect()
}
#[test]
fn an_allow_with_a_comment_above_it_is_justified() {
assert!(hits("// why this is right\n#[allow(clippy::foo)]\nfn f() {}\n").is_empty());
}
#[test]
fn a_bare_allow_is_a_violation_naming_its_line() {
let h = hits("fn f() {\n #[allow(clippy::foo)]\n let x = 1;\n}\n");
assert_eq!(h.len(), 1);
assert!(h[0].contains("src/x.rs:2:"), "{}", h[0]);
assert!(h[0].contains("no justification"), "{}", h[0]);
}
#[test]
fn a_justification_above_an_intervening_attribute_still_counts() {
assert!(
hits(
"// the prefix is intentional\n#[derive(Debug)]\n#[allow(clippy::foo)]\nstruct S;\n"
)
.is_empty()
);
}
#[test]
fn a_blank_line_separates_a_comment_from_the_attribute() {
assert_eq!(
hits("// unrelated prose\n\n#[allow(clippy::foo)]\nfn f() {}\n").len(),
1
);
}
#[test]
fn a_trailing_comment_on_the_attribute_line_justifies_it() {
assert!(hits("#[allow(clippy::foo)] // narrow, and deliberate\nfn f() {}\n").is_empty());
}
#[test]
fn an_inner_allow_at_the_top_of_a_file_is_checked_too() {
assert_eq!(hits("#![allow(dead_code)]\nfn f() {}\n").len(), 1);
assert!(
hits("//! shared fixture, not every consumer uses every path\n#![allow(dead_code)]\n")
.is_empty()
);
}
#[test]
fn only_rust_sources_are_scanned() {
let text = "#[allow(clippy::foo)]\n";
assert!(scan_unjustified_allows("docs/AGENTS.md", text).is_empty());
assert!(scan_unjustified_allows("fixtures/x.json", text).is_empty());
assert_eq!(scan_unjustified_allows("src/x.rs", text).len(), 1);
assert_eq!(scan_unjustified_allows("src/X.RS", text).len(), 1);
}
#[test]
fn a_file_declaring_itself_fixture_data_is_exempt() {
let text = "// roteiro:ignore-file — fixtures below\n#[allow(clippy::foo)]\nfn f() {}\n";
assert!(hits(text).is_empty());
assert_eq!(
hits("// fixtures below\n\n#[allow(clippy::foo)]\nfn f() {}\n").len(),
1
);
}
#[test]
fn expect_is_not_allow() {
assert!(hits("#[expect(clippy::foo)]\nfn f() {}\n").is_empty());
}
}