lmjtfy.git / apps / lmjtfy / src / release.rs

https://code.lmjtfy.fun/whiskers/latest/arm64.apk: the release files of the repositories served here, for anyone, though the repositories are private.

GitHub keeps a release's files beside its tag. This forwards them the way clone.rs forwards a clone: the read-only token (Contents: read, which covers releases) looks the release up and asks for the file, GitHub answers with a redirect to a short-lived signed address, and that address is fetched without the token and streamed to the visitor. Nothing is kept but the release's metadata, a minute per isolate, and nothing is spent: a download reaches neither Jev nor the LLM.

The addresses

<name> is a served repository's name without .git (whiskers).

PathWhat
/<name>/latest/<kind>The file of the latest release whose kind is <kind>. It moves, so it is cached five minutes.
/<name>/releases/<tag>/<asset>That exact file of that exact release. A tag does not move, so it is cached for good.
/<name>/latest/, /<name>/releases/A page listing the latest release, or the recent ones, with each file's size and link.

Anything else under /<name>/latest or /<name>/releases is 404, and so is a <name> that is not in Repo::ALL.

A file's kind

The kind is the file's name with the release's version taken out, so that the address does not change when the version does. It is everything after the first -<tag>- in the name (whiskers-2026.10.5-arm64.apk is arm64.apk; whiskersd-2026.10.5-x86_64-linux.tar.gz is x86_64-linux.tar.gz; a tag of v1.2 is also looked for as 1.2). A file whose name has no -<tag>- in it (SHA256SUMS) is its own kind. Two files of one release with the same kind is not guessed at: that kind is 404 for that release and the log names the release (Pick::Ambiguous). The pinned form is always unambiguous, since names are unique in a release.

The pure parts (routes, kinds, headers, sizes) are tested natively; only serve and the two fetches use the Worker.

41use axum::body::Body;
42use axum::extract::State;
43use axum::http::{HeaderMap, Method, Response, StatusCode, Uri, header};
44use serde::Deserialize;
46use std::cell::RefCell;
47
48use super::App;
49use crate::clone::{self, Repo};

How long an isolate keeps a release's metadata. The same minute as the code pages' other caches.

53const KEEP_MS: f64 = 60_000.0;

How many releases the page lists.

56const LISTED: usize = 20;

The longest name or tag taken as a path segment.

59const LONGEST: usize = 200;

The most entries one isolate keeps.

62const KEPT_MOST: usize = 64;

Cache-Control for the latest release's files: they move.

65pub const LATEST_CACHE: &str = "public, max-age=300";

And for a file of a named release, which does not.

67pub const PINNED_CACHE: &str = "public, max-age=86400, immutable";

---------------------------------------------------------------- routes

What a download address asks for.

72#[derive(Debug, PartialEq, Eq)]
73pub enum Route<'a> {

/<name>/latest/<kind>.

75    Latest { repo: Repo, kind: &'a str },

/<name>/releases/<tag>/<asset>.

77    Pinned { repo: Repo, tag: &'a str, asset: &'a str },

/<name>/latest or /<name>/latest/.

79    LatestPage(Repo),

/<name>/releases or /<name>/releases/.

81    ReleasesPage(Repo),
82}

Whether text may be a path segment here: letters, digits and . _ + ~ @ - and nothing else, not . or .., not empty, not long. No %, so there is no encoded slash or dot-dot to be decoded later, and no space or quote to 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}

The repository whose downloads are under path, if path is under one: /whiskers/latest... or /whiskers/releases..., of a served repository. 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}

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}

---------------------------------------------------------------- releases

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,

uploaded once it can be downloaded.

130    #[serde(default)]
131    state: String,
132}

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}
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    }

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    }

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    }

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}

The file's name with the version taken out: everything after the first -<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}

Which file of a release a kind is.

196#[derive(Debug, PartialEq, Eq)]
197pub enum Pick<'a> {
198    Found(&'a Asset),
199    Missing,

Two or more files have the kind. Never guessed between.

201    Ambiguous,
202}
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}

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}

---------------------------------------------------------------- headers

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}

attachment; filename="<name>". A name is a GitHub asset's, which may hold a quote, a backslash or a control character: each of those, and anything that is not ASCII, is an underscore, so the name cannot end the 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}

A Range header worth forwarding: one bytes= with digits, commas and 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}

Whether a request starts the file over: no range, or one from byte 0. 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}

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}

---------------------------------------------------------------- reading GitHub

What looking a release up came to.

271enum Found {
272    Releases(Vec<Release>),
273    Missing,
274    Unreachable,
275}

What is asked of GitHub, and the cache's key for it.

278enum Ask<'a> {
279    Latest,
280    Tag(&'a str),
281    List,
282}
284thread_local! {

What GitHub said, by (repository, what was asked), and when. Only good answers.

287    static KEPT: RefCell<Vec<(String, f64, Vec<Release>)>> = const { RefCell::new(Vec::new()) };
288}
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}

---------------------------------------------------------------- serving

341use worker::worker_sys::web_sys;

What the Worker answers with: a page or refusal as an http response, or a file as the fetch response it is. A file is not an http response so that its body stays the runtime's own stream from GitHub's storage, which is what keeps its Content-Length: an http body is read through Rust and written out again as a plain stream, and a plain stream is sent chunked, with no length for a download bar. (Measured on staging, 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}
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<'_> {

A file, as opposed to a listing page.

366    pub fn is_file(&self) -> bool {
367        matches!(self, Route::Latest { .. } | Route::Pinned { .. })
368    }
369}

The two listing pages. The file routes are answered by file, before the 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}
382fn unreachable() -> Response<Body> {
383    clone::refused(StatusCode::BAD_GATEWAY, "GitHub did not answer.")
384}

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}

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}

A file route (GET or HEAD), answered before the router sees it (fetch in lib.rs). Its headers come from the release's own record; for a 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}

GitHub's asset, streamed. The API answers a request for application/octet-stream with a redirect to a signed address; the token is sent to the API and never to that address, which is fetched with only 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}
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}