Skip to main content

substrate/model/
taste.rs

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    /// Gate action with optional sandbox override.
74    /// In sandbox mode, untasted and rotten spores proceed (toxic still blocks).
75    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/// Full Taste manifest (content-addressed)
141#[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/// Taste capsule containing uri, core, and core_signature
150#[derive(Serialize, Deserialize, Debug, Clone)]
151pub struct TasteCapsule {
152    pub uri: String,
153    pub core: TasteCore,
154    pub core_signature: String,
155}
156
157/// Core taste data (part of hash)
158#[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}