| 1 | //! Scope a verified payload to the running binary: per-item `applies_to`, |
| 2 | //! announcement windows, and the provider `base_url` allowlist all live here so |
| 3 | //! consumers see one already-filtered view. |
| 4 | |
| 5 | use std::collections::BTreeMap; |
| 6 | |
| 7 | use super::types::{ |
| 8 | Announcement, MAX_ANNOUNCEMENT_CHARS, ModelFact, ModelOp, ProviderDefaultFact, ReleaseFact, |
| 9 | }; |
| 10 | use super::verify::{VerifiedFacts, item_applies, parse_rfc3339_utc}; |
| 11 | use crate::provider_kind::ProviderKind; |
| 12 | |
| 13 | /// A verified payload filtered to what applies to this binary right now. |
| 14 | #[derive(Debug, Clone, PartialEq, Default)] |
| 15 | pub struct ScopedFacts { |
| 16 | pub channel: String, |
| 17 | pub facts_version: u64, |
| 18 | pub key_id: String, |
| 19 | pub sha256: String, |
| 20 | pub published_at: String, |
| 21 | pub stale: bool, |
| 22 | /// Signed expiry plus the verification grace window; never extended by 304. |
| 23 | pub valid_until: Option<u64>, |
| 24 | pub models: Vec<ModelFact>, |
| 25 | pub provider_defaults: BTreeMap<String, ProviderDefaultFact>, |
| 26 | pub release: Option<ReleaseFact>, |
| 27 | pub announcements: Vec<Announcement>, |
| 28 | /// Human-readable receipts for every item that was dropped and why. |
| 29 | pub dropped: Vec<String>, |
| 30 | } |
| 31 | |
| 32 | impl ScopedFacts { |
| 33 | #[must_use] |
| 34 | pub fn is_current_at(&self, now: u64) -> bool { |
| 35 | !self.stale && self.valid_until.is_none_or(|expires| now <= expires) |
| 36 | } |
| 37 | |
| 38 | /// Count of applied patch/default/announcement items, for `/status`. |
| 39 | #[must_use] |
| 40 | pub fn item_counts(&self) -> (usize, usize, usize) { |
| 41 | ( |
| 42 | self.models.len(), |
| 43 | self.provider_defaults.len(), |
| 44 | self.announcements.len(), |
| 45 | ) |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | fn cloud_provider(provider: &str) -> bool { |
| 50 | // Catalog aliases collapse wire variants; signed route facts retain the |
| 51 | // exact canonical config identity and its distinct endpoint contract. |
| 52 | ProviderKind::parse_config_identity(provider).is_some_and(|kind| { |
| 53 | kind.as_str() == provider |
| 54 | && !matches!( |
| 55 | kind, |
| 56 | ProviderKind::Custom |
| 57 | | ProviderKind::Ollama |
| 58 | | ProviderKind::Sglang |
| 59 | | ProviderKind::Vllm |
| 60 | | ProviderKind::OpenaiCodex |
| 61 | | ProviderKind::Antigravity |
| 62 | ) |
| 63 | }) |
| 64 | } |
| 65 | |
| 66 | fn model_id_valid(id: &str) -> bool { |
| 67 | !id.is_empty() |
| 68 | && id.len() <= 256 |
| 69 | && id.bytes().all(|byte| { |
| 70 | byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-') |
| 71 | }) |
| 72 | } |
| 73 | |
| 74 | /// Accept only an existing official HTTPS endpoint contract. Signing a fact |
| 75 | /// cannot grant a new host, neighboring API path, userinfo or credential scope. |
| 76 | #[must_use] |
| 77 | pub fn base_url_allowed(provider: &str, candidate: &str) -> bool { |
| 78 | if !cloud_provider(provider) { |
| 79 | return false; |
| 80 | } |
| 81 | let Some(kind) = ProviderKind::parse_config_identity(provider) else { |
| 82 | return false; |
| 83 | }; |
| 84 | let normalized = candidate.trim().trim_end_matches('/').to_ascii_lowercase(); |
| 85 | if !normalized.starts_with("https://") |
| 86 | || normalized |
| 87 | .chars() |
| 88 | .any(|ch| ch.is_whitespace() || ch.is_control() || matches!(ch, '?' | '#' | '@' | '\\')) |
| 89 | { |
| 90 | return false; |
| 91 | } |
| 92 | match kind { |
| 93 | ProviderKind::Codewhale => normalized == crate::DEFAULT_CODEWHALE_BASE_URL, |
| 94 | ProviderKind::Moonshot => matches!( |
| 95 | normalized.as_str(), |
| 96 | crate::DEFAULT_MOONSHOT_BASE_URL |
| 97 | | crate::MOONSHOT_CN_BASE_URL |
| 98 | | "https://api.kimi.com/coding" |
| 99 | | "https://api.kimi.com/coding/v1" |
| 100 | ), |
| 101 | _ => crate::provider_base_url_is_official(kind, &normalized), |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// Build the scoped view for `current` at `now_unix`. |
| 106 | #[must_use] |
| 107 | pub fn scoped_view( |
| 108 | verified: &VerifiedFacts, |
| 109 | current: &semver::Version, |
| 110 | now_unix: u64, |
| 111 | ) -> ScopedFacts { |
| 112 | let facts = &verified.facts; |
| 113 | let mut out = ScopedFacts { |
| 114 | channel: facts.channel.clone(), |
| 115 | facts_version: facts.facts_version, |
| 116 | key_id: verified.key_id.clone(), |
| 117 | sha256: verified.sha256.clone(), |
| 118 | published_at: facts.published_at.clone(), |
| 119 | stale: verified.stale, |
| 120 | valid_until: facts |
| 121 | .not_after |
| 122 | .as_deref() |
| 123 | .and_then(parse_rfc3339_utc) |
| 124 | .map(|expires| expires.saturating_add(super::verify::NOT_AFTER_GRACE_SECS)), |
| 125 | ..ScopedFacts::default() |
| 126 | }; |
| 127 | |
| 128 | for model in &facts.models { |
| 129 | if !cloud_provider(&model.provider) |
| 130 | || !model_id_valid(&model.id) |
| 131 | || model.context_window == Some(0) |
| 132 | || model.max_output == Some(0) |
| 133 | { |
| 134 | out.dropped |
| 135 | .push("model patch has unsupported provider, identity, or limits".into()); |
| 136 | continue; |
| 137 | } |
| 138 | if !item_applies(model.applies_to.as_deref(), current) { |
| 139 | out.dropped.push(format!( |
| 140 | "model {}/{}: applies_to {:?} does not match", |
| 141 | model.provider, |
| 142 | model.id, |
| 143 | model.applies_to.as_deref().unwrap_or("") |
| 144 | )); |
| 145 | continue; |
| 146 | } |
| 147 | let mut kept = model.clone(); |
| 148 | // An unlisted assertion overrides a provider roster's own omission, so |
| 149 | // it must be an `Upsert` and it must expire: without `not_after` the |
| 150 | // claim would outlive any ability to withdraw it by publishing. The |
| 151 | // rest of the patch still applies; only the assertion is discarded. |
| 152 | if kept.allow_unlisted && (kept.op != ModelOp::Upsert || out.valid_until.is_none()) { |
| 153 | kept.allow_unlisted = false; |
| 154 | out.dropped.push(format!( |
| 155 | "model {}/{}: allow_unlisted needs an upsert in a payload with not_after", |
| 156 | model.provider, model.id |
| 157 | )); |
| 158 | } |
| 159 | out.models.push(kept); |
| 160 | } |
| 161 | |
| 162 | for (provider, fact) in &facts.provider_defaults { |
| 163 | if !cloud_provider(provider) { |
| 164 | out.dropped.push("unsupported provider default".into()); |
| 165 | continue; |
| 166 | } |
| 167 | if !item_applies(fact.applies_to.as_deref(), current) { |
| 168 | out.dropped.push(format!( |
| 169 | "provider_defaults.{provider}: applies_to {:?} does not match", |
| 170 | fact.applies_to.as_deref().unwrap_or("") |
| 171 | )); |
| 172 | continue; |
| 173 | } |
| 174 | let mut kept = ProviderDefaultFact { |
| 175 | default_model: fact |
| 176 | .default_model |
| 177 | .as_deref() |
| 178 | .map(str::trim) |
| 179 | .filter(|m| { |
| 180 | model_id_valid(m) |
| 181 | && !m.eq_ignore_ascii_case("auto") |
| 182 | && !m.eq_ignore_ascii_case("unknown") |
| 183 | }) |
| 184 | .map(str::to_string), |
| 185 | base_url: None, |
| 186 | applies_to: None, |
| 187 | }; |
| 188 | if let Some(url) = fact.base_url.as_deref().map(str::trim) { |
| 189 | if base_url_allowed(provider, url) { |
| 190 | kept.base_url = Some(url.to_string()); |
| 191 | } else { |
| 192 | out.dropped.push(format!( |
| 193 | "provider_defaults.{provider}.base_url: outside the official HTTPS endpoint contract" |
| 194 | )); |
| 195 | } |
| 196 | } |
| 197 | if kept.default_model.is_none() && kept.base_url.is_none() { |
| 198 | continue; |
| 199 | } |
| 200 | out.provider_defaults.insert(provider.clone(), kept); |
| 201 | } |
| 202 | |
| 203 | if let Some(release) = &facts.release { |
| 204 | if item_applies(release.applies_to.as_deref(), current) { |
| 205 | out.release = Some(release.clone()); |
| 206 | } else { |
| 207 | out.dropped.push(format!( |
| 208 | "release: applies_to {:?} does not match", |
| 209 | release.applies_to.as_deref().unwrap_or("") |
| 210 | )); |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | for announcement in &facts.announcements { |
| 215 | let id = announcement.id.trim(); |
| 216 | if id.is_empty() || announcement.text.trim().is_empty() { |
| 217 | out.dropped.push("announcement without id/text".into()); |
| 218 | continue; |
| 219 | } |
| 220 | if announcement.text.chars().count() > MAX_ANNOUNCEMENT_CHARS { |
| 221 | out.dropped |
| 222 | .push(format!("announcement {id}: text too long")); |
| 223 | continue; |
| 224 | } |
| 225 | if !item_applies(announcement.applies_to.as_deref(), current) { |
| 226 | out.dropped |
| 227 | .push(format!("announcement {id}: applies_to does not match")); |
| 228 | continue; |
| 229 | } |
| 230 | if [ |
| 231 | announcement.starts_at.as_deref(), |
| 232 | announcement.expires_at.as_deref(), |
| 233 | ] |
| 234 | .into_iter() |
| 235 | .flatten() |
| 236 | .any(|value| parse_rfc3339_utc(value).is_none()) |
| 237 | { |
| 238 | out.dropped |
| 239 | .push(format!("announcement {id}: invalid time window")); |
| 240 | continue; |
| 241 | } |
| 242 | if let Some(starts) = announcement |
| 243 | .starts_at |
| 244 | .as_deref() |
| 245 | .and_then(parse_rfc3339_utc) |
| 246 | && now_unix < starts |
| 247 | { |
| 248 | out.dropped.push(format!("announcement {id}: not started")); |
| 249 | continue; |
| 250 | } |
| 251 | if let Some(expires) = announcement |
| 252 | .expires_at |
| 253 | .as_deref() |
| 254 | .and_then(parse_rfc3339_utc) |
| 255 | && now_unix >= expires |
| 256 | { |
| 257 | out.dropped.push(format!("announcement {id}: expired")); |
| 258 | continue; |
| 259 | } |
| 260 | out.announcements.push(announcement.clone()); |
| 261 | } |
| 262 | |
| 263 | out |
| 264 | } |
| 265 |