char/sorcery
static-files based git repo viewer
git clone https://git.t4t.associates/char/sorcery
42f80d8
main
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 ( 21repo_dir : & Path , 22headers : & HeaderMap , 23permit : OwnedSemaphorePermit , 24) ->Result < Response , WebError > { 25let protocol =git_protocol ( headers); 26let stdout =spawn ( repo_dir, "--http-backend-info-refs" , protocol, None , permit) ?; 27let preamble =if protocol. is_some_and ( |p| p. split ( ':' ). any ( |v| v =="version=2" )) { 28& [][ ..] 29} else { 30SERVICE_PREAMBLE 31}; 32response ( 33"application/x-git-upload-pack-advertisement" , 34 tokio:: io:: AsyncReadExt :: chain ( std:: io:: Cursor :: new ( preamble), stdout), 35) 36} 37 38pub fn upload ( 39repo_dir : & Path , 40headers : & HeaderMap , 41body : Bytes , 42permit : OwnedSemaphorePermit , 43) ->Result < Response , WebError > { 44if headers 45. get ( header:: CONTENT_TYPE ) 46. and_then ( |v| v. to_str (). ok ()) 47 !=Some ( "application/x-git-upload-pack-request" ) 48{ 49return Err (( 50StatusCode :: UNSUPPORTED_MEDIA_TYPE , 51"expected a git-upload-pack request" . into (), 52)); 53} 54 55let body =decode_body ( headers, body) ?; 56let stdout =spawn ( 57 repo_dir, 58"--stateless-rpc" , 59git_protocol ( headers), 60Some ( body), 61 permit, 62) ?; 63response ( "application/x-git-upload-pack-result" , stdout) 64} 65 66fn spawn ( 67repo_dir : & Path , 68mode : & str , 69protocol : Option < & str >, 70input : Option < Bytes >, 71permit : OwnedSemaphorePermit , 72) ->Result < tokio:: process:: ChildStdout , WebError > { 73let 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 () { 79Stdio :: piped () 80} else { 81Stdio :: null () 82}) 83. stdout ( Stdio :: piped ()) 84. stderr ( Stdio :: inherit ()) 85. kill_on_drop ( true ); 86if let Some ( protocol) = protocol{ 87 command. env ( "GIT_PROTOCOL" , protocol); 88} 89 90let mut child = command 91. spawn () 92. map_err ( |e|bad_gateway ( format! ( "spawning git upload-pack: {e}" ))) ?; 93let stdin = child. stdin . take (); 94let stdout = child. stdout . take (). expect ( "stdout was piped" ); 95 tokio:: spawn ( async move { 96if let Some ( input) = input{ 97let mut stdin = stdin. expect ( "stdin was piped" ); 98let _ = stdin. write_all ( & input). await ; 99} 100match child. wait (). await { 101Ok ( status) if !status. success () =>{ 102eprintln! ( "sorceryd: git upload-pack exited with {status}" ) 103} 104Err ( error) =>eprintln! ( "sorceryd: waiting for git upload-pack: {error}" ), 105 _ =>{} 106} 107drop ( permit); 108}); 109Ok ( stdout) 110} 111 112fn decode_body ( headers : & HeaderMap , body : Bytes ) ->Result < Bytes , WebError > { 113let Some ( encoding) = headers. get ( header:: CONTENT_ENCODING ) else { 114return Ok ( body); 115}; 116let encoding = encoding. to_str (). map_err ( |_|{ 117( 118StatusCode :: UNSUPPORTED_MEDIA_TYPE , 119"unsupported content encoding" . into (), 120) 121}) ?; 122if encoding. eq_ignore_ascii_case ( "identity" ) { 123return Ok ( body); 124} 125if !encoding. eq_ignore_ascii_case ( "gzip" ) && !encoding. eq_ignore_ascii_case ( "x-gzip" ) { 126return Err (( 127StatusCode :: UNSUPPORTED_MEDIA_TYPE , 128"unsupported content encoding" . into (), 129)); 130} 131 132let mut decoded =Vec :: new (); 133GzDecoder :: 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 ())) ?; 137if decoded. len () as u64 >MAX_INFLATED_REQUEST { 138return Err (( 139StatusCode :: PAYLOAD_TOO_LARGE , 140"git request body is too large" . into (), 141)); 142} 143Ok ( 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 ( 151reader_type : &' static str , 152reader : impl tokio:: io:: AsyncRead +Send +' static , 153) ->Result < Response , WebError > { 154Response :: 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 { 167eprintln! ( "sorceryd: {message}" ); 168( StatusCode :: BAD_GATEWAY , "git backend error" . into ()) 169} 170 171# [ cfg ( test )] 172mod tests{ 173use std:: fs; 174use std:: path:: PathBuf ; 175use std:: sync:: Arc ; 176 177use anyhow:: Result ; 178use axum:: Router ; 179use axum:: body:: Bytes ; 180use axum:: extract:: State ; 181use axum:: http:: HeaderMap ; 182use axum:: response:: Response ; 183use axum:: routing::{ get, post}; 184use tokio:: sync:: Semaphore ; 185 186use crate :: testutil::{ TempDir , commit, git, init_sha256_repo}; 187 188use super ::{ WebError , advertise, upload}; 189 190# [ derive ( Clone )] 191struct GitState { 192repo : PathBuf , 193permits : Arc < Semaphore >, 194} 195 196async fn info_refs ( 197State ( state): State < GitState >, 198headers : HeaderMap , 199) ->Result < Response , WebError > { 200advertise ( 201& state. repo , 202& headers, 203 state. permits . try_acquire_owned (). unwrap (), 204) 205} 206 207async fn upload_pack ( 208State ( state): State < GitState >, 209headers : HeaderMap , 210body : Bytes , 211) ->Result < Response , WebError > { 212upload ( 213& state. repo , 214& headers, 215 body, 216 state. permits . try_acquire_owned (). unwrap (), 217) 218} 219 220# [ tokio :: test ] 221async fn clones_sha256_over_smart_http () ->Result <()> { 222let root =TempDir :: new ( "sha256-clone" ); 223let repo = root. join ( "repo" ); 224init_sha256_repo ( & repo) ?; 225 fs:: write ( repo. join ( "file" ), "contents" ) ?; 226let head =commit ( & repo, "initial" ) ?; 227 228let listener = tokio:: net:: TcpListener :: bind ( "127.0.0.1:0" ). await ?; 229let address = listener. local_addr () ?; 230let 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, 235permits : Arc :: new ( Semaphore :: new ( 8 )), 236}); 237let server = tokio:: spawn ( async move { axum:: serve ( listener, app). await }); 238 239let root = root. to_path_buf (); 240 tokio:: task:: spawn_blocking ( move ||{ 241git ( 242& root, 243& [ 244"-c" , 245"protocol.version=2" , 246"clone" , 247"-q" , 248& format! ( "http://{address}/repo" ), 249"clone" , 250], 251) ?; 252assert_eq! ( git ( & root. join ( "clone" ), & [ "rev-parse" , "HEAD" ]) ?, head); 253assert_eq! ( 254git ( & root. join ( "clone" ), & [ "rev-parse" , "--show-object-format" ]) ?, 255"sha256" 256); 257Ok ::<(), anyhow:: Error >(()) 258}) 259. await ??; 260 server. abort (); 261Ok (()) 262} 263}