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
207impl Taste {
208 pub fn uri(&self) -> &str {
209 &self.capsule.uri
210 }
211
212 pub fn target_uri(&self) -> &str {
213 &self.capsule.core.target_uri
214 }
215
216 pub fn author_domain(&self) -> &str {
217 &self.capsule.core.domain
218 }
219
220 pub fn timestamp_ms(&self) -> u64 {
221 self.capsule.core.tasted_at_epoch_ms
222 }
223
224 pub fn embedded_core_key(&self) -> Option<&str> {
225 let key = self.capsule.core.key.as_str();
226 (!key.is_empty()).then_some(key)
227 }
228
229 pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
230 crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
231 }
232
233 pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
234 crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
235 }
236
237 pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
238 self.verify_core_signature(author_key)?;
239 self.verify_capsule_signature(host_key)
240 }
241
242 pub fn verify_self_hosted_signatures(&self, key: &str) -> Result<()> {
244 self.verify_signatures(key, key)
245 }
246
247 pub fn computed_uri_hash(&self) -> Result<String> {
248 crate::crypto::hash::compute_signed_core_hash(
249 &self.capsule.core,
250 &self.capsule.core_signature,
251 )
252 }
253
254 pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
255 let actual_hash = self.computed_uri_hash()?;
256 super::verify_expected_uri_hash(&actual_hash, expected_hash)
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280pub struct TasteVerdictRecord {
281 pub verdict: TasteVerdict,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub notes: Option<String>,
284 pub tasted_at_epoch_ms: u64,
285}
286
287impl TasteVerdictRecord {
288 pub fn new(verdict: TasteVerdict, notes: Option<&str>) -> Self {
290 let now_ms = std::time::SystemTime::now()
291 .duration_since(std::time::UNIX_EPOCH)
292 .unwrap_or_default()
293 .as_millis() as u64;
294 Self {
295 verdict,
296 notes: notes.map(|s| s.to_string()),
297 tasted_at_epoch_ms: now_ms,
298 }
299 }
300
301 pub fn with_timestamp(verdict: TasteVerdict, notes: Option<&str>, epoch_ms: u64) -> Self {
303 Self {
304 verdict,
305 notes: notes.map(|s| s.to_string()),
306 tasted_at_epoch_ms: epoch_ms,
307 }
308 }
309
310 pub fn allows_use(&self) -> bool {
312 self.verdict.allows_use()
313 }
314
315 pub fn gate_action_for(&self, operation: GateOperation) -> GateAction {
317 TasteVerdict::gate_action_for(operation, Some(self.verdict))
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 #![allow(clippy::expect_used, clippy::unwrap_used)]
324
325 use super::*;
326
327 #[test]
328 fn test_base_gate_action() {
329 assert_eq!(TasteVerdict::base_gate_action(None), GateAction::Block);
330 assert_eq!(
331 TasteVerdict::base_gate_action(Some(TasteVerdict::Toxic)),
332 GateAction::Block
333 );
334 assert_eq!(
335 TasteVerdict::base_gate_action(Some(TasteVerdict::Rotten)),
336 GateAction::Warn
337 );
338 assert_eq!(
339 TasteVerdict::base_gate_action(Some(TasteVerdict::Safe)),
340 GateAction::Proceed
341 );
342 }
343
344 #[test]
345 fn test_gate_action_for_operation() {
346 assert_eq!(
347 TasteVerdict::gate_action_for(GateOperation::Spawn, Some(TasteVerdict::Rotten)),
348 GateAction::Warn
349 );
350 assert_eq!(
351 TasteVerdict::gate_action_for(GateOperation::Taste, Some(TasteVerdict::Toxic)),
352 GateAction::Proceed
353 );
354 assert_eq!(
355 TasteVerdict::gate_action_for(GateOperation::Sense, None),
356 GateAction::Proceed
357 );
358 }
359
360 #[test]
361 fn test_gate_action_sandbox_skips_untasted() {
362 assert_eq!(
363 TasteVerdict::gate_action_for_env(GateOperation::Spawn, None, true),
364 GateAction::Proceed
365 );
366 }
367
368 #[test]
369 fn test_gate_action_sandbox_skips_rotten() {
370 assert_eq!(
371 TasteVerdict::gate_action_for_env(
372 GateOperation::Bond,
373 Some(TasteVerdict::Rotten),
374 true
375 ),
376 GateAction::Proceed
377 );
378 }
379
380 #[test]
381 fn test_gate_action_sandbox_still_blocks_toxic() {
382 assert_eq!(
383 TasteVerdict::gate_action_for_env(
384 GateOperation::Spawn,
385 Some(TasteVerdict::Toxic),
386 true
387 ),
388 GateAction::Block
389 );
390 }
391}