drep/analysis/
acknowledgements.rs1use std::collections::BTreeSet;
4use std::io::Write;
5use std::path::Path;
6
7use anyhow::{Context, Result, anyhow};
8use serde::{Deserialize, Serialize};
9
10use crate::analysis::findings::Finding;
11use crate::diff::hunks::Hunk;
12
13pub const DEFAULT_PATH: &str = ".drep/acknowledgements.toml";
14const CONTEXT_RADIUS: u32 = 3;
15
16#[derive(Debug, Default, Deserialize, Serialize)]
17#[serde(deny_unknown_fields)]
18pub struct Store {
19 #[serde(default)]
20 fingerprints: BTreeSet<String>,
21}
22
23impl Store {
24 pub fn load(root: &Path) -> Result<Self> {
25 let path = root.join(DEFAULT_PATH);
26 let raw = match std::fs::read_to_string(&path) {
27 Ok(raw) => raw,
28 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
29 Err(err) => {
30 return Err(err).with_context(|| format!("could not read {}", path.display()));
31 }
32 };
33 toml::from_str(&raw).with_context(|| format!("could not parse {}", path.display()))
34 }
35
36 pub fn contains(&self, fingerprint: &str) -> bool {
37 self.fingerprints.contains(fingerprint)
38 }
39
40 pub fn insert(&mut self, fingerprint: String) -> bool {
41 self.fingerprints.insert(fingerprint)
42 }
43
44 pub fn save(&self, root: &Path) -> Result<()> {
45 let path = root.join(DEFAULT_PATH);
46 let parent = path
47 .parent()
48 .ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
49 std::fs::create_dir_all(parent)
50 .with_context(|| format!("could not create {}", parent.display()))?;
51 let rendered = toml::to_string_pretty(self).context("could not render acknowledgements")?;
52 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
53 format!("could not create a temporary file in {}", parent.display())
54 })?;
55 temporary
56 .write_all(rendered.as_bytes())
57 .and_then(|()| temporary.flush())
58 .and_then(|()| temporary.as_file().sync_all())
59 .with_context(|| format!("could not write {}", path.display()))?;
60 temporary
61 .persist(&path)
62 .map_err(|err| err.error)
63 .with_context(|| format!("could not publish {}", path.display()))?;
64 Ok(())
65 }
66}
67
68pub fn validate_fingerprint(value: &str) -> Result<()> {
69 if value.len() == 64
70 && value
71 .bytes()
72 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
73 {
74 Ok(())
75 } else {
76 Err(anyhow!(
77 "invalid finding fingerprint `{value}`; expected 64 lowercase hexadecimal characters"
78 ))
79 }
80}
81
82pub fn apply(findings: &mut Vec<Finding>, by_file: &[Vec<Hunk>], store: &Store) {
84 let hunks_by_file: std::collections::BTreeMap<&Path, &[Hunk]> = by_file
85 .iter()
86 .filter_map(|hunks| {
87 hunks
88 .first()
89 .map(|first| (first.file_path.as_path(), hunks.as_slice()))
90 })
91 .collect();
92 for finding in findings.iter_mut() {
93 finding.fingerprint = hunks_by_file
94 .get(Path::new(&finding.file_path))
95 .and_then(|hunks| fingerprint(finding, hunks));
96 }
97 findings.retain(|finding| {
98 finding
99 .fingerprint
100 .as_deref()
101 .is_none_or(|fingerprint| !store.contains(fingerprint))
102 });
103}
104
105fn fingerprint(finding: &Finding, hunks: &[Hunk]) -> Option<String> {
106 let start = finding.line.saturating_sub(CONTEXT_RADIUS);
107 let end = finding.line.saturating_add(CONTEXT_RADIUS);
108 let mut context = Vec::new();
109 let mut contains_target = false;
110 for (number, content) in hunks.iter().flat_map(Hunk::numbered_new_lines) {
111 if number == finding.line {
112 contains_target = true;
113 }
114 if (start..=end).contains(&number) {
115 context.push((number == finding.line, content));
116 }
117 }
118 if !contains_target {
119 return None;
120 }
121 let mut hasher = blake3::Hasher::new();
122 hasher.update(b"drep-acknowledgement-v1\0");
123 hasher.update(finding.file_path.as_bytes());
124 hasher.update(b"\0");
125 hasher.update(finding.kind.as_bytes());
126 for (is_target, line) in context {
127 hasher.update(b"\0");
128 hasher.update(if is_target { b"target\0" } else { b"context\0" });
129 hasher.update(line.as_bytes());
130 }
131 Some(hasher.finalize().to_hex().to_string())
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::analysis::findings::Severity;
138 use std::path::PathBuf;
139
140 fn finding(line: u32) -> Finding {
141 Finding {
142 kind: "bug".to_owned(),
143 severity: Severity::Error,
144 file_path: "src/lib.rs".to_owned(),
145 line,
146 column: None,
147 message: "message".to_owned(),
148 suggestion: None,
149 asserts_compile_failure: false,
150 fingerprint: None,
151 }
152 }
153
154 #[test]
155 fn fingerprint_survives_line_movement_but_expires_on_source_change() {
156 let original = Hunk::whole_file(
157 PathBuf::from("src/lib.rs"),
158 "zero\na\nb\nc\ntarget\nd\ne\nf\ntail\n",
159 );
160 let shifted = Hunk::whole_file(
161 PathBuf::from("src/lib.rs"),
162 "extra\nzero\na\nb\nc\ntarget\nd\ne\nf\ntail\n",
163 );
164 let changed = Hunk::whole_file(
165 PathBuf::from("src/lib.rs"),
166 "zero\na\nb\nc\ntarget changed\nd\ne\nf\ntail\n",
167 );
168 let first = fingerprint(&finding(5), &[original]).expect("first fingerprint");
169 assert_eq!(first, fingerprint(&finding(6), &[shifted]).unwrap());
170 assert_ne!(first, fingerprint(&finding(5), &[changed]).unwrap());
171 }
172
173 #[test]
174 fn store_round_trips_atomically() {
175 let dir = tempfile::tempdir().expect("tempdir");
176 let mut store = Store::default();
177 let key = "a".repeat(64);
178 assert!(store.insert(key.clone()));
179 store.save(dir.path()).expect("save");
180 assert!(Store::load(dir.path()).expect("load").contains(&key));
181 }
182
183 #[test]
184 fn a_store_read_error_is_not_treated_as_an_absent_store() {
185 let dir = tempfile::tempdir().expect("tempdir");
186 let path = dir.path().join(DEFAULT_PATH);
187 std::fs::create_dir_all(&path).expect("directory in place of store file");
188
189 let err = Store::load(dir.path()).expect_err("a present unreadable store must fail");
190
191 assert!(
192 err.to_string().contains("could not read"),
193 "the error should identify the failed read: {err:#}"
194 );
195 }
196
197 #[test]
198 fn applying_a_recorded_fingerprint_suppresses_the_same_source_context() {
199 let hunks = vec![vec![Hunk::whole_file(
200 PathBuf::from("src/lib.rs"),
201 "one\ntarget\nthree\n",
202 )]];
203 let mut first = vec![finding(2)];
204 apply(&mut first, &hunks, &Store::default());
205 let key = first[0].fingerprint.clone().expect("fingerprint");
206
207 let mut store = Store::default();
208 store.insert(key);
209 let mut repeated = vec![finding(2)];
210 apply(&mut repeated, &hunks, &store);
211
212 assert!(repeated.is_empty());
213 }
214
215 #[test]
216 fn two_findings_in_the_same_context_have_distinct_fingerprints() {
217 let hunk = Hunk::whole_file(PathBuf::from("src/lib.rs"), "one\ntwo\nthree\n");
218 assert_ne!(
219 fingerprint(&finding(1), std::slice::from_ref(&hunk)),
220 fingerprint(&finding(2), std::slice::from_ref(&hunk))
221 );
222 }
223}