use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use crate::error::ErrorReport;
use crate::error::Fallible;
pub struct MediaResolver {
collection_path: PathBuf,
deck_path: PathBuf,
}
pub struct MediaResolverBuilder {
collection_path: Option<PathBuf>,
deck_path: Option<PathBuf>,
}
#[derive(Debug, PartialEq)]
pub enum ResolveError {
Empty,
ExternalUrl,
AbsolutePath,
ParentComponent,
OutsideCollection,
InvalidPath,
}
impl std::fmt::Display for ResolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let msg = match self {
ResolveError::Empty => "path is the empty string.",
ResolveError::ExternalUrl => "external URLs are not allowed as media paths.",
ResolveError::AbsolutePath => "absolute paths are not allowed as media paths.",
ResolveError::ParentComponent => "path has a parent component.",
ResolveError::OutsideCollection => "path is outside the collection directory.",
ResolveError::InvalidPath => "path is invalid.",
};
write!(f, "{msg}")
}
}
impl MediaResolver {
pub fn resolve(&self, path: &str) -> Result<PathBuf, ResolveError> {
let path: &str = path.trim();
if path.is_empty() {
return Err(ResolveError::Empty);
}
if path.contains("://") {
return Err(ResolveError::ExternalUrl);
}
if let Some(stripped) = path.strip_prefix("@/") {
let path: PathBuf = PathBuf::from(&stripped);
if path.is_absolute() {
return Err(ResolveError::AbsolutePath);
}
if path.components().any(|c| c == Component::ParentDir) {
return Err(ResolveError::ParentComponent);
}
let abspath: PathBuf = self.collection_path.join(&path);
if !abspath.exists() {
return Err(ResolveError::InvalidPath);
}
Ok(path)
} else {
let path: PathBuf = PathBuf::from(&path);
if path.is_absolute() {
return Err(ResolveError::AbsolutePath);
}
let deck: PathBuf = self.collection_path.join(&self.deck_path);
let deck_dir: &Path = deck.parent().ok_or(ResolveError::InvalidPath)?;
let path: PathBuf = deck_dir.join(path);
if !path.exists() {
return Err(ResolveError::InvalidPath);
}
let path: PathBuf = path.canonicalize().map_err(|_| ResolveError::InvalidPath)?;
let path: PathBuf = path
.strip_prefix(&self.collection_path)
.map_err(|_| ResolveError::OutsideCollection)?
.to_path_buf();
Ok(path)
}
}
}
impl MediaResolverBuilder {
pub fn new() -> Self {
Self {
collection_path: None,
deck_path: None,
}
}
pub fn with_collection_path(self, collection_path: PathBuf) -> Fallible<Self> {
let collection_path: PathBuf = collection_path.canonicalize()?;
if !collection_path.exists() {
return Err(ErrorReport::new("Collection path does not exist."));
}
if !collection_path.is_absolute() {
return Err(ErrorReport::new("Collection path is relative."));
}
if !collection_path.is_dir() {
return Err(ErrorReport::new("Collection path is not a directory."));
}
Ok(Self {
collection_path: Some(collection_path),
deck_path: self.deck_path,
})
}
pub fn with_deck_path(self, deck_path: PathBuf) -> Fallible<Self> {
if !deck_path.is_relative() {
return Err(ErrorReport::new("Deck path is not relative."));
}
Ok(Self {
collection_path: self.collection_path,
deck_path: Some(deck_path),
})
}
pub fn build(self) -> Fallible<MediaResolver> {
let collection_path = self
.collection_path
.ok_or(ErrorReport::new("Missing collection_path."))?;
let deck_path = self
.deck_path
.ok_or(ErrorReport::new("Missing deck_path."))?;
Ok(MediaResolver {
collection_path,
deck_path,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Fallible;
use crate::helper::create_tmp_directory;
#[test]
fn test_empty_strings_are_rejected() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("deck.md");
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(r.resolve(""), Err(ResolveError::Empty));
assert_eq!(r.resolve(" "), Err(ResolveError::Empty));
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_absolute_paths_are_rejected() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("deck.md");
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(r.resolve("/etc/passwd"), Err(ResolveError::AbsolutePath));
Ok(())
}
#[test]
fn test_external_urls_are_rejected() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("deck.md");
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(r.resolve("http://"), Err(ResolveError::ExternalUrl));
Ok(())
}
#[test]
fn test_collection_relative() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
std::fs::create_dir_all(coll_path.join("a/b/"))?;
std::fs::write(coll_path.join("foo.jpg"), "")?;
std::fs::write(coll_path.join("a/foo.jpg"), "")?;
std::fs::write(coll_path.join("a/b/foo.jpg"), "")?;
let deck_path: PathBuf = PathBuf::from("deck.md");
std::fs::write(coll_path.join("deck.md"), "")?;
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(r.resolve("@/foo.jpg"), Ok(PathBuf::from("foo.jpg")));
assert_eq!(r.resolve("@/a/foo.jpg"), Ok(PathBuf::from("a/foo.jpg")));
assert_eq!(r.resolve("@/a/b/foo.jpg"), Ok(PathBuf::from("a/b/foo.jpg")));
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_collection_relative_absolute_are_rejected() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("deck.md");
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(r.resolve("@//foo.jpg"), Err(ResolveError::AbsolutePath));
Ok(())
}
#[test]
fn test_collection_relative_parent_are_rejected() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("deck.md");
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(
r.resolve("@/a/b/../foo.jpg"),
Err(ResolveError::ParentComponent)
);
Ok(())
}
#[test]
fn test_deck_relative() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("a/b/c/deck.md");
std::fs::create_dir_all(coll_path.join("a/b/c"))?;
std::fs::write(coll_path.join("a/b/c/foo.jpg"), "")?;
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(r.resolve("foo.jpg"), Ok(PathBuf::from("a/b/c/foo.jpg")));
assert_eq!(r.resolve("./foo.jpg"), Ok(PathBuf::from("a/b/c/foo.jpg")));
assert_eq!(
r.resolve("../c/foo.jpg"),
Ok(PathBuf::from("a/b/c/foo.jpg"))
);
assert_eq!(
r.resolve("../../b/c/foo.jpg"),
Ok(PathBuf::from("a/b/c/foo.jpg"))
);
assert_eq!(
r.resolve("../c/../c/foo.jpg"),
Ok(PathBuf::from("a/b/c/foo.jpg"))
);
assert_eq!(
r.resolve("../../../a/b/c/foo.jpg"),
Ok(PathBuf::from("a/b/c/foo.jpg"))
);
Ok(())
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_relative_paths_cant_leave_collection_root() -> Fallible<()> {
let coll_path: PathBuf = create_tmp_directory()?;
let deck_path: PathBuf = PathBuf::from("deck.md");
let r: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(coll_path)?
.with_deck_path(deck_path)?
.build()?;
assert_eq!(
r.resolve("../../../../../../../../etc/passwd"),
Err(ResolveError::OutsideCollection)
);
Ok(())
}
}