1use std::collections::HashMap;
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::{Duration, Instant};
17
18use async_trait::async_trait;
19use tokio::sync::{OnceCell, Semaphore};
20
21use super::client::{CheckoutWire, LicenceClient, LicenceClientError};
22use super::{
23 CheckoutGrant, CheckoutGrantStore, LicenceError, Outcome, SignedGrant, sha256_hex, verify_grant,
24};
25use crate::chain::ChainManager;
26use crate::gate::{GateBackend, GateDecision};
27use crate::mesh_admit::PeerClass;
28use crate::mesh_artifact::ArtifactExchange;
29use crate::mesh_artifact_types::ArtifactKey;
30use crate::mesh_delivery::PeerCtx;
31use crate::workload_pkg::codec::{hex_decode_exact, hex_encode};
32
33pub const GATE_ACTION: &str = "cog.checkout";
35pub const EVENT_KIND_CHECKOUT_GRANTED: &str = "cog.checkout.granted";
37pub const EVENT_KIND_CHECKOUT_REFUSED: &str = "cog.checkout.refused";
39
40#[derive(Debug, Clone, Copy)]
42pub enum CheckoutCaller<'a> {
43 Kernel,
45 Local(&'a str),
49 Peer(&'a PeerCtx),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
57pub enum CheckoutRefusal {
58 #[error("caller is not an admitted node")]
61 NotAdmitted,
62 #[error("governance gate refused: {0}")]
64 GateDenied(String),
65 #[error("bad request: {0}")]
67 BadRequest(String),
68 #[error("licence refused: {0}")]
70 Licence(String),
71 #[error("licence link failed: {0}")]
73 LicenceLink(String),
74 #[error("returned grant did not verify: {0}")]
76 BadGrant(String),
77 #[error("the Seed's clock is ahead of this node's")]
79 SeedClockSkew,
80 #[error("artifact does not match the grant: {0}")]
82 ArtifactMismatch(String),
83 #[error("too many checkout requests from this member")]
85 RateLimited,
86 #[error("the steward is busy")]
88 Busy,
89 #[error("could not store the artifact: {0}")]
91 Storage(String),
92}
93
94impl CheckoutRefusal {
95 pub fn code(&self) -> String {
97 match self {
98 Self::NotAdmitted => "not_admitted".into(),
99 Self::GateDenied(_) => "gate_denied".into(),
100 Self::BadRequest(_) => "bad_request".into(),
101 Self::Licence(c) => c.clone(),
102 Self::LicenceLink(_) => "licence_unreachable".into(),
103 Self::BadGrant(_) => "bad_grant".into(),
104 Self::SeedClockSkew => "seed_clock_skew".into(),
105 Self::ArtifactMismatch(_) => "artifact_mismatch".into(),
106 Self::Storage(_) => "storage_failed".into(),
107 Self::RateLimited => "rate_limited".into(),
108 Self::Busy => "busy".into(),
109 }
110 }
111}
112
113impl From<LicenceClientError> for CheckoutRefusal {
114 fn from(e: LicenceClientError) -> Self {
115 match e {
116 LicenceClientError::Refused { code, .. } => Self::Licence(code),
117 LicenceClientError::Transport(w) => Self::LicenceLink(w),
118 LicenceClientError::BadResponse(w) => Self::BadGrant(w),
119 }
120 }
121}
122
123#[async_trait]
126pub trait GrantFlood: Send + Sync + 'static {
127 async fn flood(&self, grant: &SignedGrant);
130}
131
132#[derive(Debug, Default)]
134pub struct NoFlood;
135
136#[async_trait]
137impl GrantFlood for NoFlood {
138 async fn flood(&self, _: &SignedGrant) {}
139}
140
141pub fn install_grant(
145 store: &CheckoutGrantStore,
146 exchange: &ArtifactExchange,
147 signed: &SignedGrant,
148) -> Result<Outcome, LicenceError> {
149 let out = store.accept_grant(signed)?;
150 let g: CheckoutGrant = serde_json::from_str(&signed.payload)
152 .map_err(|e| LicenceError::Malformed(e.to_string()))?;
153 for v in store.verified_grants() {
154 if v.grant().cog_id == g.cog_id && v.grant().version == g.version {
155 exchange.grant_checkout(&v);
156 }
157 }
158 Ok(out)
159}
160
161#[derive(Debug, Clone)]
165pub struct RelayLimits {
166 pub per_peer: u32,
168 pub window: Duration,
170 pub concurrent: usize,
172 pub max_peers: usize,
174 pub per_local: u32,
176 pub local_total: u32,
178}
179
180impl Default for RelayLimits {
181 fn default() -> Self {
182 Self {
183 per_peer: 5,
184 window: Duration::from_secs(60),
185 concurrent: 8,
186 max_peers: 1024,
187 per_local: 5,
188 local_total: 20,
189 }
190 }
191}
192
193type Key = (String, String, String);
194type Shared = Arc<OnceCell<Result<SignedGrant, CheckoutRefusal>>>;
195
196pub struct CheckoutRelay {
198 store: Arc<CheckoutGrantStore>,
199 exchange: Arc<ArtifactExchange>,
200 client: Arc<dyn LicenceClient>,
201 gate: Arc<dyn GateBackend>,
202 flood: Arc<dyn GrantFlood>,
203 chain: Option<Arc<ChainManager>>,
204 inflight: Mutex<HashMap<Key, Shared>>,
205 limits: RelayLimits,
206 buckets: Mutex<HashMap<String, (Instant, u32)>>,
207 local_total: Mutex<(Instant, u32)>,
208 slots: Semaphore,
209}
210
211impl CheckoutRelay {
212 pub fn new(
214 store: Arc<CheckoutGrantStore>,
215 exchange: Arc<ArtifactExchange>,
216 client: Arc<dyn LicenceClient>,
217 gate: Arc<dyn GateBackend>,
218 flood: Arc<dyn GrantFlood>,
219 chain: Option<Arc<ChainManager>>,
220 ) -> Self {
221 let limits = RelayLimits::default();
222 Self {
223 store,
224 exchange,
225 client,
226 gate,
227 flood,
228 chain,
229 inflight: Mutex::default(),
230 slots: Semaphore::new(limits.concurrent),
231 buckets: Mutex::default(),
232 local_total: Mutex::new((Instant::now(), 0)),
233 limits,
234 }
235 }
236
237 pub fn with_limits(mut self, limits: RelayLimits) -> Self {
239 self.slots = Semaphore::new(limits.concurrent);
240 self.limits = limits;
241 self
242 }
243
244 fn allow_local_total(&self) -> bool {
246 let now = Instant::now();
247 let mut t = self.local_total.lock().unwrap_or_else(|p| p.into_inner());
248 if now.duration_since(t.0) >= self.limits.window {
249 *t = (now, 0);
250 }
251 if t.1 >= self.limits.local_total {
252 return false;
253 }
254 t.1 += 1;
255 true
256 }
257
258 fn allow_peer(&self, peer: &str) -> bool {
260 self.allow_key(peer, self.limits.per_peer)
261 }
262
263 fn allow_key(&self, peer: &str, budget: u32) -> bool {
264 let now = Instant::now();
265 let mut m = self.buckets.lock().unwrap_or_else(|p| p.into_inner());
266 if m.len() >= self.limits.max_peers && !m.contains_key(peer) {
267 let w = self.limits.window;
268 m.retain(|_, (start, _)| now.duration_since(*start) < w);
269 if m.len() >= self.limits.max_peers {
270 return false;
271 }
272 }
273 let e = m.entry(peer.to_owned()).or_insert((now, 0));
274 if now.duration_since(e.0) >= self.limits.window {
275 *e = (now, 0);
276 }
277 if e.1 >= budget {
278 return false;
279 }
280 e.1 += 1;
281 true
282 }
283
284 pub async fn handle(
287 &self,
288 caller: CheckoutCaller<'_>,
289 req: &CheckoutWire,
290 ) -> Result<SignedGrant, CheckoutRefusal> {
291 let who = match caller {
292 CheckoutCaller::Kernel => "kernel".to_owned(),
293 CheckoutCaller::Local(p) => format!("local:{p}"),
294 CheckoutCaller::Peer(p) if p.node_verified && p.class == PeerClass::Node => {
295 p.peer_id.clone()
296 }
297 CheckoutCaller::Peer(_) => {
298 let e = CheckoutRefusal::NotAdmitted;
299 self.chain_refused(req, "unadmitted", &e);
300 return Err(e);
301 }
302 };
303 let allowed = match caller {
306 CheckoutCaller::Kernel => true,
307 CheckoutCaller::Peer(_) => self.allow_peer(&who),
308 CheckoutCaller::Local(_) => self.allow_key(&who, self.limits.per_local) && self.allow_local_total(),
310 };
311 if !allowed {
312 return Err(CheckoutRefusal::RateLimited);
313 }
314 let Ok(_slot) = self.slots.try_acquire() else {
315 return Err(CheckoutRefusal::Busy);
316 };
317 let r = self.checked(&who, req).await;
318 if let Err(e) = &r {
319 self.chain_refused(req, &who, e);
320 }
321 r
322 }
323
324 async fn checked(&self, who: &str, req: &CheckoutWire) -> Result<SignedGrant, CheckoutRefusal> {
325 req.validate().map_err(CheckoutRefusal::BadRequest)?;
326 let ctx = serde_json::json!({
327 "cog_id": req.cog_id, "version": req.version, "arch": req.arch,
328 });
329 match self.gate.check(who, GATE_ACTION, &ctx) {
330 GateDecision::Permit { .. } => {}
331 GateDecision::Defer { reason } | GateDecision::Deny { reason, .. } => {
332 return Err(CheckoutRefusal::GateDenied(reason));
333 }
334 }
335 let key = (req.cog_id.clone(), req.version.clone(), req.arch.clone());
336 let cell: Shared = {
337 let mut m = self.inflight.lock().unwrap_or_else(|p| p.into_inner());
338 m.entry(key.clone()).or_default().clone()
339 };
340 let ran = AtomicBool::new(false);
341 let out = cell
342 .get_or_init(|| async {
343 ran.store(true, Ordering::Relaxed);
344 let r = self.relay(req).await;
345 let mut m = self.inflight.lock().unwrap_or_else(|p| p.into_inner());
348 if m.get(&key).is_some_and(|c| Arc::ptr_eq(c, &cell)) {
349 m.remove(&key);
350 }
351 r
352 })
353 .await;
354 if let Ok(signed) = out
356 && let Ok(g) = serde_json::from_str::<CheckoutGrant>(&signed.payload)
357 {
358 let b3 = g.artifact(&req.arch).map(|a| a.blake3.clone()).unwrap_or_default();
359 self.chain_granted(who, req, &g, &b3, !ran.load(Ordering::Relaxed));
360 }
361 out.clone()
362 }
363
364 async fn relay(&self, req: &CheckoutWire) -> Result<SignedGrant, CheckoutRefusal> {
365 let signed = self.client.checkout(req).await?;
366 let binding = self
367 .store
368 .active_binding()
369 .ok_or_else(|| CheckoutRefusal::BadGrant("no binding".into()))?;
370 let pk = hex_decode_exact::<32>(&binding.grant_pubkey)
371 .ok_or_else(|| CheckoutRefusal::BadGrant("bound grant key".into()))?;
372 let mesh = self
373 .store
374 .local_mesh_id()
375 .get()
376 .ok_or_else(|| CheckoutRefusal::BadGrant("no local mesh id".into()))?;
377 let g = verify_grant(&signed, &pk, &mesh).map_err(|e| CheckoutRefusal::BadGrant(e.to_string()))?;
378 if g.cog_id != req.cog_id || (req.version != "latest" && g.version != req.version) {
379 return Err(CheckoutRefusal::BadGrant("grant is for another cog or version".into()));
380 }
381 let art = g
382 .artifact(&req.arch)
383 .ok_or_else(|| CheckoutRefusal::BadGrant(format!("grant has no {} artifact", req.arch)))?
384 .clone();
385 let hash = hex_decode_exact::<32>(&art.blake3)
386 .ok_or_else(|| CheckoutRefusal::BadGrant("artifact hash".into()))?;
387 match self.exchange.resolve(&ArtifactKey::Content(hash)) {
388 Some(d) => self.check_held_sha256(&d, &art.sha256)?,
391 None => self.fetch_and_seed(&art.blake3, &art.sha256, art.size).await?,
392 }
393 install_grant(&self.store, &self.exchange, &signed).map_err(|e| match e {
394 LicenceError::NotYetValid => CheckoutRefusal::SeedClockSkew,
395 e => CheckoutRefusal::BadGrant(e.to_string()),
396 })?;
397 self.flood.flood(&signed).await;
398 Ok(signed)
399 }
400
401 fn check_held_sha256(
402 &self,
403 d: &crate::mesh_artifact_types::ArtifactDescriptor,
404 sha256: &str,
405 ) -> Result<(), CheckoutRefusal> {
406 use sha2::{Digest, Sha256};
407 let mut h = Sha256::new();
408 self.exchange
409 .read_to(&d.id(), &mut |b| {
410 h.update(b);
411 Ok(())
412 })
413 .map_err(|e| CheckoutRefusal::Storage(e.to_string()))?;
414 if hex_encode(&h.finalize()) != sha256 {
415 return Err(CheckoutRefusal::ArtifactMismatch("sha256 of the held bytes".into()));
416 }
417 Ok(())
418 }
419
420 async fn fetch_and_seed(&self, blake3: &str, sha256: &str, size: u64) -> Result<(), CheckoutRefusal> {
421 let limit = self.exchange.config().max_artifact_bytes;
422 if size > limit {
423 return Err(CheckoutRefusal::ArtifactMismatch(format!("{size} bytes is over the limit")));
424 }
425 let bytes = self.client.artifact(blake3, size).await?;
426 if bytes.len() as u64 != size {
427 return Err(CheckoutRefusal::ArtifactMismatch("size".into()));
428 }
429 if sha256_hex(&bytes) != sha256 {
430 return Err(CheckoutRefusal::ArtifactMismatch("sha256".into()));
431 }
432 if hex_encode(blake3::hash(&bytes).as_bytes()) != blake3 {
433 return Err(CheckoutRefusal::ArtifactMismatch("blake3".into()));
434 }
435 self.exchange.seed_bytes(&bytes).map_err(|e| CheckoutRefusal::Storage(e.to_string()))?;
436 Ok(())
437 }
438
439 fn chain_granted(&self, who: &str, req: &CheckoutWire, g: &CheckoutGrant, blake3: &str, merged: bool) {
440 if let Some(cm) = &self.chain {
441 cm.append(
442 "licence",
443 EVENT_KIND_CHECKOUT_GRANTED,
444 Some(serde_json::json!({
445 "cog_id": g.cog_id, "version": g.version, "arch": req.arch,
446 "grant_id": g.grant_id, "seq": g.seq, "blake3": blake3, "requester": who,
447 "merged": merged,
448 })),
449 );
450 }
451 }
452
453 fn chain_refused(&self, req: &CheckoutWire, who: &str, e: &CheckoutRefusal) {
454 if let Some(cm) = &self.chain {
455 cm.append(
456 "licence",
457 EVENT_KIND_CHECKOUT_REFUSED,
458 Some(serde_json::json!({
459 "cog_id": req.cog_id, "version": req.version, "arch": req.arch,
460 "code": e.code(), "requester": who,
461 })),
462 );
463 }
464 }
465}