use std::path::Path; use http::{Method, Request, Response, StatusCode, header}; use http_body_util::BodyExt; use serve_esm::{Body, Handler, create_handler}; use tempfile::TempDir; use tokio::{fs, process::Command}; use tower::ServiceExt; async fn git(dir: &Path, args: &[&str]) -> Vec { let output = Command::new("git") .arg("-C") .arg(dir) .args(args) .output() .await .unwrap(); assert!( output.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&output.stderr) ); output.stdout } struct Fixture { root: TempDir, service: Handler<()>, } impl Fixture { async fn new() -> Self { let root = tempfile::tempdir().unwrap(); let repo = root.path().join("lib"); fs::create_dir(&repo).await.unwrap(); git(&repo, &["init", "-q", "-b", "main"]).await; // pin identity / disable signing so tests don't depend on global git config for (key, value) in [ ("user.email", "t@example.com"), ("user.name", "t"), ("commit.gpgsign", "false"), ("tag.gpgsign", "false"), ] { git(&repo, &["config", key, value]).await; } fs::write(repo.join("mod.ts"), "export const x = 1;\n") .await .unwrap(); fs::write(repo.join("data.bin"), [0, 255, 128, 1]) .await .unwrap(); fs::write(repo.join("empty.txt"), []).await.unwrap(); fs::write(repo.join("sp ace-é.ts"), "encoded name\n") .await .unwrap(); fs::write(repo.join("[x].ts"), "literal pathspec\n") .await .unwrap(); git(&repo, &["add", "."]).await; git(&repo, &["commit", "-q", "-m", "first"]).await; git(&repo, &["tag", "v1.0.0"]).await; fs::write(repo.join("mod.ts"), "export const x = 2;\n") .await .unwrap(); fs::create_dir(repo.join("src")).await.unwrap(); fs::write(repo.join("src/util.ts"), "export const y = 3;\n") .await .unwrap(); git(&repo, &["add", "."]).await; git(&repo, &["commit", "-q", "-m", "second"]).await; git(&repo, &["tag", "v2.0.0"]).await; git(&repo, &["tag", "v10.0.0"]).await; git(&repo, &["tag", "release/v2"]).await; git(&repo, &["tag", "-a", "annotated", "-m", "annotated tag"]).await; let empty = root.path().join("empty"); fs::create_dir(&empty).await.unwrap(); git(&empty, &["init", "-q", "-b", "main"]).await; let service = create_handler(root.path()).unwrap(); Self { root, service } } async fn request(&self, method: Method, uri: &str, etags: &[&str]) -> Response { let mut request = Request::builder().method(method).uri(uri); for etag in etags { request = request.header(header::IF_NONE_MATCH, *etag); } self.service .clone() .oneshot(request.body(()).unwrap()) .await .unwrap() } async fn get(&self, uri: &str) -> Response { self.request(Method::GET, uri, &[]).await } } async fn body(response: Response) -> Vec { response .into_body() .collect() .await .unwrap() .to_bytes() .to_vec() } #[tokio::test] async fn serves_tagged_files_with_cache_headers() { let fixture = Fixture::new().await; let response = fixture.get("/lib/v1.0.0/mod.ts?ignored=true").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/typescript; charset=utf-8" ); assert_eq!( response.headers()[header::CACHE_CONTROL], "public, max-age=31536000, immutable" ); assert_eq!(response.headers()[header::CONTENT_LENGTH], "20"); let id = git( &fixture.root.path().join("lib"), &["rev-parse", "v1.0.0:mod.ts"], ) .await; assert_eq!( response.headers()[header::ETAG], format!("\"{}\"", String::from_utf8(id).unwrap().trim()) ); assert_eq!(body(response).await, b"export const x = 1;\n"); let response = fixture.get("/lib/v2.0.0/src/util.ts").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(body(response).await, b"export const y = 3;\n"); let response = fixture.get("/lib/v1.0.0/data.bin").await; assert_eq!( response.headers()[header::CONTENT_TYPE], "application/octet-stream" ); assert_eq!(response.headers()[header::CONTENT_LENGTH], "4"); assert_eq!(body(response).await, [0, 255, 128, 1]); let response = fixture.get("/lib/v1.0.0/empty.txt").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers()[header::CONTENT_LENGTH], "0"); assert!(body(response).await.is_empty()); } #[tokio::test] async fn head_and_conditional_requests() { let fixture = Fixture::new().await; let initial = fixture.get("/lib/v1.0.0/mod.ts").await; let etag = initial.headers()[header::ETAG].to_str().unwrap().to_owned(); let headers = initial.headers().clone(); for method in [Method::GET, Method::HEAD] { for value in [etag.clone(), format!("\"unrelated\", W/{etag}"), "*".into()] { let response = fixture .request(method.clone(), "/lib/v1.0.0/mod.ts", &[&value]) .await; assert_eq!(response.status(), StatusCode::NOT_MODIFIED); assert_eq!(response.headers()[header::ETAG], etag); assert_eq!( response.headers()[header::CACHE_CONTROL], headers[header::CACHE_CONTROL] ); assert!(!response.headers().contains_key(header::CONTENT_LENGTH)); assert!(body(response).await.is_empty()); } let response = fixture .request(method.clone(), "/lib/v1.0.0/mod.ts", &["\"unrelated\""]) .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.headers(), &headers); if method == Method::HEAD { assert!(body(response).await.is_empty()); } else { assert_eq!(body(response).await, b"export const x = 1;\n"); } } let response = fixture .request(Method::GET, "/lib/v1.0.0/mod.ts", &["\"unrelated\"", &etag]) .await; assert_eq!(response.status(), StatusCode::NOT_MODIFIED); } #[tokio::test] async fn missing_repositories_versions_files_and_routes() { let fixture = Fixture::new().await; for (uri, expected) in [ ("/nope/v1.0.0/mod.ts", "repository not found"), ("/lib/v1.0.0/nope.ts", "file not found"), ("/lib/v2.0.0/src", "file not found"), ("/", "not found"), ("/lib/v1.0.0/", "not found"), ] { let response = fixture.get(uri).await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); assert_eq!(body(response).await, expected.as_bytes(), "{uri}"); } let response = fixture.get("/lib/v9.9.9/mod.ts").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!(!response.headers().contains_key(header::CACHE_CONTROL)); let text = String::from_utf8(body(response).await).unwrap(); assert!(text.starts_with("version not found\n\navailable versions:\n")); assert!(text.find("v10.0.0").unwrap() < text.find("v2.0.0").unwrap()); assert!(text.find("v2.0.0").unwrap() < text.find("v1.0.0").unwrap()); let response = fixture.get("/empty/v1.0.0/mod.ts").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!( body(response).await, b"version not found\n\navailable versions:\n(no tags)\n" ); } #[tokio::test] async fn rejects_other_methods_and_suppresses_head_error_bodies() { let fixture = Fixture::new().await; for method in [Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS] { let response = fixture.request(method, "/lib/v1.0.0/mod.ts", &[]).await; assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); assert_eq!(response.headers()[header::ALLOW], "GET, HEAD"); assert_eq!(body(response).await, b"method not allowed"); } for uri in [ "/", "/nope/v1/mod.ts", "/lib/nope/mod.ts", "/lib/v1.0.0/nope.ts", ] { let response = fixture.request(Method::HEAD, uri, &[]).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!(body(response).await.is_empty()); } } #[tokio::test] async fn rejects_traversal_and_ref_expressions() { let fixture = Fixture::new().await; for uri in [ "/../v1.0.0/mod.ts", "/%2e%2e/v1.0.0/mod.ts", "/..%2Fetc/v1.0.0/passwd", "/lib%2F..%2Flib/v1.0.0/mod.ts", "/lib%00/v1.0.0/mod.ts", ] { let response = fixture.get(uri).await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); assert_eq!(body(response).await, b"repository not found", "{uri}"); } for uri in [ "/lib/v1.0.0/../mod.ts", "/lib/v1.0.0/..%2Fmod.ts", "/lib/v1.0.0/%2Fmod.ts", "/lib/v1.0.0/src//util.ts", "/lib/v1.0.0/mod.ts%00", "/lib/v1.0.0/%FF", ] { let response = fixture.get(uri).await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}"); assert_eq!(body(response).await, b"file not found", "{uri}"); } for version in ["HEAD", "v1.0.0~0", "v1.0.0%3Amod.ts", "--help"] { let response = fixture.get(&format!("/lib/{version}/mod.ts")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert!( String::from_utf8(body(response).await) .unwrap() .starts_with("version not found\n") ); } } #[tokio::test] async fn supports_encoded_names_literal_pathspecs_and_annotated_tags() { let fixture = Fixture::new().await; for (uri, expected) in [ ("/lib/v1.0.0/sp%20ace-%C3%A9.ts", "encoded name\n"), ("/lib/v1.0.0/%5Bx%5D.ts", "literal pathspec\n"), ("/lib/release%2Fv2/mod.ts", "export const x = 2;\n"), ("/lib/annotated/mod.ts", "export const x = 2;\n"), ] { let response = fixture.get(uri).await; assert_eq!(response.status(), StatusCode::OK, "{uri}"); assert_eq!(body(response).await, expected.as_bytes()); } let response = fixture.get("/lib/v1.0.0/*.ts").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn supports_bare_repositories_and_does_not_discover_parent_repositories() { let fixture = Fixture::new().await; git( fixture.root.path(), &["clone", "-q", "--bare", "lib", "bare"], ) .await; let response = fixture.get("/bare/v1.0.0/mod.ts").await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(body(response).await, b"export const x = 1;\n"); let nested = fixture.root.path().join("lib/not-a-repo"); fs::create_dir(&nested).await.unwrap(); let service = create_handler(fixture.root.path().join("lib")).unwrap(); let request = Request::builder() .uri("/not-a-repo/v1.0.0/mod.ts") .body(()) .unwrap(); let response = service.oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(body(response).await, b"internal error"); } #[cfg(unix)] #[tokio::test] async fn rejects_repository_symlinks_outside_the_root() { let fixture = Fixture::new().await; let outside = tempfile::tempdir().unwrap(); std::os::unix::fs::symlink(outside.path(), fixture.root.path().join("escape")).unwrap(); let response = fixture.get("/escape/v1.0.0/mod.ts").await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(body(response).await, b"repository not found"); } #[tokio::test] async fn reports_git_failures_without_exposing_details() { let fixture = Fixture::new().await; fs::create_dir(fixture.root.path().join("broken")) .await .unwrap(); let response = fixture.get("/broken/v1.0.0/mod.ts").await; assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); assert_eq!(body(response).await, b"internal error"); }