use std::{
collections::BTreeMap,
path::{Component, Path, PathBuf},
sync::{
RwLock,
atomic::{AtomicU64, Ordering},
},
};
use serde::{Deserialize, Serialize};
use crate::error::{LogoxideError, Result};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Stamp {
pub size: u64,
pub modified: (u64, u32),
}
pub trait Storage: Send + Sync {
fn read(&self, path: &str) -> Result<Vec<u8>>;
fn stamp(&self, _path: &str) -> Option<Stamp> {
None
}
fn location(&self) -> Option<String> {
None
}
fn list(&self, dir: &str) -> Result<Vec<String>>;
fn write(&self, _path: &str, _bytes: &[u8]) -> Result<()> {
Err(LogoxideError::ReadOnly)
}
fn remove(&self, _path: &str) -> Result<()> {
Err(LogoxideError::ReadOnly)
}
fn exists(&self, path: &str) -> bool {
self.read(path).is_ok()
}
fn with_bytes(&self, path: &str, f: &mut dyn FnMut(&[u8])) -> Result<()> {
f(&self.read(path)?);
Ok(())
}
fn with_text(&self, path: &str, f: &mut dyn FnMut(&str)) -> Result<()> {
let mut result = Ok(());
self.with_bytes(path, &mut |bytes| match std::str::from_utf8(bytes) {
Ok(text) => f(text.strip_prefix('\u{feff}').unwrap_or(text)),
Err(e) => result = Err(LogoxideError::io_message(path, e)),
})?;
result
}
fn read_string(&self, path: &str) -> Result<String> {
let bytes = self.read(path)?;
let text = String::from_utf8(bytes).map_err(|e| LogoxideError::io_message(path, e))?;
Ok(match text.strip_prefix('\u{feff}') {
Some(rest) => rest.to_string(),
None => text,
})
}
fn walk(&self, dir: &str) -> Result<Vec<String>> {
let mut files = vec![];
for name in self.list(dir)? {
let path = join(dir, &name);
match name.strip_suffix('/') {
Some(_) => files.extend(self.walk(path.trim_end_matches('/'))?),
None => files.push(path),
}
}
Ok(files)
}
}
pub fn join(dir: &str, name: &str) -> String {
match dir.trim_end_matches('/') {
"" => name.to_string(),
dir => format!("{dir}/{name}"),
}
}
fn checked(path: &str) -> Result<&str> {
let bad = path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|part| part == ".." || part == ".");
if bad {
return Err(LogoxideError::BadPath(path.to_string()));
}
Ok(path.trim_end_matches('/'))
}
#[derive(Clone, Debug)]
pub struct FsStorage {
root: PathBuf,
no_symlinks: bool,
}
impl FsStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
no_symlinks: false,
}
}
pub fn no_symlinks(mut self) -> Self {
self.no_symlinks = true;
self
}
pub fn root(&self) -> &Path {
&self.root
}
fn unlinked(&self, path: &str) -> Result<()> {
if !self.no_symlinks {
return Ok(());
}
let mut at = self.root.clone();
for part in path.split('/').filter(|p| !p.is_empty()) {
at.push(part);
match std::fs::symlink_metadata(&at) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(LogoxideError::io_message(
path,
"a link, which isn't followed here",
));
}
Ok(_) => {}
Err(_) => break,
}
}
Ok(())
}
fn path(&self, path: &str) -> Result<PathBuf> {
let path = checked(path)?;
let plain = !path.contains([':', '\0'])
&& (Path::new(path).components()).all(|c| matches!(c, Component::Normal(_)));
if !plain {
return Err(LogoxideError::BadPath(path.to_string()));
}
Ok(self.root.join(path))
}
}
fn staging_path(full: &Path) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let name = full
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
full.with_file_name(format!(".{name}.{}.{n}.tmp", std::process::id()))
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let parent = match path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
};
std::fs::create_dir_all(&parent)?;
let partial = staging_path(path);
let written = (|| {
let mut file = std::fs::File::create(&partial)?;
std::io::Write::write_all(&mut file, bytes)?;
file.sync_all()?;
drop(file);
std::fs::rename(&partial, path)?;
sync_dir(&parent)
})();
if written.is_err() {
let _ = std::fs::remove_file(&partial);
}
written
}
fn sync_dir(dir: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::fs::File::open(dir)?.sync_all()
}
#[cfg(not(unix))]
{
let _ = dir;
Ok(())
}
}
impl Storage for FsStorage {
fn read(&self, path: &str) -> Result<Vec<u8>> {
let full = self.path(path)?;
self.unlinked(path)?;
std::fs::read(&full).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LogoxideError::NotFound(path.to_string()),
_ => LogoxideError::io(path, e),
})
}
fn list(&self, dir: &str) -> Result<Vec<String>> {
let full = self.path(dir)?;
self.unlinked(dir)?;
let entries = std::fs::read_dir(&full).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => LogoxideError::NotFound(dir.to_string()),
_ => LogoxideError::io(dir, e),
})?;
let mut names: Vec<String> = entries
.flatten()
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
let kind = entry.file_type().ok()?;
if self.no_symlinks && kind.is_symlink() {
return None;
}
let dir = kind.is_dir();
Some(if dir { format!("{name}/") } else { name })
})
.collect();
names.sort();
Ok(names)
}
fn write(&self, path: &str, bytes: &[u8]) -> Result<()> {
let full = self.path(path)?;
self.unlinked(path)?;
write_atomic(&full, bytes).map_err(|e| LogoxideError::io(path, e))
}
fn remove(&self, path: &str) -> Result<()> {
let full = self.path(path)?;
self.unlinked(path)?;
match std::fs::remove_file(&full) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(LogoxideError::io(path, e)),
}
let mut dir = full.parent();
while let Some(folder) = dir.filter(|d| *d != self.root && d.starts_with(&self.root)) {
if std::fs::remove_dir(folder).is_err() {
break;
}
dir = folder.parent();
}
Ok(())
}
fn exists(&self, path: &str) -> bool {
self.path(path).is_ok_and(|p| p.exists()) && self.unlinked(path).is_ok()
}
fn stamp(&self, path: &str) -> Option<Stamp> {
let full = self.path(path).ok()?;
self.unlinked(path).ok()?;
let meta = std::fs::metadata(full).ok()?;
let modified = meta
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?;
Some(Stamp {
size: meta.len(),
modified: (modified.as_secs(), modified.subsec_nanos()),
})
}
fn location(&self) -> Option<String> {
let root = self
.root
.canonicalize()
.or_else(|_| std::path::absolute(&self.root))
.ok()?;
Some(root.display().to_string())
}
}
#[derive(Debug, Default)]
pub struct MemoryStorage {
files: RwLock<BTreeMap<String, Vec<u8>>>,
}
impl MemoryStorage {
pub fn new() -> Self {
Self::default()
}
pub fn with<P: Into<String>, B: Into<Vec<u8>>>(
files: impl IntoIterator<Item = (P, B)>,
) -> Self {
let files = files
.into_iter()
.map(|(p, b)| (p.into(), b.into()))
.collect();
Self {
files: RwLock::new(files),
}
}
}
impl Storage for MemoryStorage {
fn read(&self, path: &str) -> Result<Vec<u8>> {
let mut bytes = None;
self.with_bytes(path, &mut |b| bytes = Some(b.to_vec()))?;
Ok(bytes.unwrap_or_default())
}
fn with_bytes(&self, path: &str, f: &mut dyn FnMut(&[u8])) -> Result<()> {
let path = checked(path)?;
let files = self
.files
.read()
.map_err(|e| LogoxideError::io_message(path, e))?;
let bytes = (files.get(path)).ok_or_else(|| LogoxideError::NotFound(path.to_string()))?;
f(bytes);
Ok(())
}
fn list(&self, dir: &str) -> Result<Vec<String>> {
let dir = checked(dir)?;
let prefix = if dir.is_empty() {
String::new()
} else {
format!("{dir}/")
};
let files = self
.files
.read()
.map_err(|e| LogoxideError::io_message(dir, e))?;
let mut names: Vec<String> = files
.keys()
.filter_map(|path| {
let rest = path.strip_prefix(&prefix)?;
Some(match rest.split_once('/') {
Some((folder, _)) => format!("{folder}/"),
None => rest.to_string(),
})
})
.collect();
names.dedup();
if names.is_empty() && !dir.is_empty() {
return Err(LogoxideError::NotFound(dir.to_string()));
}
Ok(names)
}
fn write(&self, path: &str, bytes: &[u8]) -> Result<()> {
let path = checked(path)?;
let mut files = self
.files
.write()
.map_err(|e| LogoxideError::io_message(path, e))?;
files.insert(path.to_string(), bytes.to_vec());
Ok(())
}
fn remove(&self, path: &str) -> Result<()> {
let path = checked(path)?;
let mut files = self
.files
.write()
.map_err(|e| LogoxideError::io_message(path, e))?;
files.remove(path);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_lists_and_walks() {
let storage = MemoryStorage::with([
("logoxide.toml", "x"),
("usx/JHN.usx", "y"),
("usx/a/b.usx", "z"),
]);
assert_eq!(storage.list("").unwrap(), ["logoxide.toml", "usx/"]);
assert_eq!(storage.list("usx").unwrap(), ["JHN.usx", "a/"]);
assert_eq!(
storage.walk("").unwrap(),
["logoxide.toml", "usx/JHN.usx", "usx/a/b.usx"]
);
assert!(matches!(
storage.read("../x"),
Err(LogoxideError::BadPath(_))
));
assert!(matches!(
storage.read("nope"),
Err(LogoxideError::NotFound(_))
));
storage.write("cache/x.bin", b"1").unwrap();
assert_eq!(storage.read("cache/x.bin").unwrap(), b"1");
}
#[test]
fn fs_reads_writes_and_lists() {
let dir = std::env::temp_dir().join(format!("logoxide-storage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let storage = FsStorage::new(&dir);
storage.write("a/b.txt", b"hello").unwrap();
assert_eq!(storage.read_string("a/b.txt").unwrap(), "hello");
assert_eq!(storage.list("").unwrap(), ["a/"]);
assert_eq!(storage.walk("").unwrap(), ["a/b.txt"]);
assert!(storage.exists("a/b.txt") && !storage.exists("a/c.txt"));
assert_eq!(storage.stamp("a/b.txt").map(|s| s.size), Some(5));
assert!(storage.stamp("a/c.txt").is_none());
assert!(
storage
.location()
.is_some_and(|l| l.ends_with(dir.file_name().unwrap().to_str().unwrap()))
);
assert!(matches!(
storage.read("/etc/passwd"),
Err(LogoxideError::BadPath(_))
));
for bad in ["C:x", "a/C:/b", "a/b:stream", "a/\0"] {
assert!(
matches!(storage.write(bad, b"x"), Err(LogoxideError::BadPath(_))),
"{bad}"
);
}
storage.write("a/JHN.partial", b"mine").unwrap();
storage.write("a/JHN.usx", b"book").unwrap();
assert_eq!(storage.read_string("a/JHN.partial").unwrap(), "mine");
assert_eq!(
storage.walk("a").unwrap(),
["a/JHN.partial", "a/JHN.usx", "a/b.txt"]
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_writes_replace_and_leave_nothing_beside() {
let dir = std::env::temp_dir().join(format!("logoxide-atomic-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("deep/logoxide.toml");
write_atomic(&path, b"one").unwrap();
write_atomic(&path, b"two").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "two");
let names: Vec<String> = (std::fs::read_dir(dir.join("deep")).unwrap())
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["logoxide.toml"]);
std::fs::write(dir.join("file"), "x").unwrap();
assert!(write_atomic(&dir.join("file/under"), b"x").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn links_can_be_refused() {
let dir = std::env::temp_dir().join(format!("logoxide-links-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (root, outside) = (dir.join("package"), dir.join("outside"));
std::fs::create_dir_all(root.join("usx")).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(outside.join("secret"), "secret").unwrap();
std::fs::write(root.join("usx/JHN.usx"), "john").unwrap();
std::os::unix::fs::symlink(outside.join("secret"), root.join("usx/ROM.usx")).unwrap();
std::os::unix::fs::symlink(&outside, root.join("away")).unwrap();
let open = FsStorage::new(&root);
assert_eq!(open.read_string("usx/ROM.usx").unwrap(), "secret");
assert_eq!(
open.walk("").unwrap(),
["away", "usx/JHN.usx", "usx/ROM.usx"]
);
let strict = FsStorage::new(&root).no_symlinks();
assert_eq!(strict.walk("").unwrap(), ["usx/JHN.usx"]);
assert!(strict.read("usx/ROM.usx").is_err());
assert!(strict.read("away/secret").is_err());
assert!(!strict.exists("away/secret") && strict.exists("usx/JHN.usx"));
assert!(strict.write("away/new", b"x").is_err());
assert!(strict.remove("away/secret").is_err());
assert!(!outside.join("new").exists() && outside.join("secret").exists());
strict.write("usx/new/MAT.usx", b"matthew").unwrap();
assert_eq!(strict.read_string("usx/new/MAT.usx").unwrap(), "matthew");
let linked = dir.join("linked");
std::os::unix::fs::symlink(&root, &linked).unwrap();
assert_eq!(
FsStorage::new(&linked)
.no_symlinks()
.read_string("usx/JHN.usx")
.unwrap(),
"john"
);
let _ = std::fs::remove_dir_all(&dir);
}
}