Skip to main content

boxology_contract/
identity.rs

1use std::error::Error;
2use std::fmt;
3
4/// A stable box, package, import-slot, and derived-output identifier.
5#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
6pub struct BoxId(String);
7
8impl BoxId {
9    /// Validates and constructs an identifier matching `[a-z][a-z0-9-]*`.
10    pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
11        let value = value.into();
12        if valid_identifier(&value, b'-') {
13            Ok(Self(value))
14        } else {
15            Err(IdentityError::InvalidBoxId { value })
16        }
17    }
18
19    /// Returns the identifier spelling.
20    pub fn as_str(&self) -> &str {
21        &self.0
22    }
23}
24
25impl fmt::Display for BoxId {
26    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
27        formatter.write_str(self.as_str())
28    }
29}
30
31/// A capability's box-local name.
32#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
33pub struct CapabilityName(String);
34
35impl CapabilityName {
36    /// Validates and constructs a name matching `[a-z][a-z0-9_]*`.
37    pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
38        let value = value.into();
39        if valid_identifier(&value, b'_') {
40            Ok(Self(value))
41        } else {
42            Err(IdentityError::InvalidCapabilityName { value })
43        }
44    }
45
46    /// Returns the local name spelling.
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50}
51
52impl fmt::Display for CapabilityName {
53    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
54        formatter.write_str(self.as_str())
55    }
56}
57
58/// A capability's box-qualified identity.
59#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
60pub struct CapabilityId {
61    box_id: BoxId,
62    name: CapabilityName,
63}
64
65impl CapabilityId {
66    /// Joins validated box and local-name segments.
67    pub fn new(box_id: BoxId, name: CapabilityName) -> Self {
68        Self { box_id, name }
69    }
70
71    /// Returns the box segment.
72    pub fn box_id(&self) -> &BoxId {
73        &self.box_id
74    }
75
76    /// Returns the box-local capability segment.
77    pub fn name(&self) -> &CapabilityName {
78        &self.name
79    }
80}
81
82impl fmt::Display for CapabilityId {
83    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
84        write!(formatter, "{}.{}", self.box_id, self.name)
85    }
86}
87
88/// An opaque identifier for one generated contract state.
89#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
90pub struct ContractRevision(String);
91
92impl ContractRevision {
93    /// Constructs a non-empty revision without interpreting its format.
94    pub fn new(value: impl Into<String>) -> Result<Self, IdentityError> {
95        let value = value.into();
96        if value.is_empty() {
97            Err(IdentityError::EmptyRevision)
98        } else {
99            Ok(Self(value))
100        }
101    }
102
103    /// Returns the exact revision spelling.
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107}
108
109impl fmt::Display for ContractRevision {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        formatter.write_str(self.as_str())
112    }
113}
114
115/// A failure to construct a contract identity.
116#[derive(Debug, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum IdentityError {
119    /// A box identifier did not match `[a-z][a-z0-9-]*`.
120    InvalidBoxId {
121        /// The rejected spelling.
122        value: String,
123    },
124    /// A capability name did not match `[a-z][a-z0-9_]*`.
125    InvalidCapabilityName {
126        /// The rejected spelling.
127        value: String,
128    },
129    /// A contract revision was empty.
130    EmptyRevision,
131}
132
133impl fmt::Display for IdentityError {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        match self {
136            Self::InvalidBoxId { value } => write!(formatter, "invalid box id: {value:?}"),
137            Self::InvalidCapabilityName { value } => {
138                write!(formatter, "invalid capability name: {value:?}")
139            }
140            Self::EmptyRevision => formatter.write_str("contract revision must not be empty"),
141        }
142    }
143}
144
145impl Error for IdentityError {}
146
147fn valid_identifier(value: &str, separator: u8) -> bool {
148    let mut bytes = value.bytes();
149    matches!(bytes.next(), Some(b'a'..=b'z'))
150        && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == separator)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::{BoxId, CapabilityId, CapabilityName, ContractRevision, IdentityError};
156
157    #[test]
158    fn box_id_grammar_is_exact() {
159        for valid in ["a", "box", "box-1", "a0-b9", "box-", "a--b"] {
160            let id = BoxId::new(valid).unwrap();
161            assert_eq!(id.as_str(), valid);
162            assert_eq!(id.to_string(), valid);
163        }
164        for invalid in [
165            "", "A", "Box", "boX", "1box", "-box", "_box", "box%", "box name", "box.name",
166            "box_name", "boéx",
167        ] {
168            assert_eq!(
169                BoxId::new(invalid),
170                Err(IdentityError::InvalidBoxId {
171                    value: invalid.into()
172                })
173            );
174        }
175    }
176
177    #[test]
178    fn capability_name_grammar_is_exact() {
179        for valid in ["a", "capability", "capability_1", "a0_b9", "name_", "a__b"] {
180            let name = CapabilityName::new(valid).unwrap();
181            assert_eq!(name.as_str(), valid);
182            assert_eq!(name.to_string(), valid);
183        }
184        for invalid in [
185            "",
186            "A",
187            "Name",
188            "naMe",
189            "1name",
190            "_name",
191            "-name",
192            "name%",
193            "name space",
194            "name.part",
195            "name-part",
196            "naïve",
197        ] {
198            assert_eq!(
199                CapabilityName::new(invalid),
200                Err(IdentityError::InvalidCapabilityName {
201                    value: invalid.into()
202                })
203            );
204        }
205    }
206
207    #[test]
208    fn capability_id_joins_and_exposes_both_segments() {
209        let box_id = BoxId::new("billing-box").unwrap();
210        let name = CapabilityName::new("create_invoice").unwrap();
211        let id = CapabilityId::new(box_id.clone(), name.clone());
212
213        assert_eq!(id.box_id(), &box_id);
214        assert_eq!(id.name(), &name);
215        assert_eq!(id.to_string(), "billing-box.create_invoice");
216    }
217
218    #[test]
219    fn contract_revision_is_non_empty_and_otherwise_opaque() {
220        assert_eq!(ContractRevision::new(""), Err(IdentityError::EmptyRevision));
221        for spelling in ["sha256:abc/DEF", " vNEXT ", " "] {
222            let revision = ContractRevision::new(spelling).unwrap();
223            assert_eq!(revision.as_str(), spelling);
224            assert_eq!(revision.to_string(), spelling);
225        }
226    }
227
228    #[test]
229    fn identity_errors_are_equal_and_display_rejected_values() {
230        let box_error = BoxId::new("Bad.Box").unwrap_err();
231        assert_eq!(
232            box_error,
233            IdentityError::InvalidBoxId {
234                value: "Bad.Box".into()
235            }
236        );
237        assert_eq!(box_error.to_string(), "invalid box id: \"Bad.Box\"");
238
239        let name_error = CapabilityName::new("Bad-Name").unwrap_err();
240        assert_eq!(
241            name_error,
242            IdentityError::InvalidCapabilityName {
243                value: "Bad-Name".into()
244            }
245        );
246        assert_eq!(
247            name_error.to_string(),
248            "invalid capability name: \"Bad-Name\""
249        );
250        assert_eq!(
251            IdentityError::EmptyRevision.to_string(),
252            "contract revision must not be empty"
253        );
254    }
255
256    #[test]
257    fn identity_types_have_public_thread_safe_static_bounds() {
258        fn assert_bounds<T: Send + Sync + 'static>() {}
259
260        assert_bounds::<BoxId>();
261        assert_bounds::<CapabilityName>();
262        assert_bounds::<CapabilityId>();
263        assert_bounds::<ContractRevision>();
264        assert_bounds::<IdentityError>();
265    }
266}