char/serve-esm

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

Charlotte SomRewrite serve-esm in Rust using Tower and Hypere06f6b7

main
7.2 KiB211 linesraw
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>> {
21    let root = Arc::new(std::fs::canonicalize(root)?);
22    if !root.is_dir() {
23        return Err(std::io::Error::new(
24            std::io::ErrorKind::NotADirectory,
25            "TARGET_DIR must be a directory",
26        ));
27    }
28    Ok(BoxCloneService::new(service_fn(move |req: Request<B>| {
29        let root = root.clone();
30        let (parts, _) = req.into_parts();
31        async move {
32            let head = parts.method == Method::HEAD;
33            let mut response = handle(&root, parts).await.unwrap_or_else(|error| {
34                eprintln!("{error:#}");
35                text(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
36            });
37            if head {
38                *response.body_mut() = Full::default();
39            }
40            Ok(response)
41        }
42    })))
43}
44
45async fn handle(root: &Path, req: Parts) -> Result<Response<Body>> {
46    let Some(path) = req.uri.path().strip_prefix('/') else {
47        return Ok(text(StatusCode::NOT_FOUND, "not found"));
48    };
49    let mut segments = path.splitn(3, '/');
50    let (Some(repo), Some(version), Some(file)) =
51        (segments.next(), segments.next(), segments.next())
52    else {
53        return Ok(text(StatusCode::NOT_FOUND, "not found"));
54    };
55    if repo.is_empty() || version.is_empty() || file.is_empty() {
56        return Ok(text(StatusCode::NOT_FOUND, "not found"));
57    }
58    if req.method != Method::GET && req.method != Method::HEAD {
59        let mut response = text(StatusCode::METHOD_NOT_ALLOWED, "method not allowed");
60        response
61            .headers_mut()
62            .insert(header::ALLOW, "GET, HEAD".parse()?);
63        return Ok(response);
64    }
65
66    let file = percent_decode_str(file).decode_utf8();
67    let file = match file {
68        Ok(file)
69            if !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    };
78    let repo = percent_decode_str(repo).decode_utf8();
79    let repo = match repo {
80        Ok(repo) if !matches!(repo.as_ref(), "." | "..") && !repo.contains(['/', '\\', '\0']) => {
81            repo
82        }
83        _ => return Ok(text(StatusCode::NOT_FOUND, "repository not found")),
84    };
85    let repo_dir = match fs::canonicalize(root.join(repo.as_ref())).await {
86        Ok(dir)
87            if 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.
98    let tags = git(&repo_dir, &["tag", "--list", "--sort=-version:refname"]).await?;
99    let tags = String::from_utf8(tags).context("Git returned non-UTF-8 tags")?;
100    let tags: Vec<_> = tags.lines().filter(|tag| !tag.is_empty()).collect();
101    let version = percent_decode_str(version).decode_utf8();
102    let version = match version {
103        Ok(version) if tags.contains(&version.as_ref()) => version,
104        _ => {
105            let list = if tags.is_empty() {
106                "(no tags)".to_owned()
107            } else {
108                tags.join("\n")
109            };
110            return Ok(text(
111                StatusCode::NOT_FOUND,
112                format!("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.
118    let 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?;
131    let info = std::str::from_utf8(&info)?;
132    let fields: Vec<_> = info.strip_suffix('\0').unwrap_or("").split(' ').collect();
133    let (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    };
139    let etag = format!("\"{object_id}\"");
140    let 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| {
147                    let tag = candidate.trim();
148                    tag == "*" || tag.strip_prefix("W/").unwrap_or(tag) == etag
149                })
150            })
151        });
152    let response = Response::builder()
153        .header(header::CACHE_CONTROL, CACHE_CONTROL)
154        .header(header::ETAG, etag);
155    if not_modified {
156        return Ok(response
157            .status(StatusCode::NOT_MODIFIED)
158            .body(Full::default())?);
159    }
160    let response = response
161        .header(
162            header::CONTENT_TYPE,
163            get_mime_type(&file).unwrap_or("application/octet-stream"),
164        )
165        .header(header::CONTENT_LENGTH, size);
166    let body = if req.method == Method::HEAD {
167        Full::default()
168    } else {
169        Full::new(Bytes::from(
170            git(&repo_dir, &["cat-file", "blob", object_id]).await?,
171        ))
172    };
173    Ok(response.body(body)?)
174}
175
176fn text(status: StatusCode, body: impl Into<Bytes>) -> Response<Body> {
177    let body = body.into();
178    Response::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>> {
187    let 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()))?;
202    if !output.status.success() {
203        bail!(
204            "git {args:?} in {} failed ({}): {}",
205            repo.display(),
206            output.status,
207            String::from_utf8_lossy(&output.stderr).trim_end()
208        );
209    }
210    Ok(output.stdout)
211}