Skip to main content

solid_pod_rs_forge/
bodies.rs

1//! Two-phase pod-write coordinator.
2//!
3//! Issue/PR/comment bodies live in the author's own pod. The write is
4//! two-phase (cites JSS `forge` Tier 2 behaviour by name only):
5//!
6//! 1. The browser signs a proof bound to *its own* pod and PUTs the
7//!    JSON-LD body into its forge area
8//!    (`<pod>/public/forge/<owner>--<repo>/…jsonld`), then POSTs only the
9//!    `resourceUrl` pointer to the forge.
10//! 2. The forge validates the URL is inside the caller's **own** forge
11//!    area ([`own_area_ok`] — this is also the SSRF guard: it pins the
12//!    loopback to our own origin), unauthenticated-GETs it once to
13//!    confirm public readability, and stores only the pointer.
14//!
15//! Bodies are re-fetched at read time, bounded: `fetch_concurrency`-way
16//! parallel, `fetch_timeout_secs` each, `thread_cap` pointers max,
17//! `max_body_bytes` per body. A deleted pod resource renders as
18//! "content removed by its author".
19
20use std::sync::Arc;
21
22use async_trait::async_trait;
23use tokio::sync::Semaphore;
24use tokio::task::JoinSet;
25
26use crate::config::ForgeConfig;
27use crate::error::ForgeError;
28use crate::request::looks_textual;
29use crate::spine::issues::ThreadPointer;
30
31/// Result of an unauthenticated body fetch.
32#[derive(Debug, Clone)]
33pub enum FetchResult {
34    /// The body bytes (already bounded to `max_bytes` by the fetcher).
35    Body(Vec<u8>),
36    /// The resource returned 404/410 — its author deleted it.
37    Removed,
38    /// The resource exceeded `max_bytes`.
39    TooLarge,
40    /// A network/timeout/other error (the message is for diagnostics).
41    Error(String),
42}
43
44/// Unauthenticated loopback GET against the server's own origin. The
45/// server injects a reqwest-backed implementation that reuses its vetted
46/// HTTP client + SSRF posture; tests inject an in-memory mock.
47#[async_trait]
48pub trait LoopbackFetch: Send + Sync {
49    /// GET `url` with no credentials, bounding the body to `max_bytes`
50    /// and the request to `timeout_secs`.
51    async fn get(&self, url: &str, max_bytes: usize, timeout_secs: u64) -> FetchResult;
52}
53
54/// Reader for forge-hosted bodies (podless `did:nostr` agents). Wired in
55/// the tokens phase; kept here so the read-time coordinator signature is
56/// stable across phases.
57#[async_trait]
58pub trait HostedReader: Send + Sync {
59    /// Read a hosted body identified by its `resource_url` ref.
60    async fn read_hosted(&self, resource_ref: &str, max_bytes: usize) -> FetchResult;
61}
62
63/// The forge-area URL prefix a caller's body must live under. The body
64/// lives in the **caller's own** pod (`caller`), namespaced by the target
65/// repo (`repo_owner--repo`). For issue creation the caller *is* the repo
66/// owner; for a comment the caller is the commenter (a different pod).
67#[must_use]
68pub fn forge_area_prefix(host: &str, caller: &str, repo_owner: &str, repo: &str) -> String {
69    let host = host.trim_end_matches('/');
70    format!("{host}/{caller}/public/forge/{repo_owner}--{repo}/")
71}
72
73/// Validate that `resource_url` is inside the caller's own forge area:
74/// same origin (`host`), under `/<caller>/public/forge/<repo_owner>--<repo>/`,
75/// no `..` traversal, and a JSON-LD/JSON leaf. This is the two-phase
76/// write's SSRF guard — it guarantees the subsequent loopback GET only
77/// ever hits our own origin and the caller's own namespace.
78pub fn own_area_ok(
79    resource_url: &str,
80    host: &str,
81    caller: &str,
82    repo_owner: &str,
83    repo: &str,
84) -> Result<(), ForgeError> {
85    let prefix = forge_area_prefix(host, caller, repo_owner, repo);
86    if !resource_url.starts_with(&prefix) {
87        return Err(ForgeError::Forbidden(format!(
88            "resourceUrl must be under {prefix}"
89        )));
90    }
91    if resource_url.contains("..") {
92        return Err(ForgeError::PathTraversal(resource_url.to_string()));
93    }
94    // The leaf must be an author body document, never an arbitrary path.
95    if !(resource_url.ends_with(".jsonld") || resource_url.ends_with(".json")) {
96        return Err(ForgeError::BadRequest(
97            "resourceUrl must be a .jsonld/.json body".into(),
98        ));
99    }
100    Ok(())
101}
102
103/// The rendered outcome for one thread pointer.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum BodyOutcome {
106    /// The re-fetched (textual) body.
107    Present(String),
108    /// The author deleted the pod resource.
109    Removed,
110    /// The body exceeded the size cap.
111    TooLarge,
112    /// A fetch/config error (no resolver, network failure, …).
113    Unavailable(String),
114}
115
116/// A thread comment ready to render: author + timestamp + body outcome.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct RenderedThread {
119    /// Author id.
120    pub author: String,
121    /// Unix seconds recorded.
122    pub at: u64,
123    /// The re-fetched body (or a deletion/error placeholder).
124    pub body: BodyOutcome,
125}
126
127fn to_outcome(res: FetchResult) -> BodyOutcome {
128    match res {
129        FetchResult::Body(bytes) => {
130            if looks_textual(&bytes) {
131                BodyOutcome::Present(String::from_utf8_lossy(&bytes).into_owned())
132            } else {
133                BodyOutcome::Unavailable("non-textual body".into())
134            }
135        }
136        FetchResult::Removed => BodyOutcome::Removed,
137        FetchResult::TooLarge => BodyOutcome::TooLarge,
138        FetchResult::Error(e) => BodyOutcome::Unavailable(e),
139    }
140}
141
142async fn resolve_one(
143    p: &ThreadPointer,
144    loopback: Option<&Arc<dyn LoopbackFetch>>,
145    hosted: Option<&Arc<dyn HostedReader>>,
146    max_bytes: usize,
147    timeout_secs: u64,
148) -> BodyOutcome {
149    if p.hosted {
150        match hosted {
151            Some(h) => to_outcome(h.read_hosted(&p.resource_url, max_bytes).await),
152            None => BodyOutcome::Unavailable("hosted store unavailable".into()),
153        }
154    } else {
155        match loopback {
156            Some(lb) => to_outcome(lb.get(&p.resource_url, max_bytes, timeout_secs).await),
157            None => BodyOutcome::Unavailable("loopback fetch unavailable".into()),
158        }
159    }
160}
161
162/// Re-fetch every thread body, bounded per [`ForgeConfig`], preserving
163/// pointer order. Pointers beyond `thread_cap` are dropped (the caller
164/// should surface a "truncated" notice — see the handler).
165pub async fn render_thread(
166    pointers: &[ThreadPointer],
167    loopback: Option<Arc<dyn LoopbackFetch>>,
168    hosted: Option<Arc<dyn HostedReader>>,
169    cfg: &ForgeConfig,
170) -> Vec<RenderedThread> {
171    let n = pointers.len().min(cfg.thread_cap);
172    if n == 0 {
173        return Vec::new();
174    }
175    let sem = Arc::new(Semaphore::new(cfg.fetch_concurrency.max(1)));
176    let mut set: JoinSet<(usize, RenderedThread)> = JoinSet::new();
177
178    for (i, p) in pointers.iter().take(n).enumerate() {
179        let sem = sem.clone();
180        let p = p.clone();
181        let loopback = loopback.clone();
182        let hosted = hosted.clone();
183        let max = cfg.max_body_bytes;
184        let to = cfg.fetch_timeout_secs;
185        set.spawn(async move {
186            // Bound concurrency; a closed semaphore never happens here.
187            let _permit = sem.acquire_owned().await.ok();
188            let outcome = resolve_one(&p, loopback.as_ref(), hosted.as_ref(), max, to).await;
189            (
190                i,
191                RenderedThread {
192                    author: p.author.clone(),
193                    at: p.at,
194                    body: outcome,
195                },
196            )
197        });
198    }
199
200    let mut slots: Vec<Option<RenderedThread>> = (0..n).map(|_| None).collect();
201    while let Some(joined) = set.join_next().await {
202        if let Ok((i, rt)) = joined {
203            slots[i] = Some(rt);
204        }
205    }
206    slots.into_iter().flatten().collect()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::collections::HashMap;
213    use std::sync::Mutex;
214
215    #[test]
216    fn own_area_accepts_valid_and_rejects_others() {
217        let host = "https://pod.example";
218        // Repo owner alice opening an issue in her own repo.
219        let ok = "https://pod.example/alice/public/forge/alice--demo/issue-1.jsonld";
220        assert!(own_area_ok(ok, host, "alice", "alice", "demo").is_ok());
221
222        // A commenter (bob) commenting on alice/demo — body in bob's pod,
223        // namespaced by the repo. Legitimate.
224        let comment = "https://pod.example/bob/public/forge/alice--demo/comment-1.jsonld";
225        assert!(own_area_ok(comment, host, "bob", "alice", "demo").is_ok());
226
227        // Caller claims bob's pod but is authenticated as alice → rejected.
228        assert!(matches!(
229            own_area_ok(comment, host, "alice", "alice", "demo"),
230            Err(ForgeError::Forbidden(_))
231        ));
232
233        // Different origin (SSRF attempt to an internal service).
234        let ssrf = "http://169.254.169.254/alice/public/forge/alice--demo/x.jsonld";
235        assert!(own_area_ok(ssrf, host, "alice", "alice", "demo").is_err());
236
237        // Traversal out of the area.
238        let trav = "https://pod.example/alice/public/forge/alice--demo/../../secret.jsonld";
239        assert!(matches!(
240            own_area_ok(trav, host, "alice", "alice", "demo"),
241            Err(ForgeError::PathTraversal(_))
242        ));
243
244        // Not a body document.
245        let notbody = "https://pod.example/alice/public/forge/alice--demo/index.html";
246        assert!(matches!(
247            own_area_ok(notbody, host, "alice", "alice", "demo"),
248            Err(ForgeError::BadRequest(_))
249        ));
250    }
251
252    /// In-memory loopback returning canned bodies; a missing key = Removed.
253    struct MockLoopback {
254        map: Mutex<HashMap<String, FetchResult>>,
255    }
256    impl MockLoopback {
257        fn new(pairs: &[(&str, FetchResult)]) -> Self {
258            let mut m = HashMap::new();
259            for (k, v) in pairs {
260                m.insert((*k).to_string(), v.clone());
261            }
262            Self { map: Mutex::new(m) }
263        }
264    }
265    #[async_trait]
266    impl LoopbackFetch for MockLoopback {
267        async fn get(&self, url: &str, _max: usize, _to: u64) -> FetchResult {
268            self.map
269                .lock()
270                .unwrap()
271                .get(url)
272                .cloned()
273                .unwrap_or(FetchResult::Removed)
274        }
275    }
276
277    fn ptr(url: &str) -> ThreadPointer {
278        ThreadPointer {
279            author: "did:nostr:abc".into(),
280            resource_url: url.into(),
281            at: 10,
282            hosted: false,
283        }
284    }
285
286    #[tokio::test]
287    async fn render_thread_fetches_present_and_removed_in_order() {
288        let lb: Arc<dyn LoopbackFetch> = Arc::new(MockLoopback::new(&[
289            ("u1", FetchResult::Body(b"first body".to_vec())),
290            ("u3", FetchResult::Body(b"third body".to_vec())),
291            // u2 absent → Removed.
292        ]));
293        let cfg = ForgeConfig::default();
294        let pointers = vec![ptr("u1"), ptr("u2"), ptr("u3")];
295        let out = render_thread(&pointers, Some(lb), None, &cfg).await;
296        assert_eq!(out.len(), 3);
297        assert_eq!(out[0].body, BodyOutcome::Present("first body".into()));
298        assert_eq!(out[1].body, BodyOutcome::Removed);
299        assert_eq!(out[2].body, BodyOutcome::Present("third body".into()));
300    }
301
302    #[tokio::test]
303    async fn render_thread_without_resolver_is_unavailable() {
304        let cfg = ForgeConfig::default();
305        let out = render_thread(&[ptr("u1")], None, None, &cfg).await;
306        assert!(matches!(out[0].body, BodyOutcome::Unavailable(_)));
307    }
308
309    #[tokio::test]
310    async fn render_thread_honours_thread_cap() {
311        let lb: Arc<dyn LoopbackFetch> = Arc::new(MockLoopback::new(&[(
312            "u",
313            FetchResult::Body(b"x".to_vec()),
314        )]));
315        let cfg = ForgeConfig {
316            thread_cap: 2,
317            ..Default::default()
318        };
319        let pointers = vec![ptr("u"), ptr("u"), ptr("u"), ptr("u")];
320        let out = render_thread(&pointers, Some(lb), None, &cfg).await;
321        assert_eq!(out.len(), 2, "must cap at thread_cap");
322    }
323
324    #[tokio::test]
325    async fn too_large_and_error_map_through() {
326        let lb: Arc<dyn LoopbackFetch> = Arc::new(MockLoopback::new(&[
327            ("big", FetchResult::TooLarge),
328            ("err", FetchResult::Error("boom".into())),
329        ]));
330        let cfg = ForgeConfig::default();
331        let out = render_thread(&[ptr("big"), ptr("err")], Some(lb), None, &cfg).await;
332        assert_eq!(out[0].body, BodyOutcome::TooLarge);
333        assert_eq!(out[1].body, BodyOutcome::Unavailable("boom".into()));
334    }
335}