Skip to main content

rustev_contract/
scope.rs

1//! Replay isolation scopes (spec 004, 3.1.3 to 3.1.5).
2//!
3//! A scope is a host-configured isolation key for retained evidence, never
4//! an authority grant. Its two modes are mutually exclusive; a serialized
5//! scope always states its mode and every field of that mode, and a field of
6//! the other mode is refused. Principal scope is the default: a missing
7//! principal handle is an error, never a downgrade to tenant-only.
8
9use std::fmt;
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer};
12
13/// The most bytes of one scope handle.
14pub const MAX_HANDLE_BYTES: usize = 256;
15
16/// Why a string is not a scope handle.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct HandleError {
19    pub len: usize,
20}
21
22impl fmt::Display for HandleError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(
25            f,
26            "a scope handle is 1 to {MAX_HANDLE_BYTES} bytes, not {}",
27            self.len
28        )
29    }
30}
31
32impl std::error::Error for HandleError {}
33
34/// An opaque, non-secret, non-empty host handle of at most 256 bytes: a
35/// tenant, a context revision or a principal scope. Rustev compares
36/// handles and nothing else; a raw credential never belongs in one.
37#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub struct Handle(String);
39
40impl Handle {
41    pub fn new(s: impl Into<String>) -> Result<Self, HandleError> {
42        let s = s.into();
43        if s.is_empty() || s.len() > MAX_HANDLE_BYTES {
44            return Err(HandleError { len: s.len() });
45        }
46        Ok(Self(s))
47    }
48
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54impl Serialize for Handle {
55    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
56        s.serialize_str(&self.0)
57    }
58}
59
60impl schemars::JsonSchema for Handle {
61    fn schema_name() -> std::borrow::Cow<'static, str> {
62        "Handle".into()
63    }
64
65    /// 1 to `MAX_HANDLE_BYTES` bytes; a schema bounds characters, so the
66    /// byte bound is the parser's.
67    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
68        schemars::json_schema!({
69            "type": "string",
70            "minLength": 1,
71            "maxLength": MAX_HANDLE_BYTES
72        })
73    }
74}
75
76impl<'de> Deserialize<'de> for Handle {
77    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
78        Handle::new(String::deserialize(d)?).map_err(serde::de::Error::custom)
79    }
80}
81
82/// The isolation scope of a capture, a bundle and every request identity.
83#[derive(
84    Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema,
85)]
86#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
87pub enum Scope {
88    /// Reuse across principals within one tenant and context revision: an
89    /// explicit host assertion that the computation is principal-independent
90    /// and that the host authorizes every reader (spec 004, 3.1.4).
91    TenantOnly {
92        tenant: Handle,
93        context_revision: Handle,
94    },
95    /// Reuse only within one tenant, context revision and effective
96    /// principal scope. The default.
97    Principal {
98        tenant: Handle,
99        context_revision: Handle,
100        principal_scope: Handle,
101    },
102}
103
104impl Scope {
105    /// The default mode: principal scope, every handle required.
106    pub fn principal(tenant: Handle, context_revision: Handle, principal_scope: Handle) -> Self {
107        Scope::Principal {
108            tenant,
109            context_revision,
110            principal_scope,
111        }
112    }
113
114    /// The explicit tenant-only opt-in.
115    pub fn tenant_only(tenant: Handle, context_revision: Handle) -> Self {
116        Scope::TenantOnly {
117            tenant,
118            context_revision,
119        }
120    }
121
122    pub fn mode(&self) -> &'static str {
123        match self {
124            Scope::TenantOnly { .. } => "tenant_only",
125            Scope::Principal { .. } => "principal",
126        }
127    }
128
129    pub fn tenant(&self) -> &Handle {
130        match self {
131            Scope::TenantOnly { tenant, .. } | Scope::Principal { tenant, .. } => tenant,
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn h(s: &str) -> Handle {
141        Handle::new(s).unwrap()
142    }
143
144    #[test]
145    fn handles_are_bounded_and_non_empty() {
146        assert!(Handle::new("").is_err());
147        assert!(Handle::new("x".repeat(MAX_HANDLE_BYTES)).is_ok());
148        assert!(Handle::new("x".repeat(MAX_HANDLE_BYTES + 1)).is_err());
149        assert!(serde_json::from_str::<Handle>("\"\"").is_err());
150    }
151
152    #[test]
153    fn both_modes_round_trip_and_state_their_mode() {
154        for s in [
155            Scope::tenant_only(h("t"), h("r1")),
156            Scope::principal(h("t"), h("r1"), h("p")),
157        ] {
158            let text = serde_json::to_string(&s).unwrap();
159            assert!(
160                text.contains(&format!("\"mode\":\"{}\"", s.mode())),
161                "{text}"
162            );
163            assert_eq!(serde_json::from_str::<Scope>(&text).unwrap(), s);
164        }
165    }
166
167    #[test]
168    fn omitted_unknown_or_mixed_modes_are_refused() {
169        for bad in [
170            // No mode: never an implied default or public namespace.
171            r#"{"tenant":"t","context_revision":"r"}"#,
172            r#"{"mode":"public","tenant":"t","context_revision":"r"}"#,
173            // Fields of the other variant.
174            r#"{"mode":"tenant_only","tenant":"t","context_revision":"r","principal_scope":"p"}"#,
175            // A principal scope without its principal handle never downgrades.
176            r#"{"mode":"principal","tenant":"t","context_revision":"r"}"#,
177            r#"{"mode":"principal","tenant":"t","context_revision":"","principal_scope":"p"}"#,
178        ] {
179            assert!(serde_json::from_str::<Scope>(bad).is_err(), "{bad}");
180        }
181        // Negative control.
182        assert!(
183            serde_json::from_str::<Scope>(
184                r#"{"mode":"principal","tenant":"t","context_revision":"r","principal_scope":"p"}"#
185            )
186            .is_ok()
187        );
188    }
189
190    #[test]
191    fn modes_never_compare_equal() {
192        assert_ne!(
193            Scope::tenant_only(h("t"), h("r")),
194            Scope::principal(h("t"), h("r"), h("p"))
195        );
196    }
197}