1use std::path::{Component, Path, PathBuf};
8
9use sley_config::GitConfig;
10use sley_core::{GitError, Result};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum HttpBackendService {
15 UploadPack,
17 ReceivePack,
19}
20
21impl HttpBackendService {
22 pub const fn wire_name(self) -> &'static str {
24 match self {
25 Self::UploadPack => "git-upload-pack",
26 Self::ReceivePack => "git-receive-pack",
27 }
28 }
29
30 pub const fn media_type_name(self) -> &'static str {
32 match self {
33 Self::UploadPack => "upload-pack",
34 Self::ReceivePack => "receive-pack",
35 }
36 }
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum HttpBackendOperation {
42 Advertise,
44 Rpc,
46}
47
48#[derive(Clone, Copy, Debug)]
50pub struct HttpBackendRequest<'a> {
51 pub method: &'a str,
53 pub path_info: Option<&'a str>,
55 pub path_translated: Option<&'a Path>,
57 pub project_root: Option<&'a Path>,
59 pub query_string: &'a str,
61}
62
63#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct HttpBackendPlan {
66 pub repository: PathBuf,
68 pub service: HttpBackendService,
70 pub operation: HttpBackendOperation,
72 pub head_only: bool,
74}
75
76impl HttpBackendPlan {
77 pub fn response_content_type(&self) -> String {
79 let suffix = match self.operation {
80 HttpBackendOperation::Advertise => "advertisement",
81 HttpBackendOperation::Rpc => "result",
82 };
83 format!(
84 "application/x-git-{}-{suffix}",
85 self.service.media_type_name()
86 )
87 }
88
89 pub fn request_content_type(&self) -> Option<String> {
91 (self.operation == HttpBackendOperation::Rpc).then(|| {
92 format!(
93 "application/x-git-{}-request",
94 self.service.media_type_name()
95 )
96 })
97 }
98}
99
100pub fn plan_http_backend_request(request: HttpBackendRequest<'_>) -> Result<HttpBackendPlan> {
102 let translated = translated_request_path(&request)?;
103 let (repository, service, operation) = if let Some(repository) =
104 strip_path_suffix(&translated, Path::new("info/refs"))
105 {
106 let service = query_parameter(request.query_string, "service")
107 .and_then(parse_service)
108 .ok_or_else(|| GitError::Unsupported("smart HTTP service is missing".into()))?;
109 (repository, service, HttpBackendOperation::Advertise)
110 } else if let Some(repository) = strip_path_suffix(&translated, Path::new("git-upload-pack")) {
111 (
112 repository,
113 HttpBackendService::UploadPack,
114 HttpBackendOperation::Rpc,
115 )
116 } else if let Some(repository) = strip_path_suffix(&translated, Path::new("git-receive-pack")) {
117 (
118 repository,
119 HttpBackendService::ReceivePack,
120 HttpBackendOperation::Rpc,
121 )
122 } else {
123 return Err(GitError::Unsupported(format!(
124 "smart HTTP request path: {}",
125 translated.display()
126 )));
127 };
128
129 let head_only = request.method == "HEAD";
130 let expected_method = match operation {
131 HttpBackendOperation::Advertise => "GET",
132 HttpBackendOperation::Rpc => "POST",
133 };
134 let effective_method = if head_only { "GET" } else { request.method };
135 if effective_method != expected_method {
136 return Err(GitError::InvalidFormat(format!(
137 "smart HTTP {operation:?} requires {expected_method}, got {}",
138 request.method
139 )));
140 }
141
142 let repository = repository.to_path_buf();
143 if repository.as_os_str().is_empty() {
144 return Err(GitError::InvalidPath(
145 "smart HTTP repository path is empty".into(),
146 ));
147 }
148 Ok(HttpBackendPlan {
149 repository,
150 service,
151 operation,
152 head_only,
153 })
154}
155
156fn strip_path_suffix<'a>(path: &'a Path, suffix: &Path) -> Option<&'a Path> {
157 if !path.ends_with(suffix) {
158 return None;
159 }
160 let mut prefix = path;
161 for _ in suffix.components() {
162 prefix = prefix.parent()?;
163 }
164 Some(prefix)
165}
166
167pub fn http_backend_service_enabled(
169 service: HttpBackendService,
170 config: &GitConfig,
171 authenticated: bool,
172) -> bool {
173 match service {
174 HttpBackendService::UploadPack => {
175 config.get_bool("http", None, "uploadpack").unwrap_or(true)
176 }
177 HttpBackendService::ReceivePack => config
178 .get_bool("http", None, "receivepack")
179 .unwrap_or(authenticated),
180 }
181}
182
183fn translated_request_path(request: &HttpBackendRequest<'_>) -> Result<PathBuf> {
184 if let Some(root) = request
185 .project_root
186 .filter(|root| !root.as_os_str().is_empty())
187 {
188 let path_info = request
189 .path_info
190 .filter(|path| !path.is_empty())
191 .ok_or_else(|| {
192 GitError::InvalidPath("GIT_PROJECT_ROOT is set but PATH_INFO is not".into())
193 })?;
194 let relative = Path::new(path_info.trim_start_matches('/'));
195 if relative
196 .components()
197 .any(|component| matches!(component, Component::ParentDir | Component::RootDir))
198 {
199 return Err(GitError::InvalidPath(format!(
200 "aliased smart HTTP path: {path_info}"
201 )));
202 }
203 return Ok(root.join(relative));
204 }
205 request
206 .path_translated
207 .filter(|path| !path.as_os_str().is_empty())
208 .map(Path::to_path_buf)
209 .ok_or_else(|| {
210 GitError::InvalidPath("No GIT_PROJECT_ROOT or PATH_TRANSLATED from server".into())
211 })
212}
213
214fn query_parameter<'a>(query: &'a str, wanted: &str) -> Option<&'a str> {
215 query.split('&').rev().find_map(|part| {
216 let (name, value) = part.split_once('=')?;
217 (name == wanted).then_some(value)
218 })
219}
220
221fn parse_service(value: &str) -> Option<HttpBackendService> {
222 match value {
223 "git-upload-pack" => Some(HttpBackendService::UploadPack),
224 "git-receive-pack" => Some(HttpBackendService::ReceivePack),
225 _ => None,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use sley_config::{ConfigEntry, ConfigSection};
233
234 #[test]
235 fn plans_receive_advertisement_below_project_root() {
236 let plan = plan_http_backend_request(HttpBackendRequest {
237 method: "GET",
238 path_info: Some("/repo.git/info/refs"),
239 path_translated: None,
240 project_root: Some(Path::new("/srv/git")),
241 query_string: "service=git-receive-pack",
242 })
243 .expect("valid smart HTTP advertisement");
244 assert_eq!(plan.repository, Path::new("/srv/git/repo.git"));
245 assert_eq!(plan.service, HttpBackendService::ReceivePack);
246 assert_eq!(plan.operation, HttpBackendOperation::Advertise);
247 assert_eq!(
248 plan.response_content_type(),
249 "application/x-git-receive-pack-advertisement"
250 );
251 }
252
253 #[test]
254 fn plans_upload_rpc_from_path_translated() {
255 let plan = plan_http_backend_request(HttpBackendRequest {
256 method: "POST",
257 path_info: None,
258 path_translated: Some(Path::new("/srv/git/repo.git/git-upload-pack")),
259 project_root: None,
260 query_string: "",
261 })
262 .expect("valid smart HTTP RPC");
263 assert_eq!(plan.repository, Path::new("/srv/git/repo.git"));
264 assert_eq!(plan.service, HttpBackendService::UploadPack);
265 assert_eq!(plan.operation, HttpBackendOperation::Rpc);
266 }
267
268 #[test]
269 fn rejects_aliased_project_root_path() {
270 let error = plan_http_backend_request(HttpBackendRequest {
271 method: "GET",
272 path_info: Some("/../repo.git/info/refs"),
273 path_translated: None,
274 project_root: Some(Path::new("/srv/git")),
275 query_string: "service=git-upload-pack",
276 })
277 .expect_err("parent traversal must be rejected");
278 assert!(matches!(error, GitError::InvalidPath(_)));
279 }
280
281 #[test]
282 fn receive_pack_defaults_to_authenticated_only() {
283 let config = GitConfig::default();
284 assert!(!http_backend_service_enabled(
285 HttpBackendService::ReceivePack,
286 &config,
287 false
288 ));
289 assert!(http_backend_service_enabled(
290 HttpBackendService::ReceivePack,
291 &config,
292 true
293 ));
294 let config = GitConfig {
295 sections: vec![ConfigSection::new(
296 "http",
297 None,
298 vec![ConfigEntry::new("receivepack", Some("true".into()))],
299 )],
300 ..GitConfig::default()
301 };
302 assert!(http_backend_service_enabled(
303 HttpBackendService::ReceivePack,
304 &config,
305 false
306 ));
307 }
308}