use std::collections::HashMap;
use std::fs;
use std::io;
use std::ops::Deref;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Disk {
pub root: PathBuf,
pub url: Option<String>,
}
impl Disk {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
url: None,
}
}
pub fn with_url(self, url: impl Into<String>) -> Self {
Self {
url: Some(url.into()),
..self
}
}
pub fn path(&self, path: &str) -> io::Result<PathBuf> {
let relative = Path::new(path);
let safe = !path.is_empty()
&& relative
.components()
.all(|part| matches!(part, Component::Normal(_) | Component::CurDir));
if !safe {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("storage path {path:?} is not a relative path inside the disk"),
));
}
Ok(self.root.join(relative))
}
pub fn put(&self, path: &str, contents: impl AsRef<[u8]>) -> io::Result<()> {
let file = self.path(path)?;
if let Some(folder) = file.parent() {
fs::create_dir_all(folder)?;
}
fs::write(file, contents)
}
pub fn get(&self, path: &str) -> io::Result<Vec<u8>> {
fs::read(self.path(path)?)
}
pub fn exists(&self, path: &str) -> bool {
self.path(path).is_ok_and(|file| file.is_file())
}
pub fn delete(&self, path: &str) -> io::Result<()> {
match fs::remove_file(self.path(path)?) {
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
done => done,
}
}
pub fn url(&self, path: &str) -> Option<String> {
let base = self.url.as_deref()?;
Some(format!(
"{}/{}",
base.trim_end_matches('/'),
path.trim_start_matches('/')
))
}
}
#[derive(Debug, Clone)]
pub struct Settings {
pub default: String,
pub disks: HashMap<String, Disk>,
pub links: Vec<(PathBuf, PathBuf)>,
}
#[derive(Debug, Clone)]
pub struct Storage {
settings: Settings,
}
impl Storage {
pub fn new(settings: Settings) -> Self {
assert!(
settings.disks.contains_key(&settings.default),
"config key FILESYSTEM_DISK is {}, which is not a disk",
settings.default
);
Self { settings }
}
pub fn disk(&self, name: &str) -> &Disk {
self.settings.disks.get(name).unwrap_or_else(|| {
panic!("no storage disk {name}; add it in app/config/filesystems.rs")
})
}
pub fn link(&self) -> io::Result<()> {
for (link, target) in &self.settings.links {
let target = std::path::absolute(target)?;
fs::create_dir_all(&target)?;
if let Ok(meta) = fs::symlink_metadata(link) {
if !meta.is_symlink() {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} exists and is not a link", link.display()),
));
}
if fs::read_link(link)? == target {
continue;
}
fs::remove_file(link)?;
}
if let Some(folder) = link.parent() {
fs::create_dir_all(folder)?;
}
symlink(&target, link)?;
}
Ok(())
}
}
impl Deref for Storage {
type Target = Disk;
fn deref(&self) -> &Disk {
self.disk(&self.settings.default)
}
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
#[cfg(not(any(unix, windows)))]
fn symlink(_: &Path, _: &Path) -> io::Result<()> {
Err(io::ErrorKind::Unsupported.into())
}
#[cfg(feature = "web")]
impl crate::web::Request {
pub fn storage(&self) -> &Storage {
self.state::<Storage>()
.expect("no storage: add .state(storage) to the router")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("rustclamp-storage-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
dir
}
#[test]
fn refuses_paths_that_leave_the_disk() {
let disk = Disk::new("root");
for path in ["", "/etc/passwd", "../x", "a/../../x", "a/.."] {
assert!(disk.path(path).is_err(), "{path} was accepted");
}
assert_eq!(disk.path("./a/b.txt").unwrap(), Path::new("root/a/b.txt"));
}
#[test]
fn urls_only_for_public_disks() {
assert_eq!(Disk::new("x").url("a.png"), None);
let public = Disk::new("x").with_url("/storage/");
assert_eq!(public.url("/a.png").as_deref(), Some("/storage/a.png"));
}
#[test]
#[should_panic(expected = "FILESYSTEM_DISK is s3")]
fn unknown_default_disk_stops_startup() {
Storage::new(Settings {
default: "s3".into(),
disks: HashMap::new(),
links: Vec::new(),
});
}
#[cfg(unix)]
#[test]
fn link_serves_the_public_disk_and_follows_a_move() {
let dir = temp("link");
let (link, old, new) = (dir.join("public/storage"), dir.join("old"), dir.join("new"));
let storage = |target: &Path| {
Storage::new(Settings {
default: "public".into(),
disks: HashMap::from([("public".into(), Disk::new(target))]),
links: vec![(link.clone(), target.to_owned())],
})
};
storage(&old).link().unwrap();
storage(&old).link().unwrap();
storage(&old).put("a.txt", "one").unwrap();
assert_eq!(fs::read(link.join("a.txt")).unwrap(), b"one");
storage(&new).link().unwrap();
assert_eq!(fs::read_link(&link).unwrap(), new);
let blocked = dir.join("blocked");
fs::create_dir_all(&blocked).unwrap();
let in_the_way = Storage::new(Settings {
default: "public".into(),
disks: HashMap::from([("public".into(), Disk::new(&old))]),
links: vec![(blocked, old.clone())],
});
assert!(in_the_way.link().is_err());
fs::remove_dir_all(dir).unwrap();
}
}