//! typed request keys (packet §29). //! //! the generator walks the finite request space as `RequestKey` values; only the nginx //! emitter stringifies them. two derivations matter: //! //! - `cas_key`: resolved-commit-keyed; identical semantics share CAS bytes regardless //! of which alias reached them. //! - `route_key_spelled`: alias-keyed; distinct wire spellings get their own nginx map //! entries pointing at the same CAS object. use knot_types::{BranchName, TagName}; /// how a repo was named on the wire. canonical identity stays /// repoDid -> owner + rkey in meta.git; aliases are routing conveniences. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RepoAlias { Did(String), OwnerRkey { owner: String, rkey: String }, OwnerName { owner: String, name: String }, } impl RepoAlias { pub fn to_param(&self) -> String { match self { Self::Did(did) => did.clone(), Self::OwnerRkey { owner, rkey } => format!("{owner}/{rkey}"), Self::OwnerName { owner, name } => format!("{owner}/{name}"), } } } /// a ref spelling accepted by the finite revspec profile (packet §9). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RefAlias { /// empty ref: knot2 defines it as HEAD. Head, ShortBranch(BranchName), FullBranch(String), ShortTag(TagName), FullTag(String), /// full object-format-width public commit OID. Commit(String), } impl RefAlias { pub fn to_param(&self) -> String { match self { Self::Head => String::new(), Self::ShortBranch(name) => name.as_str().to_string(), Self::ShortTag(name) => name.as_str().to_string(), Self::FullBranch(name) | Self::FullTag(name) | Self::Commit(name) => name.clone(), } } } /// whether the response bytes for a request key can change across generations. /// commit-OID-keyed reads are immutable: the same oid always yields the same bytes, /// so they carry long cache lifetimes and feed the cross-generation response cache. /// everything anchored to a named ref or the registry is mutable and recomputed every build. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mutability { Immutable, Mutable, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ArchiveKind { TarGz, Zip, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum RequestKey { DidDocument, Version, Owner, Health, DescribeRepo { repo: RepoAlias, }, GetDefaultBranch { repo: RepoAlias, }, Branch { repo: RepoAlias, name: BranchName, }, Branches { repo: RepoAlias, limit: usize, cursor: usize, }, Tag { repo: RepoAlias, name: TagName, }, Tags { repo: RepoAlias, limit: usize, cursor: usize, }, ListRefs { repo: RepoAlias, limit: usize, cursor: usize, }, ListRepos { limit: usize, cursor: usize, descending: bool, }, // echo-bearing endpoints carry the refspec SPELLING: knot2 wire bodies echo // the requested ref, so ref=main and ref= are distinct bytes. Tree { repo: RepoAlias, refspec: RefAlias, path: Option, }, Blob { repo: RepoAlias, refspec: RefAlias, path: String, raw: bool, }, Languages { repo: RepoAlias, refspec: RefAlias, }, Diff { repo: RepoAlias, refspec: RefAlias, }, Compare { repo: RepoAlias, rev1: RefAlias, rev2: RefAlias, }, Log { repo: RepoAlias, refspec: RefAlias, limit: usize, cursor: usize, }, Archive { repo: RepoAlias, refspec: RefAlias, kind: ArchiveKind, }, Blame { repo: RepoAlias, refspec: RefAlias, path: String, }, /// a known NSID or shape the static knot intentionally does not serve. Unsupported { nsid: String, }, } impl RequestKey { /// cache lifetime class. drives both Cache-Control headers and whether the /// cross-generation response cache may satisfy this key. pub fn mutability(&self) -> Mutability { let is_immutable = match self { Self::Tree { refspec, .. } | Self::Blob { refspec, .. } | Self::Languages { refspec, .. } | Self::Diff { refspec, .. } | Self::Blame { refspec, .. } | Self::Log { refspec, .. } | Self::Archive { refspec, .. } => matches!(refspec, RefAlias::Commit(_)), Self::Compare { rev1, rev2, .. } => { matches!((rev1, rev2), (RefAlias::Commit(_), RefAlias::Commit(_))) } _ => false, }; if is_immutable { Mutability::Immutable } else { Mutability::Mutable } } /// Cache-Control for this route, per the static knot's serving policy. pub fn cache_control(&self) -> &'static str { match self.mutability() { Mutability::Immutable => "public, max-age=31536000, immutable", Mutability::Mutable => match self { // identity/config documents change rarely; still short TTL. Self::DidDocument => "public, max-age=300", _ => "public, max-age=60, stale-while-revalidate=300", }, } } /// alias-insensitive identity: what dedups into one CAS object. pub fn cas_key(&self) -> String { #![allow(clippy::match_same_arms)] match self { Self::DidDocument => "did.json".into(), Self::Version => "version".into(), Self::Owner => "owner".into(), Self::Health => "health".into(), Self::DescribeRepo { repo } => format!("describe-repo:{repo:?}"), Self::GetDefaultBranch { repo } => format!("default-branch:{repo:?}"), Self::Branch { repo, name } => format!("branch:{repo:?}:{name}"), Self::Branches { repo, limit, cursor, } => { format!("branches:{repo:?}:{limit}:{cursor}") } Self::Tag { repo, name } => format!("tag:{repo:?}:{name}"), Self::Tags { repo, limit, cursor, } => format!("tags:{repo:?}:{limit}:{cursor}"), Self::ListRefs { repo, limit, cursor, } => { format!("list-refs:{repo:?}:{limit}:{cursor}") } Self::ListRepos { limit, cursor, descending, } => { format!("list-repos:{limit}:{cursor}:{descending}") } Self::Tree { repo, refspec, path, } => { format!("tree:{repo:?}:{refspec:?}:{path:?}") } Self::Blob { repo, refspec, path, raw, } => { format!("blob:{repo:?}:{refspec:?}:{path}:{raw}") } Self::Languages { repo, refspec } => format!("languages:{repo:?}:{refspec:?}"), Self::Diff { repo, refspec } => format!("diff:{repo:?}:{refspec:?}"), Self::Compare { repo, rev1, rev2 } => format!("compare:{repo:?}:{rev1:?}:{rev2:?}"), Self::Log { repo, refspec, limit, cursor, } => { format!("log:{repo:?}:{refspec:?}:{limit}:{cursor}") } Self::Archive { repo, refspec, kind, } => { format!("archive:{repo:?}:{refspec:?}:{kind:?}") } Self::Blame { repo, refspec, path, } => { format!("blame:{repo:?}:{refspec:?}:{path}") } Self::Unsupported { nsid } => format!("unsupported:{nsid}"), } } /// wire spelling: repo alias param + endpoint + query, as Tangled clients send them, /// with the limit pair optionally replaced by a raw wire spelling (e.g. "500", or "" for /// the default). used verbatim as the nginx map key fragment. pub fn route_key_spelled(&self, nsid: &str, limit_override: Option<&str>) -> String { let mut pairs = self.query_pairs(); if let (Some(raw), Some(slot)) = ( limit_override, pairs.iter_mut().find(|pair| pair.starts_with("limit=")), ) { *slot = format!("limit={raw}"); } format!("{nsid}?{}", pairs.join("&")) } pub fn query_pairs(&self) -> Vec { match self { Self::DidDocument | Self::Version | Self::Owner | Self::Health => vec![], // NOTE: describeRepo's wire param is repoDid (not repo) — this is // what Tangled's generated Go client and repoverify send. Self::DescribeRepo { repo } => vec![format!("repoDid={}", repo.to_param())], Self::GetDefaultBranch { repo } => vec![format!("repo={}", repo.to_param())], Self::Branch { repo, name } => { vec![format!("repo={}", repo.to_param()), format!("name={name}")] } Self::Branches { repo, limit, cursor, } => vec![ format!("repo={}", repo.to_param()), format!("limit={limit}"), format!("cursor={cursor}"), ], Self::Tag { repo, name } => { vec![format!("repo={}", repo.to_param()), format!("tag={name}")] } Self::Tags { repo, limit, cursor, } => vec![ format!("repo={}", repo.to_param()), format!("limit={limit}"), format!("cursor={cursor}"), ], Self::ListRefs { repo, limit, cursor, } => vec![ format!("repo={}", repo.to_param()), format!("limit={limit}"), format!("cursor={cursor}"), ], Self::ListRepos { limit, cursor, descending, } => vec![ format!("limit={limit}"), format!("cursor={cursor}"), format!("order={}", if *descending { "desc" } else { "asc" }), ], Self::Tree { repo, refspec, path, } => { let mut pairs = vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), ]; if let Some(path) = path { pairs.push(format!("path={path}")); } pairs } Self::Blob { repo, refspec, path, raw, } => { let mut pairs = vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), format!("path={path}"), ]; if *raw { pairs.push("raw=true".into()); } pairs } Self::Languages { repo, refspec } => { vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), ] } Self::Diff { repo, refspec } => { vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), ] } Self::Compare { repo, rev1, rev2 } => vec![ format!("repo={}", repo.to_param()), format!("rev1={}", rev1.to_param()), format!("rev2={}", rev2.to_param()), ], Self::Log { repo, refspec, limit, cursor, } => { vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), format!("limit={limit}"), format!("cursor={cursor}"), ] } Self::Archive { repo, refspec, kind, } => { let mut pairs = vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), ]; match kind { ArchiveKind::TarGz => pairs.push("format=tar.gz".into()), ArchiveKind::Zip => pairs.push("format=zip".into()), } pairs } Self::Blame { repo, refspec, path, } => vec![ format!("repo={}", repo.to_param()), format!("ref={}", refspec.to_param()), format!("path={path}"), ], Self::Unsupported { nsid } => vec![nsid.clone()], } } }