use std::fs;
use std::path::{Path, PathBuf};
use crate::{RhoResult, bytes_digest, ensure_parent, file_digest, validate_relative_safe_path};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageEntry {
pub path: String,
pub digest: String,
pub bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StateDigest(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProposalId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RhoSpace {
pub id: String,
pub owner: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub members: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub storage: Vec<StorageLocator>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transfer: Vec<TransferLocator>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct StorageLocator {
pub kind: String,
pub uri: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TransferLocator {
pub kind: String,
pub uri: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSetManifest {
pub version: u32,
pub change_set: ChangeSet,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSet {
pub id: String,
pub space_id: String,
pub author: String,
pub base_state: StateDigest,
pub created_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<ChangeSetFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub signatures: Vec<ChangeSetSignature>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSetFile {
pub path: String,
pub op: ChangeSetOp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub encrypted: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ChangeSetOp {
Upsert,
Delete,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ChangeSetSignature {
pub signer: String,
pub key_id: String,
pub algorithm: String,
pub signature: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedChangeSet {
pub id: String,
pub bytes: Vec<u8>,
}
impl SignedChangeSet {
pub fn from_manifest(manifest: &ChangeSetManifest) -> RhoResult<Self> {
Ok(Self {
id: manifest.change_set.id.clone(),
bytes: serde_yaml::to_string(manifest)?.into_bytes(),
})
}
pub fn manifest(&self) -> RhoResult<ChangeSetManifest> {
Ok(serde_yaml::from_slice(&self.bytes)?)
}
}
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/{}.txt", metadata_key(space_id));
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(())
}
fn metadata_key(value: &str) -> String {
bytes_digest(value.as_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn serializes_signed_change_set_manifest() {
let manifest = ChangeSetManifest {
version: 1,
change_set: ChangeSet {
id: "chg-1".to_string(),
space_id: "rho://space/github/example/project".to_string(),
author: "rho://id/nostr/npub1abc".to_string(),
base_state: StateDigest("sha256:base".to_string()),
created_at: "2026-06-16T00:00:00Z".to_string(),
message: Some("test".to_string()),
files: vec![ChangeSetFile {
path: "README.md".to_string(),
op: ChangeSetOp::Upsert,
sha256: Some("sha256:file".to_string()),
previous_sha256: None,
bytes: Some(12),
encrypted: Some(false),
}],
signatures: Vec::new(),
},
};
let signed = SignedChangeSet::from_manifest(&manifest).unwrap();
assert_eq!(signed.id, "chg-1");
assert_eq!(signed.manifest().unwrap(), manifest);
}
#[test]
fn local_storage_puts_lists_and_fetches_proposals() {
let root = temp_root("rho-storage-test");
let storage = LocalFsStorage::new(&root);
let digest = storage.put("docs/a.txt", b"hello", None).unwrap();
assert_eq!(storage.get("docs/a.txt").unwrap(), b"hello");
assert_eq!(storage.list("docs").unwrap()[0].digest, digest);
let proposal = SignedChangeSet {
id: "chg-2".to_string(),
bytes: b"version: 1\n".to_vec(),
};
let id = storage.propose(&proposal).unwrap();
assert_eq!(storage.fetch_proposal(&id).unwrap(), proposal);
let _ = fs::remove_dir_all(root);
}
fn temp_root(prefix: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{nonce}"))
}
}