use camino::{Utf8Path, Utf8PathBuf}; use core::fmt; use std::{io, ops::Deref, str::FromStr}; use crate::RuntimeError; /// Webpath is the path we get from HTTP requests. It's garunteed to not fall /// below the webroot and will never start or end with a slash. #[derive(Clone, Debug, PartialEq)] pub struct Webpath { pub webcanon: Utf8PathBuf, is_dir: bool, } impl FromStr for Webpath { type Err = RuntimeError; fn from_str(raw: &str) -> Result { let is_dir = raw.trim_end().ends_with('/'); let mut curr = Utf8PathBuf::new(); for component in raw.split('/') { match component { "." => continue, ".." => { if !curr.pop() { return Err(RuntimeError::PathTooLow { path: raw.to_owned(), }); } } comp => curr.push(comp), } } Ok(Self { webcanon: curr, is_dir, }) } } impl Webpath { /// Return whether or not this Webpath is empty which would indicate it's /// the homepage. pub fn is_index(&self) -> bool { self.webcanon == "" } /// Whether or not this Webpath points to a directory (has a trailing slash) pub fn is_dir(&self) -> bool { self.is_dir } /// Return a String that interprets this Webpath as a directory, adding a /// trailing slash pub fn as_dir(&self) -> String { format!("/{}/", self.webcanon) } /// Returns the first directory of the Webpath. Assumes the path is a /// directory if it ends in a `/`, otherwise pops the last component. pub fn first_dir(&self) -> String { if self.is_dir { self.webcanon.to_string() } else { let mut dir = self.webcanon.clone(); dir.pop(); dir.to_string() } } } impl Deref for Webpath { type Target = Utf8Path; fn deref(&self) -> &Self::Target { &self.webcanon } } impl AsRef for Webpath { fn as_ref(&self) -> &Utf8Path { &self.webcanon } } impl PartialEq for Webpath { fn eq(&self, other: &str) -> bool { self.webcanon.eq(other) } } impl fmt::Display for Webpath { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "/{}", self.webcanon) } } const ROOT_INDEX: &str = "home.html"; const DIRFILE_EXT: &str = "html"; pub struct PathResolution { pub filepath: Utf8PathBuf, pub is_dirfile: bool, } #[derive(Clone, Debug)] pub struct Filesystem { webroot: Utf8PathBuf, } impl Filesystem { pub fn new>(root: P) -> Self { Self { webroot: root.into(), } } pub fn resolve(&self, webpath: &Webpath) -> Result { if webpath.is_index() || webpath == ROOT_INDEX { return Ok(PathResolution { filepath: self.webroot.join(ROOT_INDEX), is_dirfile: false, }); } let path = self.webroot.join(webpath); let metadata = Self::metadata(&path)?; // If it's a file then return the path immediatly if metadata.is_file() { Ok(PathResolution { filepath: path, is_dirfile: false, }) } else { // we check this above. but still.. //TODO: gen- probably don't unwrap let filename = path.file_name().unwrap(); let dirfile = path.join(format!("{filename}.{DIRFILE_EXT}")); if dirfile.exists() { Ok(PathResolution { filepath: dirfile, is_dirfile: true, }) } else { Err(RuntimeError::NotFound { source: io::ErrorKind::NotFound.into(), path: dirfile, }) } } } pub fn metadata>(path: P) -> Result { path.as_ref() .metadata() .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) } pub async fn open>(path: P) -> Result { tokio::fs::File::open(path.as_ref()) .await .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) } pub async fn read>(path: P) -> Result, RuntimeError> { tokio::fs::read(path.as_ref()) .await .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) } pub async fn read_to_string>(path: P) -> Result { tokio::fs::read_to_string(path.as_ref()) .await .map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned())) } } #[cfg(test)] mod test { use std::str::FromStr; use crate::fs::{Webpath, ROOT_INDEX}; use super::Filesystem; macro_rules! webpath { ($location:expr) => { Webpath::from_str($location).unwrap() }; } #[test] fn webpath_finds_too_low() { assert!(Webpath::from_str("/..").is_err()); assert!(Webpath::from_str("/one/..").is_ok()); assert!(Webpath::from_str("/one/../..").is_err()); assert!(Webpath::from_str("/one/../two").is_ok()) } #[test] fn webpath_path_correct() { assert_eq!(Webpath::from_str("/one").unwrap().webcanon, "one"); assert_eq!(Webpath::from_str("/one/..").unwrap().webcanon, ""); assert_eq!(Webpath::from_str("/one/two/..").unwrap().webcanon, "one"); assert_eq!(Webpath::from_str("/one/../two").unwrap().webcanon, "two"); assert_eq!( Webpath::from_str("/one/two/three/..").unwrap().webcanon, "one/two" ); assert_eq!( Webpath::from_str("/one/../home.html").unwrap().webcanon, "home.html" ); } const TESTROOT: &str = "test/serve"; #[test] fn filesystem_resolves_index() { let fs = Filesystem::new(TESTROOT); assert_eq!( fs.resolve(&webpath!("/")).unwrap().filepath, format!("{TESTROOT}/{ROOT_INDEX}") ); assert_eq!( fs.resolve(&webpath!("/one/..")).unwrap().filepath, format!("{TESTROOT}/{ROOT_INDEX}") ); } #[test] fn filesystem_resolves_dirfile() { let fs = Filesystem::new(TESTROOT); assert_eq!( fs.resolve(&webpath!("/one")).unwrap().filepath, format!("{TESTROOT}/one/one.html") ); assert_eq!( fs.resolve(&webpath!("/one/eleven/")).unwrap().filepath, format!("{TESTROOT}/one/eleven/eleven.html") ); assert_eq!( fs.resolve(&webpath!("/one/eleven/..")).unwrap().filepath, format!("{TESTROOT}/one/one.html") ); } }