char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somexperiment: support sha-256 oids in git repos42f80d8

main
7.6 KiB263 linesraw
1//! Read-only Git smart-HTTP support backed directly by stateless
2//! `git upload-pack` processes.
3
4use std::io::Read as _;
5use std::path::Path;
6use std::process::Stdio;
7
8use axum::body::{Body, Bytes};
9use axum::http::{HeaderMap, StatusCode, header};
10use axum::response::Response;
11use flate2::read::GzDecoder;
12use tokio::io::AsyncWriteExt as _;
13use tokio::sync::OwnedSemaphorePermit;
14use tokio_util::io::ReaderStream;
15
16const MAX_INFLATED_REQUEST: u64 = 64 * 1024 * 1024;
17const SERVICE_PREAMBLE: &[u8] = b"001e# service=git-upload-pack\n0000";
18type WebError = (StatusCode, String);
19
20pub fn advertise(
21    repo_dir: &Path,
22    headers: &HeaderMap,
23    permit: OwnedSemaphorePermit,
24) -> Result<Response, WebError> {
25    let protocol = git_protocol(headers);
26    let stdout = spawn(repo_dir, "--http-backend-info-refs", protocol, None, permit)?;
27    let preamble = if protocol.is_some_and(|p| p.split(':').any(|v| v == "version=2")) {
28        &[][..]
29    } else {
30        SERVICE_PREAMBLE
31    };
32    response(
33        "application/x-git-upload-pack-advertisement",
34        tokio::io::AsyncReadExt::chain(std::io::Cursor::new(preamble), stdout),
35    )
36}
37
38pub fn upload(
39    repo_dir: &Path,
40    headers: &HeaderMap,
41    body: Bytes,
42    permit: OwnedSemaphorePermit,
43) -> Result<Response, WebError> {
44    if headers
45        .get(header::CONTENT_TYPE)
46        .and_then(|v| v.to_str().ok())
47        != Some("application/x-git-upload-pack-request")
48    {
49        return Err((
50            StatusCode::UNSUPPORTED_MEDIA_TYPE,
51            "expected a git-upload-pack request".into(),
52        ));
53    }
54
55    let body = decode_body(headers, body)?;
56    let stdout = spawn(
57        repo_dir,
58        "--stateless-rpc",
59        git_protocol(headers),
60        Some(body),
61        permit,
62    )?;
63    response("application/x-git-upload-pack-result", stdout)
64}
65
66fn spawn(
67    repo_dir: &Path,
68    mode: &str,
69    protocol: Option<&str>,
70    input: Option<Bytes>,
71    permit: OwnedSemaphorePermit,
72) -> Result<tokio::process::ChildStdout, WebError> {
73    let mut command = tokio::process::Command::new("git");
74    command
75        .arg("upload-pack")
76        .arg(mode)
77        .arg(repo_dir)
78        .stdin(if input.is_some() {
79            Stdio::piped()
80        } else {
81            Stdio::null()
82        })
83        .stdout(Stdio::piped())
84        .stderr(Stdio::inherit())
85        .kill_on_drop(true);
86    if let Some(protocol) = protocol {
87        command.env("GIT_PROTOCOL", protocol);
88    }
89
90    let mut child = command
91        .spawn()
92        .map_err(|e| bad_gateway(format!("spawning git upload-pack: {e}")))?;
93    let stdin = child.stdin.take();
94    let stdout = child.stdout.take().expect("stdout was piped");
95    tokio::spawn(async move {
96        if let Some(input) = input {
97            let mut stdin = stdin.expect("stdin was piped");
98            let _ = stdin.write_all(&input).await;
99        }
100        match child.wait().await {
101            Ok(status) if !status.success() => {
102                eprintln!("sorceryd: git upload-pack exited with {status}")
103            }
104            Err(error) => eprintln!("sorceryd: waiting for git upload-pack: {error}"),
105            _ => {}
106        }
107        drop(permit);
108    });
109    Ok(stdout)
110}
111
112fn decode_body(headers: &HeaderMap, body: Bytes) -> Result<Bytes, WebError> {
113    let Some(encoding) = headers.get(header::CONTENT_ENCODING) else {
114        return Ok(body);
115    };
116    let encoding = encoding.to_str().map_err(|_| {
117        (
118            StatusCode::UNSUPPORTED_MEDIA_TYPE,
119            "unsupported content encoding".into(),
120        )
121    })?;
122    if encoding.eq_ignore_ascii_case("identity") {
123        return Ok(body);
124    }
125    if !encoding.eq_ignore_ascii_case("gzip") && !encoding.eq_ignore_ascii_case("x-gzip") {
126        return Err((
127            StatusCode::UNSUPPORTED_MEDIA_TYPE,
128            "unsupported content encoding".into(),
129        ));
130    }
131
132    let mut decoded = Vec::new();
133    GzDecoder::new(body.as_ref())
134        .take(MAX_INFLATED_REQUEST + 1)
135        .read_to_end(&mut decoded)
136        .map_err(|_| (StatusCode::BAD_REQUEST, "invalid gzip request body".into()))?;
137    if decoded.len() as u64 > MAX_INFLATED_REQUEST {
138        return Err((
139            StatusCode::PAYLOAD_TOO_LARGE,
140            "git request body is too large".into(),
141        ));
142    }
143    Ok(decoded.into())
144}
145
146fn git_protocol(headers: &HeaderMap) -> Option<&str> {
147    headers.get("git-protocol").and_then(|v| v.to_str().ok())
148}
149
150fn response(
151    reader_type: &'static str,
152    reader: impl tokio::io::AsyncRead + Send + 'static,
153) -> Result<Response, WebError> {
154    Response::builder()
155        .header(header::CONTENT_TYPE, reader_type)
156        .header(header::EXPIRES, "Fri, 01 Jan 1980 00:00:00 GMT")
157        .header(header::PRAGMA, "no-cache")
158        .header(
159            header::CACHE_CONTROL,
160            "no-cache, max-age=0, must-revalidate",
161        )
162        .body(Body::from_stream(ReaderStream::new(reader)))
163        .map_err(|e| bad_gateway(format!("building response: {e}")))
164}
165
166fn bad_gateway(message: String) -> WebError {
167    eprintln!("sorceryd: {message}");
168    (StatusCode::BAD_GATEWAY, "git backend error".into())
169}
170
171#[cfg(test)]
172mod tests {
173    use std::fs;
174    use std::path::PathBuf;
175    use std::sync::Arc;
176
177    use anyhow::Result;
178    use axum::Router;
179    use axum::body::Bytes;
180    use axum::extract::State;
181    use axum::http::HeaderMap;
182    use axum::response::Response;
183    use axum::routing::{get, post};
184    use tokio::sync::Semaphore;
185
186    use crate::testutil::{TempDir, commit, git, init_sha256_repo};
187
188    use super::{WebError, advertise, upload};
189
190    #[derive(Clone)]
191    struct GitState {
192        repo: PathBuf,
193        permits: Arc<Semaphore>,
194    }
195
196    async fn info_refs(
197        State(state): State<GitState>,
198        headers: HeaderMap,
199    ) -> Result<Response, WebError> {
200        advertise(
201            &state.repo,
202            &headers,
203            state.permits.try_acquire_owned().unwrap(),
204        )
205    }
206
207    async fn upload_pack(
208        State(state): State<GitState>,
209        headers: HeaderMap,
210        body: Bytes,
211    ) -> Result<Response, WebError> {
212        upload(
213            &state.repo,
214            &headers,
215            body,
216            state.permits.try_acquire_owned().unwrap(),
217        )
218    }
219
220    #[tokio::test]
221    async fn clones_sha256_over_smart_http() -> Result<()> {
222        let root = TempDir::new("sha256-clone");
223        let repo = root.join("repo");
224        init_sha256_repo(&repo)?;
225        fs::write(repo.join("file"), "contents")?;
226        let head = commit(&repo, "initial")?;
227
228        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
229        let address = listener.local_addr()?;
230        let app = Router::new()
231            .route("/repo/info/refs", get(info_refs))
232            .route("/repo/git-upload-pack", post(upload_pack))
233            .with_state(GitState {
234                repo,
235                permits: Arc::new(Semaphore::new(8)),
236            });
237        let server = tokio::spawn(async move { axum::serve(listener, app).await });
238
239        let root = root.to_path_buf();
240        tokio::task::spawn_blocking(move || {
241            git(
242                &root,
243                &[
244                    "-c",
245                    "protocol.version=2",
246                    "clone",
247                    "-q",
248                    &format!("http://{address}/repo"),
249                    "clone",
250                ],
251            )?;
252            assert_eq!(git(&root.join("clone"), &["rev-parse", "HEAD"])?, head);
253            assert_eq!(
254                git(&root.join("clone"), &["rev-parse", "--show-object-format"])?,
255                "sha256"
256            );
257            Ok::<(), anyhow::Error>(())
258        })
259        .await??;
260        server.abort();
261        Ok(())
262    }
263}