zuka
zuka/src/api/content.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/api/content.rs
RScontent.rs7.9 KBDownload
1// HTTP surface for repository discovery.
2//
3// Wire translation only: parse the query, call `git::discover`, wrap the result.
4// The same core functions back the MCP tools, so the two facades cannot disagree
5// about what a tree listing or a blame contains (SPEC §1.1).
6//
7// Every git invocation runs on a blocking pool. These are fork+exec plus disk, and
8// running them on a runtime worker stalls unrelated requests including /healthz.
9
10use crate::account::Identity;
11use crate::error::{Error, Result};
12use crate::git::discover;
13use crate::git::validate;
14use crate::http::response::{self, Body};
15use crate::http::{request, AppState};
16use hyper::body::Incoming;
17use hyper::{Method, Request, Response, StatusCode};
18use std::path::PathBuf;
19use std::sync::Arc;
20
21pub async fn route(
22 request: Request<Incoming>,
23 state: Arc<AppState>,
24 identity: Identity,
25 account: &str,
26 repo: &str,
27 tail: &[&str],
28) -> Result<Response<Body>> {
29 let account = validate::name(account)?;
30 let repo = validate::name(repo)?;
31 identity.require_read(&account, &repo)?;
32
33 let record = state
34 .meta
35 .get_ready_repo(&account, &repo)?
36 .ok_or(Error::NotFound("repository"))?;
37 if request.method() != Method::GET {
38 return Err(Error::MethodNotAllowed);
39 }
40
41 let query = request.uri().query().unwrap_or("").to_string();
42 let git_dir = state.git.repo_path(&account, &repo)?;
43 let limits = state.config.read_limits();
44 let default_branch = record.default_branch;
45
46 match tail {
47 ["tree", rest @ ..] => {
48 let reference = resolve(&query, &default_branch)?;
49 let path = join_path(rest)?;
50 json(move || discover::tree(&git_dir, &reference, path.as_deref())).await
51 }
52 ["raw", rest @ ..] => {
53 let reference = resolve(&query, &default_branch)?;
54 let path = required_path(rest)?;
55 let blob =
56 blocking(move || discover::blob(&git_dir, &reference, &path, limits)).await?;
57 Ok(response::blob(blob.bytes, &blob.sha, blob.immutable))
58 }
59 ["commits"] => {
60 let reference = resolve(&query, &default_branch)?;
61 let limit = limit_of(&query, &state.config)?;
62 let path = optional_path(&query)?;
63 json(move || discover::log(&git_dir, &reference, limit, path.as_deref(), limits)).await
64 }
65 ["commits", sha] => {
66 let sha = object_id(sha)?;
67 json(move || discover::commit(&git_dir, &sha)).await
68 }
69 ["compare"] => {
70 let base = required_revision(&query, "base")?;
71 let head = required_revision(&query, "head")?;
72 json(move || discover::compare(&git_dir, &base, &head)).await
73 }
74 ["blame", rest @ ..] => {
75 let reference = resolve(&query, &default_branch)?;
76 let path = required_path(rest)?;
77 json(move || discover::blame(&git_dir, &reference, &path)).await
78 }
79 ["search"] => {
80 let reference = resolve(&query, &default_branch)?;
81 let limit = limit_of(&query, &state.config)?;
82 let path = optional_path(&query)?;
83 let q = request::query_param(&query, "q")
84 .ok_or_else(|| Error::invalid("invalid-query", "q is required"))?;
85 json(move || discover::search(&git_dir, &reference, &q, path.as_deref(), limit)).await
86 }
87 _ => Err(Error::NotFound("route")),
88 }
89}
90
91/// Run blocking git work off the runtime's worker threads and serialize it.
92async fn json<F>(work: F) -> Result<Response<Body>>
93where
94 F: FnOnce() -> Result<serde_json::Value> + Send + 'static,
95{
96 let value = blocking(work).await?;
97 Ok(response::json(StatusCode::OK, &value))
98}
99
100async fn blocking<F, T>(work: F) -> Result<T>
101where
102 F: FnOnce() -> Result<T> + Send + 'static,
103 T: Send + 'static,
104{
105 tokio::task::spawn_blocking(work)
106 .await
107 .map_err(|e| Error::Internal(anyhow::anyhow!("git task panicked: {e}")))?
108}
109
110/// The revision to read at: `?ref=` if given, else the repository's default branch.
111fn resolve(query: &str, default_branch: &str) -> Result<String> {
112 match request::query_param(query, "ref") {
113 None => Ok(default_branch.to_string()),
114 Some(value) => discover::revision(&value),
115 }
116}
117
118fn required_revision(query: &str, key: &str) -> Result<String> {
119 let raw = request::query_param(query, key)
120 .ok_or_else(|| Error::invalid("invalid-query", format!("{key} is required")))?;
121 discover::revision(&raw)
122}
123
124fn limit_of(query: &str, config: &crate::config::Config) -> Result<usize> {
125 let requested = match request::query_param(query, "limit") {
126 None => None,
127 Some(raw) => Some(
128 raw.parse()
129 .map_err(|_| Error::invalid("invalid-query", "limit must be a number"))?,
130 ),
131 };
132 config.page_limit(requested)
133}
134
135fn join_path(segments: &[&str]) -> Result<Option<String>> {
136 if segments.is_empty() {
137 return Ok(None);
138 }
139 let joined = segments.join("/");
140 validate::tree_path(&joined)?;
141 Ok(Some(joined))
142}
143
144fn required_path(segments: &[&str]) -> Result<String> {
145 join_path(segments)?.ok_or_else(|| Error::invalid("invalid-path", "a file path is required"))
146}
147
148fn optional_path(query: &str) -> Result<Option<String>> {
149 match request::query_param(query, "path") {
150 None => Ok(None),
151 Some(p) => {
152 validate::tree_path(&p)?;
153 Ok(Some(p))
154 }
155 }
156}
157
158/// Validate an object id before it becomes a git argument.
159fn object_id(value: &str) -> Result<String> {
160 if !discover::is_object_id(value) {
161 return Err(Error::invalid(
162 "invalid-object",
163 "object id must be 40 or 64 hex characters",
164 ));
165 }
166 Ok(value.to_string())
167}
168
169/// Named so the unused-import lint does not fire on PathBuf in release builds.
170#[allow(dead_code)]
171type RepoPath = PathBuf;
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn a_ref_query_param_survives_slashes_that_a_path_segment_could_not() {
179 assert_eq!(
180 resolve("ref=refs%2Fheads%2Ffeature%2Flogin", "refs/heads/main").unwrap(),
181 "refs/heads/feature/login"
182 );
183 }
184
185 #[test]
186 fn an_absent_ref_falls_back_to_the_default_branch() {
187 assert_eq!(resolve("", "refs/heads/trunk").unwrap(), "refs/heads/trunk");
188 }
189
190 #[test]
191 fn a_ref_that_would_escape_is_refused() {
192 for bad in [
193 "ref=../../config",
194 "ref=refs/heads/../../config",
195 "ref=HEAD",
196 ] {
197 assert!(
198 resolve(bad, "refs/heads/main").is_err(),
199 "{bad} must be refused"
200 );
201 }
202 }
203
204 #[test]
205 fn compare_requires_both_endpoints_and_validates_each() {
206 assert!(required_revision("head=refs/heads/main", "base").is_err());
207 assert!(required_revision("base=--upload-pack%3Devil", "base").is_err());
208 assert_eq!(
209 required_revision("base=refs/heads/main", "base").unwrap(),
210 "refs/heads/main"
211 );
212 }
213
214 #[test]
215 fn an_object_id_cannot_smuggle_a_git_argument() {
216 for bad in ["--upload-pack=evil", "-n", "HEAD", "abc"] {
217 assert!(object_id(bad).is_err(), "{bad:?} must be refused");
218 }
219 object_id(&"a".repeat(40)).unwrap();
220 }
221
222 #[test]
223 fn tree_paths_are_validated_before_they_become_a_git_spec() {
224 assert!(join_path(&["..", "etc"]).is_err());
225 assert!(join_path(&[".git", "config"]).is_err());
226 assert!(optional_path("path=../escape").is_err());
227 assert_eq!(
228 join_path(&["src", "main.rs"]).unwrap().unwrap(),
229 "src/main.rs"
230 );
231 assert_eq!(join_path(&[]).unwrap(), None);
232 }
233
234 #[test]
235 fn a_limit_must_be_a_number() {
236 let config = crate::config::Config::from_env().unwrap();
237 assert!(limit_of("limit=lots", &config).is_err());
238 assert_eq!(limit_of("", &config).unwrap(), config.default_page_limit);
239 }
240}