char/serve-esm
git clone https://git.t4t.associates/char/serve-esm
e06f6b7
main
1mod mime; 2 3pub use mime:: get_mime_type; 4 5use std::{ convert:: Infallible , path:: Path , sync:: Arc }; 6 7use anyhow::{ Context as _, Result , bail}; 8use bytes:: Bytes ; 9use http::{ Method , Request , Response , StatusCode , header, request:: Parts }; 10use http_body_util:: Full ; 11use percent_encoding:: percent_decode_str; 12use tokio::{ fs, process:: Command }; 13use tower::{ service_fn, util:: BoxCloneService }; 14 15pub type Body =Full < Bytes >; 16const CACHE_CONTROL : & str ="public, max-age=31536000, immutable" ; 17 18pub type Handler < B > =BoxCloneService < Request < B >, Response < Body >, Infallible >; 19 20pub fn create_handler < B : ' static >( root : impl AsRef < Path >) -> std:: io:: Result < Handler < B >> { 21let root =Arc :: new ( std:: fs:: canonicalize ( root) ?); 22if !root. is_dir () { 23return Err ( std:: io:: Error :: new ( 24 std:: io:: ErrorKind :: NotADirectory , 25"TARGET_DIR must be a directory" , 26)); 27} 28Ok ( BoxCloneService :: new ( service_fn ( move |req : Request < B > |{ 29let root = root. clone (); 30let ( parts, _) = req. into_parts (); 31async move { 32let head = parts. method ==Method :: HEAD ; 33let mut response =handle ( & root, parts). await . unwrap_or_else ( |error|{ 34eprintln! ( "{error:#}" ); 35text ( StatusCode :: INTERNAL_SERVER_ERROR , "internal error" ) 36}); 37if head{ 38* response. body_mut () =Full :: default (); 39} 40Ok ( response) 41} 42}))) 43} 44 45async fn handle ( root : & Path , req : Parts ) ->Result < Response < Body >> { 46let Some ( path) = req. uri . path (). strip_prefix ( '/' ) else { 47return Ok ( text ( StatusCode :: NOT_FOUND , "not found" )); 48}; 49let mut segments = path. splitn ( 3 , '/' ); 50let ( Some ( repo), Some ( version), Some ( file)) = 51( segments. next (), segments. next (), segments. next ()) 52else { 53return Ok ( text ( StatusCode :: NOT_FOUND , "not found" )); 54}; 55if repo. is_empty () || version. is_empty () || file. is_empty () { 56return Ok ( text ( StatusCode :: NOT_FOUND , "not found" )); 57} 58if req. method !=Method :: GET && req. method !=Method :: HEAD { 59let mut response =text ( StatusCode :: METHOD_NOT_ALLOWED , "method not allowed" ); 60 response 61. headers_mut () 62. insert ( header:: ALLOW , "GET, HEAD" . parse () ?); 63return Ok ( response); 64} 65 66let file =percent_decode_str ( file). decode_utf8 (); 67let file =match file{ 68Ok ( file) 69if !file. contains ( '\0' ) 70 && !file 71. split ( '/' ) 72. any ( |segment| segment. is_empty () || segment ==".." ) => 73{ 74 file 75} 76 _ =>return Ok ( text ( StatusCode :: NOT_FOUND , "file not found" )), 77}; 78let repo =percent_decode_str ( repo). decode_utf8 (); 79let repo =match repo{ 80Ok ( repo) if !matches! ( repo. as_ref (), "." |".." ) && !repo. contains ([ '/' , '\\' , '\0' ]) =>{ 81 repo 82} 83 _ =>return Ok ( text ( StatusCode :: NOT_FOUND , "repository not found" )), 84}; 85let repo_dir =match fs:: canonicalize ( root. join ( repo. as_ref ())). await { 86Ok ( dir) 87if dir. starts_with ( root) 88 && fs:: metadata ( & dir) 89. await 90. is_ok_and ( |metadata| metadata. is_dir ()) => 91{ 92 dir 93} 94 _ =>return Ok ( text ( StatusCode :: NOT_FOUND , "repository not found" )), 95}; 96 97// Check membership before using a version in a Git ref expression. 98let tags =git ( & repo_dir, & [ "tag" , "--list" , "--sort=-version:refname" ]). await ?; 99let tags =String :: from_utf8 ( tags). context ( "Git returned non-UTF-8 tags" ) ?; 100let tags: Vec < _ > = tags. lines (). filter ( |tag| !tag. is_empty ()). collect (); 101let version =percent_decode_str ( version). decode_utf8 (); 102let version =match version{ 103Ok ( version) if tags. contains ( & version. as_ref ()) => version, 104 _ =>{ 105let list =if tags. is_empty () { 106"(no tags)" . to_owned () 107} else { 108 tags. join ( "\n" ) 109}; 110return Ok ( text ( 111StatusCode :: NOT_FOUND , 112format! ( "version not found\n\navailable versions:\n{list}\n" ), 113)); 114} 115}; 116 117// The object ID lets HEAD and conditional requests skip reading the blob. 118let info =git ( 119& repo_dir, 120& [ 121"--literal-pathspecs" , 122"ls-tree" , 123"-z" , 124"--format=%(objectname) %(objecttype) %(objectsize)" , 125& format! ( "refs/tags/{version}" ), 126"--" , 127& file, 128], 129) 130. await ?; 131let info = std:: str:: from_utf8 ( & info) ?; 132let fields: Vec < _ > = info. strip_suffix ( '\0' ). unwrap_or ( "" ). split ( ' ' ). collect (); 133let ( object_id, size) =match fields. as_slice () { 134[ id, "blob" , size] if !id. is_empty () && id. bytes (). all ( |b| b. is_ascii_hexdigit ()) =>{ 135( * id, size. parse ::< u64 >() ?) 136} 137 _ =>return Ok ( text ( StatusCode :: NOT_FOUND , "file not found" )), 138}; 139let etag =format! ( "\"{object_id}\"" ); 140let not_modified = req 141. headers 142. get_all ( header:: IF_NONE_MATCH ) 143. iter () 144. any ( |value|{ 145 value. to_str (). is_ok_and ( |value|{ 146 value. split ( ',' ). any ( |candidate|{ 147let tag = candidate. trim (); 148 tag =="*" || tag. strip_prefix ( "W/" ). unwrap_or ( tag) == etag 149}) 150}) 151}); 152let response =Response :: builder () 153. header ( header:: CACHE_CONTROL , CACHE_CONTROL ) 154. header ( header:: ETAG , etag); 155if not_modified{ 156return Ok ( response 157. status ( StatusCode :: NOT_MODIFIED ) 158. body ( Full :: default ()) ?); 159} 160let response = response 161. header ( 162 header:: CONTENT_TYPE , 163get_mime_type ( & file). unwrap_or ( "application/octet-stream" ), 164) 165. header ( header:: CONTENT_LENGTH , size); 166let body =if req. method ==Method :: HEAD { 167Full :: default () 168} else { 169Full :: new ( Bytes :: from ( 170git ( & repo_dir, & [ "cat-file" , "blob" , object_id]). await ?, 171)) 172}; 173Ok ( response. body ( body) ?) 174} 175 176fn text ( status : StatusCode , body : impl Into < Bytes >) ->Response < Body > { 177let body = body. into (); 178Response :: builder () 179. status ( status) 180. header ( header:: CONTENT_TYPE , "text/plain; charset=utf-8" ) 181. header ( header:: CONTENT_LENGTH , body. len ()) 182. body ( Full :: new ( body)) 183. expect ( "static response headers are valid" ) 184} 185 186async fn git ( repo : & Path , args : & [ & str ]) ->Result < Vec < u8 >> { 187let output =Command :: new ( "git" ) 188. arg ( "-C" ) 189. arg ( repo) 190. args ( args) 191// Do not discover a parent repository or inherit an unrelated Git context. 192. env ( "GIT_CEILING_DIRECTORIES" , repo. parent (). unwrap_or ( repo)) 193. env_remove ( "GIT_DIR" ) 194. env_remove ( "GIT_WORK_TREE" ) 195. env_remove ( "GIT_COMMON_DIR" ) 196. env_remove ( "GIT_OBJECT_DIRECTORY" ) 197. env_remove ( "GIT_ALTERNATE_OBJECT_DIRECTORIES" ) 198. kill_on_drop ( true ) 199. output () 200. await 201. with_context ( ||format! ( "running git {args:?} in {}" , repo. display ())) ?; 202if !output. status . success () { 203bail! ( 204"git {args:?} in {} failed ({}): {}" , 205 repo. display (), 206 output. status , 207String :: from_utf8_lossy ( & output. stderr ). trim_end () 208); 209} 210Ok ( output. stdout ) 211}