use bytes::Bytes;
use chrono::{DateTime, Utc};
use rainier_support::{BoxFuture, Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Metadata {
pub path: String,
pub size: u64,
pub last_modified: Option<DateTime<Utc>>,
}
pub trait Filesystem: Send + Sync + 'static {
fn name(&self) -> &str;
fn get<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Bytes>>>;
fn put<'a>(&'a self, path: &'a str, contents: Bytes) -> BoxFuture<'a, Result<()>>;
fn delete<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>>;
fn exists<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<bool>>;
fn metadata<'a>(&'a self, path: &'a str) -> BoxFuture<'a, Result<Option<Metadata>>>;
fn list<'a>(&'a self, prefix: &'a str) -> BoxFuture<'a, Result<Vec<Metadata>>>;
fn copy<'a>(&'a self, from: &'a str, to: &'a str) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
let contents = self
.get(from)
.await?
.ok_or_else(|| Error::not_found(format!("`{from}` does not exist")))?;
self.put(to, contents).await
})
}
fn rename<'a>(&'a self, from: &'a str, to: &'a str) -> BoxFuture<'a, Result<()>> {
Box::pin(async move {
self.copy(from, to).await?;
self.delete(from).await?;
Ok(())
})
}
fn url(&self, path: &str) -> Option<String> {
let _ = path;
None
}
}
#[async_trait::async_trait]
pub trait FilesystemExt: Filesystem {
async fn get_string(&self, path: &str) -> Result<Option<String>> {
match self.get(path).await? {
Some(bytes) => String::from_utf8(bytes.to_vec())
.map(Some)
.map_err(|_| Error::internal(format!("`{path}` is not valid UTF-8"))),
None => Ok(None),
}
}
async fn put_string(&self, path: &str, contents: &str) -> Result<()> {
self.put(path, Bytes::from(contents.to_string().into_bytes())).await
}
async fn get_or_fail(&self, path: &str) -> Result<Bytes> {
self.get(path).await?.ok_or_else(|| Error::not_found(format!("`{path}` does not exist")))
}
async fn size(&self, path: &str) -> Result<Option<u64>> {
Ok(self.metadata(path).await?.map(|meta| meta.size))
}
async fn last_modified(&self, path: &str) -> Result<Option<DateTime<Utc>>> {
Ok(self.metadata(path).await?.and_then(|meta| meta.last_modified))
}
async fn append(&self, path: &str, contents: &[u8]) -> Result<()> {
let mut existing = self.get(path).await?.map(|b| b.to_vec()).unwrap_or_default();
existing.extend_from_slice(contents);
self.put(path, Bytes::from(existing)).await
}
}
impl<F: Filesystem + ?Sized> FilesystemExt for F {}
pub fn normalise_path(path: &str) -> Result<String> {
let path = path.replace('\\', "/");
let mut segments = Vec::new();
for segment in path.split('/') {
match segment {
"" | "." => continue,
".." => {
return Err(Error::bad_request(
"a storage path must not contain `..`; it is either an attempt to escape \
the root or a path that was built wrong",
))
}
segment => segments.push(segment),
}
}
if segments.is_empty() {
return Err(Error::bad_request("a storage path must not be empty"));
}
let joined = segments.join("/");
if joined.contains('\0') {
return Err(Error::bad_request("a storage path must not contain a NUL byte"));
}
Ok(joined)
}
pub fn normalise_prefix(prefix: &str) -> Result<String> {
let trimmed = prefix.trim_matches('/');
if trimmed.is_empty() {
return Ok(String::new());
}
normalise_path(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_path_passes_through() {
assert_eq!(normalise_path("a/b/c.txt").unwrap(), "a/b/c.txt");
}
#[test]
fn separators_are_normalised() {
assert_eq!(normalise_path("a\\b\\c.txt").unwrap(), "a/b/c.txt");
assert_eq!(normalise_path("a//b///c.txt").unwrap(), "a/b/c.txt");
assert_eq!(normalise_path("/a/b/").unwrap(), "a/b");
assert_eq!(normalise_path("./a/./b").unwrap(), "a/b");
}
#[test]
fn a_traversal_is_refused_rather_than_resolved() {
for hostile in ["../secrets", "a/../../etc/passwd", "..", "a/..", "..\\windows"] {
let err = normalise_path(hostile).unwrap_err();
assert_eq!(err.status(), 400, "{hostile}");
assert!(err.message().contains(".."), "{hostile}");
}
}
#[test]
fn an_empty_path_is_refused() {
for empty in ["", "/", "//", ".", "./"] {
assert!(normalise_path(empty).is_err(), "{empty:?}");
}
}
#[test]
fn a_nul_byte_is_refused() {
assert!(normalise_path("a\0b.txt").is_err());
}
#[test]
fn a_prefix_may_be_empty_but_still_may_not_escape() {
assert_eq!(normalise_prefix("").unwrap(), "");
assert_eq!(normalise_prefix("/").unwrap(), "");
assert_eq!(normalise_prefix("uploads/").unwrap(), "uploads");
assert!(normalise_prefix("../etc").is_err());
}
#[test]
fn unicode_and_spaces_survive() {
assert_eq!(normalise_path("uploads/my file 🎉.png").unwrap(), "uploads/my file 🎉.png");
}
}