solid_pod_rs_forge/
hosted.rs1use 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
18pub const REF_SCHEME: &str = "hosted:";
20
21#[derive(Debug, Clone)]
23pub struct HostedStore {
24 root: PathBuf,
25}
26
27impl HostedStore {
28 #[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 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 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 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 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 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 #[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 assert!(matches!(
176 store.read_hosted(&rref, 1024).await,
177 FetchResult::Body(_)
178 ));
179 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 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}