//! A repository's releases, as its owner published them //! (docs/design/packages.md): each version of each package, newest first, //! marked as its package's latest where it is, an image's marked as one; //! and, for its pages' header, the release to show and its packages. use super::repo::{found, invalid}; use super::*; use crate::releases::Records; use axum::http::HeaderName; use knotless::records::{ReleaseFile, ReleaseRecord}; use knotless::release; /// Releases a page shows unless asked for more or fewer. const PAGE: usize = 30; /// `dev.knotless.defs#releaseViewBasic`. #[derive(Serialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ReleaseViewBasic { uri: String, package: String, version: String, #[serde(skip_serializing_if = "std::ops::Not::not")] latest: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] image: bool, created_at: String, } /// `dev.knotless.defs#releaseView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReleaseView { uri: String, package: String, version: String, #[serde(skip_serializing_if = "std::ops::Not::not")] latest: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] image: bool, #[serde(skip_serializing_if = "std::ops::Not::not")] yanked: bool, #[serde(rename = "crate", skip_serializing_if = "std::ops::Not::not")] is_crate: bool, #[serde(skip_serializing_if = "Option::is_none")] commit: Option, #[serde(skip_serializing_if = "Option::is_none")] check: Option, #[serde(skip_serializing_if = "Option::is_none")] notes: Option, #[serde(skip_serializing_if = "Option::is_none")] html: Option, files: Vec, created_at: String, } /// `dev.knotless.defs#releaseFileView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ReleaseFileView { name: String, size: u64, sha256: String, } /// `dev.knotless.defs#packageView`. #[derive(Serialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct PackageView { name: String, #[serde(skip_serializing_if = "Option::is_none")] latest: Option, #[serde(skip_serializing_if = "std::ops::Not::not")] image: bool, release_count: usize, } /// `dev.knotless.defs#releasesView`. #[derive(Serialize, Debug, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ReleasesView { count: usize, release: ReleaseViewBasic, packages: Vec, } /// `dev.knotless.listReleases`'s answer. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct Releases { releases: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] packages: Vec, /// Absent on the last page. #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, } #[derive(Deserialize)] pub struct ReleasesParams { repo: String, package: Option, limit: Option, cursor: Option, } /// Whether `r` is a container image: an OCI layout, whose `index.json` /// names its manifest. fn image(r: &ReleaseRecord) -> bool { r.files.iter().any(|f| f.name == "index.json") } /// A repository's releases, newest first, each with whether it is its /// package's latest, as [`release::latest`] finds it. pub(super) struct Published { pub(super) all: Vec<(String, ReleaseRecord, bool)>, } impl Published { /// `repo`'s, as the index has them: none for a private repository, /// which cannot publish, or without an index. pub(super) fn of(app: &App, repo: &Repo) -> Published { let index = app.index.as_ref().filter(|_| repo.in_space.is_none()); let found = index.and_then(|i| i.releases(&repo.did, &repo.key).ok()); Published::new(found.unwrap_or_default()) } fn new(found: Vec<(String, ReleaseRecord)>) -> Published { let records: Vec = found.iter().map(|(_, r)| r.clone()).collect(); let all = found.into_iter().map(|(uri, r)| { let latest = release::latest(&records, &r.package).map(|l| &l.version); let latest = latest == Some(&r.version); (uri, r, latest) }); Published { all: all.collect() } } /// Each package, by name, with its latest version, how many versions /// it has, and whether its latest, or its newest when it has none, is /// an image. fn packages(&self) -> Vec { let mut packages: BTreeMap<&str, PackageView> = BTreeMap::new(); for (_, r, latest) in &self.all { let p = packages.entry(&r.package).or_insert_with(|| PackageView { name: r.package.clone(), latest: None, image: image(r), release_count: 0, }); p.release_count += 1; if *latest { p.latest = Some(r.version.clone()); p.image = image(r); } } packages.into_values().collect() } /// What the pages of the repository `name` say of its releases under /// its name: the latest of the package named as it is, its own, else /// the newest that is its package's latest, or the newest of all when /// each is a pre-release; none with no releases, or when each is yanked. fn summary(&self, name: &str) -> Option { let latest = || self.all.iter().filter(|(_, _, latest)| *latest); let own = latest().find(|(_, r, _)| r.package == name); let live = self.all.iter().find(|(_, r, _)| !r.yanked); let (uri, r, latest) = own.or(latest().next()).or(live)?; Some(ReleasesView { count: self.all.len(), release: ReleaseViewBasic { uri: uri.clone(), package: r.package.clone(), version: r.version.clone(), latest: *latest, image: image(r), created_at: r.created_at.clone(), }, packages: self.packages(), }) } } /// What `repo`'s pages say of its releases under its name. pub(super) fn summary(app: &App, repo: &Repo) -> Option { Published::of(app, repo).summary(&repo.name) } /// `dev.knotless.listReleases`. pub(super) async fn list_releases( State(app): State>, Query(p): Query, ) -> Result { let limit = p.limit.unwrap_or(PAGE).clamp(1, 100); let skip = match p.cursor.as_deref().map(str::parse::) { None => 0, Some(Ok(skip)) => skip, Some(Err(_)) => return Err(invalid("cursor is no count of releases".into())), }; let repo = found(&app, &p.repo).await?; let published = Published::of(&app, &repo); let packages = published.packages(); let base = repo.href(""); let wanted = |r: &ReleaseRecord| p.package.as_ref().is_none_or(|name| &r.package == name); let mut all = published.all.into_iter().filter(|(_, r, _)| wanted(r)); let releases: Vec = all .by_ref() .skip(skip) .take(limit) .map(|(uri, r, latest)| ReleaseView { html: r .notes .as_deref() .map(|n| markdown(n, &base, &base, "", None)), image: image(&r), is_crate: r.cargo.is_some(), uri, package: r.package, version: r.version, latest, yanked: r.yanked, commit: r.commit, check: r.check, notes: r.notes, files: r.files.into_iter().map(file_view).collect(), created_at: r.created_at, }) .collect(); let more = all.next().is_some(); let cursor = more.then(|| (skip + releases.len()).to_string()); Ok(answer(Releases { releases, packages, cursor, })) } /// `///releases/download///`: a /// file of a public repository's release, read from its owner's PDS the /// first time it is asked for, checked against its record and kept, as /// `--releases` hands out its own. Read as no one, so a private /// repository's, or one taken down, is not found. pub(super) async fn download( State(app): State>, headers: HeaderMap, Path((authority, name, rest)): RestPath, ) -> Result { let repo = Repo::open(&app, &authority, &name).await?; let Some((package, f)) = released(&app, &repo, &rest) else { let why = anyhow::anyhow!("{} has released no {rest}", repo.href("")); return Err(not_found(why)); }; let etag = format!("\"{}\"", f.sha256); let seen = headers.get(header::IF_NONE_MATCH); if seen.is_some_and(|v| v.as_bytes() == etag.as_bytes()) { return Ok((StatusCode::NOT_MODIFIED, [(header::ETAG, etag)]).into_response()); } let url = format!("knotless://{}/{}", repo.did, repo.name); let url = RepoUrl::parse(&url).map_err(AppError::internal)?; let records = Records::new(app.ctx.clone(), url, package); let sent = records.send(&f, sending(&f, etag)).await; let sent = sent.map_err(AppError::upstream)?; Ok(sent) } /// The package and file `rest`, `//`, names among /// `repo`'s releases, as its releases page lists them. fn released(app: &App, repo: &Repo, rest: &str) -> Option<(String, ReleaseFile)> { let (rest, file) = rest.rsplit_once('/')?; let (package, version) = rest.rsplit_once('/')?; let published = Published::of(app, repo).all.into_iter(); let mut asked = published.filter(|(_, r, _)| r.package == package && r.version == version); let (_, r, _) = asked.next()?; let f = r.files.into_iter().find(|f| f.name == file); // Its name goes in a header, so only one a publish takes. let f = f.filter(|f| release::valid_file(&f.name))?; Some((r.package, f)) } /// What a release's file `f` is sent with: as bytes to save, never a page /// of the site; checked again each time, so a takedown holds at once; and /// `etag`, its hash, for a cache to ask by. pub(super) fn sending(f: &ReleaseFile, etag: String) -> [(HeaderName, String); 6] { let says = |s: &str| s.to_string(); let saved = format!("attachment; filename=\"{}\"", f.name); [ (header::CONTENT_TYPE, says("application/octet-stream")), (header::CONTENT_DISPOSITION, saved), (header::CACHE_CONTROL, says("no-cache")), (header::ETAG, etag), (header::X_CONTENT_TYPE_OPTIONS, says("nosniff")), ( header::CONTENT_SECURITY_POLICY, says("sandbox; default-src 'none'"), ), ] } fn file_view(f: ReleaseFile) -> ReleaseFileView { ReleaseFileView { name: f.name, size: f.size, sha256: f.sha256, } } #[cfg(test)] mod tests { use super::*; fn record(package: &str, version: &str, files: &[&str]) -> (String, ReleaseRecord) { let files = files.iter().map(|name| ReleaseFile { name: name.to_string(), size: 1, sha256: "0".repeat(64), parts: Vec::new(), }); let r = ReleaseRecord { repo: "k".into(), package: package.into(), version: version.into(), commit: None, check: None, notes: None, files: files.collect(), created_at: "2026-10-01T00:00:00Z".into(), cargo: None, yanked: false, }; ( format!("at://did:a/{}/{package}{version}", nsid::RELEASE), r, ) } #[test] fn the_release_shown_is_its_own_or_the_newest_latest_or_else_the_newest() { let only_pre = Published::new(vec![ record("a", "1.0.0-rc.2", &["x"]), record("a", "1.0.0-rc.1", &["x"]), ]); let shown = only_pre.summary("a").unwrap(); assert_eq!( (shown.release.version.as_str(), shown.release.latest), ("1.0.0-rc.2", false) ); assert_eq!(shown.packages[0].latest, None); let mixed = Published::new(vec![ record("img", "2.0.0-rc.1", &["x"]), record("img", "1.0.0", &["index.json", "sha256-0"]), record("cli", "0.1.0", &["x"]), ]); let shown = mixed.summary("tool").unwrap(); assert_eq!(shown.release.package, "img"); assert!(shown.release.latest && shown.release.image); // The package named as the repository is the one shown, newest // though another's latest is. let own = mixed.summary("cli").unwrap(); assert_eq!( (own.release.package.as_str(), own.release.version.as_str()), ("cli", "0.1.0") ); let names: Vec<_> = shown .packages .iter() .map(|p| (p.name.as_str(), p.image, p.release_count)) .collect(); assert_eq!(names, [("cli", false, 1), ("img", true, 2)]); assert!(Published::new(Vec::new()).summary("a").is_none()); // A yanked version is never the one shown, nor its package's latest. let yanked = |version| { let (uri, r) = record("a", version, &["x"]); (uri, ReleaseRecord { yanked: true, ..r }) }; let some = Published::new(vec![yanked("2.0.0"), record("a", "1.0.0", &["x"])]); let shown = some.summary("a").unwrap(); assert_eq!(shown.release.version, "1.0.0"); assert_eq!(shown.packages[0].latest.as_deref(), Some("1.0.0")); let pre = Published::new(vec![ yanked("2.0.0-rc.2"), record("a", "2.0.0-rc.1", &["x"]), ]); assert_eq!(pre.summary("a").unwrap().release.version, "2.0.0-rc.1"); assert!(Published::new(vec![yanked("1.0.0")]).summary("a").is_none()); } }