rto_exec/snippet.rs
1//! The source text a finding points at, read from the analyzed worktree.
2//!
3//! # Why this exists rather than using the analyzer's own snippet
4//!
5//! ADR-0012's identity recipe for semgrep ends in a `<snippet-hash>`, so that a
6//! finding whose rule and byte offset are unchanged but whose *code* changed is a
7//! new finding rather than the old one silently carried forward.
8//!
9//! Semgrep cannot supply that snippet. Its JSON output carries `extra.lines` and
10//! `extra.fingerprint`, and in the open-source CLI both are the literal string
11//! `"requires login"` unless the caller is authenticated to Semgrep's hosted
12//! platform — verified directly against semgrep 1.136.0 with `--json`, with and
13//! without `--quiet`. Hashing that would make every finding's identity component
14//! a constant, and worse, would make finding keys *change* the day somebody logs
15//! in.
16//!
17//! So the snippet is read from the tree instead. That is strictly better: it is
18//! a function of the source rather than of the analyzer's authentication state,
19//! which is what an identity component ought to be, and it makes a subprocess run
20//! and an ingest of the same report agree by construction — both read the same
21//! checkout.
22//!
23//! @rto:0012
24
25use std::cell::RefCell;
26use std::collections::HashMap;
27use std::path::{Path, PathBuf};
28
29use crate::runner::check_reported_path;
30
31/// Somewhere the bytes a finding points at can be read from.
32pub trait SnippetSource {
33 /// The text between `start` and `end` in `path`, or `None` when it cannot be
34 /// read — no such file, offsets past the end, or bytes that are not UTF-8.
35 ///
36 /// `None` is a normal answer, not an error: a report can legitimately
37 /// describe a tree the caller does not have.
38 fn snippet(&self, path: &str, start: u32, end: u32) -> Option<String>;
39}
40
41/// A [`SnippetSource`] that never has anything — for callers with no checkout,
42/// and for tests that do not care.
43#[derive(Debug, Clone, Copy, Default)]
44pub struct NoSnippets;
45
46impl SnippetSource for NoSnippets {
47 fn snippet(&self, _path: &str, _start: u32, _end: u32) -> Option<String> {
48 None
49 }
50}
51
52/// Reads snippets out of a worktree, caching each file it touches.
53///
54/// A scan produces many findings in few files, so the cache turns a read per
55/// finding into a read per file. It is bounded by
56/// [`WorktreeSnippets::MAX_FILE_BYTES`] per file: a report naming a huge
57/// generated file must not be able to make Roteiro read it into memory.
58#[derive(Debug)]
59pub struct WorktreeSnippets {
60 root: PathBuf,
61 cache: RefCell<HashMap<String, Option<Vec<u8>>>>,
62}
63
64impl WorktreeSnippets {
65 /// Largest file a snippet will be read from. Beyond this the snippet is
66 /// unavailable and the identity says so, which is a better outcome than
67 /// buffering an arbitrary file because a report asked.
68 pub const MAX_FILE_BYTES: u64 = 8 << 20;
69
70 /// Read snippets relative to `root`.
71 #[must_use]
72 pub fn new(root: impl Into<PathBuf>) -> Self {
73 Self {
74 root: root.into(),
75 cache: RefCell::new(HashMap::new()),
76 }
77 }
78
79 /// The file's bytes, read once and remembered — including the fact that it
80 /// could not be read, so a missing file is not re-stat'd per finding.
81 fn bytes(&self, path: &str) -> Option<Vec<u8>> {
82 if let Some(hit) = self.cache.borrow().get(path) {
83 return hit.clone();
84 }
85 let read = self.read(path);
86 self.cache
87 .borrow_mut()
88 .insert(path.to_owned(), read.clone());
89 read
90 }
91
92 fn read(&self, path: &str) -> Option<Vec<u8>> {
93 // A report is untrusted input. The same check the ingest path applies to
94 // a finding's path applies here, *before* the path is joined onto the
95 // root — otherwise a report naming `../../.ssh/id_ed25519` would have
96 // Roteiro read it and hash it into a stored record.
97 check_reported_path(path).ok()?;
98 let full = self.root.join(Path::new(path));
99 let meta = std::fs::metadata(&full).ok()?;
100 if !meta.is_file() || meta.len() > Self::MAX_FILE_BYTES {
101 return None;
102 }
103 std::fs::read(&full).ok()
104 }
105}
106
107impl SnippetSource for WorktreeSnippets {
108 fn snippet(&self, path: &str, start: u32, end: u32) -> Option<String> {
109 if end < start {
110 return None;
111 }
112 let bytes = self.bytes(path)?;
113 let (from, to) = (start as usize, end as usize);
114 // An offset past the end means the report and the tree disagree about
115 // the file. Returning `None` rather than a truncated slice keeps the
116 // identity from being built out of the wrong bytes.
117 if to > bytes.len() {
118 return None;
119 }
120 String::from_utf8(bytes[from..to].to_vec()).ok()
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::{NoSnippets, SnippetSource, WorktreeSnippets};
127
128 /// A throwaway directory that removes itself, so these tests need no
129 /// dev-dependency on a temp-dir crate.
130 struct Scratch(std::path::PathBuf);
131
132 impl Scratch {
133 fn new(name: &str) -> Self {
134 let dir = std::env::temp_dir().join(format!("rto-exec-snippet-{name}"));
135 std::fs::remove_dir_all(&dir).ok();
136 std::fs::create_dir_all(dir.join("src")).expect("create");
137 std::fs::write(dir.join("src/app.py"), b"import os\nos.system(cmd)\n").expect("write");
138 Self(dir)
139 }
140 }
141
142 impl Drop for Scratch {
143 fn drop(&mut self) {
144 std::fs::remove_dir_all(&self.0).ok();
145 }
146 }
147
148 #[test]
149 fn reads_the_bytes_a_finding_points_at() {
150 let scratch = Scratch::new("reads");
151 let snippets = WorktreeSnippets::new(&scratch.0);
152 assert_eq!(
153 snippets.snippet("src/app.py", 10, 24).as_deref(),
154 Some("os.system(cmd)")
155 );
156 // The second read is served from the cache and must agree with the first.
157 assert_eq!(
158 snippets.snippet("src/app.py", 10, 24).as_deref(),
159 Some("os.system(cmd)")
160 );
161 }
162
163 #[test]
164 fn a_file_it_does_not_have_is_simply_unavailable() {
165 let scratch = Scratch::new("missing");
166 let snippets = WorktreeSnippets::new(&scratch.0);
167 assert!(snippets.snippet("src/nope.py", 0, 4).is_none());
168 }
169
170 /// A report is untrusted input, so a path that climbs out of the worktree is
171 /// refused here as well as on the ingest path. Reading it would put file
172 /// contents from outside the tree into a stored identity.
173 #[test]
174 fn refuses_to_read_outside_the_worktree() {
175 let scratch = Scratch::new("escape");
176 let snippets = WorktreeSnippets::new(&scratch.0);
177 for hostile in ["../../../etc/passwd", "/etc/passwd", ""] {
178 assert!(snippets.snippet(hostile, 0, 4).is_none(), "{hostile:?}");
179 }
180 }
181
182 #[test]
183 fn offsets_past_the_end_yield_nothing_rather_than_a_truncated_slice() {
184 let scratch = Scratch::new("bounds");
185 let snippets = WorktreeSnippets::new(&scratch.0);
186 assert!(snippets.snippet("src/app.py", 0, 9_999).is_none());
187 assert!(snippets.snippet("src/app.py", 20, 5).is_none());
188 }
189
190 #[test]
191 fn the_empty_source_answers_nothing() {
192 assert!(NoSnippets.snippet("src/app.py", 0, 4).is_none());
193 }
194}