use crate::local::{FlushMethod, flush_file, sync_dir};
use std::collections::BTreeSet;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
pub trait Target: Send + Sync {
fn put(&self, name: &str, bytes: &[u8]) -> io::Result<()>;
fn get(&self, name: &str) -> io::Result<Option<Vec<u8>>>;
fn list(&self, prefix: &str) -> io::Result<Vec<String>>;
fn delete(&self, name: &str) -> io::Result<()>;
fn put_manifest(&self, bytes: &[u8]) -> io::Result<()>;
fn get_manifest(&self) -> io::Result<Option<Vec<u8>>>;
fn describe(&self) -> String;
fn cleanup(&self) -> io::Result<()> {
Ok(())
}
}
pub(crate) fn check_name(name: &str) -> io::Result<()> {
let ok = !name.is_empty()
&& !name.ends_with(".tmp")
&& name.split('/').all(|part| {
!part.is_empty()
&& part != "."
&& part != ".."
&& part
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
});
if ok {
Ok(())
} else {
Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("invalid backup object name {name:?}"),
))
}
}
const MANIFEST: &str = "manifest.json";
pub struct LocalTarget {
root: PathBuf,
durable: bool,
touched: Mutex<BTreeSet<PathBuf>>,
}
impl LocalTarget {
pub fn new(root: impl AsRef<Path>) -> io::Result<Self> {
let root = root.as_ref().to_owned();
fs::create_dir_all(&root)?;
Ok(Self {
root,
durable: true,
touched: Mutex::new(BTreeSet::new()),
})
}
pub fn without_sync(mut self) -> Self {
self.durable = false;
self
}
pub fn root(&self) -> &Path {
&self.root
}
fn path(&self, name: &str) -> io::Result<PathBuf> {
check_name(name)?;
if name == MANIFEST {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"the manifest is not an object",
));
}
Ok(self.root.join(name))
}
fn write_new(&self, path: &Path, bytes: &[u8], method: FlushMethod) -> io::Result<()> {
let dir = path.parent().unwrap_or(&self.root);
fs::create_dir_all(dir)?;
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = PathBuf::from(tmp);
{
let mut f = File::create(&tmp)?;
f.write_all(bytes)?;
if self.durable {
flush_file(&f, method)?;
}
}
fs::rename(&tmp, path)?;
self.touched
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(dir.to_owned());
Ok(())
}
}
impl Target for LocalTarget {
fn put(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
self.write_new(&self.path(name)?, bytes, FlushMethod::Fsync)
}
fn get(&self, name: &str) -> io::Result<Option<Vec<u8>>> {
match fs::read(self.path(name)?) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn list(&self, prefix: &str) -> io::Result<Vec<String>> {
fn walk(dir: &Path, rel: &str, out: &mut Vec<String>) -> io::Result<()> {
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
for entry in entries {
let entry = entry?;
let name = entry.file_name().to_string_lossy().into_owned();
let path = if rel.is_empty() {
name.clone()
} else {
format!("{rel}/{name}")
};
if entry.file_type()?.is_dir() {
walk(&entry.path(), &path, out)?;
} else if !name.ends_with(".tmp") && path != MANIFEST {
out.push(path);
}
}
Ok(())
}
let mut out = Vec::new();
walk(&self.root, "", &mut out)?;
out.retain(|n| n.starts_with(prefix));
out.sort();
Ok(out)
}
fn delete(&self, name: &str) -> io::Result<()> {
match fs::remove_file(self.path(name)?) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
fn put_manifest(&self, bytes: &[u8]) -> io::Result<()> {
if self.durable {
let dirs = std::mem::take(&mut *self.touched.lock().unwrap_or_else(|e| e.into_inner()));
for d in dirs.iter().rev() {
sync_dir(d, FlushMethod::Fsync)?;
}
}
self.write_new(
&self.root.join(MANIFEST),
bytes,
FlushMethod::platform_default(),
)?;
if self.durable {
sync_dir(&self.root, FlushMethod::platform_default())?;
}
Ok(())
}
fn get_manifest(&self) -> io::Result<Option<Vec<u8>>> {
match fs::read(self.root.join(MANIFEST)) {
Ok(b) => Ok(Some(b)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn describe(&self) -> String {
format!("file://{}", self.root.display())
}
fn cleanup(&self) -> io::Result<()> {
fn walk(dir: &Path) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
walk(&entry.path())?;
} else if entry.file_name().to_string_lossy().ends_with(".tmp") {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
walk(&self.root)
}
}
pub fn open_target(spec: &str) -> io::Result<Arc<dyn Target>> {
if let Some(path) = spec.strip_prefix("file://") {
return Ok(Arc::new(LocalTarget::new(path)?));
}
if spec.starts_with("s3://") {
#[cfg(feature = "s3")]
{
return Ok(Arc::new(super::s3::S3Target::from_url(spec)?));
}
#[cfg(not(feature = "s3"))]
{
return Err(io::Error::other(
"this build has no S3 support (cargo feature `s3` of the fusee crate)",
));
}
}
if spec.contains("://") {
return Err(io::Error::other(format!(
"unknown backup target {spec:?}: use a directory, file:///path or s3://bucket/prefix"
)));
}
Ok(Arc::new(LocalTarget::new(spec)?))
}