pub(crate) fn is_http_url(path_str: &str) -> bool {
let lower = path_str.to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
#[derive(Debug)]
pub(crate) enum ImagePathRefusal {
Policy(String),
NotFound(String),
}
pub(crate) fn resolve_image_path(
path: &std::path::Path,
root: Option<&std::path::Path>,
allow_absolute: bool,
) -> Result<std::path::PathBuf, ImagePathRefusal> {
if path.is_absolute() && !allow_absolute {
return Err(ImagePathRefusal::Policy(format!(
"absolute image paths are disabled (allow_absolute_image_paths = false): {:?}",
path
)));
}
let Some(root) = root else {
return Ok(path.to_path_buf());
};
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
};
let canonical_root = std::fs::canonicalize(root).map_err(|e| {
ImagePathRefusal::NotFound(format!(
"image_root {:?} could not be resolved: {}",
root, e
))
})?;
let canonical_candidate = std::fs::canonicalize(&candidate).map_err(|e| {
ImagePathRefusal::NotFound(format!(
"image path {:?} not found under image_root {:?}: {}",
candidate, root, e
))
})?;
if canonical_candidate.starts_with(&canonical_root) {
Ok(canonical_candidate)
} else {
Err(ImagePathRefusal::Policy(format!(
"image path {:?} escapes the configured image_root {:?}",
candidate, root
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn is_http_url_is_case_insensitive() {
assert!(is_http_url("http://example.com/x.png"));
assert!(is_http_url("https://example.com/x.png"));
assert!(is_http_url("HTTP://example.com/x.png"));
assert!(is_http_url("HTTPS://example.com/x.png"));
assert!(is_http_url("HttP://Example.COM/x.PNG"));
assert!(!is_http_url("ftp://example.com/x.png"));
assert!(!is_http_url("relative/path.png"));
assert!(!is_http_url("/abs/local/path.png"));
}
mod image_path_policy {
use super::*;
struct TempDir(PathBuf);
impl TempDir {
fn new(name: &str) -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"m2p_imgroot_{}_{}_{}",
std::process::id(),
name,
n
));
std::fs::create_dir_all(&dir).expect("create temp dir");
Self(dir)
}
fn path(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn no_root_absolute_path_allowed_when_flag_set() {
let abs = std::env::temp_dir().join("m2p_no_root_case.png");
let result = resolve_image_path(&abs, None, true);
assert_eq!(result.unwrap(), abs);
}
#[test]
fn no_root_absolute_path_refused_when_flag_unset() {
let abs = std::env::temp_dir().join("m2p_no_root_case2.png");
assert!(resolve_image_path(&abs, None, false).is_err());
}
#[test]
fn root_relative_path_inside_root_allowed() {
let dir = TempDir::new("rel_inside");
let file = dir.path().join("pic.png");
std::fs::write(&file, b"fake").unwrap();
let rel = std::path::Path::new("pic.png");
let resolved = resolve_image_path(rel, Some(dir.path()), true).unwrap();
let canonical_root = std::fs::canonicalize(dir.path()).unwrap();
assert!(
resolved.starts_with(&canonical_root),
"resolved path {:?} must live under root {:?}",
resolved,
canonical_root
);
}
#[test]
fn root_dot_dot_escape_refused() {
let dir = TempDir::new("dotdot");
let outside = dir
.path()
.parent()
.unwrap()
.join(format!("m2p_outside_{}.png", std::process::id()));
std::fs::write(&outside, b"fake").unwrap();
let rel = std::path::Path::new("..").join(outside.file_name().unwrap());
let result = resolve_image_path(&rel, Some(dir.path()), true);
assert!(result.is_err(), "`..` escape must be refused");
let _ = std::fs::remove_file(&outside);
}
#[test]
fn root_absolute_path_outside_root_refused() {
let dir = TempDir::new("abs_outside");
let outside =
std::env::temp_dir().join(format!("m2p_abs_outside_{}.png", std::process::id()));
std::fs::write(&outside, b"fake").unwrap();
let result = resolve_image_path(&outside, Some(dir.path()), true);
assert!(
result.is_err(),
"absolute path outside root must be refused"
);
let _ = std::fs::remove_file(&outside);
}
#[test]
fn root_absolute_path_inside_root_allowed() {
let dir = TempDir::new("abs_inside");
let file = dir.path().join("inside.png");
std::fs::write(&file, b"fake").unwrap();
let result = resolve_image_path(&file, Some(dir.path()), true);
assert!(result.is_ok(), "absolute path inside root must be allowed");
}
#[cfg(unix)]
#[test]
fn root_symlink_inside_root_pointing_outside_refused() {
let dir = TempDir::new("symlink_escape");
let outside_target =
std::env::temp_dir().join(format!("m2p_symlink_target_{}.png", std::process::id()));
std::fs::write(&outside_target, b"fake").unwrap();
let link = dir.path().join("evil_link.png");
std::os::unix::fs::symlink(&outside_target, &link).expect("create symlink");
let rel = std::path::Path::new("evil_link.png");
let result = resolve_image_path(rel, Some(dir.path()), true);
assert!(
result.is_err(),
"symlink inside root pointing outside must be refused, got {:?}",
result
);
let _ = std::fs::remove_file(&outside_target);
}
#[test]
fn root_nonexistent_file_refused() {
let dir = TempDir::new("missing");
let rel = std::path::Path::new("does-not-exist.png");
let result = resolve_image_path(rel, Some(dir.path()), true);
assert!(result.is_err(), "nonexistent file must be refused");
}
}
}