use std::path::{Component, Path, PathBuf};
use std::sync::OnceLock;
use anyhow::{Result, anyhow, bail};
use serde_json::Value;
static ROOT: OnceLock<PathBuf> = OnceLock::new();
pub const PATH_KEYS: [&str; 10] = [
"input", "inputs", "output", "out_dir", "image", "font", "cert", "key", "p12", "trust",
];
pub fn set_root(dir: &Path) -> Result<()> {
let root = dir
.canonicalize()
.map_err(|e| anyhow!("cannot use {} as root: {e}", dir.display()))?;
if !root.is_dir() {
bail!("root {} is not a directory", root.display());
}
std::env::set_current_dir(&root)?;
ROOT.set(root)
.map_err(|_| anyhow!("the root is already set"))
}
pub fn active() -> bool {
ROOT.get().is_some()
}
fn resolve(path: &Path, base: &Path) -> Result<PathBuf> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
};
let mut existing = absolute.as_path();
let mut rest: Vec<&std::ffi::OsStr> = Vec::new();
while !existing.exists() {
let (Some(parent), Some(name)) = (existing.parent(), existing.file_name()) else {
bail!("{} cannot be resolved", path.display());
};
rest.push(name);
existing = parent;
}
let mut real = existing.canonicalize()?;
for name in rest.into_iter().rev() {
if !matches!(
Path::new(name).components().next(),
Some(Component::Normal(_))
) {
bail!("{} cannot be resolved", path.display());
}
real.push(name);
}
Ok(real)
}
pub fn within(root: &Path, path: &Path) -> Result<()> {
let real = resolve(path, root)?;
if real.starts_with(root) {
Ok(())
} else {
bail!(
"{} is outside the allowed directory {}",
path.display(),
root.display()
)
}
}
pub fn check(path: &Path) -> Result<()> {
match ROOT.get() {
Some(root) => within(root, path),
None => Ok(()),
}
}
pub fn check_args(args: &Value) -> Result<()> {
if !active() {
return Ok(());
}
for key in PATH_KEYS {
match args.get(key) {
Some(Value::String(path)) => check(Path::new(path))?,
Some(Value::Array(paths)) => {
for path in paths.iter().filter_map(Value::as_str) {
check(Path::new(path))?;
}
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::within;
#[test]
fn paths_are_confined_to_the_root() {
let outer = std::env::temp_dir().join(format!("pdfops-sandbox-{}", std::process::id()));
let root = outer.join("root");
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(outer.join("secret.pdf"), b"x").unwrap();
let root = root.canonicalize().unwrap();
assert!(within(&root, "a.pdf".as_ref()).is_ok());
assert!(within(&root, "sub/new/out.pdf".as_ref()).is_ok());
assert!(within(&root, &root.join("sub/a.pdf")).is_ok());
assert!(within(&root, "../secret.pdf".as_ref()).is_err());
assert!(within(&root, "sub/../../secret.pdf".as_ref()).is_err());
assert!(within(&root, &outer.join("secret.pdf")).is_err());
assert!(within(&root, "new/../../secret.pdf".as_ref()).is_err());
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outer, root.join("link")).unwrap();
assert!(within(&root, "link/secret.pdf".as_ref()).is_err());
}
std::fs::remove_dir_all(&outer).unwrap();
}
}