char/serve-esm
git clone https://git.t4t.associates/char/serve-esm
e06f6b7
main
1use std:: path:: Path ; 2 3use http::{ Method , Request , Response , StatusCode , header}; 4use http_body_util:: BodyExt ; 5use serve_esm::{ Body , Handler , create_handler}; 6use tempfile:: TempDir ; 7use tokio::{ fs, process:: Command }; 8use tower:: ServiceExt ; 9 10async fn git ( dir : & Path , args : & [ & str ]) ->Vec < u8 > { 11let output =Command :: new ( "git" ) 12. arg ( "-C" ) 13. arg ( dir) 14. args ( args) 15. output () 16. await 17. unwrap (); 18assert! ( 19 output. status . success (), 20"git {args:?}: {}" , 21String :: from_utf8_lossy ( & output. stderr ) 22); 23 output. stdout 24} 25 26struct Fixture { 27root : TempDir , 28service : Handler <()>, 29} 30 31impl Fixture { 32async fn new () ->Self { 33let root = tempfile:: tempdir (). unwrap (); 34let repo = root. path (). join ( "lib" ); 35 fs:: create_dir ( & repo). await . unwrap (); 36git ( & repo, & [ "init" , "-q" , "-b" , "main" ]). await ; 37// pin identity / disable signing so tests don't depend on global git config 38for ( key, value) in [ 39( "user.email" , "t@example.com" ), 40( "user.name" , "t" ), 41( "commit.gpgsign" , "false" ), 42( "tag.gpgsign" , "false" ), 43] { 44git ( & repo, & [ "config" , key, value]). await ; 45} 46 fs:: write ( repo. join ( "mod.ts" ), "export const x = 1;\n" ) 47. await 48. unwrap (); 49 fs:: write ( repo. join ( "data.bin" ), [ 0 , 255 , 128 , 1 ]) 50. await 51. unwrap (); 52 fs:: write ( repo. join ( "empty.txt" ), []). await . unwrap (); 53 fs:: write ( repo. join ( "sp ace-é.ts" ), "encoded name\n" ) 54. await 55. unwrap (); 56 fs:: write ( repo. join ( "[x].ts" ), "literal pathspec\n" ) 57. await 58. unwrap (); 59git ( & repo, & [ "add" , "." ]). await ; 60git ( & repo, & [ "commit" , "-q" , "-m" , "first" ]). await ; 61git ( & repo, & [ "tag" , "v1.0.0" ]). await ; 62 fs:: write ( repo. join ( "mod.ts" ), "export const x = 2;\n" ) 63. await 64. unwrap (); 65 fs:: create_dir ( repo. join ( "src" )). await . unwrap (); 66 fs:: write ( repo. join ( "src/util.ts" ), "export const y = 3;\n" ) 67. await 68. unwrap (); 69git ( & repo, & [ "add" , "." ]). await ; 70git ( & repo, & [ "commit" , "-q" , "-m" , "second" ]). await ; 71git ( & repo, & [ "tag" , "v2.0.0" ]). await ; 72git ( & repo, & [ "tag" , "v10.0.0" ]). await ; 73git ( & repo, & [ "tag" , "release/v2" ]). await ; 74git ( & repo, & [ "tag" , "-a" , "annotated" , "-m" , "annotated tag" ]). await ; 75let empty = root. path (). join ( "empty" ); 76 fs:: create_dir ( & empty). await . unwrap (); 77git ( & empty, & [ "init" , "-q" , "-b" , "main" ]). await ; 78let service =create_handler ( root. path ()). unwrap (); 79Self { root, service} 80} 81 82async fn request ( & self , method : Method , uri : & str , etags : & [ & str ]) ->Response < Body > { 83let mut request =Request :: builder (). method ( method). uri ( uri); 84for etagin etags{ 85 request = request. header ( header:: IF_NONE_MATCH , * etag); 86} 87self . service 88. clone () 89. oneshot ( request. body (()). unwrap ()) 90. await 91. unwrap () 92} 93 94async fn get ( & self , uri : & str ) ->Response < Body > { 95self . request ( Method :: GET , uri, & []). await 96} 97} 98 99async fn body ( response : Response < Body >) ->Vec < u8 > { 100 response 101. into_body () 102. collect () 103. await 104. unwrap () 105. to_bytes () 106. to_vec () 107} 108 109# [ tokio :: test ] 110async fn serves_tagged_files_with_cache_headers () { 111let fixture =Fixture :: new (). await ; 112let response = fixture. get ( "/lib/v1.0.0/mod.ts?ignored=true" ). await ; 113assert_eq! ( response. status (), StatusCode :: OK ); 114assert_eq! ( 115 response. headers ()[ header:: CONTENT_TYPE ], 116"text/typescript; charset=utf-8" 117); 118assert_eq! ( 119 response. headers ()[ header:: CACHE_CONTROL ], 120"public, max-age=31536000, immutable" 121); 122assert_eq! ( response. headers ()[ header:: CONTENT_LENGTH ], "20" ); 123let id =git ( 124& fixture. root . path (). join ( "lib" ), 125& [ "rev-parse" , "v1.0.0:mod.ts" ], 126) 127. await ; 128assert_eq! ( 129 response. headers ()[ header:: ETAG ], 130format! ( "\"{}\"" , String :: from_utf8 ( id). unwrap (). trim ()) 131); 132assert_eq! ( body ( response). await , b"export const x = 1;\n" ); 133let response = fixture. get ( "/lib/v2.0.0/src/util.ts" ). await ; 134assert_eq! ( response. status (), StatusCode :: OK ); 135assert_eq! ( body ( response). await , b"export const y = 3;\n" ); 136let response = fixture. get ( "/lib/v1.0.0/data.bin" ). await ; 137assert_eq! ( 138 response. headers ()[ header:: CONTENT_TYPE ], 139"application/octet-stream" 140); 141assert_eq! ( response. headers ()[ header:: CONTENT_LENGTH ], "4" ); 142assert_eq! ( body ( response). await , [ 0 , 255 , 128 , 1 ]); 143let response = fixture. get ( "/lib/v1.0.0/empty.txt" ). await ; 144assert_eq! ( response. status (), StatusCode :: OK ); 145assert_eq! ( response. headers ()[ header:: CONTENT_LENGTH ], "0" ); 146assert! ( body ( response). await . is_empty ()); 147} 148 149# [ tokio :: test ] 150async fn head_and_conditional_requests () { 151let fixture =Fixture :: new (). await ; 152let initial = fixture. get ( "/lib/v1.0.0/mod.ts" ). await ; 153let etag = initial. headers ()[ header:: ETAG ]. to_str (). unwrap (). to_owned (); 154let headers = initial. headers (). clone (); 155for methodin [ Method :: GET , Method :: HEAD ] { 156for valuein [ etag. clone (), format! ( "\"unrelated\", W/{etag}" ), "*" . into ()] { 157let response = fixture 158. request ( method. clone (), "/lib/v1.0.0/mod.ts" , & [ & value]) 159. await ; 160assert_eq! ( response. status (), StatusCode :: NOT_MODIFIED ); 161assert_eq! ( response. headers ()[ header:: ETAG ], etag); 162assert_eq! ( 163 response. headers ()[ header:: CACHE_CONTROL ], 164 headers[ header:: CACHE_CONTROL ] 165); 166assert! ( !response. headers (). contains_key ( header:: CONTENT_LENGTH )); 167assert! ( body ( response). await . is_empty ()); 168} 169let response = fixture 170. request ( method. clone (), "/lib/v1.0.0/mod.ts" , & [ "\"unrelated\"" ]) 171. await ; 172assert_eq! ( response. status (), StatusCode :: OK ); 173assert_eq! ( response. headers (), & headers); 174if method ==Method :: HEAD { 175assert! ( body ( response). await . is_empty ()); 176} else { 177assert_eq! ( body ( response). await , b"export const x = 1;\n" ); 178} 179} 180let response = fixture 181. request ( Method :: GET , "/lib/v1.0.0/mod.ts" , & [ "\"unrelated\"" , & etag]) 182. await ; 183assert_eq! ( response. status (), StatusCode :: NOT_MODIFIED ); 184} 185 186# [ tokio :: test ] 187async fn missing_repositories_versions_files_and_routes () { 188let fixture =Fixture :: new (). await ; 189for ( uri, expected) in [ 190( "/nope/v1.0.0/mod.ts" , "repository not found" ), 191( "/lib/v1.0.0/nope.ts" , "file not found" ), 192( "/lib/v2.0.0/src" , "file not found" ), 193( "/" , "not found" ), 194( "/lib/v1.0.0/" , "not found" ), 195] { 196let response = fixture. get ( uri). await ; 197assert_eq! ( response. status (), StatusCode :: NOT_FOUND , "{uri}" ); 198assert_eq! ( body ( response). await , expected. as_bytes (), "{uri}" ); 199} 200let response = fixture. get ( "/lib/v9.9.9/mod.ts" ). await ; 201assert_eq! ( response. status (), StatusCode :: NOT_FOUND ); 202assert! ( !response. headers (). contains_key ( header:: CACHE_CONTROL )); 203let text =String :: from_utf8 ( body ( response). await ). unwrap (); 204assert! ( text. starts_with ( "version not found\n\navailable versions:\n" )); 205assert! ( text. find ( "v10.0.0" ). unwrap () < text. find ( "v2.0.0" ). unwrap ()); 206assert! ( text. find ( "v2.0.0" ). unwrap () < text. find ( "v1.0.0" ). unwrap ()); 207let response = fixture. get ( "/empty/v1.0.0/mod.ts" ). await ; 208assert_eq! ( response. status (), StatusCode :: NOT_FOUND ); 209assert_eq! ( 210body ( response). await , 211b"version not found\n\navailable versions:\n(no tags)\n" 212); 213} 214 215# [ tokio :: test ] 216async fn rejects_other_methods_and_suppresses_head_error_bodies () { 217let fixture =Fixture :: new (). await ; 218for methodin [ Method :: POST , Method :: PUT , Method :: DELETE , Method :: OPTIONS ] { 219let response = fixture. request ( method, "/lib/v1.0.0/mod.ts" , & []). await ; 220assert_eq! ( response. status (), StatusCode :: METHOD_NOT_ALLOWED ); 221assert_eq! ( response. headers ()[ header:: ALLOW ], "GET, HEAD" ); 222assert_eq! ( body ( response). await , b"method not allowed" ); 223} 224for uriin [ 225"/" , 226"/nope/v1/mod.ts" , 227"/lib/nope/mod.ts" , 228"/lib/v1.0.0/nope.ts" , 229] { 230let response = fixture. request ( Method :: HEAD , uri, & []). await ; 231assert_eq! ( response. status (), StatusCode :: NOT_FOUND ); 232assert! ( body ( response). await . is_empty ()); 233} 234} 235 236# [ tokio :: test ] 237async fn rejects_traversal_and_ref_expressions () { 238let fixture =Fixture :: new (). await ; 239for uriin [ 240"/../v1.0.0/mod.ts" , 241"/%2e%2e/v1.0.0/mod.ts" , 242"/..%2Fetc/v1.0.0/passwd" , 243"/lib%2F..%2Flib/v1.0.0/mod.ts" , 244"/lib%00/v1.0.0/mod.ts" , 245] { 246let response = fixture. get ( uri). await ; 247assert_eq! ( response. status (), StatusCode :: NOT_FOUND , "{uri}" ); 248assert_eq! ( body ( response). await , b"repository not found" , "{uri}" ); 249} 250for uriin [ 251"/lib/v1.0.0/../mod.ts" , 252"/lib/v1.0.0/..%2Fmod.ts" , 253"/lib/v1.0.0/%2Fmod.ts" , 254"/lib/v1.0.0/src//util.ts" , 255"/lib/v1.0.0/mod.ts%00" , 256"/lib/v1.0.0/%FF" , 257] { 258let response = fixture. get ( uri). await ; 259assert_eq! ( response. status (), StatusCode :: NOT_FOUND , "{uri}" ); 260assert_eq! ( body ( response). await , b"file not found" , "{uri}" ); 261} 262for versionin [ "HEAD" , "v1.0.0~0" , "v1.0.0%3Amod.ts" , "--help" ] { 263let response = fixture. get ( & format! ( "/lib/{version}/mod.ts" )). await ; 264assert_eq! ( response. status (), StatusCode :: NOT_FOUND ); 265assert! ( 266String :: from_utf8 ( body ( response). await ) 267. unwrap () 268. starts_with ( "version not found\n" ) 269); 270} 271} 272 273# [ tokio :: test ] 274async fn supports_encoded_names_literal_pathspecs_and_annotated_tags () { 275let fixture =Fixture :: new (). await ; 276for ( uri, expected) in [ 277( "/lib/v1.0.0/sp%20ace-%C3%A9.ts" , "encoded name\n" ), 278( "/lib/v1.0.0/%5Bx%5D.ts" , "literal pathspec\n" ), 279( "/lib/release%2Fv2/mod.ts" , "export const x = 2;\n" ), 280( "/lib/annotated/mod.ts" , "export const x = 2;\n" ), 281] { 282let response = fixture. get ( uri). await ; 283assert_eq! ( response. status (), StatusCode :: OK , "{uri}" ); 284assert_eq! ( body ( response). await , expected. as_bytes ()); 285} 286let response = fixture. get ( "/lib/v1.0.0/*.ts" ). await ; 287assert_eq! ( response. status (), StatusCode :: NOT_FOUND ); 288} 289 290# [ tokio :: test ] 291async fn supports_bare_repositories_and_does_not_discover_parent_repositories () { 292let fixture =Fixture :: new (). await ; 293git ( 294 fixture. root . path (), 295& [ "clone" , "-q" , "--bare" , "lib" , "bare" ], 296) 297. await ; 298let response = fixture. get ( "/bare/v1.0.0/mod.ts" ). await ; 299assert_eq! ( response. status (), StatusCode :: OK ); 300assert_eq! ( body ( response). await , b"export const x = 1;\n" ); 301let nested = fixture. root . path (). join ( "lib/not-a-repo" ); 302 fs:: create_dir ( & nested). await . unwrap (); 303let service =create_handler ( fixture. root . path (). join ( "lib" )). unwrap (); 304let request =Request :: builder () 305. uri ( "/not-a-repo/v1.0.0/mod.ts" ) 306. body (()) 307. unwrap (); 308let response = service. oneshot ( request). await . unwrap (); 309assert_eq! ( response. status (), StatusCode :: INTERNAL_SERVER_ERROR ); 310assert_eq! ( body ( response). await , b"internal error" ); 311} 312 313# [ cfg ( unix )] 314# [ tokio :: test ] 315async fn rejects_repository_symlinks_outside_the_root () { 316let fixture =Fixture :: new (). await ; 317let outside = tempfile:: tempdir (). unwrap (); 318 std:: os:: unix:: fs:: symlink ( outside. path (), fixture. root . path (). join ( "escape" )). unwrap (); 319let response = fixture. get ( "/escape/v1.0.0/mod.ts" ). await ; 320assert_eq! ( response. status (), StatusCode :: NOT_FOUND ); 321assert_eq! ( body ( response). await , b"repository not found" ); 322} 323 324# [ tokio :: test ] 325async fn reports_git_failures_without_exposing_details () { 326let fixture =Fixture :: new (). await ; 327 fs:: create_dir ( fixture. root . path (). join ( "broken" )) 328. await 329. unwrap (); 330let response = fixture. get ( "/broken/v1.0.0/mod.ts" ). await ; 331assert_eq! ( response. status (), StatusCode :: INTERNAL_SERVER_ERROR ); 332assert_eq! ( body ( response). await , b"internal error" ); 333}