char/serve-esm

git clone https://git.t4t.associates/char/serve-esm

Charlotte SomRewrite serve-esm in Rust using Tower and Hypere06f6b7

main
12.1 KiB333 linesraw
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> {
11    let output = Command::new("git")
12        .arg("-C")
13        .arg(dir)
14        .args(args)
15        .output()
16        .await
17        .unwrap();
18    assert!(
19        output.status.success(),
20        "git {args:?}: {}",
21        String::from_utf8_lossy(&output.stderr)
22    );
23    output.stdout
24}
25
26struct Fixture {
27    root: TempDir,
28    service: Handler<()>,
29}
30
31impl Fixture {
32    async fn new() -> Self {
33        let root = tempfile::tempdir().unwrap();
34        let repo = root.path().join("lib");
35        fs::create_dir(&repo).await.unwrap();
36        git(&repo, &["init", "-q", "-b", "main"]).await;
37        // pin identity / disable signing so tests don't depend on global git config
38        for (key, value) in [
39            ("user.email", "t@example.com"),
40            ("user.name", "t"),
41            ("commit.gpgsign", "false"),
42            ("tag.gpgsign", "false"),
43        ] {
44            git(&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();
59        git(&repo, &["add", "."]).await;
60        git(&repo, &["commit", "-q", "-m", "first"]).await;
61        git(&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();
69        git(&repo, &["add", "."]).await;
70        git(&repo, &["commit", "-q", "-m", "second"]).await;
71        git(&repo, &["tag", "v2.0.0"]).await;
72        git(&repo, &["tag", "v10.0.0"]).await;
73        git(&repo, &["tag", "release/v2"]).await;
74        git(&repo, &["tag", "-a", "annotated", "-m", "annotated tag"]).await;
75        let empty = root.path().join("empty");
76        fs::create_dir(&empty).await.unwrap();
77        git(&empty, &["init", "-q", "-b", "main"]).await;
78        let service = create_handler(root.path()).unwrap();
79        Self { root, service }
80    }
81
82    async fn request(&self, method: Method, uri: &str, etags: &[&str]) -> Response<Body> {
83        let mut request = Request::builder().method(method).uri(uri);
84        for etag in etags {
85            request = request.header(header::IF_NONE_MATCH, *etag);
86        }
87        self.service
88            .clone()
89            .oneshot(request.body(()).unwrap())
90            .await
91            .unwrap()
92    }
93
94    async fn get(&self, uri: &str) -> Response<Body> {
95        self.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() {
111    let fixture = Fixture::new().await;
112    let response = fixture.get("/lib/v1.0.0/mod.ts?ignored=true").await;
113    assert_eq!(response.status(), StatusCode::OK);
114    assert_eq!(
115        response.headers()[header::CONTENT_TYPE],
116        "text/typescript; charset=utf-8"
117    );
118    assert_eq!(
119        response.headers()[header::CACHE_CONTROL],
120        "public, max-age=31536000, immutable"
121    );
122    assert_eq!(response.headers()[header::CONTENT_LENGTH], "20");
123    let id = git(
124        &fixture.root.path().join("lib"),
125        &["rev-parse", "v1.0.0:mod.ts"],
126    )
127    .await;
128    assert_eq!(
129        response.headers()[header::ETAG],
130        format!("\"{}\"", String::from_utf8(id).unwrap().trim())
131    );
132    assert_eq!(body(response).await, b"export const x = 1;\n");
133    let response = fixture.get("/lib/v2.0.0/src/util.ts").await;
134    assert_eq!(response.status(), StatusCode::OK);
135    assert_eq!(body(response).await, b"export const y = 3;\n");
136    let response = fixture.get("/lib/v1.0.0/data.bin").await;
137    assert_eq!(
138        response.headers()[header::CONTENT_TYPE],
139        "application/octet-stream"
140    );
141    assert_eq!(response.headers()[header::CONTENT_LENGTH], "4");
142    assert_eq!(body(response).await, [0, 255, 128, 1]);
143    let response = fixture.get("/lib/v1.0.0/empty.txt").await;
144    assert_eq!(response.status(), StatusCode::OK);
145    assert_eq!(response.headers()[header::CONTENT_LENGTH], "0");
146    assert!(body(response).await.is_empty());
147}
148
149#[tokio::test]
150async fn head_and_conditional_requests() {
151    let fixture = Fixture::new().await;
152    let initial = fixture.get("/lib/v1.0.0/mod.ts").await;
153    let etag = initial.headers()[header::ETAG].to_str().unwrap().to_owned();
154    let headers = initial.headers().clone();
155    for method in [Method::GET, Method::HEAD] {
156        for value in [etag.clone(), format!("\"unrelated\", W/{etag}"), "*".into()] {
157            let response = fixture
158                .request(method.clone(), "/lib/v1.0.0/mod.ts", &[&value])
159                .await;
160            assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
161            assert_eq!(response.headers()[header::ETAG], etag);
162            assert_eq!(
163                response.headers()[header::CACHE_CONTROL],
164                headers[header::CACHE_CONTROL]
165            );
166            assert!(!response.headers().contains_key(header::CONTENT_LENGTH));
167            assert!(body(response).await.is_empty());
168        }
169        let response = fixture
170            .request(method.clone(), "/lib/v1.0.0/mod.ts", &["\"unrelated\""])
171            .await;
172        assert_eq!(response.status(), StatusCode::OK);
173        assert_eq!(response.headers(), &headers);
174        if method == Method::HEAD {
175            assert!(body(response).await.is_empty());
176        } else {
177            assert_eq!(body(response).await, b"export const x = 1;\n");
178        }
179    }
180    let response = fixture
181        .request(Method::GET, "/lib/v1.0.0/mod.ts", &["\"unrelated\"", &etag])
182        .await;
183    assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
184}
185
186#[tokio::test]
187async fn missing_repositories_versions_files_and_routes() {
188    let fixture = Fixture::new().await;
189    for (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    ] {
196        let response = fixture.get(uri).await;
197        assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
198        assert_eq!(body(response).await, expected.as_bytes(), "{uri}");
199    }
200    let response = fixture.get("/lib/v9.9.9/mod.ts").await;
201    assert_eq!(response.status(), StatusCode::NOT_FOUND);
202    assert!(!response.headers().contains_key(header::CACHE_CONTROL));
203    let text = String::from_utf8(body(response).await).unwrap();
204    assert!(text.starts_with("version not found\n\navailable versions:\n"));
205    assert!(text.find("v10.0.0").unwrap() < text.find("v2.0.0").unwrap());
206    assert!(text.find("v2.0.0").unwrap() < text.find("v1.0.0").unwrap());
207    let response = fixture.get("/empty/v1.0.0/mod.ts").await;
208    assert_eq!(response.status(), StatusCode::NOT_FOUND);
209    assert_eq!(
210        body(response).await,
211        b"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() {
217    let fixture = Fixture::new().await;
218    for method in [Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS] {
219        let response = fixture.request(method, "/lib/v1.0.0/mod.ts", &[]).await;
220        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
221        assert_eq!(response.headers()[header::ALLOW], "GET, HEAD");
222        assert_eq!(body(response).await, b"method not allowed");
223    }
224    for uri in [
225        "/",
226        "/nope/v1/mod.ts",
227        "/lib/nope/mod.ts",
228        "/lib/v1.0.0/nope.ts",
229    ] {
230        let response = fixture.request(Method::HEAD, uri, &[]).await;
231        assert_eq!(response.status(), StatusCode::NOT_FOUND);
232        assert!(body(response).await.is_empty());
233    }
234}
235
236#[tokio::test]
237async fn rejects_traversal_and_ref_expressions() {
238    let fixture = Fixture::new().await;
239    for uri in [
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    ] {
246        let response = fixture.get(uri).await;
247        assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
248        assert_eq!(body(response).await, b"repository not found", "{uri}");
249    }
250    for uri in [
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    ] {
258        let response = fixture.get(uri).await;
259        assert_eq!(response.status(), StatusCode::NOT_FOUND, "{uri}");
260        assert_eq!(body(response).await, b"file not found", "{uri}");
261    }
262    for version in ["HEAD", "v1.0.0~0", "v1.0.0%3Amod.ts", "--help"] {
263        let response = fixture.get(&format!("/lib/{version}/mod.ts")).await;
264        assert_eq!(response.status(), StatusCode::NOT_FOUND);
265        assert!(
266            String::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() {
275    let fixture = Fixture::new().await;
276    for (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    ] {
282        let response = fixture.get(uri).await;
283        assert_eq!(response.status(), StatusCode::OK, "{uri}");
284        assert_eq!(body(response).await, expected.as_bytes());
285    }
286    let response = fixture.get("/lib/v1.0.0/*.ts").await;
287    assert_eq!(response.status(), StatusCode::NOT_FOUND);
288}
289
290#[tokio::test]
291async fn supports_bare_repositories_and_does_not_discover_parent_repositories() {
292    let fixture = Fixture::new().await;
293    git(
294        fixture.root.path(),
295        &["clone", "-q", "--bare", "lib", "bare"],
296    )
297    .await;
298    let response = fixture.get("/bare/v1.0.0/mod.ts").await;
299    assert_eq!(response.status(), StatusCode::OK);
300    assert_eq!(body(response).await, b"export const x = 1;\n");
301    let nested = fixture.root.path().join("lib/not-a-repo");
302    fs::create_dir(&nested).await.unwrap();
303    let service = create_handler(fixture.root.path().join("lib")).unwrap();
304    let request = Request::builder()
305        .uri("/not-a-repo/v1.0.0/mod.ts")
306        .body(())
307        .unwrap();
308    let response = service.oneshot(request).await.unwrap();
309    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
310    assert_eq!(body(response).await, b"internal error");
311}
312
313#[cfg(unix)]
314#[tokio::test]
315async fn rejects_repository_symlinks_outside_the_root() {
316    let fixture = Fixture::new().await;
317    let outside = tempfile::tempdir().unwrap();
318    std::os::unix::fs::symlink(outside.path(), fixture.root.path().join("escape")).unwrap();
319    let response = fixture.get("/escape/v1.0.0/mod.ts").await;
320    assert_eq!(response.status(), StatusCode::NOT_FOUND);
321    assert_eq!(body(response).await, b"repository not found");
322}
323
324#[tokio::test]
325async fn reports_git_failures_without_exposing_details() {
326    let fixture = Fixture::new().await;
327    fs::create_dir(fixture.root.path().join("broken"))
328        .await
329        .unwrap();
330    let response = fixture.get("/broken/v1.0.0/mod.ts").await;
331    assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
332    assert_eq!(body(response).await, b"internal error");
333}