Krolyxon
Commit 55c1225

ferreniumsrcserverdispatch.rs

141 lines · 4.4 KB · Rust

1//! The catch-all route: map a URL path onto a repository and an action.
2//!
3//! Repository names may contain `/`, so the longest prefix of the path that
4//! names a repository wins; the next segment is the action. Ferrenium's own
5//! endpoints live under `/-/`, which never names a repository.
6
7use std::sync::Arc;
8
9use axum::extract::{Query, Request, State};
10use axum::http::{StatusCode, header};
11use axum::response::{IntoResponse, Redirect, Response};
12
13use crate::handlers::error;
14use crate::handlers::repo::{PageQuery, RepoRequest, handle};
15use crate::index::{Repo, RepoIndex};
16use crate::render::{encode_path, percent_decode};
17use crate::state::AppState;
18
19pub enum Match<'a> {
20 Repo {
21 repo: Arc<Repo>,
22 rest: &'a [Vec<u8>],
23 },
24 /// `/{repo}.git` → `/{repo}`.
25 DotGit {
26 name: String,
27 },
28 None,
29}
30
31/// Split a raw URL path into percent-decoded segments, dropping empty ones.
32/// `None` for `.`/`..` segments, which are never valid.
33pub fn segments(path: &str) -> Option<Vec<Vec<u8>>> {
34 let segments: Vec<Vec<u8>> = path
35 .split('/')
36 .filter(|s| !s.is_empty())
37 .map(percent_decode)
38 .collect();
39 let bad = segments
40 .iter()
41 .any(|s| s == b"." || s == b".." || s.contains(&b'/') || s.contains(&0));
42 (!bad).then_some(segments)
43}
44
45/// Find the repository named by the longest prefix of `segments`.
46pub fn find_repo<'a>(index: &RepoIndex, segments: &'a [Vec<u8>]) -> Match<'a> {
47 if segments.first().is_some_and(|s| s == b"-") {
48 return Match::None;
49 }
50 for n in (1..=segments.len()).rev() {
51 let parts: Option<Vec<&str>> = segments[..n]
52 .iter()
53 .map(|s| std::str::from_utf8(s).ok())
54 .collect();
55 let Some(parts) = parts else {
56 continue;
57 };
58 let name = parts.join("/");
59 if let Some(repo) = index.repos.get(&name) {
60 return Match::Repo {
61 repo: repo.clone(),
62 rest: &segments[n..],
63 };
64 }
65 if let Some(stripped) = name.strip_suffix(".git")
66 && index.repos.contains_key(stripped)
67 {
68 // `/{repo}.git/…` is the HTTPS clone endpoint (not served yet).
69 return if n == segments.len() {
70 Match::DotGit {
71 name: stripped.to_string(),
72 }
73 } else {
74 Match::None
75 };
76 }
77 }
78 Match::None
79}
80
81pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Response {
82 let config = state.config.load_full();
83 let index = state.index.load();
84 let Some(segments) = segments(req.uri().path()) else {
85 return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
86 };
87 let (repo, rest) = match find_repo(&index, &segments) {
88 Match::Repo { repo, rest } => (repo, rest.to_vec()),
89 Match::DotGit { name } => {
90 let url = format!("{}{}", config.server.base_path, encode_path(&name));
91 return Redirect::permanent(&url).into_response();
92 }
93 Match::None => return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND"),
94 };
95 let query = Query::<PageQuery>::try_from_uri(req.uri())
96 .map(|q| q.0)
97 .unwrap_or_default();
98 let request = RepoRequest {
99 repo,
100 segments: rest,
101 query,
102 if_none_match: req.headers().get(header::IF_NONE_MATCH).cloned(),
103 config,
104 };
105 match state.blocking(move |state| handle(state, request)).await {
106 Ok(response) => response,
107 Err(e) => {
108 tracing::error!("request failed: {e:#}");
109 error::page(&state, StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL ERROR")
110 }
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::index::RepoIndex;
118
119 #[test]
120 fn segment_parsing() {
121 assert_eq!(
122 segments("/a//b%2Fc/").map(|s| s.len()),
123 None,
124 "encoded slash is rejected"
125 );
126 assert_eq!(
127 segments("/a//b/%C3%BC/").unwrap(),
128 vec![b"a".to_vec(), b"b".to_vec(), "ü".as_bytes().to_vec()]
129 );
130 assert!(segments("/a/../b").is_none());
131 assert!(segments("/a/%2e%2e/b").is_none());
132 assert!(segments("/").unwrap().is_empty());
133 }
134
135 #[test]
136 fn no_repos_no_match() {
137 let index = RepoIndex::default();
138 let segs = segments("/-/x").unwrap();
139 assert!(matches!(find_repo(&index, &segs), Match::None));
140 }
141}