solid_pod_rs_forge/spine/
mod.rs1pub mod issues;
11
12use std::path::{Path, PathBuf};
13
14use async_trait::async_trait;
15
16use crate::error::ForgeError;
17use crate::ownership::valid_name_segment;
18
19#[async_trait]
24pub trait SpineStore: Send + Sync {
25 async fn load(
28 &self,
29 kind: &str,
30 owner: &str,
31 repo: &str,
32 ) -> Result<Option<Vec<u8>>, ForgeError>;
33
34 async fn store(
36 &self,
37 kind: &str,
38 owner: &str,
39 repo: &str,
40 bytes: &[u8],
41 ) -> Result<(), ForgeError>;
42}
43
44#[derive(Debug, Clone)]
47pub struct FsSpineStore {
48 root: PathBuf,
49}
50
51impl FsSpineStore {
52 #[must_use]
55 pub fn new(root: impl Into<PathBuf>) -> Self {
56 Self { root: root.into() }
57 }
58
59 fn index_path(&self, kind: &str, owner: &str, repo: &str) -> Result<PathBuf, ForgeError> {
62 if !valid_name_segment(kind) {
63 return Err(ForgeError::PathTraversal(format!("kind: {kind}")));
64 }
65 if !valid_name_segment(owner) {
66 return Err(ForgeError::PathTraversal(format!("owner: {owner}")));
67 }
68 if !valid_name_segment(repo) {
69 return Err(ForgeError::PathTraversal(format!("repo: {repo}")));
70 }
71 Ok(self
72 .root
73 .join(kind)
74 .join(owner)
75 .join(format!("{repo}.json")))
76 }
77}
78
79#[async_trait]
80impl SpineStore for FsSpineStore {
81 async fn load(
82 &self,
83 kind: &str,
84 owner: &str,
85 repo: &str,
86 ) -> Result<Option<Vec<u8>>, ForgeError> {
87 let path = self.index_path(kind, owner, repo)?;
88 match tokio::fs::read(&path).await {
89 Ok(bytes) => Ok(Some(bytes)),
90 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
91 Err(e) => Err(ForgeError::Io(e)),
92 }
93 }
94
95 async fn store(
96 &self,
97 kind: &str,
98 owner: &str,
99 repo: &str,
100 bytes: &[u8],
101 ) -> Result<(), ForgeError> {
102 let path = self.index_path(kind, owner, repo)?;
103 let dir = path
104 .parent()
105 .ok_or_else(|| ForgeError::Backend("index path has no parent".into()))?;
106 tokio::fs::create_dir_all(dir).await?;
107 atomic_write(&path, bytes).await
108 }
109}
110
111pub async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), ForgeError> {
116 let dir = path
117 .parent()
118 .ok_or_else(|| ForgeError::Backend("write path has no parent".into()))?;
119 let fname = path
120 .file_name()
121 .and_then(|s| s.to_str())
122 .unwrap_or("index.json");
123 let tmp = dir.join(format!(".{fname}.{}.tmp", uuid::Uuid::new_v4()));
125
126 {
128 use tokio::io::AsyncWriteExt;
129 let mut f = tokio::fs::File::create(&tmp).await?;
130 f.write_all(bytes).await?;
131 f.flush().await?;
132 let _ = f.sync_all().await;
134 }
135
136 match tokio::fs::rename(&tmp, path).await {
137 Ok(()) => Ok(()),
138 Err(e) => {
139 let _ = tokio::fs::remove_file(&tmp).await;
141 Err(ForgeError::Io(e))
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use tempfile::TempDir;
150
151 #[tokio::test]
152 async fn load_absent_is_none() {
153 let td = TempDir::new().unwrap();
154 let store = FsSpineStore::new(td.path());
155 let got = store.load("issues", "alice", "repo").await.unwrap();
156 assert!(got.is_none());
157 }
158
159 #[tokio::test]
160 async fn store_then_load_roundtrips() {
161 let td = TempDir::new().unwrap();
162 let store = FsSpineStore::new(td.path());
163 store
164 .store("issues", "alice", "repo", b"{\"x\":1}")
165 .await
166 .unwrap();
167 let got = store
168 .load("issues", "alice", "repo")
169 .await
170 .unwrap()
171 .unwrap();
172 assert_eq!(got, b"{\"x\":1}");
173 let dir = td.path().join("issues/alice");
175 let mut leftover = Vec::new();
176 let mut rd = tokio::fs::read_dir(&dir).await.unwrap();
177 while let Some(e) = rd.next_entry().await.unwrap() {
178 leftover.push(e.file_name().to_string_lossy().into_owned());
179 }
180 assert_eq!(leftover, vec!["repo.json".to_string()]);
181 }
182
183 #[tokio::test]
184 async fn store_rejects_traversal() {
185 let td = TempDir::new().unwrap();
186 let store = FsSpineStore::new(td.path());
187 assert!(store.store("issues", "..", "r", b"{}").await.is_err());
188 assert!(store.store("issues", "alice", "../x", b"{}").await.is_err());
189 assert!(store.store("../etc", "a", "r", b"{}").await.is_err());
190 }
191
192 #[tokio::test]
193 async fn overwrite_is_atomic_replace() {
194 let td = TempDir::new().unwrap();
195 let store = FsSpineStore::new(td.path());
196 store.store("issues", "a", "r", b"first").await.unwrap();
197 store
198 .store("issues", "a", "r", b"second-longer")
199 .await
200 .unwrap();
201 let got = store.load("issues", "a", "r").await.unwrap().unwrap();
202 assert_eq!(got, b"second-longer");
203 }
204}