use std::fs;
use std::path::{Path, PathBuf};
use crate::{RhoResult, ensure_parent, file_digest, validate_relative_safe_path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageEntry {
pub path: String,
pub digest: String,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateDigest(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProposalId(pub String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedChangeSet {
pub id: String,
pub bytes: Vec<u8>,
}
pub trait StorageBackend {
fn get(&self, path_or_hash: &str) -> RhoResult<Vec<u8>>;
fn put(
&self,
path: &str,
bytes: &[u8],
expected_previous_digest: Option<&str>,
) -> RhoResult<String>;
fn list(&self, prefix: &str) -> RhoResult<Vec<StorageEntry>>;
fn head(&self, space_id: &str) -> RhoResult<StateDigest>;
fn propose(&self, change_set: &SignedChangeSet) -> RhoResult<ProposalId>;
fn fetch_proposal(&self, id: &ProposalId) -> RhoResult<SignedChangeSet>;
}
#[derive(Debug, Clone)]
pub struct LocalFsStorage {
root: PathBuf,
}
impl LocalFsStorage {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn path(&self, relative: &str) -> RhoResult<PathBuf> {
validate_relative_safe_path(relative)?;
Ok(self.root.join(relative))
}
}
impl StorageBackend for LocalFsStorage {
fn get(&self, path_or_hash: &str) -> RhoResult<Vec<u8>> {
Ok(fs::read(self.path(path_or_hash)?)?)
}
fn put(
&self,
path: &str,
bytes: &[u8],
expected_previous_digest: Option<&str>,
) -> RhoResult<String> {
let target = self.path(path)?;
if let Some(expected) = expected_previous_digest {
let actual = if target.is_file() {
file_digest(&target)?
} else {
String::new()
};
if actual != expected {
return Err(format!(
"previous digest mismatch for {path}: expected {expected}, got {actual}"
)
.into());
}
}
ensure_parent(&target)?;
fs::write(&target, bytes)?;
file_digest(&target)
}
fn list(&self, prefix: &str) -> RhoResult<Vec<StorageEntry>> {
let base = self.path(prefix)?;
let mut entries = Vec::new();
if !base.exists() {
return Ok(entries);
}
collect_entries(&self.root, &base, &mut entries)?;
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
fn head(&self, space_id: &str) -> RhoResult<StateDigest> {
let head_path = format!("rho/heads/{space_id}.txt");
let text = fs::read_to_string(self.path(&head_path)?)?;
Ok(StateDigest(text.trim().to_string()))
}
fn propose(&self, change_set: &SignedChangeSet) -> RhoResult<ProposalId> {
validate_relative_safe_path(&change_set.id)?;
let path = format!("rho/proposals/{}.yaml", change_set.id);
self.put(&path, &change_set.bytes, None)?;
Ok(ProposalId(change_set.id.clone()))
}
fn fetch_proposal(&self, id: &ProposalId) -> RhoResult<SignedChangeSet> {
validate_relative_safe_path(&id.0)?;
let path = format!("rho/proposals/{}.yaml", id.0);
Ok(SignedChangeSet {
id: id.0.clone(),
bytes: self.get(&path)?,
})
}
}
fn collect_entries(root: &Path, dir: &Path, entries: &mut Vec<StorageEntry>) -> RhoResult<()> {
if dir.is_file() {
let relative = dir
.strip_prefix(root)?
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
entries.push(StorageEntry {
path: relative,
digest: file_digest(dir)?,
bytes: fs::metadata(dir)?.len(),
});
return Ok(());
}
for entry in fs::read_dir(dir)? {
collect_entries(root, &entry?.path(), entries)?;
}
Ok(())
}