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 DIRECTORY: &str = ".drep";
16const CONTEXT_RADIUS: u32 = 3;
17const PARSE_MESSAGE_MAX: usize = 200;
19
20#[derive(Debug, Default, Deserialize, Serialize)]
21#[serde(deny_unknown_fields)]
22pub struct Store {
23 #[serde(default)]
24 fingerprints: BTreeSet<String>,
25}
26
27impl Store {
28 pub fn load(root: &Path) -> Result<Self> {
37 let path = root.join(DEFAULT_PATH);
38 refuse_link(&root.join(DIRECTORY))?;
39 refuse_link(&path)?;
40 let raw = match std::fs::read_to_string(&path) {
41 Ok(raw) => raw,
42 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
43 Err(err) => {
44 return Err(err).with_context(|| format!("could not read {}", path.display()));
45 }
46 };
47 toml::from_str(&raw).map_err(|err: toml::de::Error| {
48 anyhow!(
49 "could not parse {}: {}",
50 path.display(),
51 crate::text::excerpt(err.message(), PARSE_MESSAGE_MAX)
52 )
53 })
54 }
55
56 pub fn contains(&self, fingerprint: &str) -> bool {
57 self.fingerprints.contains(fingerprint)
58 }
59
60 pub fn insert(&mut self, fingerprint: String) -> bool {
61 self.fingerprints.insert(fingerprint)
62 }
63
64 pub fn save(&self, root: &Path) -> Result<()> {
65 let path = root.join(DEFAULT_PATH);
66 let parent = path
67 .parent()
68 .ok_or_else(|| anyhow!("{} has no parent", path.display()))?;
69 refuse_link(parent)?;
74 std::fs::create_dir_all(parent)
75 .with_context(|| format!("could not create {}", parent.display()))?;
76 let rendered = toml::to_string_pretty(self).context("could not render acknowledgements")?;
77 let mut temporary = tempfile::NamedTempFile::new_in(parent).with_context(|| {
78 format!("could not create a temporary file in {}", parent.display())
79 })?;
80 temporary
81 .write_all(rendered.as_bytes())
82 .and_then(|()| temporary.flush())
83 .and_then(|()| temporary.as_file().sync_all())
84 .with_context(|| format!("could not write {}", path.display()))?;
85 temporary
86 .persist(&path)
87 .map_err(|err| err.error)
88 .with_context(|| format!("could not publish {}", path.display()))?;
89 Ok(())
90 }
91}
92
93fn refuse_link(path: &Path) -> Result<()> {
98 if std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
99 return Err(anyhow!(
100 "{} is a symbolic link; acknowledgements are read and written only inside the \
101 repository",
102 path.display()
103 ));
104 }
105 Ok(())
106}
107
108pub fn validate_fingerprint(value: &str) -> Result<()> {
109 if value.len() == 64
110 && value
111 .bytes()
112 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
113 {
114 Ok(())
115 } else {
116 Err(anyhow!(
117 "invalid finding fingerprint `{value}`; expected 64 lowercase hexadecimal characters"
118 ))
119 }
120}
121
122pub fn apply(findings: &mut Vec<Finding>, by_file: &[Vec<Hunk>], store: &Store) {
124 let hunks_by_file: std::collections::BTreeMap<&Path, &[Hunk]> = by_file
125 .iter()
126 .filter_map(|hunks| {
127 hunks
128 .first()
129 .map(|first| (first.file_path.as_path(), hunks.as_slice()))
130 })
131 .collect();
132 for finding in findings.iter_mut() {
133 finding.fingerprint = hunks_by_file
134 .get(Path::new(&finding.file_path))
135 .and_then(|hunks| fingerprint(finding, hunks));
136 }
137 findings.retain(|finding| {
138 finding
139 .fingerprint
140 .as_deref()
141 .is_none_or(|fingerprint| !store.contains(fingerprint))
142 });
143}
144
145fn fingerprint(finding: &Finding, hunks: &[Hunk]) -> Option<String> {
146 let start = finding.line.saturating_sub(CONTEXT_RADIUS);
147 let end = finding.line.saturating_add(CONTEXT_RADIUS);
148 let mut context = Vec::new();
149 let mut contains_target = false;
150 for (number, content) in hunks.iter().flat_map(Hunk::numbered_new_lines) {
151 if number == finding.line {
152 contains_target = true;
153 }
154 if (start..=end).contains(&number) {
155 context.push((number == finding.line, content));
156 }
157 }
158 if !contains_target {
159 return None;
160 }
161 let mut hasher = blake3::Hasher::new();
162 hasher.update(b"drep-acknowledgement-v1\0");
163 hasher.update(finding.file_path.as_bytes());
164 hasher.update(b"\0");
165 hasher.update(finding.kind.as_bytes());
166 for (is_target, line) in context {
167 hasher.update(b"\0");
168 hasher.update(if is_target { b"target\0" } else { b"context\0" });
169 hasher.update(line.as_bytes());
170 }
171 Some(hasher.finalize().to_hex().to_string())
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::analysis::findings::Severity;
178 use std::path::PathBuf;
179
180 fn finding(line: u32) -> Finding {
181 Finding {
182 kind: "bug".to_owned(),
183 severity: Severity::Error,
184 file_path: "src/lib.rs".to_owned(),
185 line,
186 column: None,
187 message: "message".to_owned(),
188 suggestion: None,
189 asserts_compile_failure: false,
190 fingerprint: None,
191 }
192 }
193
194 #[test]
195 fn fingerprint_survives_line_movement_but_expires_on_source_change() {
196 let original = Hunk::whole_file(
197 PathBuf::from("src/lib.rs"),
198 "zero\na\nb\nc\ntarget\nd\ne\nf\ntail\n",
199 );
200 let shifted = Hunk::whole_file(
201 PathBuf::from("src/lib.rs"),
202 "extra\nzero\na\nb\nc\ntarget\nd\ne\nf\ntail\n",
203 );
204 let changed = Hunk::whole_file(
205 PathBuf::from("src/lib.rs"),
206 "zero\na\nb\nc\ntarget changed\nd\ne\nf\ntail\n",
207 );
208 let first = fingerprint(&finding(5), &[original]).expect("first fingerprint");
209 assert_eq!(first, fingerprint(&finding(6), &[shifted]).unwrap());
210 assert_ne!(first, fingerprint(&finding(5), &[changed]).unwrap());
211 }
212
213 #[test]
214 fn store_round_trips_atomically() {
215 let dir = tempfile::tempdir().expect("tempdir");
216 let mut store = Store::default();
217 let key = "a".repeat(64);
218 assert!(store.insert(key.clone()));
219 store.save(dir.path()).expect("save");
220 assert!(Store::load(dir.path()).expect("load").contains(&key));
221 }
222
223 #[test]
224 fn a_store_read_error_is_not_treated_as_an_absent_store() {
225 let dir = tempfile::tempdir().expect("tempdir");
226 let path = dir.path().join(DEFAULT_PATH);
227 std::fs::create_dir_all(&path).expect("directory in place of store file");
228
229 let err = Store::load(dir.path()).expect_err("a present unreadable store must fail");
230
231 assert!(
232 err.to_string().contains("could not read"),
233 "the error should identify the failed read: {err:#}"
234 );
235 }
236
237 #[cfg(unix)]
238 #[test]
239 fn a_store_linked_out_of_the_repository_is_neither_read_nor_quoted() {
240 let outside = tempfile::tempdir().expect("outside dir");
244 let secret = outside.path().join("netrc");
245 std::fs::write(&secret, "machine example.com password hunter2-secret\n")
246 .expect("outside file");
247 let repo = tempfile::tempdir().expect("repo");
248 std::fs::create_dir(repo.path().join(".drep")).expect(".drep");
249 std::os::unix::fs::symlink(&secret, repo.path().join(DEFAULT_PATH)).expect("symlink");
250
251 let err = Store::load(repo.path()).expect_err("a linked store must be refused");
252 let rendered = format!("{err:#} {err:?}");
253
254 assert!(
255 !rendered.contains("hunter2-secret"),
256 "the error quoted a file outside the repository: {rendered}"
257 );
258 assert!(rendered.contains("is a symbolic link"), "{rendered}");
259 }
260
261 #[cfg(unix)]
262 #[test]
263 fn a_store_inside_a_linked_directory_is_not_read() {
264 let outside = tempfile::tempdir().expect("outside dir");
266 let mut store = Store::default();
267 store.insert("a".repeat(64));
268 store.save(outside.path()).expect("outside store");
269 let repo = tempfile::tempdir().expect("repo");
270 std::os::unix::fs::symlink(outside.path().join(".drep"), repo.path().join(".drep"))
271 .expect("symlink");
272
273 let err = Store::load(repo.path()).expect_err("a linked directory must be refused");
274
275 assert!(format!("{err:#}").contains("is a symbolic link"), "{err:#}");
276 }
277
278 #[test]
279 fn a_parse_error_quotes_neither_the_source_line_nor_its_control_characters() {
280 let repo = tempfile::tempdir().expect("repo");
281 let path = repo.path().join(DEFAULT_PATH);
282 std::fs::create_dir_all(path.parent().expect("parent")).expect(".drep");
283 std::fs::write(
284 &path,
285 "\"\\u001b]52;c;Zm9v\\u0007\" = \"source-line-text\"\n",
286 )
287 .expect("store");
288
289 let err = Store::load(repo.path()).expect_err("an unknown field is a parse error");
290 let rendered = format!("{err:#}");
291
292 assert!(rendered.contains("unknown field"), "{rendered}");
293 assert!(!rendered.contains("source-line-text"), "{rendered}");
294 assert!(!rendered.chars().any(char::is_control), "{rendered:?}");
295 }
296
297 #[cfg(unix)]
298 #[test]
299 fn a_linked_store_directory_is_never_written_through() {
300 let outside = tempfile::tempdir().expect("outside dir");
301 let repo = tempfile::tempdir().expect("repo");
302 std::os::unix::fs::symlink(outside.path(), repo.path().join(".drep")).expect("symlink");
303 let mut store = Store::default();
304 store.insert("a".repeat(64));
305
306 let err = store
307 .save(repo.path())
308 .expect_err("a linked directory must be refused");
309
310 assert!(format!("{err:#}").contains("is a symbolic link"), "{err:#}");
311 assert!(
312 std::fs::read_dir(outside.path())
313 .expect("outside dir")
314 .next()
315 .is_none(),
316 "save wrote into a directory outside the repository"
317 );
318 }
319
320 #[test]
321 fn applying_a_recorded_fingerprint_suppresses_the_same_source_context() {
322 let hunks = vec![vec![Hunk::whole_file(
323 PathBuf::from("src/lib.rs"),
324 "one\ntarget\nthree\n",
325 )]];
326 let mut first = vec![finding(2)];
327 apply(&mut first, &hunks, &Store::default());
328 let key = first[0].fingerprint.clone().expect("fingerprint");
329
330 let mut store = Store::default();
331 store.insert(key);
332 let mut repeated = vec![finding(2)];
333 apply(&mut repeated, &hunks, &store);
334
335 assert!(repeated.is_empty());
336 }
337
338 #[test]
339 fn two_findings_in_the_same_context_have_distinct_fingerprints() {
340 let hunk = Hunk::whole_file(PathBuf::from("src/lib.rs"), "one\ntwo\nthree\n");
341 assert_ne!(
342 fingerprint(&finding(1), std::slice::from_ref(&hunk)),
343 fingerprint(&finding(2), std::slice::from_ref(&hunk))
344 );
345 }
346}