Skip to main content

solid_pod_rs_forge/
hosted.rs

1//! Forge-hosted body storage for podless `did:nostr` agents.
2//!
3//! A nostr agent has no pod, so the "words in pods" rule degrades to
4//! "words in the forge": the agent submits the body inline and the forge
5//! stores it at `pluginDir/hosted/<hex>/<uuid>.json` with mode `0600`.
6//! The corresponding [`ThreadPointer`](crate::spine::issues::ThreadPointer)
7//! carries `hosted = true` and a `hosted:<hex>/<uuid>` resource ref that
8//! this store resolves at read time.
9
10use std::path::PathBuf;
11
12use async_trait::async_trait;
13
14use crate::bodies::{FetchResult, HostedReader};
15use crate::error::ForgeError;
16use solid_pod_rs::did_nostr_types::is_valid_hex_pubkey;
17
18/// The `hosted:` ref scheme prefix.
19pub const REF_SCHEME: &str = "hosted:";
20
21/// Local body store rooted at `pluginDir/hosted`.
22#[derive(Debug, Clone)]
23pub struct HostedStore {
24    root: PathBuf,
25}
26
27impl HostedStore {
28    /// Create a store rooted at `<plugin_dir>/hosted`.
29    #[must_use]
30    pub fn new(plugin_dir: impl Into<PathBuf>) -> Self {
31        Self {
32            root: plugin_dir.into().join("hosted"),
33        }
34    }
35
36    /// Validate a uuid token: 1..=64 chars, hex/dash only (matches
37    /// `uuid::Uuid` output and forbids traversal).
38    fn valid_uuid(id: &str) -> bool {
39        !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
40    }
41
42    /// Persist `body` for owner `hex`, returning its `hosted:<hex>/<uuid>`
43    /// resource ref. The file is written `0600` (owner-only) on Unix.
44    pub async fn write(&self, hex: &str, body: &[u8]) -> Result<String, ForgeError> {
45        if !is_valid_hex_pubkey(hex) {
46            return Err(ForgeError::BadRequest(format!("invalid pubkey: {hex}")));
47        }
48        let id = uuid::Uuid::new_v4().to_string();
49        let dir = self.root.join(hex);
50        tokio::fs::create_dir_all(&dir).await?;
51        let path = dir.join(format!("{id}.json"));
52        crate::spine::atomic_write(&path, body).await?;
53        #[cfg(unix)]
54        {
55            use std::os::unix::fs::PermissionsExt;
56            let _ = tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await;
57        }
58        Ok(format!("{REF_SCHEME}{hex}/{id}"))
59    }
60
61    /// Read a stored body by `(hex, uuid)`, bounded to `max_bytes`.
62    pub async fn read(
63        &self,
64        hex: &str,
65        uuid: &str,
66        max_bytes: usize,
67    ) -> Result<Option<Vec<u8>>, ForgeError> {
68        let Some(path) = self.path_for(hex, uuid) else {
69            return Err(ForgeError::PathTraversal(format!("{hex}/{uuid}")));
70        };
71        match tokio::fs::read(&path).await {
72            Ok(bytes) if bytes.len() > max_bytes => {
73                Err(ForgeError::BadRequest("body too large".into()))
74            }
75            Ok(bytes) => Ok(Some(bytes)),
76            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
77            Err(e) => Err(ForgeError::Io(e)),
78        }
79    }
80
81    /// Delete a stored body. Returns `true` if it existed.
82    pub async fn delete(&self, hex: &str, uuid: &str) -> Result<bool, ForgeError> {
83        let Some(path) = self.path_for(hex, uuid) else {
84            return Err(ForgeError::PathTraversal(format!("{hex}/{uuid}")));
85        };
86        match tokio::fs::remove_file(&path).await {
87            Ok(()) => Ok(true),
88            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
89            Err(e) => Err(ForgeError::Io(e)),
90        }
91    }
92
93    /// Resolve and validate the on-disk path for `(hex, uuid)`.
94    fn path_for(&self, hex: &str, uuid: &str) -> Option<PathBuf> {
95        if !is_valid_hex_pubkey(hex) || !Self::valid_uuid(uuid) {
96            return None;
97        }
98        Some(self.root.join(hex).join(format!("{uuid}.json")))
99    }
100
101    /// Parse a `hosted:<hex>/<uuid>` ref into its parts.
102    #[must_use]
103    pub fn parse_ref(resource_ref: &str) -> Option<(String, String)> {
104        let rest = resource_ref.strip_prefix(REF_SCHEME)?;
105        let (hex, uuid) = rest.split_once('/')?;
106        if is_valid_hex_pubkey(hex) && Self::valid_uuid(uuid) {
107            Some((hex.to_string(), uuid.to_string()))
108        } else {
109            None
110        }
111    }
112}
113
114#[async_trait]
115impl HostedReader for HostedStore {
116    async fn read_hosted(&self, resource_ref: &str, max_bytes: usize) -> FetchResult {
117        let Some((hex, uuid)) = Self::parse_ref(resource_ref) else {
118            return FetchResult::Error("malformed hosted ref".into());
119        };
120        match self.read(&hex, &uuid, max_bytes).await {
121            Ok(Some(bytes)) => FetchResult::Body(bytes),
122            Ok(None) => FetchResult::Removed,
123            Err(ForgeError::BadRequest(_)) => FetchResult::TooLarge,
124            Err(e) => FetchResult::Error(e.to_string()),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use tempfile::TempDir;
133
134    fn hex() -> String {
135        "b".repeat(64)
136    }
137
138    #[tokio::test]
139    async fn write_read_delete_roundtrip() {
140        let td = TempDir::new().unwrap();
141        let store = HostedStore::new(td.path());
142        let rref = store.write(&hex(), b"hosted body").await.unwrap();
143        assert!(rref.starts_with("hosted:"));
144        let (h, u) = HostedStore::parse_ref(&rref).unwrap();
145        assert_eq!(h, hex());
146
147        let got = store.read(&h, &u, 1024).await.unwrap().unwrap();
148        assert_eq!(got, b"hosted body");
149
150        assert!(store.delete(&h, &u).await.unwrap());
151        assert!(store.read(&h, &u, 1024).await.unwrap().is_none());
152        assert!(!store.delete(&h, &u).await.unwrap());
153    }
154
155    #[cfg(unix)]
156    #[tokio::test]
157    async fn written_file_is_0600() {
158        use std::os::unix::fs::PermissionsExt;
159        let td = TempDir::new().unwrap();
160        let store = HostedStore::new(td.path());
161        let rref = store.write(&hex(), b"secret").await.unwrap();
162        let (h, u) = HostedStore::parse_ref(&rref).unwrap();
163        let path = td.path().join("hosted").join(&h).join(format!("{u}.json"));
164        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
165        assert_eq!(mode, 0o600, "hosted body must be owner-only");
166    }
167
168    #[tokio::test]
169    async fn reader_trait_maps_outcomes() {
170        let td = TempDir::new().unwrap();
171        let store = HostedStore::new(td.path());
172        let rref = store.write(&hex(), b"x").await.unwrap();
173
174        // Present.
175        assert!(matches!(
176            store.read_hosted(&rref, 1024).await,
177            FetchResult::Body(_)
178        ));
179        // Removed after delete.
180        let (h, u) = HostedStore::parse_ref(&rref).unwrap();
181        store.delete(&h, &u).await.unwrap();
182        assert!(matches!(
183            store.read_hosted(&rref, 1024).await,
184            FetchResult::Removed
185        ));
186        // Malformed ref.
187        assert!(matches!(
188            store.read_hosted("hosted:bad", 1024).await,
189            FetchResult::Error(_)
190        ));
191    }
192
193    #[tokio::test]
194    async fn rejects_invalid_pubkey_and_traversal() {
195        let td = TempDir::new().unwrap();
196        let store = HostedStore::new(td.path());
197        assert!(store.write("nothex", b"x").await.is_err());
198        assert!(store.read(&hex(), "../escape", 1024).await.is_err());
199        assert_eq!(HostedStore::parse_ref("hosted:short/uuid"), None);
200    }
201}