Skip to main content

rho_core/
storage.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::{RhoResult, bytes_digest, ensure_parent, file_digest, validate_relative_safe_path};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct StorageEntry {
9    pub path: String,
10    pub digest: String,
11    pub bytes: u64,
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct StateDigest(pub String);
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct ProposalId(pub String);
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct RhoSpace {
22    pub id: String,
23    pub owner: String,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub members: Vec<String>,
26    #[serde(default, skip_serializing_if = "Vec::is_empty")]
27    pub storage: Vec<StorageLocator>,
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub transfer: Vec<TransferLocator>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33pub struct StorageLocator {
34    pub kind: String,
35    pub uri: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub branch: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct TransferLocator {
42    pub kind: String,
43    pub uri: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct ChangeSetManifest {
48    pub version: u32,
49    pub change_set: ChangeSet,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct ChangeSet {
54    pub id: String,
55    pub space_id: String,
56    pub author: String,
57    pub base_state: StateDigest,
58    pub created_at: String,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub message: Option<String>,
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub files: Vec<ChangeSetFile>,
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub signatures: Vec<ChangeSetSignature>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct ChangeSetFile {
69    pub path: String,
70    pub op: ChangeSetOp,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub sha256: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub previous_sha256: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub bytes: Option<u64>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub encrypted: Option<bool>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82#[serde(rename_all = "snake_case")]
83pub enum ChangeSetOp {
84    Upsert,
85    Delete,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
89pub struct ChangeSetSignature {
90    pub signer: String,
91    pub key_id: String,
92    pub algorithm: String,
93    pub signature: String,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct SignedChangeSet {
98    pub id: String,
99    pub bytes: Vec<u8>,
100}
101
102impl SignedChangeSet {
103    pub fn from_manifest(manifest: &ChangeSetManifest) -> RhoResult<Self> {
104        Ok(Self {
105            id: manifest.change_set.id.clone(),
106            bytes: serde_yaml::to_string(manifest)?.into_bytes(),
107        })
108    }
109
110    pub fn manifest(&self) -> RhoResult<ChangeSetManifest> {
111        Ok(serde_yaml::from_slice(&self.bytes)?)
112    }
113}
114
115pub trait StorageBackend {
116    fn get(&self, path_or_hash: &str) -> RhoResult<Vec<u8>>;
117    fn put(
118        &self,
119        path: &str,
120        bytes: &[u8],
121        expected_previous_digest: Option<&str>,
122    ) -> RhoResult<String>;
123    fn list(&self, prefix: &str) -> RhoResult<Vec<StorageEntry>>;
124    fn head(&self, space_id: &str) -> RhoResult<StateDigest>;
125    fn propose(&self, change_set: &SignedChangeSet) -> RhoResult<ProposalId>;
126    fn fetch_proposal(&self, id: &ProposalId) -> RhoResult<SignedChangeSet>;
127}
128
129#[derive(Debug, Clone)]
130pub struct LocalFsStorage {
131    root: PathBuf,
132}
133
134impl LocalFsStorage {
135    pub fn new(root: impl Into<PathBuf>) -> Self {
136        Self { root: root.into() }
137    }
138
139    fn path(&self, relative: &str) -> RhoResult<PathBuf> {
140        validate_relative_safe_path(relative)?;
141        Ok(self.root.join(relative))
142    }
143}
144
145impl StorageBackend for LocalFsStorage {
146    fn get(&self, path_or_hash: &str) -> RhoResult<Vec<u8>> {
147        Ok(fs::read(self.path(path_or_hash)?)?)
148    }
149
150    fn put(
151        &self,
152        path: &str,
153        bytes: &[u8],
154        expected_previous_digest: Option<&str>,
155    ) -> RhoResult<String> {
156        let target = self.path(path)?;
157        if let Some(expected) = expected_previous_digest {
158            let actual = if target.is_file() {
159                file_digest(&target)?
160            } else {
161                String::new()
162            };
163            if actual != expected {
164                return Err(format!(
165                    "previous digest mismatch for {path}: expected {expected}, got {actual}"
166                )
167                .into());
168            }
169        }
170        ensure_parent(&target)?;
171        fs::write(&target, bytes)?;
172        file_digest(&target)
173    }
174
175    fn list(&self, prefix: &str) -> RhoResult<Vec<StorageEntry>> {
176        let base = self.path(prefix)?;
177        let mut entries = Vec::new();
178        if !base.exists() {
179            return Ok(entries);
180        }
181        collect_entries(&self.root, &base, &mut entries)?;
182        entries.sort_by(|a, b| a.path.cmp(&b.path));
183        Ok(entries)
184    }
185
186    fn head(&self, space_id: &str) -> RhoResult<StateDigest> {
187        let head_path = format!("rho/heads/{}.txt", metadata_key(space_id));
188        let text = fs::read_to_string(self.path(&head_path)?)?;
189        Ok(StateDigest(text.trim().to_string()))
190    }
191
192    fn propose(&self, change_set: &SignedChangeSet) -> RhoResult<ProposalId> {
193        validate_relative_safe_path(&change_set.id)?;
194        let path = format!("rho/proposals/{}.yaml", change_set.id);
195        self.put(&path, &change_set.bytes, None)?;
196        Ok(ProposalId(change_set.id.clone()))
197    }
198
199    fn fetch_proposal(&self, id: &ProposalId) -> RhoResult<SignedChangeSet> {
200        validate_relative_safe_path(&id.0)?;
201        let path = format!("rho/proposals/{}.yaml", id.0);
202        Ok(SignedChangeSet {
203            id: id.0.clone(),
204            bytes: self.get(&path)?,
205        })
206    }
207}
208
209fn collect_entries(root: &Path, dir: &Path, entries: &mut Vec<StorageEntry>) -> RhoResult<()> {
210    if dir.is_file() {
211        let relative = dir
212            .strip_prefix(root)?
213            .to_string_lossy()
214            .replace(std::path::MAIN_SEPARATOR, "/");
215        entries.push(StorageEntry {
216            path: relative,
217            digest: file_digest(dir)?,
218            bytes: fs::metadata(dir)?.len(),
219        });
220        return Ok(());
221    }
222    for entry in fs::read_dir(dir)? {
223        collect_entries(root, &entry?.path(), entries)?;
224    }
225    Ok(())
226}
227
228fn metadata_key(value: &str) -> String {
229    bytes_digest(value.as_bytes())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::time::{SystemTime, UNIX_EPOCH};
236
237    #[test]
238    fn serializes_signed_change_set_manifest() {
239        let manifest = ChangeSetManifest {
240            version: 1,
241            change_set: ChangeSet {
242                id: "chg-1".to_string(),
243                space_id: "rho://space/github/example/project".to_string(),
244                author: "rho://id/nostr/npub1abc".to_string(),
245                base_state: StateDigest("sha256:base".to_string()),
246                created_at: "2026-06-16T00:00:00Z".to_string(),
247                message: Some("test".to_string()),
248                files: vec![ChangeSetFile {
249                    path: "README.md".to_string(),
250                    op: ChangeSetOp::Upsert,
251                    sha256: Some("sha256:file".to_string()),
252                    previous_sha256: None,
253                    bytes: Some(12),
254                    encrypted: Some(false),
255                }],
256                signatures: Vec::new(),
257            },
258        };
259
260        let signed = SignedChangeSet::from_manifest(&manifest).unwrap();
261        assert_eq!(signed.id, "chg-1");
262        assert_eq!(signed.manifest().unwrap(), manifest);
263    }
264
265    #[test]
266    fn local_storage_puts_lists_and_fetches_proposals() {
267        let root = temp_root("rho-storage-test");
268        let storage = LocalFsStorage::new(&root);
269        let digest = storage.put("docs/a.txt", b"hello", None).unwrap();
270
271        assert_eq!(storage.get("docs/a.txt").unwrap(), b"hello");
272        assert_eq!(storage.list("docs").unwrap()[0].digest, digest);
273
274        let proposal = SignedChangeSet {
275            id: "chg-2".to_string(),
276            bytes: b"version: 1\n".to_vec(),
277        };
278        let id = storage.propose(&proposal).unwrap();
279        assert_eq!(storage.fetch_proposal(&id).unwrap(), proposal);
280
281        let _ = fs::remove_dir_all(root);
282    }
283
284    fn temp_root(prefix: &str) -> PathBuf {
285        let nonce = SystemTime::now()
286            .duration_since(UNIX_EPOCH)
287            .unwrap()
288            .as_nanos();
289        std::env::temp_dir().join(format!("{prefix}-{nonce}"))
290    }
291}