use std::collections::BTreeMap;
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use std::time::SystemTime;
use crate::atomic::atomic_write;
pub trait ConfigStore: Send + Sync {
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, data: &str) -> io::Result<()>;
fn remove(&self, name: &str) -> io::Result<()>;
fn list(&self, dir: &str) -> Vec<String>;
}
pub trait FileOps: Send + Sync {
fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
fn write(&self, path: &Path, data: &[u8]) -> io::Result<()>;
fn remove(&self, path: &Path) -> io::Result<()>;
fn list(&self, dir: &Path) -> Vec<(String, SystemTime)>;
fn is_file(&self, path: &Path) -> bool;
}
pub fn check_name(name: &str) -> io::Result<()> {
let bad = || io::Error::new(io::ErrorKind::InvalidInput, format!("invalid config name `{name}`"));
if name.is_empty() || name.contains('\\') || name.contains('\0') {
return Err(bad());
}
for part in name.split('/') {
if part.is_empty() || part == "." || part == ".." {
return Err(bad());
}
}
if Path::new(name).components().any(|c| !matches!(c, Component::Normal(_))) {
return Err(bad());
}
Ok(())
}
fn not_found(e: io::Error) -> io::Result<()> {
if e.kind() == io::ErrorKind::NotFound { Ok(()) } else { Err(e) }
}
#[derive(Clone, Debug)]
pub struct DirConfig {
pub dir: PathBuf,
}
impl DirConfig {
pub fn new(dir: impl Into<PathBuf>) -> DirConfig {
DirConfig { dir: dir.into() }
}
}
impl ConfigStore for DirConfig {
fn read(&self, name: &str) -> Option<String> {
check_name(name).ok()?;
std::fs::read_to_string(self.dir.join(name)).ok()
}
fn write(&self, name: &str, data: &str) -> io::Result<()> {
check_name(name)?;
StdFiles.write(&self.dir.join(name), data.as_bytes())
}
fn remove(&self, name: &str) -> io::Result<()> {
check_name(name)?;
StdFiles.remove(&self.dir.join(name))
}
fn list(&self, dir: &str) -> Vec<String> {
let path = if dir.is_empty() {
self.dir.clone()
} else {
if check_name(dir).is_err() {
return vec![];
}
self.dir.join(dir)
};
StdFiles.list(&path).into_iter().map(|(n, _)| n).collect()
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct StdFiles;
impl FileOps for StdFiles {
fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(d)?;
}
atomic_write(path, data)
}
fn remove(&self, path: &Path) -> io::Result<()> {
std::fs::remove_file(path).or_else(not_found)
}
fn list(&self, dir: &Path) -> Vec<(String, SystemTime)> {
let Ok(rd) = std::fs::read_dir(dir) else { return vec![] };
let mut v: Vec<(String, SystemTime)> = rd
.flatten()
.filter_map(|e| {
let m = e.metadata().ok().filter(|m| m.is_file())?;
let name = e.file_name().into_string().ok()?;
Some((name, m.modified().unwrap_or(SystemTime::UNIX_EPOCH)))
})
.filter(|(n, _)| !crate::atomic::is_temp_name(n))
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
fn is_file(&self, path: &Path) -> bool {
path.is_file()
}
}
#[derive(Debug, Default)]
pub struct MemoryConfig {
files: Mutex<BTreeMap<String, String>>,
}
impl MemoryConfig {
pub fn new() -> MemoryConfig {
MemoryConfig::default()
}
}
impl ConfigStore for MemoryConfig {
fn read(&self, name: &str) -> Option<String> {
self.files.lock().unwrap_or_else(PoisonError::into_inner).get(name).cloned()
}
fn write(&self, name: &str, data: &str) -> io::Result<()> {
check_name(name)?;
self.files.lock().unwrap_or_else(PoisonError::into_inner).insert(name.to_string(), data.to_string());
Ok(())
}
fn remove(&self, name: &str) -> io::Result<()> {
self.files.lock().unwrap_or_else(PoisonError::into_inner).remove(name);
Ok(())
}
fn list(&self, dir: &str) -> Vec<String> {
let pre = if dir.is_empty() { String::new() } else { format!("{}/", dir.trim_end_matches('/')) };
let m = self.files.lock().unwrap_or_else(PoisonError::into_inner);
m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
}
}
#[derive(Debug, Default)]
pub struct MemFiles {
files: Mutex<BTreeMap<PathBuf, (Vec<u8>, SystemTime)>>,
}
impl MemFiles {
pub fn new() -> MemFiles {
MemFiles::default()
}
}
impl FileOps for MemFiles {
fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
let m = self.files.lock().unwrap_or_else(PoisonError::into_inner);
m.get(path).map(|f| f.0.clone()).ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, path.display().to_string()))
}
fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
let now = now_or_epoch();
self.files.lock().unwrap_or_else(PoisonError::into_inner).insert(path.to_path_buf(), (data.to_vec(), now));
Ok(())
}
fn remove(&self, path: &Path) -> io::Result<()> {
self.files.lock().unwrap_or_else(PoisonError::into_inner).remove(path);
Ok(())
}
fn list(&self, dir: &Path) -> Vec<(String, SystemTime)> {
let m = self.files.lock().unwrap_or_else(PoisonError::into_inner);
m.iter().filter(|(p, _)| p.parent() == Some(dir)).filter_map(|(p, f)| Some((p.file_name()?.to_str()?.to_string(), f.1))).collect()
}
fn is_file(&self, path: &Path) -> bool {
self.files.lock().unwrap_or_else(PoisonError::into_inner).contains_key(path)
}
}
pub(crate) fn now_or_epoch() -> SystemTime {
#[cfg(not(target_arch = "wasm32"))]
return SystemTime::now();
#[cfg(target_arch = "wasm32")]
SystemTime::UNIX_EPOCH
}
#[cfg(test)]
mod tests {
use super::*;
fn tmpdir(tag: &str) -> tempfile::TempDir {
tempfile::Builder::new().prefix(&format!("ashlar-backend-{tag}-")).tempdir().unwrap()
}
fn exercise(s: &dyn ConfigStore) {
assert_eq!(s.read("a.json"), None);
s.write("a.json", "1").unwrap();
s.write("sub/b.json", "2").unwrap();
s.write("sub/c.json", "3").unwrap();
assert_eq!(s.read("a.json").as_deref(), Some("1"));
assert_eq!(s.list(""), vec!["a.json"]);
assert_eq!(s.list("sub"), vec!["b.json", "c.json"]);
s.remove("sub/b.json").unwrap();
s.remove("sub/b.json").unwrap();
assert_eq!(s.list("sub"), vec!["c.json"]);
for bad in ["", "../x", "/etc/passwd", "a/../../x", "a//b", "./a", "a\\b", "a/."] {
assert!(s.write(bad, "x").is_err(), "{bad}");
assert_eq!(s.read(bad), None);
}
}
#[test]
fn memory_config() {
exercise(&MemoryConfig::new());
}
#[test]
fn dir_config() {
let tmp = tmpdir("cfg");
let d = tmp.path();
exercise(&DirConfig::new(d));
assert!(!d.parent().unwrap().join("x").exists());
}
fn exercise_files(f: &dyn FileOps, root: &Path) {
let p = root.join("dir/a.bin");
assert!(!f.is_file(&p));
assert!(f.read(&p).is_err());
f.write(&p, b"xy").unwrap();
assert!(f.is_file(&p));
assert_eq!(f.read(&p).unwrap(), b"xy");
f.write(&root.join("dir/b.bin"), b"z").unwrap();
let names: Vec<String> = f.list(&root.join("dir")).into_iter().map(|e| e.0).collect();
assert_eq!(names, vec!["a.bin", "b.bin"]);
f.remove(&p).unwrap();
f.remove(&p).unwrap();
assert!(!f.is_file(&p));
assert!(f.list(&root.join("missing")).is_empty());
}
#[test]
fn mem_files() {
exercise_files(&MemFiles::new(), Path::new("/virtual"));
}
#[test]
fn std_files() {
let tmp = tmpdir("files");
let d = tmp.path();
exercise_files(&StdFiles, d);
}
}