1use std::cell::RefCell;
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Component, Path, PathBuf};
15
16use pmpx_loader::Files;
17
18pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
21
22pub const MAX_FILES: usize = 16;
25
26pub struct Declared {
28 root: PathBuf,
29 names: Vec<String>,
30 cache: RefCell<BTreeMap<String, Option<Vec<u8>>>>,
32 notes: RefCell<Vec<String>>,
34}
35
36impl Declared {
37 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 pub fn take_notes(&self) -> Vec<String> {
61 std::mem::take(&mut self.notes.borrow_mut())
62 }
63
64 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 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 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
122pub 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 Component::CurDir => {}
139 Component::Normal(part) => relative.push(part),
140 _ => 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 #[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 #[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 #[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 #[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}