about summary refs log tree commit diff
path: root/src/fs.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/fs.rs')
-rw-r--r--src/fs.rs206
1 files changed, 206 insertions, 0 deletions
diff --git a/src/fs.rs b/src/fs.rs
new file mode 100644
index 0000000..e13f405
--- /dev/null
+++ b/src/fs.rs
@@ -0,0 +1,206 @@
+use camino::{Utf8Path, Utf8PathBuf};
+use std::{io, ops::Deref, str::FromStr};
+
+/// 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 {
+	webcanon: Utf8PathBuf,
+}
+
+impl FromStr for Webpath {
+	type Err = RuntimeError;
+
+	fn from_str(raw: &str) -> Result<Self, Self::Err> {
+		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 })
+	}
+}
+
+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 == ""
+	}
+}
+
+impl Deref for Webpath {
+	type Target = Utf8Path;
+
+	fn deref(&self) -> &Self::Target {
+		&self.webcanon
+	}
+}
+
+impl AsRef<Utf8Path> for Webpath {
+	fn as_ref(&self) -> &Utf8Path {
+		&self.webcanon
+	}
+}
+
+impl PartialEq<str> for Webpath {
+	fn eq(&self, other: &str) -> bool {
+		self.webcanon.eq(other)
+	}
+}
+
+const ROOT_INDEX: &str = "home.html";
+const DIRFILE_EXT: &str = "html";
+
+pub struct Filesystem {
+	webroot: Utf8PathBuf,
+}
+
+impl Filesystem {
+	pub fn new<P: Into<Utf8PathBuf>>(root: P) -> Self {
+		Self {
+			webroot: root.into(),
+		}
+	}
+
+	fn resolve(&self, webpath: &Webpath) -> Result<Utf8PathBuf, RuntimeError> {
+		if webpath.is_index() || webpath == ROOT_INDEX {
+			return Ok(self.webroot.join(ROOT_INDEX));
+		}
+
+		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(path)
+		} 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(dirfile)
+			} else {
+				Err(RuntimeError::NotFound {
+					source: io::ErrorKind::NotFound.into(),
+					path: dirfile,
+				})
+			}
+		}
+	}
+
+	fn metadata<P: AsRef<Utf8Path>>(path: P) -> Result<std::fs::Metadata, RuntimeError> {
+		path.as_ref()
+			.metadata()
+			.map_err(|ioe| RuntimeError::from_io(ioe, path.as_ref().to_owned()))
+	}
+}
+
+#[derive(Debug, snafu::Snafu)]
+pub enum RuntimeError {
+	#[snafu(display("the path was not found: {path}"))]
+	NotFound {
+		source: io::Error,
+		path: Utf8PathBuf,
+	},
+	#[snafu(display("io error: {path}: {source}"))]
+	UnknownIo {
+		source: io::Error,
+		path: Utf8PathBuf,
+	},
+	#[snafu(display("path tried to go below webroot: {path}"))]
+	PathTooLow { path: String },
+}
+
+impl RuntimeError {
+	pub fn from_io(source: io::Error, path: Utf8PathBuf) -> Self {
+		match source.kind() {
+			io::ErrorKind::NotFound => RuntimeError::NotFound { source, path },
+			_ => RuntimeError::UnknownIo { source, path },
+		}
+	}
+}
+
+#[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(),
+			format!("{TESTROOT}/{ROOT_INDEX}")
+		);
+		assert_eq!(
+			fs.resolve(&webpath!("/one/..")).unwrap(),
+			format!("{TESTROOT}/{ROOT_INDEX}")
+		);
+	}
+
+	#[test]
+	fn filesystem_resolves_dirfile() {
+		let fs = Filesystem::new(TESTROOT);
+		assert_eq!(
+			fs.resolve(&webpath!("/one")).unwrap(),
+			format!("{TESTROOT}/one/one.html")
+		);
+		assert_eq!(
+			fs.resolve(&webpath!("/one/eleven/")).unwrap(),
+			format!("{TESTROOT}/one/eleven/eleven.html")
+		);
+		assert_eq!(
+			fs.resolve(&webpath!("/one/eleven/..")).unwrap(),
+			format!("{TESTROOT}/one/one.html")
+		);
+	}
+}