Skip to main content

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}