1use std::collections::HashMap;
2use std::fmt::{Display, Formatter};
3use std::str::FromStr;
4
5use anyhow::{anyhow, Result};
6use serde::{Deserialize, Serialize};
7
8pub const TASTE_SCHEMA: &str = "https://cmn.dev/schemas/v1/taste.json";
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum GateAction {
13 Block,
14 Warn,
15 Proceed,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum GateOperation {
21 Spawn,
22 Grow,
23 Absorb,
24 Bond,
25 Replicate,
26 Taste,
27 Sense,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum TasteVerdict {
32 Sweet,
33 Fresh,
34 Safe,
35 Rotten,
36 Toxic,
37}
38
39impl TasteVerdict {
40 pub const ALL: [Self; 5] = [
41 Self::Sweet,
42 Self::Fresh,
43 Self::Safe,
44 Self::Rotten,
45 Self::Toxic,
46 ];
47
48 pub fn as_str(self) -> &'static str {
49 match self {
50 Self::Sweet => "sweet",
51 Self::Fresh => "fresh",
52 Self::Safe => "safe",
53 Self::Rotten => "rotten",
54 Self::Toxic => "toxic",
55 }
56 }
57
58 pub fn allows_use(self) -> bool {
59 !matches!(self, Self::Toxic)
60 }
61
62 pub fn base_gate_action(verdict: Option<Self>) -> GateAction {
63 match verdict {
64 None | Some(Self::Toxic) => GateAction::Block,
65 Some(Self::Rotten) => GateAction::Warn,
66 Some(Self::Safe | Self::Fresh | Self::Sweet) => GateAction::Proceed,
67 }
68 }
69
70 pub fn gate_action_for(operation: GateOperation, verdict: Option<Self>) -> GateAction {
71 Self::gate_action_for_env(operation, verdict, false)
72 }
73
74 pub fn gate_action_for_env(
77 operation: GateOperation,
78 verdict: Option<Self>,
79 sandboxed: bool,
80 ) -> GateAction {
81 match operation {
82 GateOperation::Taste | GateOperation::Sense => GateAction::Proceed,
83 GateOperation::Spawn
84 | GateOperation::Grow
85 | GateOperation::Absorb
86 | GateOperation::Bond
87 | GateOperation::Replicate => {
88 if sandboxed && verdict != Some(Self::Toxic) {
89 GateAction::Proceed
90 } else {
91 Self::base_gate_action(verdict)
92 }
93 }
94 }
95 }
96}
97
98impl Display for TasteVerdict {
99 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
100 f.write_str(self.as_str())
101 }
102}
103
104impl FromStr for TasteVerdict {
105 type Err = anyhow::Error;
106
107 fn from_str(value: &str) -> anyhow::Result<Self> {
108 match value {
109 "sweet" => Ok(Self::Sweet),
110 "fresh" => Ok(Self::Fresh),
111 "safe" => Ok(Self::Safe),
112 "rotten" => Ok(Self::Rotten),
113 "toxic" => Ok(Self::Toxic),
114 _ => Err(anyhow!(
115 "Invalid verdict '{}'. Must be one of: sweet, fresh, safe, rotten, toxic",
116 value
117 )),
118 }
119 }
120}
121
122impl Serialize for TasteVerdict {
123 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
124 where
125 S: serde::Serializer,
126 {
127 serializer.serialize_str(self.as_str())
128 }
129}
130
131impl<'de> Deserialize<'de> for TasteVerdict {
132 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
133 where
134 D: serde::Deserializer<'de>,
135 {
136 let value = String::deserialize(deserializer)?;
137 Self::from_str(&value).map_err(serde::de::Error::custom)
138 }
139}
140
141#[derive(Serialize, Deserialize, Debug, Clone)]
143pub struct Taste {
144 #[serde(rename = "$schema")]
145 pub schema: String,
146 pub capsule: TasteCapsule,
147 pub capsule_signature: String,
148}
149
150#[derive(Serialize, Deserialize, Debug, Clone)]
152pub struct TasteCapsule {
153 pub uri: String,
154 pub core: TasteCore,
155 pub core_signature: String,
156}
157
158#[derive(Serialize, Deserialize, Debug, Clone)]
160pub struct TasteCore {
161 pub domain: String,
162 pub key: String,
163 pub target_uri: String,
164 pub verdict: TasteVerdict,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub notes: Vec<String>,
167 pub tasted_at_epoch_ms: u64,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct VerdictSummary {
173 pub counts: HashMap<TasteVerdict, u64>,
174 pub total: u64,
175}
176
177impl VerdictSummary {
178 pub fn from_tastes(tastes: &[Taste]) -> Self {
180 let mut counts = HashMap::with_capacity(TasteVerdict::ALL.len());
181 for v in TasteVerdict::ALL {
182 counts.insert(v, 0);
183 }
184 for taste in tastes {
185 *counts.entry(taste.capsule.core.verdict).or_insert(0) += 1;
186 }
187 Self {
188 counts,
189 total: tastes.len() as u64,
190 }
191 }
192
193 pub fn to_json_map(&self) -> serde_json::Map<String, serde_json::Value> {
195 let mut map = serde_json::Map::new();
196 for v in TasteVerdict::ALL {
197 let count = self.counts.get(&v).copied().unwrap_or(0);
198 map.insert(
199 v.as_str().to_string(),
200 serde_json::Value::Number(count.into()),
201 );
202 }
203 map
204 }
205}
206
207pub fn latest_taste_reports_by_taster(tastes: &[Taste]) -> Vec<&Taste> {
209 let mut latest: HashMap<(&str, &str, &str), &Taste> = HashMap::new();
210 for taste in tastes {
211 let key = (
212 taste.capsule.core.domain.as_str(),
213 taste.capsule.core.key.as_str(),
214 taste.capsule.core.target_uri.as_str(),
215 );
216 match latest.get(&key) {
217 Some(existing)
218 if existing.capsule.core.tasted_at_epoch_ms
219 >= taste.capsule.core.tasted_at_epoch_ms => {}
220 _ => {
221 latest.insert(key, taste);
222 }
223 }
224 }
225 latest.into_values().collect()
226}
227
228impl Taste {
229 pub fn uri(&self) -> &str {
230 &self.capsule.uri
231 }
232
233 pub fn target_uri(&self) -> &str {
234 &self.capsule.core.target_uri
235 }
236
237 pub fn author_domain(&self) -> &str {
238 &self.capsule.core.domain
239 }
240
241 pub fn timestamp_ms(&self) -> u64 {
242 self.capsule.core.tasted_at_epoch_ms
243 }
244
245 pub fn embedded_core_key(&self) -> Option<&str> {
246 let key = self.capsule.core.key.as_str();
247 (!key.is_empty()).then_some(key)
248 }
249
250 pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
251 crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
252 }
253
254 pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
255 crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
256 }
257
258 pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
259 self.verify_core_signature(author_key)?;
260 self.verify_capsule_signature(host_key)
261 }
262
263 pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
265 self.verify_signatures(key, key)
266 }
267
268 pub fn computed_uri_hash(&self) -> Result<String> {
269 crate::crypto::hash::compute_signed_core_hash(
270 &self.capsule.core,
271 &self.capsule.core_signature,
272 )
273 }
274
275 pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
276 let actual_hash = self.computed_uri_hash()?;
277 super::verify_expected_uri_hash(&actual_hash, expected_hash)
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
301pub struct TasteVerdictRecord {
302 pub verdict: TasteVerdict,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub notes: Option<String>,
305 pub tasted_at_epoch_ms: u64,
306}
307
308impl TasteVerdictRecord {
309 pub fn new(verdict: TasteVerdict, notes: Option<&str>) -> Self {
311 let now_ms = std::time::SystemTime::now()
312 .duration_since(std::time::UNIX_EPOCH)
313 .unwrap_or_default()
314 .as_millis() as u64;
315 Self {
316 verdict,
317 notes: notes.map(|s| s.to_string()),
318 tasted_at_epoch_ms: now_ms,
319 }
320 }
321
322 pub fn with_timestamp(verdict: TasteVerdict, notes: Option<&str>, epoch_ms: u64) -> Self {
324 Self {
325 verdict,
326 notes: notes.map(|s| s.to_string()),
327 tasted_at_epoch_ms: epoch_ms,
328 }
329 }
330
331 pub fn allows_use(&self) -> bool {
333 self.verdict.allows_use()
334 }
335
336 pub fn gate_action_for(&self, operation: GateOperation) -> GateAction {
338 TasteVerdict::gate_action_for(operation, Some(self.verdict))
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 #![allow(clippy::expect_used, clippy::unwrap_used)]
345
346 use super::*;
347
348 fn taste(domain: &str, key: &str, target: &str, verdict: TasteVerdict, ts: u64) -> Taste {
349 Taste {
350 schema: TASTE_SCHEMA.to_string(),
351 capsule: TasteCapsule {
352 uri: format!("cmn://{domain}/taste/b3.fake{ts}"),
353 core: TasteCore {
354 domain: domain.to_string(),
355 key: key.to_string(),
356 target_uri: target.to_string(),
357 verdict,
358 notes: vec![],
359 tasted_at_epoch_ms: ts,
360 },
361 core_signature: "ed25519.fake".to_string(),
362 },
363 capsule_signature: "ed25519.fake".to_string(),
364 }
365 }
366
367 #[test]
368 fn test_latest_taste_reports_by_taster_uses_newest_per_target() {
369 let reports = vec![
370 taste(
371 "alice.dev",
372 "ed25519.a",
373 "cmn://target.dev/b3.x",
374 TasteVerdict::Safe,
375 10,
376 ),
377 taste(
378 "alice.dev",
379 "ed25519.a",
380 "cmn://target.dev/b3.x",
381 TasteVerdict::Toxic,
382 20,
383 ),
384 taste(
385 "bob.dev",
386 "ed25519.b",
387 "cmn://target.dev/b3.x",
388 TasteVerdict::Fresh,
389 5,
390 ),
391 ];
392
393 let latest = latest_taste_reports_by_taster(&reports);
394 assert_eq!(latest.len(), 2);
395 assert!(latest.iter().any(|t| {
396 t.author_domain() == "alice.dev" && t.capsule.core.verdict == TasteVerdict::Toxic
397 }));
398 assert!(latest.iter().any(|t| {
399 t.author_domain() == "bob.dev" && t.capsule.core.verdict == TasteVerdict::Fresh
400 }));
401 }
402
403 #[test]
404 fn test_base_gate_action() {
405 assert_eq!(TasteVerdict::base_gate_action(None), GateAction::Block);
406 assert_eq!(
407 TasteVerdict::base_gate_action(Some(TasteVerdict::Toxic)),
408 GateAction::Block
409 );
410 assert_eq!(
411 TasteVerdict::base_gate_action(Some(TasteVerdict::Rotten)),
412 GateAction::Warn
413 );
414 assert_eq!(
415 TasteVerdict::base_gate_action(Some(TasteVerdict::Safe)),
416 GateAction::Proceed
417 );
418 }
419
420 #[test]
421 fn test_gate_action_for_operation() {
422 assert_eq!(
423 TasteVerdict::gate_action_for(GateOperation::Spawn, Some(TasteVerdict::Rotten)),
424 GateAction::Warn
425 );
426 assert_eq!(
427 TasteVerdict::gate_action_for(GateOperation::Taste, Some(TasteVerdict::Toxic)),
428 GateAction::Proceed
429 );
430 assert_eq!(
431 TasteVerdict::gate_action_for(GateOperation::Sense, None),
432 GateAction::Proceed
433 );
434 }
435
436 #[test]
437 fn test_gate_action_sandbox_skips_untasted() {
438 assert_eq!(
439 TasteVerdict::gate_action_for_env(GateOperation::Spawn, None, true),
440 GateAction::Proceed
441 );
442 }
443
444 #[test]
445 fn test_gate_action_sandbox_skips_rotten() {
446 assert_eq!(
447 TasteVerdict::gate_action_for_env(
448 GateOperation::Bond,
449 Some(TasteVerdict::Rotten),
450 true
451 ),
452 GateAction::Proceed
453 );
454 }
455
456 #[test]
457 fn test_gate_action_sandbox_still_blocks_toxic() {
458 assert_eq!(
459 TasteVerdict::gate_action_for_env(
460 GateOperation::Spawn,
461 Some(TasteVerdict::Toxic),
462 true
463 ),
464 GateAction::Block
465 );
466 }
467}