use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use pmpx_loader::Files;
pub const MAX_FILE_BYTES: u64 = 1024 * 1024;
pub const MAX_FILES: usize = 16;
pub struct Declared {
root: PathBuf,
names: Vec<String>,
cache: RefCell<BTreeMap<String, Option<Vec<u8>>>>,
notes: RefCell<Vec<String>>,
}
impl Declared {
pub fn new(root: &Path, wanted: &[String]) -> Self {
let mut notes = Vec::new();
if wanted.len() > MAX_FILES {
notes.push(format!(
"the manifest declares {} context files; only the first {MAX_FILES} are read",
wanted.len()
));
}
let names = wanted.iter().take(MAX_FILES).cloned().collect();
Self {
root: root.to_path_buf(),
names,
cache: RefCell::new(BTreeMap::new()),
notes: RefCell::new(notes),
}
}
pub fn take_notes(&self) -> Vec<String> {
std::mem::take(&mut self.notes.borrow_mut())
}
fn read(&self, name: &str) -> Option<Vec<u8>> {
let note = |message: String| self.notes.borrow_mut().push(message);
if !self.names.iter().any(|declared| declared == name) {
note(format!(
"ignoring the context file {name:?}: the manifest does not declare it"
));
return None;
}
let Some(relative) = inside_project(name) else {
note(format!(
"ignoring the declared context file {name:?}: only plain relative paths inside the \
project can be read"
));
return None;
};
match fs::read(self.root.join(&relative)) {
Ok(mut bytes) => {
if bytes.len() as u64 > MAX_FILE_BYTES {
bytes.truncate(MAX_FILE_BYTES as usize);
note(format!(
"the declared context file {name:?} is larger than {MAX_FILE_BYTES} bytes; the \
plugin gets only its beginning"
));
}
Some(bytes)
}
Err(e) => {
note(format!(
"the declared context file {name:?} was not read: {e}"
));
None
}
}
}
}
impl Files for Declared {
fn contents(&self, name: &str) -> Option<Vec<u8>> {
let mut cache = self.cache.borrow_mut();
if let Some(known) = cache.get(name) {
return known.clone();
}
let answer = self.read(name);
cache.insert(name.to_string(), answer.clone());
answer
}
}
pub fn inside_project(declaration: &str) -> Option<PathBuf> {
let path = Path::new(declaration);
if path.as_os_str().is_empty() || path.is_absolute() {
return None;
}
let mut relative = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => relative.push(part),
_ => return None,
}
}
if relative.as_os_str().is_empty() {
None
} else {
Some(relative)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn declared(names: &[&str]) -> Vec<String> {
names.iter().map(|s| s.to_string()).collect()
}
fn provider(root: &Path, names: &[&str]) -> Declared {
Declared::new(root, &declared(names))
}
#[test]
fn a_declared_file_is_read() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("package.json"), "{\"name\":\"x\"}").unwrap();
let files = provider(dir.path(), &["package.json"]);
assert_eq!(
files.contents("package.json"),
Some(b"{\"name\":\"x\"}".to_vec())
);
assert!(files.take_notes().is_empty(), "nothing to report");
}
#[test]
fn an_undeclared_file_is_refused_and_said_so() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("secret.txt"), "not yours").unwrap();
let files = provider(dir.path(), &["package.json"]);
assert_eq!(files.contents("secret.txt"), None);
let notes = files.take_notes();
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("does not declare"), "{notes:?}");
assert!(files.take_notes().is_empty(), "notes are drained once");
}
#[test]
fn a_missing_file_is_simply_absent() {
let dir = tempfile::tempdir().unwrap();
let files = provider(dir.path(), &["nope.toml"]);
assert_eq!(files.contents("nope.toml"), None);
assert!(
files.take_notes()[0].contains("was not read"),
"{:?}",
files.take_notes()
);
}
#[test]
fn a_path_that_leaves_the_project_is_refused() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("secret.txt"), "not yours").unwrap();
let files = provider(
dir.path(),
&["../secret.txt", "/etc/passwd", "a/../../secret.txt"],
);
for name in ["../secret.txt", "/etc/passwd", "a/../../secret.txt"] {
assert_eq!(files.contents(name), None, "{name} must not be readable");
}
assert!(inside_project("../secret.txt").is_none());
assert!(inside_project("/etc/passwd").is_none());
assert!(inside_project("a/../../secret.txt").is_none());
assert_eq!(
inside_project("./package.json"),
Some(PathBuf::from("package.json"))
);
}
#[test]
fn a_file_larger_than_the_limit_comes_back_truncated() {
let dir = tempfile::tempdir().unwrap();
let big = vec![b'x'; (MAX_FILE_BYTES + 10) as usize];
fs::write(dir.path().join("big.lock"), &big).unwrap();
let files = provider(dir.path(), &["big.lock"]);
assert_eq!(
files.contents("big.lock").map(|bytes| bytes.len() as u64),
Some(MAX_FILE_BYTES)
);
assert!(
files.take_notes()[0].contains("larger than"),
"truncation is reported"
);
}
#[test]
fn only_the_first_files_are_readable() {
let dir = tempfile::tempdir().unwrap();
let names: Vec<String> = (0..MAX_FILES + 3).map(|i| format!("f{i}.txt")).collect();
for name in &names {
fs::write(dir.path().join(name), "x").unwrap();
}
let declared: Vec<&str> = names.iter().map(String::as_str).collect();
let files = provider(dir.path(), &declared);
assert!(files.contents(&names[MAX_FILES - 1]).is_some());
assert_eq!(
files.contents(&names[MAX_FILES]),
None,
"past the declared cap"
);
assert!(
files.take_notes()[0].contains("only the first"),
"the cap is reported"
);
}
#[test]
fn a_directory_is_not_a_file() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("sub")).unwrap();
let files = provider(dir.path(), &["sub"]);
assert_eq!(files.contents("sub"), None);
}
}