Skip to main content

solid_pod_rs_forge/
router.rs

1//! Pure route dispatch — maps `(method, path)` to a [`Route`].
2//!
3//! The parser is deliberately side-effect free so the whole dispatch
4//! table is unit-testable without a filesystem or a git binary. The
5//! embedding [`crate::ForgeService::handle`] strips the configured prefix,
6//! calls [`parse_route`], and switches on the result.
7
8/// A resolved forge route. Method-specific behaviour (GET list vs POST
9/// create) is decided by the handler from [`crate::ForgeRequest::method`];
10/// this enum captures only the addressed resource.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Route {
13    /// `GET /` — global repo list.
14    Index,
15    /// `GET /<owner>` — a single owner's repo list.
16    OwnerIndex {
17        /// Owner namespace segment.
18        owner: String,
19    },
20    /// Any git smart-HTTP path (`…/<owner>/<repo>.git/{info/refs,
21    /// git-upload-pack,git-receive-pack}`). The full prefix-stripped path
22    /// is forwarded verbatim to the git CGI service.
23    GitSmart {
24        /// Path relative to the repos root (leading `/`).
25        rel_path: String,
26    },
27    /// `GET /<owner>/<repo>` — repo overview (branches + tree + README).
28    RepoOverview {
29        /// Owner namespace.
30        owner: String,
31        /// Repository name (without the on-disk `.git` suffix).
32        repo: String,
33    },
34    /// `GET /<owner>/<repo>/tree/<ref>[/<path>]` — directory listing.
35    Tree {
36        /// Owner namespace.
37        owner: String,
38        /// Repository name.
39        repo: String,
40        /// Git ref (branch/tag/sha).
41        rev: String,
42        /// Repo-relative sub-path (may be empty for the root).
43        path: String,
44    },
45    /// `GET /<owner>/<repo>/blob/<ref>/<path>` — rendered file view.
46    Blob {
47        /// Owner namespace.
48        owner: String,
49        /// Repository name.
50        repo: String,
51        /// Git ref.
52        rev: String,
53        /// Repo-relative file path.
54        path: String,
55    },
56    /// `GET /<owner>/<repo>/raw/<ref>/<path>` — defended raw bytes.
57    Raw {
58        /// Owner namespace.
59        owner: String,
60        /// Repository name.
61        repo: String,
62        /// Git ref.
63        rev: String,
64        /// Repo-relative file path.
65        path: String,
66    },
67    /// `GET /<owner>/<repo>/commits/<ref>` — paginated commit log.
68    Commits {
69        /// Owner namespace.
70        owner: String,
71        /// Repository name.
72        repo: String,
73        /// Git ref.
74        rev: String,
75    },
76    /// `GET /<owner>/<repo>/commit/<sha>` — single-commit view.
77    Commit {
78        /// Owner namespace.
79        owner: String,
80        /// Repository name.
81        repo: String,
82        /// Commit sha (any rev git accepts).
83        sha: String,
84    },
85    /// `GET /<owner>/<repo>/branches`.
86    Branches {
87        /// Owner namespace.
88        owner: String,
89        /// Repository name.
90        repo: String,
91    },
92    /// `GET /<owner>/<repo>/tags`.
93    Tags {
94        /// Owner namespace.
95        owner: String,
96        /// Repository name.
97        repo: String,
98    },
99    /// `GET/POST /<owner>/<repo>/issues` — list or create.
100    Issues {
101        /// Owner namespace.
102        owner: String,
103        /// Repository name.
104        repo: String,
105    },
106    /// `GET /<owner>/<repo>/issues/new` — new-issue form.
107    IssueNew {
108        /// Owner namespace.
109        owner: String,
110        /// Repository name.
111        repo: String,
112    },
113    /// `GET/POST /<owner>/<repo>/issues/<num>` — thread view / comment.
114    IssueDetail {
115        /// Owner namespace.
116        owner: String,
117        /// Repository name.
118        repo: String,
119        /// Issue number.
120        num: u64,
121    },
122    /// `POST /api/token` — mint a forge push token.
123    ApiToken,
124    /// `GET/DELETE /api/hosted/<hex>/<uuid>` — podless body store.
125    ApiHosted {
126        /// 64-hex owner pubkey.
127        hex: String,
128        /// Body uuid.
129        id: String,
130    },
131    /// `GET /api/repos` — JSON repo list.
132    ApiReposList,
133    /// `GET /api/repos/<owner>/<repo>` — JSON repo model.
134    ApiRepo {
135        /// Owner namespace.
136        owner: String,
137        /// Repository name.
138        repo: String,
139    },
140    /// Unmatched — the handler returns 404.
141    NotFound,
142}
143
144/// Git smart-HTTP suffixes that identify a CGI request.
145const GIT_SUFFIXES: &[&str] = &["/info/refs", "/git-upload-pack", "/git-receive-pack"];
146
147/// Strip `prefix` from `path`. Returns the remainder with a guaranteed
148/// leading `/` (or `"/"` for an exact-prefix match). Returns `None` when
149/// `path` is not under `prefix`.
150#[must_use]
151pub fn strip_prefix<'a>(prefix: &str, path: &'a str) -> Option<std::borrow::Cow<'a, str>> {
152    if prefix.is_empty() {
153        return Some(std::borrow::Cow::Borrowed(path));
154    }
155    if let Some(rest) = path.strip_prefix(prefix) {
156        if rest.is_empty() {
157            Some(std::borrow::Cow::Borrowed("/"))
158        } else if rest.starts_with('/') {
159            Some(std::borrow::Cow::Borrowed(rest))
160        } else {
161            // e.g. prefix "/forge" vs path "/forgery" — not a real match.
162            None
163        }
164    } else {
165        None
166    }
167}
168
169/// Parse a prefix-stripped `path` into a [`Route`]. `rel` must have a
170/// leading slash (as produced by [`strip_prefix`]).
171#[must_use]
172pub fn parse_route(rel: &str) -> Route {
173    // Split query defensively (callers pass path only, but be robust).
174    let rel = rel.split('?').next().unwrap_or(rel);
175
176    if rel.is_empty() || rel == "/" {
177        return Route::Index;
178    }
179
180    // Git smart-HTTP: any path ending in a git service suffix.
181    for suffix in GIT_SUFFIXES {
182        if rel.ends_with(suffix) {
183            return Route::GitSmart {
184                rel_path: rel.to_string(),
185            };
186        }
187    }
188
189    let segs: Vec<&str> = rel
190        .trim_matches('/')
191        .split('/')
192        .filter(|s| !s.is_empty())
193        .collect();
194    if segs.is_empty() {
195        return Route::Index;
196    }
197
198    // API namespace.
199    if segs[0] == "api" {
200        return parse_api(&segs[1..]);
201    }
202
203    match segs.as_slice() {
204        [owner] => Route::OwnerIndex {
205            owner: (*owner).to_string(),
206        },
207        [owner, repo] => Route::RepoOverview {
208            owner: (*owner).to_string(),
209            repo: strip_git_suffix(repo),
210        },
211        [owner, repo, verb, rest @ ..] => parse_repo_verb(owner, repo, verb, rest),
212        _ => Route::NotFound,
213    }
214}
215
216fn parse_repo_verb(owner: &str, repo: &str, verb: &str, rest: &[&str]) -> Route {
217    let owner = owner.to_string();
218    let repo = strip_git_suffix(repo);
219    match verb {
220        "tree" | "blob" | "raw" => {
221            if rest.is_empty() {
222                return Route::NotFound;
223            }
224            let rev = rest[0].to_string();
225            let path = rest[1..].join("/");
226            match verb {
227                "tree" => Route::Tree {
228                    owner,
229                    repo,
230                    rev,
231                    path,
232                },
233                "blob" => Route::Blob {
234                    owner,
235                    repo,
236                    rev,
237                    path,
238                },
239                _ => Route::Raw {
240                    owner,
241                    repo,
242                    rev,
243                    path,
244                },
245            }
246        }
247        "commits" => {
248            let rev = rest.first().map(|s| (*s).to_string()).unwrap_or_default();
249            if rev.is_empty() {
250                return Route::NotFound;
251            }
252            Route::Commits { owner, repo, rev }
253        }
254        "commit" => {
255            let sha = rest.first().map(|s| (*s).to_string()).unwrap_or_default();
256            if sha.is_empty() {
257                return Route::NotFound;
258            }
259            Route::Commit { owner, repo, sha }
260        }
261        "branches" if rest.is_empty() => Route::Branches { owner, repo },
262        "tags" if rest.is_empty() => Route::Tags { owner, repo },
263        "issues" => match rest {
264            [] => Route::Issues { owner, repo },
265            ["new"] => Route::IssueNew { owner, repo },
266            [num] => match num.parse::<u64>() {
267                Ok(n) => Route::IssueDetail {
268                    owner,
269                    repo,
270                    num: n,
271                },
272                Err(_) => Route::NotFound,
273            },
274            _ => Route::NotFound,
275        },
276        _ => Route::NotFound,
277    }
278}
279
280fn parse_api(segs: &[&str]) -> Route {
281    match segs {
282        ["token"] => Route::ApiToken,
283        ["hosted", hex, id] => Route::ApiHosted {
284            hex: (*hex).to_string(),
285            id: (*id).to_string(),
286        },
287        ["repos"] => Route::ApiReposList,
288        ["repos", owner, repo] => Route::ApiRepo {
289            owner: (*owner).to_string(),
290            repo: strip_git_suffix(repo),
291        },
292        _ => Route::NotFound,
293    }
294}
295
296/// Remove a trailing `.git` from a repo name so the browse URL `<n>` and
297/// the clone URL `<n>.git` address the same repository.
298#[must_use]
299pub fn strip_git_suffix(repo: &str) -> String {
300    repo.strip_suffix(".git").unwrap_or(repo).to_string()
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn strip_prefix_variants() {
309        assert_eq!(strip_prefix("/forge", "/forge").as_deref(), Some("/"));
310        assert_eq!(
311            strip_prefix("/forge", "/forge/alice/repo").as_deref(),
312            Some("/alice/repo")
313        );
314        assert_eq!(strip_prefix("/forge", "/forgery").as_deref(), None);
315        assert_eq!(strip_prefix("/forge", "/other").as_deref(), None);
316        // Empty prefix (root mount) passes everything through.
317        assert_eq!(
318            strip_prefix("", "/alice/repo").as_deref(),
319            Some("/alice/repo")
320        );
321    }
322
323    #[test]
324    fn index_route() {
325        assert_eq!(parse_route("/"), Route::Index);
326        assert_eq!(parse_route(""), Route::Index);
327    }
328
329    #[test]
330    fn git_smart_routes_take_priority() {
331        assert_eq!(
332            parse_route("/alice/repo.git/info/refs"),
333            Route::GitSmart {
334                rel_path: "/alice/repo.git/info/refs".into()
335            }
336        );
337        assert_eq!(
338            parse_route("/alice/repo.git/git-receive-pack"),
339            Route::GitSmart {
340                rel_path: "/alice/repo.git/git-receive-pack".into()
341            }
342        );
343    }
344
345    #[test]
346    fn owner_and_repo_overview() {
347        assert_eq!(
348            parse_route("/alice"),
349            Route::OwnerIndex {
350                owner: "alice".into()
351            }
352        );
353        assert_eq!(
354            parse_route("/alice/myrepo"),
355            Route::RepoOverview {
356                owner: "alice".into(),
357                repo: "myrepo".into()
358            }
359        );
360        // `.git` suffix on the browse URL is normalised away.
361        assert_eq!(
362            parse_route("/alice/myrepo.git"),
363            Route::RepoOverview {
364                owner: "alice".into(),
365                repo: "myrepo".into()
366            }
367        );
368    }
369
370    #[test]
371    fn tree_blob_raw_split_rev_and_path() {
372        assert_eq!(
373            parse_route("/alice/repo/tree/main/src/lib.rs"),
374            Route::Tree {
375                owner: "alice".into(),
376                repo: "repo".into(),
377                rev: "main".into(),
378                path: "src/lib.rs".into()
379            }
380        );
381        assert_eq!(
382            parse_route("/alice/repo/tree/main"),
383            Route::Tree {
384                owner: "alice".into(),
385                repo: "repo".into(),
386                rev: "main".into(),
387                path: String::new()
388            }
389        );
390        assert_eq!(
391            parse_route("/alice/repo/raw/main/bin/data"),
392            Route::Raw {
393                owner: "alice".into(),
394                repo: "repo".into(),
395                rev: "main".into(),
396                path: "bin/data".into()
397            }
398        );
399    }
400
401    #[test]
402    fn commit_and_commits() {
403        assert_eq!(
404            parse_route("/alice/repo/commits/main"),
405            Route::Commits {
406                owner: "alice".into(),
407                repo: "repo".into(),
408                rev: "main".into()
409            }
410        );
411        assert_eq!(
412            parse_route("/alice/repo/commit/deadbeef"),
413            Route::Commit {
414                owner: "alice".into(),
415                repo: "repo".into(),
416                sha: "deadbeef".into()
417            }
418        );
419    }
420
421    #[test]
422    fn issues_routes() {
423        assert_eq!(
424            parse_route("/alice/repo/issues"),
425            Route::Issues {
426                owner: "alice".into(),
427                repo: "repo".into()
428            }
429        );
430        assert_eq!(
431            parse_route("/alice/repo/issues/new"),
432            Route::IssueNew {
433                owner: "alice".into(),
434                repo: "repo".into()
435            }
436        );
437        assert_eq!(
438            parse_route("/alice/repo/issues/42"),
439            Route::IssueDetail {
440                owner: "alice".into(),
441                repo: "repo".into(),
442                num: 42
443            }
444        );
445        assert_eq!(parse_route("/alice/repo/issues/notanum"), Route::NotFound);
446    }
447
448    #[test]
449    fn api_routes() {
450        assert_eq!(parse_route("/api/token"), Route::ApiToken);
451        assert_eq!(parse_route("/api/repos"), Route::ApiReposList);
452        assert_eq!(
453            parse_route("/api/repos/alice/repo"),
454            Route::ApiRepo {
455                owner: "alice".into(),
456                repo: "repo".into()
457            }
458        );
459        assert_eq!(
460            parse_route("/api/hosted/abc/xyz"),
461            Route::ApiHosted {
462                hex: "abc".into(),
463                id: "xyz".into()
464            }
465        );
466    }
467
468    #[test]
469    fn unknown_is_not_found() {
470        assert_eq!(parse_route("/alice/repo/bogusverb/x"), Route::NotFound);
471        assert_eq!(parse_route("/api/unknown"), Route::NotFound);
472    }
473}