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}