Skip to main content

solid_pod_rs_forge/spine/
mod.rs

1//! The spine — the forge's only durable non-git metadata.
2//!
3//! "Words in pods, metadata in the forge": issue/PR/comment *bodies* live
4//! in the author's pod (or forge-hosted storage for podless agents); the
5//! spine keeps only an index of pointers. This module defines the
6//! [`SpineStore`] persistence trait and its filesystem implementation,
7//! which writes each index atomically (`tmp` + `rename`) so a crash
8//! mid-write can never corrupt a live index.
9
10pub 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/// Object-safe byte-level persistence for spine indices. Typed helpers
20/// (e.g. [`issues::load_issue_index`]) sit on top of this. Kept
21/// byte-level so it stays `dyn`-compatible; the atomicity guarantee lives
22/// in the implementation.
23#[async_trait]
24pub trait SpineStore: Send + Sync {
25    /// Load the raw JSON bytes for `(kind, owner, repo)`, or `None` when
26    /// no index has been written yet.
27    async fn load(
28        &self,
29        kind: &str,
30        owner: &str,
31        repo: &str,
32    ) -> Result<Option<Vec<u8>>, ForgeError>;
33
34    /// Atomically persist `bytes` for `(kind, owner, repo)`.
35    async fn store(
36        &self,
37        kind: &str,
38        owner: &str,
39        repo: &str,
40        bytes: &[u8],
41    ) -> Result<(), ForgeError>;
42}
43
44/// Filesystem-backed spine store rooted at the plugin dir. Index files
45/// live at `<root>/<kind>/<owner>/<repo>.json`.
46#[derive(Debug, Clone)]
47pub struct FsSpineStore {
48    root: PathBuf,
49}
50
51impl FsSpineStore {
52    /// Create a store rooted at `plugin_dir` (the same dir passed to
53    /// [`crate::ForgeService::new`]).
54    #[must_use]
55    pub fn new(root: impl Into<PathBuf>) -> Self {
56        Self { root: root.into() }
57    }
58
59    /// Compute and validate the on-disk path for an index, rejecting any
60    /// traversal in `kind`/`owner`/`repo`.
61    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
111/// Write `bytes` to `path` atomically: write to a unique sibling temp
112/// file, `fsync`, then `rename` over the target. A rename within a
113/// directory is atomic on POSIX, so a reader sees either the old file or
114/// the fully-written new one — never a torn write.
115pub 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    // Unique temp name; uuid avoids collisions under concurrent writers.
124    let tmp = dir.join(format!(".{fname}.{}.tmp", uuid::Uuid::new_v4()));
125
126    // Scoped so the file is closed (and its handle dropped) before rename.
127    {
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        // Best-effort durability; ignore platforms that lack fsync.
133        let _ = f.sync_all().await;
134    }
135
136    match tokio::fs::rename(&tmp, path).await {
137        Ok(()) => Ok(()),
138        Err(e) => {
139            // Clean up the temp file on failure.
140            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        // The file lives where we expect and no temp files leaked.
174        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}