Skip to main content

pmpx_engine/
files.rs

1//! Reading the files a plugin asked to see.
2//!
3//! A plugin declares them in its manifest's `[context] files`, and the loader asks this provider for
4//! one by name when the plugin asks for it. That keeps two things true at once: what a plugin can see is
5//! written down in one auditable place, and pmpx still knows nothing about what any of these files
6//! mean.
7//!
8//! The declaration is the **allowlist**: a name that is not in it is never read, whether or not the
9//! plugin asks, and a name that would leave the project is refused whatever it says.
10
11use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Component, Path, PathBuf};
15
16use pmpx_loader::Files;
17
18/// The most one file may contribute. A lockfile or a manifest is kilobytes; anything at this size is a
19/// mistake, and the plugin is handed the beginning rather than the lot.
20pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
21
22/// The most files one plugin may declare. Past this the declarations are ignored, with a note -- a
23/// plugin that declares hundreds of files is not doing something this channel is for.
24pub const MAX_FILES: usize = 16;
25
26/// What a plugin declared, and the root to read it from.
27pub struct Declared {
28    root: PathBuf,
29    names: Vec<String>,
30    /// Answers already given: a file is read once per run, however often it is asked for.
31    cache: RefCell<BTreeMap<String, Option<Vec<u8>>>>,
32    /// What happened while reading, for the caller to turn into events.
33    notes: RefCell<Vec<String>>,
34}
35
36impl Declared {
37    /// Take the declarations from one plugin's manifest.
38    pub fn new(root: &Path, wanted: &[String]) -> Self {
39        let mut notes = Vec::new();
40        if wanted.len() > MAX_FILES {
41            notes.push(format!(
42                "the manifest declares {} context files; only the first {MAX_FILES} are read",
43                wanted.len()
44            ));
45        }
46
47        let names = wanted.iter().take(MAX_FILES).cloned().collect();
48        Self {
49            root: root.to_path_buf(),
50            names,
51            cache: RefCell::new(BTreeMap::new()),
52            notes: RefCell::new(notes),
53        }
54    }
55
56    /// Everything worth saying about the reads so far, oldest first.
57    ///
58    /// Collected rather than reported because the loader calls this from inside a plugin's call, where
59    /// there is no event sink to hand: the engine drains this once the call returns.
60    pub fn take_notes(&self) -> Vec<String> {
61        std::mem::take(&mut self.notes.borrow_mut())
62    }
63
64    /// Read one file, or explain why it cannot be handed over.
65    fn read(&self, name: &str) -> Option<Vec<u8>> {
66        let note = |message: String| self.notes.borrow_mut().push(message);
67
68        if !self.names.iter().any(|declared| declared == name) {
69            // Not the plugin's to read: the manifest is the allowlist, so this is refused without
70            // touching the filesystem at all.
71            note(format!(
72                "ignoring the context file {name:?}: the manifest does not declare it"
73            ));
74            return None;
75        }
76
77        let Some(relative) = inside_project(name) else {
78            note(format!(
79                "ignoring the declared context file {name:?}: only plain relative paths inside the \
80                 project can be read"
81            ));
82            return None;
83        };
84
85        match fs::read(self.root.join(&relative)) {
86            Ok(mut bytes) => {
87                if bytes.len() as u64 > MAX_FILE_BYTES {
88                    bytes.truncate(MAX_FILE_BYTES as usize);
89                    note(format!(
90                        "the declared context file {name:?} is larger than {MAX_FILE_BYTES} bytes; the \
91                         plugin gets only its beginning"
92                    ));
93                }
94                Some(bytes)
95            }
96            // The plugin asked for something that is not there; that is an answer too, and the plugin
97            // can tell the difference between "empty" and "not given".
98            Err(e) => {
99                note(format!(
100                    "the declared context file {name:?} was not read: {e}"
101                ));
102                None
103            }
104        }
105    }
106}
107
108impl Files for Declared {
109    fn contents(&self, name: &str) -> Option<Vec<u8>> {
110        let mut cache = self.cache.borrow_mut();
111
112        if let Some(known) = cache.get(name) {
113            return known.clone();
114        }
115
116        let answer = self.read(name);
117        cache.insert(name.to_string(), answer.clone());
118        answer
119    }
120}
121
122/// The path to read for one declaration, or `None` when it is not something this channel may read.
123///
124/// Rejecting is the point: a manifest is written by whoever published the plugin, so `..` or an
125/// absolute path there would be a way to ask for files outside the project -- a plugin's business is the
126/// project, not the machine it happens to sit on.
127pub fn inside_project(declaration: &str) -> Option<PathBuf> {
128    let path = Path::new(declaration);
129
130    if path.as_os_str().is_empty() || path.is_absolute() {
131        return None;
132    }
133
134    let mut relative = PathBuf::new();
135    for component in path.components() {
136        match component {
137            // `./x` and `x` mean the same thing; normalising keeps the join honest.
138            Component::CurDir => {}
139            Component::Normal(part) => relative.push(part),
140            // `..`, a drive letter, a root, or a UNC prefix: all ways out of the project.
141            _ => return None,
142        }
143    }
144
145    if relative.as_os_str().is_empty() {
146        None
147    } else {
148        Some(relative)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn declared(names: &[&str]) -> Vec<String> {
157        names.iter().map(|s| s.to_string()).collect()
158    }
159
160    fn provider(root: &Path, names: &[&str]) -> Declared {
161        Declared::new(root, &declared(names))
162    }
163
164    #[test]
165    fn a_declared_file_is_read() {
166        let dir = tempfile::tempdir().unwrap();
167        fs::write(dir.path().join("package.json"), "{\"name\":\"x\"}").unwrap();
168
169        let files = provider(dir.path(), &["package.json"]);
170
171        assert_eq!(
172            files.contents("package.json"),
173            Some(b"{\"name\":\"x\"}".to_vec())
174        );
175        assert!(files.take_notes().is_empty(), "nothing to report");
176    }
177
178    /// A file the manifest did not declare is never read, even if the plugin asks for it by name.
179    #[test]
180    fn an_undeclared_file_is_refused_and_said_so() {
181        let dir = tempfile::tempdir().unwrap();
182        fs::write(dir.path().join("secret.txt"), "not yours").unwrap();
183
184        let files = provider(dir.path(), &["package.json"]);
185
186        assert_eq!(files.contents("secret.txt"), None);
187        let notes = files.take_notes();
188        assert_eq!(notes.len(), 1, "{notes:?}");
189        assert!(notes[0].contains("does not declare"), "{notes:?}");
190        assert!(files.take_notes().is_empty(), "notes are drained once");
191    }
192
193    /// A file that is not there is simply absent: the plugin declared it, so it knows what "not in the
194    /// answer" means, and nothing here should be able to fail a run.
195    #[test]
196    fn a_missing_file_is_simply_absent() {
197        let dir = tempfile::tempdir().unwrap();
198
199        let files = provider(dir.path(), &["nope.toml"]);
200
201        assert_eq!(files.contents("nope.toml"), None);
202        assert!(
203            files.take_notes()[0].contains("was not read"),
204            "{:?}",
205            files.take_notes()
206        );
207    }
208
209    /// Anything that could leave the project is refused, declared or not.
210    #[test]
211    fn a_path_that_leaves_the_project_is_refused() {
212        let dir = tempfile::tempdir().unwrap();
213        fs::write(dir.path().join("secret.txt"), "not yours").unwrap();
214
215        let files = provider(
216            dir.path(),
217            &["../secret.txt", "/etc/passwd", "a/../../secret.txt"],
218        );
219
220        for name in ["../secret.txt", "/etc/passwd", "a/../../secret.txt"] {
221            assert_eq!(files.contents(name), None, "{name} must not be readable");
222        }
223        assert!(inside_project("../secret.txt").is_none());
224        assert!(inside_project("/etc/passwd").is_none());
225        assert!(inside_project("a/../../secret.txt").is_none());
226        assert_eq!(
227            inside_project("./package.json"),
228            Some(PathBuf::from("package.json"))
229        );
230    }
231
232    #[test]
233    fn a_file_larger_than_the_limit_comes_back_truncated() {
234        let dir = tempfile::tempdir().unwrap();
235        let big = vec![b'x'; (MAX_FILE_BYTES + 10) as usize];
236        fs::write(dir.path().join("big.lock"), &big).unwrap();
237
238        let files = provider(dir.path(), &["big.lock"]);
239
240        assert_eq!(
241            files.contents("big.lock").map(|bytes| bytes.len() as u64),
242            Some(MAX_FILE_BYTES)
243        );
244        assert!(
245            files.take_notes()[0].contains("larger than"),
246            "truncation is reported"
247        );
248    }
249
250    #[test]
251    fn only_the_first_files_are_readable() {
252        let dir = tempfile::tempdir().unwrap();
253        let names: Vec<String> = (0..MAX_FILES + 3).map(|i| format!("f{i}.txt")).collect();
254        for name in &names {
255            fs::write(dir.path().join(name), "x").unwrap();
256        }
257
258        let declared: Vec<&str> = names.iter().map(String::as_str).collect();
259        let files = provider(dir.path(), &declared);
260
261        assert!(files.contents(&names[MAX_FILES - 1]).is_some());
262        assert_eq!(
263            files.contents(&names[MAX_FILES]),
264            None,
265            "past the declared cap"
266        );
267        assert!(
268            files.take_notes()[0].contains("only the first"),
269            "the cap is reported"
270        );
271    }
272
273    /// A directory is not a file: reading one fails on every platform, and it must not turn into an
274    /// empty entry the plugin would take for an empty file.
275    #[test]
276    fn a_directory_is_not_a_file() {
277        let dir = tempfile::tempdir().unwrap();
278        fs::create_dir(dir.path().join("sub")).unwrap();
279
280        let files = provider(dir.path(), &["sub"]);
281
282        assert_eq!(files.contents("sub"), None);
283    }
284}