Skip to main content

sley_remote/
http_backend.rs

1//! Native smart-HTTP CGI request planning.
2//!
3//! The CGI wrapper owns environment and byte-stream I/O. This module owns the
4//! stable routing rules which turn `PATH_INFO`/`QUERY_STRING` into an explicit
5//! repository, service, and operation.
6
7use std::path::{Component, Path, PathBuf};
8
9use sley_config::GitConfig;
10use sley_core::{GitError, Result};
11
12/// A smart-HTTP RPC service supported by Sley's native backend.
13#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum HttpBackendService {
15    /// Fetch/clone service.
16    UploadPack,
17    /// Push service.
18    ReceivePack,
19}
20
21impl HttpBackendService {
22    /// Git's wire/CGI service name.
23    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    /// CGI media type stem used for advertisements, requests, and results.
31    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/// The smart-HTTP operation selected for a CGI request.
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
41pub enum HttpBackendOperation {
42    /// `GET <repo>/info/refs?service=git-...`.
43    Advertise,
44    /// `POST <repo>/git-...`.
45    Rpc,
46}
47
48/// Environment-derived input to [`plan_http_backend_request`].
49#[derive(Clone, Copy, Debug)]
50pub struct HttpBackendRequest<'a> {
51    /// CGI request method (`GET`, `HEAD`, or `POST`).
52    pub method: &'a str,
53    /// CGI `PATH_INFO`.
54    pub path_info: Option<&'a str>,
55    /// CGI `PATH_TRANSLATED`, used when `GIT_PROJECT_ROOT` is absent.
56    pub path_translated: Option<&'a Path>,
57    /// Optional `GIT_PROJECT_ROOT`.
58    pub project_root: Option<&'a Path>,
59    /// CGI query string without the leading `?`.
60    pub query_string: &'a str,
61}
62
63/// A validated native smart-HTTP operation.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct HttpBackendPlan {
66    /// Repository path passed to the owning engine command.
67    pub repository: PathBuf,
68    /// Selected smart service.
69    pub service: HttpBackendService,
70    /// Advertisement or RPC execution.
71    pub operation: HttpBackendOperation,
72    /// Whether a CGI `HEAD` request must suppress the response body.
73    pub head_only: bool,
74}
75
76impl HttpBackendPlan {
77    /// Response media type for this plan.
78    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    /// Required request media type for an RPC POST.
90    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
100/// Plan one CGI request using Git's smart-HTTP path conventions.
101pub 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
167/// Apply Git's default `http.uploadpack`/`http.receivepack` policy.
168pub 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}