1use std::fmt::{Display, Formatter};
2use std::str::FromStr;
3
4use anyhow::{anyhow, Result};
5use serde::{Deserialize, Serialize};
6
7pub const TASTE_SCHEMA: &str = "https://cmn.dev/schemas/v1/taste.json";
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum GateAction {
12 Block,
13 Warn,
14 Proceed,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum GateOperation {
20 Spawn,
21 Grow,
22 Absorb,
23 Bond,
24 Replicate,
25 Taste,
26 Sense,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum TasteVerdict {
31 Sweet,
32 Fresh,
33 Safe,
34 Rotten,
35 Toxic,
36}
37
38impl TasteVerdict {
39 pub const ALL: [Self; 5] = [
40 Self::Sweet,
41 Self::Fresh,
42 Self::Safe,
43 Self::Rotten,
44 Self::Toxic,
45 ];
46
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Self::Sweet => "sweet",
50 Self::Fresh => "fresh",
51 Self::Safe => "safe",
52 Self::Rotten => "rotten",
53 Self::Toxic => "toxic",
54 }
55 }
56
57 pub fn allows_use(self) -> bool {
58 !matches!(self, Self::Toxic)
59 }
60
61 pub fn base_gate_action(verdict: Option<Self>) -> GateAction {
62 match verdict {
63 None | Some(Self::Toxic) => GateAction::Block,
64 Some(Self::Rotten) => GateAction::Warn,
65 Some(Self::Safe | Self::Fresh | Self::Sweet) => GateAction::Proceed,
66 }
67 }
68
69 pub fn gate_action_for(operation: GateOperation, verdict: Option<Self>) -> GateAction {
70 Self::gate_action_for_env(operation, verdict, false)
71 }
72
73 pub fn gate_action_for_env(
76 operation: GateOperation,
77 verdict: Option<Self>,
78 sandboxed: bool,
79 ) -> GateAction {
80 match operation {
81 GateOperation::Taste | GateOperation::Sense => GateAction::Proceed,
82 GateOperation::Spawn
83 | GateOperation::Grow
84 | GateOperation::Absorb
85 | GateOperation::Bond
86 | GateOperation::Replicate => {
87 if sandboxed && verdict != Some(Self::Toxic) {
88 GateAction::Proceed
89 } else {
90 Self::base_gate_action(verdict)
91 }
92 }
93 }
94 }
95}
96
97impl Display for TasteVerdict {
98 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
99 f.write_str(self.as_str())
100 }
101}
102
103impl FromStr for TasteVerdict {
104 type Err = anyhow::Error;
105
106 fn from_str(value: &str) -> anyhow::Result<Self> {
107 match value {
108 "sweet" => Ok(Self::Sweet),
109 "fresh" => Ok(Self::Fresh),
110 "safe" => Ok(Self::Safe),
111 "rotten" => Ok(Self::Rotten),
112 "toxic" => Ok(Self::Toxic),
113 _ => Err(anyhow!(
114 "Invalid verdict '{}'. Must be one of: sweet, fresh, safe, rotten, toxic",
115 value
116 )),
117 }
118 }
119}
120
121impl Serialize for TasteVerdict {
122 fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
123 where
124 S: serde::Serializer,
125 {
126 serializer.serialize_str(self.as_str())
127 }
128}
129
130impl<'de> Deserialize<'de> for TasteVerdict {
131 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
132 where
133 D: serde::Deserializer<'de>,
134 {
135 let value = String::deserialize(deserializer)?;
136 Self::from_str(&value).map_err(serde::de::Error::custom)
137 }
138}
139
140#[derive(Serialize, Deserialize, Debug, Clone)]
142pub struct Taste {
143 #[serde(rename = "$schema")]
144 pub schema: String,
145 pub capsule: TasteCapsule,
146 pub capsule_signature: String,
147}
148
149#[derive(Serialize, Deserialize, Debug, Clone)]
151pub struct TasteCapsule {
152 pub uri: String,
153 pub core: TasteCore,
154 pub core_signature: String,
155}
156
157#[derive(Serialize, Deserialize, Debug, Clone)]
159pub struct TasteCore {
160 pub target_uri: String,
161 pub domain: String,
162 pub key: String,
163 pub verdict: TasteVerdict,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
165 pub notes: Vec<String>,
166 pub tasted_at_epoch_ms: u64,
167}
168
169impl Taste {
170 pub fn uri(&self) -> &str {
171 &self.capsule.uri
172 }
173
174 pub fn target_uri(&self) -> &str {
175 &self.capsule.core.target_uri
176 }
177
178 pub fn author_domain(&self) -> &str {
179 &self.capsule.core.domain
180 }
181
182 pub fn timestamp_ms(&self) -> u64 {
183 self.capsule.core.tasted_at_epoch_ms
184 }
185
186 pub fn embedded_core_key(&self) -> Option<&str> {
187 let key = self.capsule.core.key.as_str();
188 (!key.is_empty()).then_some(key)
189 }
190
191 pub fn verify_core_signature(&self, author_key: &str) -> Result<()> {
192 crate::verify_json_signature(&self.capsule.core, &self.capsule.core_signature, author_key)
193 }
194
195 pub fn verify_capsule_signature(&self, host_key: &str) -> Result<()> {
196 crate::verify_json_signature(&self.capsule, &self.capsule_signature, host_key)
197 }
198
199 pub fn verify_signatures(&self, host_key: &str, author_key: &str) -> Result<()> {
200 self.verify_core_signature(author_key)?;
201 self.verify_capsule_signature(host_key)
202 }
203
204 pub fn computed_uri_hash(&self) -> Result<String> {
205 crate::crypto::hash::compute_signed_core_hash(
206 &self.capsule.core,
207 &self.capsule.core_signature,
208 )
209 }
210
211 pub fn verify_uri_hash(&self, expected_hash: &str) -> Result<()> {
212 let actual_hash = self.computed_uri_hash()?;
213 super::verify_expected_uri_hash(&actual_hash, expected_hash)
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 #![allow(clippy::expect_used, clippy::unwrap_used)]
220
221 use super::*;
222
223 #[test]
224 fn test_base_gate_action() {
225 assert_eq!(TasteVerdict::base_gate_action(None), GateAction::Block);
226 assert_eq!(
227 TasteVerdict::base_gate_action(Some(TasteVerdict::Toxic)),
228 GateAction::Block
229 );
230 assert_eq!(
231 TasteVerdict::base_gate_action(Some(TasteVerdict::Rotten)),
232 GateAction::Warn
233 );
234 assert_eq!(
235 TasteVerdict::base_gate_action(Some(TasteVerdict::Safe)),
236 GateAction::Proceed
237 );
238 }
239
240 #[test]
241 fn test_gate_action_for_operation() {
242 assert_eq!(
243 TasteVerdict::gate_action_for(GateOperation::Spawn, Some(TasteVerdict::Rotten)),
244 GateAction::Warn
245 );
246 assert_eq!(
247 TasteVerdict::gate_action_for(GateOperation::Taste, Some(TasteVerdict::Toxic)),
248 GateAction::Proceed
249 );
250 assert_eq!(
251 TasteVerdict::gate_action_for(GateOperation::Sense, None),
252 GateAction::Proceed
253 );
254 }
255
256 #[test]
257 fn test_gate_action_sandbox_skips_untasted() {
258 assert_eq!(
259 TasteVerdict::gate_action_for_env(GateOperation::Spawn, None, true),
260 GateAction::Proceed
261 );
262 }
263
264 #[test]
265 fn test_gate_action_sandbox_skips_rotten() {
266 assert_eq!(
267 TasteVerdict::gate_action_for_env(
268 GateOperation::Bond,
269 Some(TasteVerdict::Rotten),
270 true
271 ),
272 GateAction::Proceed
273 );
274 }
275
276 #[test]
277 fn test_gate_action_sandbox_still_blocks_toxic() {
278 assert_eq!(
279 TasteVerdict::gate_action_for_env(
280 GateOperation::Spawn,
281 Some(TasteVerdict::Toxic),
282 true
283 ),
284 GateAction::Block
285 );
286 }
287}