lmjtfy.git / apps / lmjtfy / src / release.rs
1//! `https://code.lmjtfy.fun/whiskers/latest/arm64.apk`: the release files of
2//! the repositories served here, for anyone, though the repositories are
3//! private.
4//!
5//! GitHub keeps a release's files beside its tag. This forwards them the way
6//! `clone.rs` forwards a clone: the read-only token (Contents: read, which
7//! covers releases) looks the release up and asks for the file, GitHub
8//! answers with a redirect to a short-lived signed address, and that address
9//! is fetched without the token and streamed to the visitor. Nothing is
10//! kept but the release's metadata, a minute per isolate, and nothing is
11//! spent: a download reaches neither Jev nor the LLM.
12//!
13//! # The addresses
14//!
15//! `<name>` is a served repository's name without `.git` (`whiskers`).
16//!
17//! | Path | What |
18//! | --- | --- |
19//! | `/<name>/latest/<kind>` | The file of the latest release whose kind is `<kind>`. It moves, so it is cached five minutes. |
20//! | `/<name>/releases/<tag>/<asset>` | That exact file of that exact release. A tag does not move, so it is cached for good. |
21//! | `/<name>/latest/`, `/<name>/releases/` | A page listing the latest release, or the recent ones, with each file's size and link. |
22//!
23//! Anything else under `/<name>/latest` or `/<name>/releases` is `404`, and
24//! so is a `<name>` that is not in `Repo::ALL`.
25//!
26//! # A file's kind
27//!
28//! The kind is the file's name with the release's version taken out, so that
29//! the address does not change when the version does. It is everything after
30//! the first `-<tag>-` in the name (`whiskers-2026.10.5-arm64.apk` is
31//! `arm64.apk`; `whiskersd-2026.10.5-x86_64-linux.tar.gz` is
32//! `x86_64-linux.tar.gz`; a tag of `v1.2` is also looked for as `1.2`). A
33//! file whose name has no `-<tag>-` in it (`SHA256SUMS`) is its own kind. Two
34//! files of one release with the same kind is not guessed at: that kind is
35//! `404` for that release and the log names the release (`Pick::Ambiguous`).
36//! The pinned form is always unambiguous, since names are unique in a release.
37//!
38//! The pure parts (routes, kinds, headers, sizes) are tested natively; only
39//! `serve` and the two fetches use the Worker.
40
41use axum::body::Body;
42use axum::extract::State;
43use axum::http::{HeaderMap, Method, Response, StatusCode, Uri, header};
44use serde::Deserialize;
45
46use std::cell::RefCell;
47
48use super::App;
49use crate::clone::{self, Repo};
50
51/// How long an isolate keeps a release's metadata. The same minute as the
52/// code pages' other caches.
53const KEEP_MS: f64 = 60_000.0;
54
55/// How many releases the page lists.
56const LISTED: usize = 20;
57
58/// The longest name or tag taken as a path segment.
59const LONGEST: usize = 200;
60
61/// The most entries one isolate keeps.
62const KEPT_MOST: usize = 64;
63
64/// `Cache-Control` for the latest release's files: they move.
65pub const LATEST_CACHE: &str = "public, max-age=300";
66/// And for a file of a named release, which does not.
67pub const PINNED_CACHE: &str = "public, max-age=86400, immutable";
68
69// ---------------------------------------------------------------- routes
70
71/// What a download address asks for.
72#[derive(Debug, PartialEq, Eq)]
73pub enum Route<'a> {
74    /// `/<name>/latest/<kind>`.
75    Latest { repo: Repo, kind: &'a str },
76    /// `/<name>/releases/<tag>/<asset>`.
77    Pinned { repo: Repo, tag: &'a str, asset: &'a str },
78    /// `/<name>/latest` or `/<name>/latest/`.
79    LatestPage(Repo),
80    /// `/<name>/releases` or `/<name>/releases/`.
81    ReleasesPage(Repo),
82}
83
84/// Whether `text` may be a path segment here: letters, digits and `. _ + ~ @ -`
85/// and nothing else, not `.` or `..`, not empty, not long. No `%`, so there is
86/// no encoded slash or dot-dot to be decoded later, and no space or quote to
87/// reach a header. GitHub's own names for files and tags are of this kind.
88pub fn segment(text: &str) -> bool {
89    !text.is_empty()
90        && text.len() <= LONGEST
91        && text != "."
92        && text != ".."
93        && text.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | '@' | '-'))
94}
95
96/// The repository whose downloads are under `path`, if `path` is under one:
97/// `/whiskers/latest...` or `/whiskers/releases...`, of a served repository.
98/// What `host::gate` sends to the code host, and what `route` reads further.
99pub fn under(path: &str) -> Option<Repo> {
100    let mut parts = path.strip_prefix('/')?.split('/');
101    let name = parts.next()?;
102    matches!(parts.next()?, "latest" | "releases").then_some(())?;
103    Repo::ALL.into_iter().find(|repo| repo.bare() == name)
104}
105
106/// The route for `path`, or `None` for anything the downloads do not serve.
107pub fn route(path: &str) -> Option<Route<'_>> {
108    let repo = under(path)?;
109    let rest = path.strip_prefix('/')?.split_once('/')?.1;
110    let parts: Vec<&str> = rest.split('/').collect();
111    match parts.as_slice() {
112        ["latest"] | ["latest", ""] => Some(Route::LatestPage(repo)),
113        ["releases"] | ["releases", ""] => Some(Route::ReleasesPage(repo)),
114        ["latest", kind] if segment(kind) => Some(Route::Latest { repo, kind }),
115        ["releases", tag, asset] if segment(tag) && segment(asset) => Some(Route::Pinned { repo, tag, asset }),
116        _ => None,
117    }
118}
119
120// ---------------------------------------------------------------- releases
121
122/// A file of a release, as GitHub lists it.
123#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
124pub struct Asset {
125    pub id: u64,
126    pub name: String,
127    #[serde(default)]
128    pub size: u64,
129    /// `uploaded` once it can be downloaded.
130    #[serde(default)]
131    state: String,
132}
133
134/// A release, as far as the downloads read it.
135#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
136pub struct Release {
137    #[serde(rename = "tag_name")]
138    pub tag: String,
139    #[serde(default)]
140    pub name: Option<String>,
141    #[serde(default)]
142    pub published_at: Option<String>,
143    #[serde(default)]
144    pub draft: bool,
145    #[serde(default)]
146    pub prerelease: bool,
147    #[serde(default)]
148    pub assets: Vec<Asset>,
149}
150
151impl Release {
152    pub fn parse(body: &str) -> Option<Release> {
153        let mut release: Release = serde_json::from_str(body).ok()?;
154        release.assets.retain(|asset| asset.state == "uploaded");
155        (!release.draft).then_some(release)
156    }
157
158    /// GitHub's list of releases, newest first, without drafts.
159    pub fn parse_list(body: &str) -> Option<Vec<Release>> {
160        let mut releases: Vec<Release> = serde_json::from_str(body).ok()?;
161        releases.retain(|release| !release.draft);
162        for release in &mut releases {
163            release.assets.retain(|asset| asset.state == "uploaded");
164        }
165        Some(releases)
166    }
167
168    /// The date it was published, `2026-10-05`, or nothing.
169    pub fn date(&self) -> &str {
170        self.published_at.as_deref().and_then(|at| at.get(..10)).unwrap_or_default()
171    }
172
173    /// The kind of each asset, in order.
174    pub fn kinds(&self) -> Vec<String> {
175        self.assets.iter().map(|asset| kind_of(&asset.name, &self.tag)).collect()
176    }
177}
178
179/// The file's name with the version taken out: everything after the first
180/// `-<tag>-` (or `-<tag without a leading v>-`), else the whole name.
181pub fn kind_of(name: &str, tag: &str) -> String {
182    let bare = tag.strip_prefix(['v', 'V']).filter(|bare| !bare.is_empty());
183    for version in std::iter::once(tag).chain(bare) {
184        let marker = format!("-{version}-");
185        if let Some((before, after)) = name.split_once(&marker)
186            && !before.is_empty()
187            && !after.is_empty()
188        {
189            return after.to_owned();
190        }
191    }
192    name.to_owned()
193}
194
195/// Which file of a release a kind is.
196#[derive(Debug, PartialEq, Eq)]
197pub enum Pick<'a> {
198    Found(&'a Asset),
199    Missing,
200    /// Two or more files have the kind. Never guessed between.
201    Ambiguous,
202}
203
204pub fn pick<'a>(release: &'a Release, kind: &str) -> Pick<'a> {
205    let mut found = release.assets.iter().filter(|asset| kind_of(&asset.name, &release.tag) == kind);
206    match (found.next(), found.next()) {
207        (None, _) => Pick::Missing,
208        (Some(asset), None) => Pick::Found(asset),
209        (Some(_), Some(_)) => Pick::Ambiguous,
210    }
211}
212
213/// The file of a release with exactly this name.
214pub fn named<'a>(release: &'a Release, name: &str) -> Option<&'a Asset> {
215    release.assets.iter().find(|asset| asset.name == name)
216}
217
218// ---------------------------------------------------------------- headers
219
220/// The `content-type` a file is given, by its name.
221pub fn content_type(name: &str) -> &'static str {
222    let lower = name.to_ascii_lowercase();
223    if lower.ends_with(".apk") {
224        "application/vnd.android.package-archive"
225    } else if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
226        "application/gzip"
227    } else if lower == "sha256sums" || lower.ends_with(".sha256") || lower.ends_with(".txt") {
228        "text/plain; charset=utf-8"
229    } else {
230        "application/octet-stream"
231    }
232}
233
234/// `attachment; filename="<name>"`. A name is a GitHub asset's, which may
235/// hold a quote, a backslash or a control character: each of those, and
236/// anything that is not ASCII, is an underscore, so the name cannot end the
237/// quotes or the header.
238pub fn disposition(name: &str) -> String {
239    let safe: String = name.chars().map(|c| if c.is_ascii_graphic() && c != '"' && c != '\\' { c } else if c == ' ' { ' ' } else { '_' }).collect();
240    format!("attachment; filename=\"{safe}\"")
241}
242
243/// A `Range` header worth forwarding: one `bytes=` with digits, commas and
244/// dashes. Anything else is left out, and the whole file is sent.
245pub fn range(value: &str) -> Option<&str> {
246    let spec = value.strip_prefix("bytes=")?;
247    (!spec.is_empty() && spec.len() <= 100 && spec.chars().all(|c| c.is_ascii_digit() || c == '-' || c == ',')).then_some(value)
248}
249
250/// Whether a request starts the file over: no range, or one from byte 0.
251/// What counts as a download in the archive; a resumed one does not.
252pub fn from_start(range: Option<&str>) -> bool {
253    range.is_none_or(|range| range.starts_with("bytes=0-"))
254}
255
256/// A size as people read it: `76.7 MB`, in GitHub's decimal megabytes.
257pub fn human(bytes: u64) -> String {
258    const UNITS: [&str; 4] = ["B", "kB", "MB", "GB"];
259    let mut size = bytes as f64;
260    let mut unit = 0;
261    while size >= 1000.0 && unit < UNITS.len() - 1 {
262        size /= 1000.0;
263        unit += 1;
264    }
265    if unit == 0 { format!("{bytes} B") } else { format!("{size:.1} {}", UNITS[unit]) }
266}
267
268// ---------------------------------------------------------------- reading GitHub
269
270/// What looking a release up came to.
271enum Found {
272    Releases(Vec<Release>),
273    Missing,
274    Unreachable,
275}
276
277/// What is asked of GitHub, and the cache's key for it.
278enum Ask<'a> {
279    Latest,
280    Tag(&'a str),
281    List,
282}
283
284thread_local! {
285    /// What GitHub said, by `(repository, what was asked)`, and when. Only
286    /// good answers.
287    static KEPT: RefCell<Vec<(String, f64, Vec<Release>)>> = const { RefCell::new(Vec::new()) };
288}
289
290async fn lookup(env: &worker::Env, repo: Repo, ask: Ask<'_>) -> Found {
291    let (key, url) = match ask {
292        Ask::Latest => (format!("{}:latest", repo.github()), format!("https://api.github.com/repos/{}/releases/latest", repo.github())),
293        Ask::Tag(tag) => (format!("{}:tag:{tag}", repo.github()), format!("https://api.github.com/repos/{}/releases/tags/{tag}", repo.github())),
294        Ask::List => (format!("{}:list", repo.github()), format!("https://api.github.com/repos/{}/releases?per_page={LISTED}", repo.github())),
295    };
296    let now = js_sys::Date::now();
297    let kept = KEPT.with(|kept| kept.borrow().iter().find(|(seen, at, _)| *seen == key && now - at < KEEP_MS).map(|(_, _, releases)| releases.clone()));
298    if let Some(releases) = kept {
299        return Found::Releases(releases);
300    }
301    let listing = matches!(ask, Ask::List);
302    let read = match clone::github(env, &url).await {
303        Some((200, body, _)) if listing => Release::parse_list(&body),
304        Some((200, body, _)) => Release::parse(&body).map(|release| vec![release]),
305        Some((404, _, _)) => {
306            // GitHub answers 404 for a repository the token cannot read as
307            // well as for a release that is not there, so say which it was.
308            worker::console_log!("release: {} {} has no such release (404)", repo.github(), key.split_once(':').map_or("", |(_, what)| what));
309            return Found::Missing;
310        }
311        Some((status, _, _)) => {
312            worker::console_error!("release: GitHub answered {status} for {} {}", repo.github(), key.split_once(':').map_or("", |(_, what)| what));
313            return Found::Unreachable;
314        }
315        None => {
316            worker::console_error!("release: GitHub could not be reached for {} (or the token is not set)", repo.github());
317            return Found::Unreachable;
318        }
319    };
320    match read {
321        Some(releases) => {
322            KEPT.with(|kept| {
323                let mut kept = kept.borrow_mut();
324                kept.retain(|(seen, at, _)| *seen != key && now - at < KEEP_MS);
325                if kept.len() >= KEPT_MOST {
326                    kept.remove(0);
327                }
328                kept.push((key, now, releases.clone()));
329            });
330            Found::Releases(releases)
331        }
332        None => {
333            worker::console_error!("release: GitHub's answer for {} could not be read", repo.github());
334            Found::Unreachable
335        }
336    }
337}
338
339// ---------------------------------------------------------------- serving
340
341use worker::worker_sys::web_sys;
342
343/// What the Worker answers with: a page or refusal as an `http` response, or
344/// a file as the `fetch` response it is. A file is not an `http` response so
345/// that its body stays the runtime's own stream from GitHub's storage, which
346/// is what keeps its `Content-Length`: an `http` body is read through Rust
347/// and written out again as a plain stream, and a plain stream is sent
348/// chunked, with no length for a download bar. (Measured on staging,
349/// 2026-10-05.) It also keeps 200 MB out of the Worker's own memory and CPU.
350pub enum Served {
351    Http(Response<Body>),
352    File(web_sys::Response),
353}
354
355impl worker::IntoResponse for Served {
356    fn into_raw(self) -> Result<web_sys::Response, impl Into<Box<dyn std::error::Error>>> {
357        match self {
358            Served::Http(response) => worker::IntoResponse::into_raw(response).map_err(|error| -> Box<dyn std::error::Error> { error.into() }),
359            Served::File(response) => Ok(response),
360        }
361    }
362}
363
364impl Route<'_> {
365    /// A file, as opposed to a listing page.
366    pub fn is_file(&self) -> bool {
367        matches!(self, Route::Latest { .. } | Route::Pinned { .. })
368    }
369}
370
371/// The two listing pages. The file routes are answered by `file`, before the
372/// router, so one that reaches here (and anything that is no route) is a `404`.
373#[worker::send]
374pub async fn serve(State(app): State<App>, headers: HeaderMap, uri: Uri) -> Response<Body> {
375    match route(uri.path()) {
376        Some(Route::LatestPage(repo)) => page(&app, &headers, repo, Ask::Latest, false).await,
377        Some(Route::ReleasesPage(repo)) => page(&app, &headers, repo, Ask::List, true).await,
378        _ => clone::refused(StatusCode::NOT_FOUND, "No such file."),
379    }
380}
381
382fn unreachable() -> Response<Body> {
383    clone::refused(StatusCode::BAD_GATEWAY, "GitHub did not answer.")
384}
385
386/// The page listing one release (`latest`) or the recent ones.
387async fn page(app: &App, headers: &HeaderMap, repo: Repo, ask: Ask<'_>, many: bool) -> Response<Body> {
388    let releases = match lookup(&app.env, repo, ask).await {
389        Found::Releases(releases) => releases,
390        // A repository with no release yet has an empty page, not a 404.
391        Found::Missing if many => Vec::new(),
392        Found::Missing => return clone::refused(StatusCode::NOT_FOUND, "No release yet."),
393        Found::Unreachable => return unreachable(),
394    };
395    let origin = super::origin(headers);
396    let html = crate::view::releases::page(&origin, repo.bare(), repo.project(), many, &releases);
397    Response::builder()
398        .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
399        .header(header::CACHE_CONTROL, "no-cache")
400        .body(Body::from(html.into_string()))
401        .expect("static headers are valid")
402}
403
404/// Which file a route is, and how long it may be cached.
405async fn find(env: &worker::Env, route: &Route<'_>) -> Result<(Repo, Release, Asset, &'static str), Response<Body>> {
406    let missing = |why: &str| clone::refused(StatusCode::NOT_FOUND, why);
407    match route {
408        Route::Latest { repo, kind } => {
409            let release = match lookup(env, *repo, Ask::Latest).await {
410                Found::Releases(mut releases) => releases.swap_remove(0),
411                Found::Missing => return Err(missing("No such release.")),
412                Found::Unreachable => return Err(unreachable()),
413            };
414            match pick(&release, kind) {
415                Pick::Found(asset) => Ok((*repo, release.clone(), asset.clone(), LATEST_CACHE)),
416                Pick::Missing => Err(missing("No such file.")),
417                Pick::Ambiguous => {
418                    worker::console_error!("release: {} {}: two files have one kind, so none is sent", repo.github(), release.tag);
419                    Err(missing("No such file."))
420                }
421            }
422        }
423        Route::Pinned { repo, tag, asset } => {
424            let release = match lookup(env, *repo, Ask::Tag(tag)).await {
425                Found::Releases(mut releases) => releases.swap_remove(0),
426                Found::Missing => return Err(missing("No such release.")),
427                Found::Unreachable => return Err(unreachable()),
428            };
429            // GitHub's `tags/<tag>` is exact; hold it to the tag asked for anyway.
430            if release.tag != *tag {
431                return Err(missing("No such release."));
432            }
433            match named(&release, asset) {
434                Some(found) => Ok((*repo, release.clone(), found.clone(), PINNED_CACHE)),
435                None => Err(missing("No such file.")),
436            }
437        }
438        Route::LatestPage(_) | Route::ReleasesPage(_) => Err(missing("No such file.")),
439    }
440}
441
442/// A file route (`GET` or `HEAD`), answered before the router sees it
443/// (`fetch` in lib.rs). Its headers come from the release's own record; for a
444/// `GET` its body is GitHub's signed address's, passed on as it streams.
445pub async fn file(env: &worker::Env, event: crate::events::Event, route: Route<'_>, method: &Method, headers: &HeaderMap) -> Served {
446    let (repo, release, asset, cache) = match find(env, &route).await {
447        Ok(found) => found,
448        Err(response) => return Served::Http(response),
449    };
450    let wanted = headers.get(header::RANGE).and_then(|value| value.to_str().ok()).and_then(range);
451    if *method == Method::HEAD {
452        let response = Response::builder()
453            .header(header::CONTENT_TYPE, content_type(&asset.name))
454            .header(header::CONTENT_DISPOSITION, disposition(&asset.name))
455            .header("x-content-type-options", "nosniff")
456            .header(header::CACHE_CONTROL, cache)
457            .header(header::ACCEPT_RANGES, "bytes")
458            .header(header::CONTENT_LENGTH, asset.size)
459            .body(Body::empty())
460            .expect("static headers are valid");
461        return Served::Http(response);
462    }
463    let (status, upstream) = match fetch(env, repo, asset.id, wanted).await {
464        Ok(answer) => answer,
465        Err(why) => {
466            worker::console_error!("release: {} {} asset {}: {why}", repo.github(), release.tag, asset.id);
467            return Served::Http(unreachable());
468        }
469    };
470    let upstream: web_sys::Response = upstream.into();
471    let sent = worker::Headers::new();
472    let set = |name: &str, value: &str| sent.set(name, value).is_ok();
473    let mut ok = set("content-type", content_type(&asset.name))
474        && set("content-disposition", &disposition(&asset.name))
475        && set("x-content-type-options", "nosniff")
476        && set("cache-control", cache)
477        && set("accept-ranges", "bytes");
478    // As GitHub's storage sent them: how long the body is, and, for a range,
479    // which part of the file it is.
480    let length = upstream.headers().get("content-length").ok().flatten();
481    for name in ["content-length", "content-range"] {
482        if let Some(value) = upstream.headers().get(name).ok().flatten() {
483            ok &= set(name, &value);
484        }
485    }
486    let Some(body) = upstream.body().filter(|_| ok) else {
487        worker::console_error!("release: {} {} asset {}: the answer had no body to send", repo.github(), release.tag, asset.id);
488        return Served::Http(unreachable());
489    };
490    worker::console_log!("release: {} {} asset {} sent, status {status}, {} bytes long", repo.github(), release.tag, asset.id, length.as_deref().unwrap_or("?"));
491    if from_start(wanted) {
492        let mut event = event.named("download").with(format!("{}/{}", repo.bare(), asset.name));
493        event.status = f64::from(status);
494        crate::events::record(env, event).await;
495    }
496    Served::File(worker::ResponseBuilder::new().with_status(status).with_headers(sent).stream(body).into())
497}
498
499/// GitHub's asset, streamed. The API answers a request for
500/// `application/octet-stream` with a redirect to a signed address; the token
501/// is sent to the API and never to that address, which is fetched with only
502/// the range. `Err` carries fixed words for the log, never GitHub's text.
503async fn fetch(env: &worker::Env, repo: Repo, id: u64, wanted: Option<&str>) -> Result<(u16, worker::Response), &'static str> {
504    let token = env.secret(clone::TOKEN).map_err(|_| "the token is not set")?.to_string();
505    let url = format!("https://api.github.com/repos/{}/releases/assets/{id}", repo.github());
506    let asked = worker::Headers::new();
507    asked.set("authorization", &format!("Bearer {token}")).map_err(|_| "a header was refused")?;
508    asked.set("accept", "application/octet-stream").map_err(|_| "a header was refused")?;
509    asked.set("user-agent", "lmjtfy").map_err(|_| "a header was refused")?;
510    let mut init = worker::RequestInit::new();
511    init.with_headers(asked).with_redirect(worker::RequestRedirect::Manual);
512    let request = worker::Request::new_with_init(&url, &init).map_err(|_| "the request could not be made")?;
513    let first = worker::Fetch::Request(request).send().await.map_err(|_| "GitHub did not answer")?;
514    let signed = match first.status_code() {
515        // GitHub answers a file with a redirect.
516        301 | 302 | 303 | 307 | 308 => first.headers().get("location").ok().flatten().filter(|to| to.starts_with("https://")).ok_or("the redirect had no https address")?,
517        status @ (200 | 206) => return Ok((status, first)),
518        401 | 403 | 404 => return Err("GitHub refused the token for the asset (does it have Contents: read on this repository?)"),
519        _ => return Err("GitHub answered the asset request with an unexpected status"),
520    };
521    // No credentials past this point: the signed address is another host.
522    let second = worker::Headers::new();
523    second.set("user-agent", "lmjtfy").map_err(|_| "a header was refused")?;
524    if let Some(wanted) = wanted {
525        second.set("range", wanted).map_err(|_| "a header was refused")?;
526    }
527    let mut init = worker::RequestInit::new();
528    init.with_headers(second);
529    let request = worker::Request::new_with_init(&signed, &init).map_err(|_| "the signed address could not be used")?;
530    let response = worker::Fetch::Request(request).send().await.map_err(|_| "the signed address did not answer")?;
531    match response.status_code() {
532        status @ (200 | 206) => Ok((status, response)),
533        _ => Err("the signed address answered with an unexpected status"),
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use axum::http::HeaderValue;
541
542    fn asset(name: &str) -> Asset {
543        Asset { id: 1, name: name.to_owned(), size: 10, state: "uploaded".into() }
544    }
545
546    fn release(tag: &str, names: &[&str]) -> Release {
547        Release { tag: tag.to_owned(), name: None, published_at: Some("2026-10-05T01:02:03Z".into()), draft: false, prerelease: false, assets: names.iter().map(|name| asset(name)).collect() }
548    }
549
550    fn whiskers() -> Release {
551        release(
552            "2026.10.5",
553            &[
554                "whiskers-2026.10.5-arm64.apk",
555                "whiskers-2026.10.5-arm64-lite.apk",
556                "whiskers-2026.10.5-universal.apk",
557                "whiskersd-2026.10.5-x86_64-linux.tar.gz",
558                "SHA256SUMS",
559            ],
560        )
561    }
562
563    #[test]
564    fn a_kind_is_the_name_without_the_version() {
565        let kinds = whiskers().kinds();
566        assert_eq!(kinds, ["arm64.apk", "arm64-lite.apk", "universal.apk", "x86_64-linux.tar.gz", "SHA256SUMS"]);
567        // A leading v on the tag is not in the file names.
568        assert_eq!(kind_of("app-1.2-x.zip", "v1.2"), "x.zip");
569        assert_eq!(kind_of("app-v1.2-x.zip", "v1.2"), "x.zip");
570        // No version in the name: its own kind. So is a name where the
571        // version is not between dashes.
572        assert_eq!(kind_of("app-2026.10.5.apk", "2026.10.5"), "app-2026.10.5.apk");
573        assert_eq!(kind_of("-2026.10.5-x", "2026.10.5"), "-2026.10.5-x");
574        assert_eq!(kind_of("a-2026.10.5-", "2026.10.5"), "a-2026.10.5-");
575    }
576
577    #[test]
578    fn the_first_version_marker_splits_the_name() {
579        assert_eq!(kind_of("a-1.0-b-1.0-c", "1.0"), "b-1.0-c");
580    }
581
582    #[test]
583    fn a_tag_with_regex_characters_is_matched_as_text() {
584        let tag = "v1.0+(x)[a]*";
585        assert_eq!(kind_of("app-1.0+(x)[a]*-linux.tar.gz", tag), "linux.tar.gz");
586        assert_eq!(kind_of(&format!("app-{tag}-linux.tar.gz"), tag), "linux.tar.gz");
587        // The dot is a dot, not any character.
588        assert_eq!(kind_of("app-1x0-linux", "1.0"), "app-1x0-linux");
589        assert_eq!(kind_of("app-1.0-linux", ".*"), "app-1.0-linux");
590    }
591
592    #[test]
593    fn a_kind_finds_its_file() {
594        let release = whiskers();
595        let found = |kind: &str| match pick(&release, kind) {
596            Pick::Found(asset) => Some(asset.name.clone()),
597            _ => None,
598        };
599        assert_eq!(found("arm64.apk").as_deref(), Some("whiskers-2026.10.5-arm64.apk"));
600        assert_eq!(found("arm64-lite.apk").as_deref(), Some("whiskers-2026.10.5-arm64-lite.apk"));
601        assert_eq!(found("x86_64-linux.tar.gz").as_deref(), Some("whiskersd-2026.10.5-x86_64-linux.tar.gz"));
602        assert_eq!(found("SHA256SUMS").as_deref(), Some("SHA256SUMS"));
603        // A file is not found by its versioned name, or by part of a kind.
604        assert_eq!(pick(&release, "whiskers-2026.10.5-arm64.apk"), Pick::Missing);
605        assert_eq!(pick(&release, "apk"), Pick::Missing);
606        assert_eq!(pick(&release, ""), Pick::Missing);
607    }
608
609    #[test]
610    fn two_files_of_one_kind_are_not_guessed_between() {
611        let release = release("1.0", &["a-1.0-x.zip", "b-1.0-x.zip", "a-1.0-y.zip"]);
612        assert_eq!(pick(&release, "x.zip"), Pick::Ambiguous);
613        assert!(matches!(pick(&release, "y.zip"), Pick::Found(_)));
614        // The pinned form names the file exactly.
615        assert_eq!(named(&release, "b-1.0-x.zip").map(|asset| asset.name.as_str()), Some("b-1.0-x.zip"));
616        assert_eq!(named(&release, "x.zip"), None);
617    }
618
619    #[test]
620    fn the_routes_are_read() {
621        let w = Repo::Whiskers;
622        assert_eq!(route("/whiskers/latest/arm64.apk"), Some(Route::Latest { repo: w, kind: "arm64.apk" }));
623        assert_eq!(route("/whiskers/latest/SHA256SUMS"), Some(Route::Latest { repo: w, kind: "SHA256SUMS" }));
624        assert_eq!(
625            route("/whiskers/releases/2026.10.5/whiskers-2026.10.5-arm64.apk"),
626            Some(Route::Pinned { repo: w, tag: "2026.10.5", asset: "whiskers-2026.10.5-arm64.apk" })
627        );
628        assert_eq!(route("/whiskers/latest"), Some(Route::LatestPage(w)));
629        assert_eq!(route("/whiskers/latest/"), Some(Route::LatestPage(w)));
630        assert_eq!(route("/whiskers/releases"), Some(Route::ReleasesPage(w)));
631        assert_eq!(route("/whiskers/releases/"), Some(Route::ReleasesPage(w)));
632        assert_eq!(route("/lmjtfy/latest/x"), Some(Route::Latest { repo: Repo::Lmjtfy, kind: "x" }));
633    }
634
635    #[test]
636    fn what_is_not_a_route_is_not_one() {
637        for path in [
638            // Not served, or not a name of one: `.git` is the clone's name.
639            "/nixos-config/latest/x",
640            "/whiskers.git/latest/x",
641            "/Whiskers/latest/x",
642            "/latest/x",
643            "/",
644            "",
645            // Path tricks.
646            "/whiskers/latest/..",
647            "/whiskers/latest/.",
648            "/whiskers/latest/../x",
649            "/whiskers/releases/../latest/x",
650            "/whiskers/releases/1.0/..",
651            "/whiskers/latest/%2e%2e",
652            "/whiskers/latest/a%2fb",
653            "/whiskers/latest/a%2Fb",
654            "/whiskers/latest/a\\b",
655            "/whiskers/latest/a b",
656            "/whiskers/latest/a\"b",
657            "/whiskers/latest/a\nb",
658            // Empty parts, too many parts, too few.
659            "/whiskers/latest//x",
660            "/whiskers/latest/a/b",
661            "/whiskers/releases/1.0",
662            "/whiskers/releases/1.0/",
663            "/whiskers/releases//x",
664            "/whiskers/releases/1.0/a/b",
665            "/whiskers/releases/1.0//",
666            // Other things under the repository's name.
667            "/whiskers/other/x",
668            "/whiskers/latestx/x",
669            "//whiskers/latest/x",
670        ] {
671            assert_eq!(route(path), None, "{path:?}");
672        }
673        let long = format!("/whiskers/latest/{}", "a".repeat(LONGEST + 1));
674        assert_eq!(route(&long), None);
675    }
676
677    #[test]
678    fn a_path_under_the_prefix_is_the_codes_even_if_malformed() {
679        // What the apex sends on to the code host, which then says 404.
680        assert_eq!(under("/whiskers/latest/../x"), Some(Repo::Whiskers));
681        assert_eq!(under("/whiskers/releases"), Some(Repo::Whiskers));
682        assert_eq!(under("/whiskers/latest"), Some(Repo::Whiskers));
683        assert_eq!(under("/whiskers"), None);
684        assert_eq!(under("/whiskers/"), None);
685        assert_eq!(under("/whiskers.git/latest"), None);
686        assert_eq!(under("/nope/latest"), None);
687        assert_eq!(under("/lmjtfy.git/info/refs"), None);
688    }
689
690    #[test]
691    fn every_served_repository_has_downloads_under_its_bare_name() {
692        for repo in Repo::ALL {
693            let path = format!("/{}/latest/x", repo.bare());
694            assert_eq!(route(&path), Some(Route::Latest { repo, kind: "x" }), "{path}");
695        }
696    }
697
698    #[test]
699    fn a_file_is_given_its_type_by_its_name() {
700        assert_eq!(content_type("whiskers-2026.10.5-arm64.apk"), "application/vnd.android.package-archive");
701        assert_eq!(content_type("X.APK"), "application/vnd.android.package-archive");
702        assert_eq!(content_type("whiskersd-2026.10.5-x86_64-linux.tar.gz"), "application/gzip");
703        assert_eq!(content_type("SHA256SUMS"), "text/plain; charset=utf-8");
704        assert_eq!(content_type("thing.zip"), "application/octet-stream");
705        assert_eq!(content_type("apk"), "application/octet-stream");
706    }
707
708    #[test]
709    fn the_filename_cannot_leave_its_quotes() {
710        assert_eq!(disposition("whiskers-2026.10.5-arm64.apk"), "attachment; filename=\"whiskers-2026.10.5-arm64.apk\"");
711        assert_eq!(disposition("a\"b\\c\r\nd"), "attachment; filename=\"a_b_c__d\"");
712        assert_eq!(disposition("é.apk"), "attachment; filename=\"_.apk\"");
713        assert!(HeaderValue::from_str(&disposition("x\ny\u{7f}")).is_ok());
714    }
715
716    #[test]
717    fn only_a_plain_range_is_forwarded() {
718        assert_eq!(range("bytes=0-1048575"), Some("bytes=0-1048575"));
719        assert_eq!(range("bytes=100-"), Some("bytes=100-"));
720        assert_eq!(range("bytes=-500"), Some("bytes=-500"));
721        assert_eq!(range("bytes=0-1,5-9"), Some("bytes=0-1,5-9"));
722        assert_eq!(range("items=0-1"), None);
723        assert_eq!(range("bytes="), None);
724        assert_eq!(range("bytes=a-b"), None);
725        assert_eq!(range("bytes=0-1\r\nx: y"), None);
726        assert!(from_start(None));
727        assert!(from_start(Some("bytes=0-1048575")));
728        assert!(!from_start(Some("bytes=100-")));
729    }
730
731    #[test]
732    fn sizes_are_read_by_people() {
733        assert_eq!(human(0), "0 B");
734        assert_eq!(human(999), "999 B");
735        assert_eq!(human(1500), "1.5 kB");
736        assert_eq!(human(80_400_000), "80.4 MB");
737        assert_eq!(human(2_500_000_000), "2.5 GB");
738    }
739
740    #[test]
741    fn githubs_release_is_read_without_drafts_or_unfinished_files() {
742        let body = r#"{"tag_name":"2026.10.5","name":"Oct","published_at":"2026-10-05T01:02:03Z","draft":false,"prerelease":false,
743            "assets":[{"id":7,"name":"a-2026.10.5-x.apk","size":12,"state":"uploaded"},{"id":8,"name":"b","size":1,"state":"starter"}]}"#;
744        let release = Release::parse(body).unwrap();
745        assert_eq!(release.assets.len(), 1);
746        assert_eq!(release.assets[0].id, 7);
747        assert_eq!(release.date(), "2026-10-05");
748        assert!(Release::parse(&body.replace("\"draft\":false", "\"draft\":true")).is_none());
749        assert!(Release::parse("not json").is_none());
750        let list = format!("[{body},{}]", body.replace("\"draft\":false", "\"draft\":true"));
751        assert_eq!(Release::parse_list(&list).unwrap().len(), 1);
752    }
753}