Skip to main content

icydb_schema/
key.rs

1//! Current source names and opaque proposal routing tokens.
2
3use std::fmt::{self, Display, Formatter};
4
5use candid::CandidType;
6use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
7use sha2::{Digest, Sha256};
8
9use crate::{MAX_SCHEMA_SUBMISSION_KEY_BYTES, MAX_SOURCE_KEY_BYTES, SchemaContractError};
10
11fn validate_source_key(value: &str) -> Result<(), SchemaContractError> {
12    validate_bounded_identity(value, MAX_SOURCE_KEY_BYTES)?;
13    if !value.bytes().all(|byte| {
14        byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'/')
15    }) {
16        return Err(SchemaContractError::InvalidSourceKey);
17    }
18    Ok(())
19}
20
21const fn validate_bounded_identity(value: &str, max: usize) -> Result<(), SchemaContractError> {
22    if value.is_empty() {
23        return Err(SchemaContractError::EmptyIdentity);
24    }
25    if value.len() > max {
26        return Err(SchemaContractError::IdentityTooLong {
27            len: value.len(),
28            max,
29        });
30    }
31    Ok(())
32}
33
34macro_rules! source_key {
35    ($name:ident) => {
36        #[doc = concat!("Typed proposal key derived from one current ", stringify!($name), " name.")]
37        #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
38        pub struct $name(String);
39
40        impl $name {
41            /// Construct a bounded canonical source key.
42            ///
43            /// # Errors
44            ///
45            /// Returns a typed contract error for empty, oversized, or
46            /// non-canonical input.
47            pub fn try_new(value: impl Into<String>) -> Result<Self, SchemaContractError> {
48                let value = value.into();
49                validate_source_key(&value)?;
50                Ok(Self(value))
51            }
52
53            /// Borrow the canonical key text.
54            #[must_use]
55            pub const fn as_str(&self) -> &str {
56                self.0.as_str()
57            }
58
59            pub(crate) fn from_name(name: &SchemaName) -> Self {
60                Self(name.0.clone())
61            }
62        }
63
64        impl Display for $name {
65            fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
66                formatter.write_str(self.as_str())
67            }
68        }
69
70        impl CandidType for $name {
71            fn _ty() -> candid::types::Type {
72                <String as CandidType>::_ty()
73            }
74
75            fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
76            where
77                S: candid::types::Serializer,
78            {
79                serializer.serialize_text(self.as_str())
80            }
81        }
82
83        impl<'de> Deserialize<'de> for $name {
84            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
85            where
86                D: Deserializer<'de>,
87            {
88                let value = String::deserialize(deserializer)?;
89                Self::try_new(value).map_err(D::Error::custom)
90            }
91        }
92    };
93}
94
95source_key!(EntitySourceKey);
96source_key!(FieldSourceKey);
97source_key!(TypeSourceKey);
98source_key!(ConstraintSourceKey);
99source_key!(IndexSourceKey);
100source_key!(RelationSourceKey);
101source_key!(RuleSourceKey);
102
103impl ConstraintSourceKey {
104    /// Derive one targeted-rule proposal key under a persisted root.
105    ///
106    /// The domain-separated digest keeps the result within the frozen source
107    /// key bound even when every authored identity uses its maximum length.
108    #[must_use]
109    pub fn for_targeted_field_rule(
110        field: &FieldSourceKey,
111        target_type: &TypeSourceKey,
112        rule: &RuleSourceKey,
113    ) -> Self {
114        let mut hasher = Sha256::new();
115        hasher.update(b"icydb:constraint-source:targeted-field-rule:v1");
116        hash_bounded_part(&mut hasher, field.as_str());
117        hash_bounded_part(&mut hasher, target_type.as_str());
118        hash_bounded_part(&mut hasher, rule.as_str());
119        let digest = hasher.finalize();
120        let mut value = String::with_capacity(5 + (digest.len() * 2));
121        value.push_str("rule:");
122        for byte in digest {
123            value.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
124            value.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
125        }
126        Self(value)
127    }
128}
129
130const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
131
132fn hash_bounded_part(hasher: &mut Sha256, value: &str) {
133    hasher.update(u32::try_from(value.len()).unwrap_or(u32::MAX).to_be_bytes());
134    hasher.update(value.as_bytes());
135}
136
137/// Bounded current schema name.
138#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
139pub struct SchemaName(String);
140
141impl SchemaName {
142    /// Construct a bounded nonempty name.
143    ///
144    /// # Errors
145    ///
146    /// Returns a typed contract error for empty, oversized, or non-canonical
147    /// input.
148    pub fn try_new(value: impl Into<String>) -> Result<Self, SchemaContractError> {
149        let value = value.into();
150        validate_source_key(&value)?;
151        Ok(Self(value))
152    }
153
154    /// Borrow the name.
155    #[must_use]
156    pub const fn as_str(&self) -> &str {
157        self.0.as_str()
158    }
159
160    pub(crate) fn for_targeted_rule(source_key: &ConstraintSourceKey) -> Self {
161        Self(format!("__icydb_{}", source_key.as_str().replace(':', "_")))
162    }
163}
164
165impl<'de> Deserialize<'de> for SchemaName {
166    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
167    where
168        D: Deserializer<'de>,
169    {
170        Self::try_new(String::deserialize(deserializer)?).map_err(D::Error::custom)
171    }
172}
173
174impl CandidType for SchemaName {
175    fn _ty() -> candid::types::Type {
176        <String as CandidType>::_ty()
177    }
178
179    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
180    where
181        S: candid::types::Serializer,
182    {
183        serializer.serialize_text(self.as_str())
184    }
185}
186
187/// Caller-generated immutable schema-submission key.
188#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
189pub struct SchemaSubmissionKey(String);
190
191impl SchemaSubmissionKey {
192    /// Construct a bounded submission key.
193    ///
194    /// # Errors
195    ///
196    /// Returns a typed contract error for empty or oversized input.
197    pub fn try_new(value: impl Into<String>) -> Result<Self, SchemaContractError> {
198        let value = value.into();
199        validate_bounded_identity(&value, MAX_SCHEMA_SUBMISSION_KEY_BYTES)?;
200        Ok(Self(value))
201    }
202
203    /// Borrow the key.
204    #[must_use]
205    pub const fn as_str(&self) -> &str {
206        self.0.as_str()
207    }
208}
209
210impl<'de> Deserialize<'de> for SchemaSubmissionKey {
211    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
212    where
213        D: Deserializer<'de>,
214    {
215        Self::try_new(String::deserialize(deserializer)?).map_err(D::Error::custom)
216    }
217}
218
219impl CandidType for SchemaSubmissionKey {
220    fn _ty() -> candid::types::Type {
221        <String as CandidType>::_ty()
222    }
223
224    fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
225    where
226        S: candid::types::Serializer,
227    {
228        serializer.serialize_text(self.as_str())
229    }
230}
231
232macro_rules! opaque_token {
233    ($name:ident, $doc:literal) => {
234        #[doc = $doc]
235        #[derive(
236            CandidType,
237            Clone,
238            Copy,
239            Debug,
240            Deserialize,
241            Eq,
242            Hash,
243            Ord,
244            PartialEq,
245            PartialOrd,
246            Serialize,
247        )]
248        #[serde(transparent)]
249        pub struct $name([u8; 32]);
250
251        impl $name {
252            /// Construct from opaque bytes issued by IcyDB.
253            #[must_use]
254            pub const fn from_bytes(bytes: [u8; 32]) -> Self {
255                Self(bytes)
256            }
257
258            /// Return the opaque bytes.
259            #[must_use]
260            pub const fn to_bytes(self) -> [u8; 32] {
261                self.0
262            }
263        }
264    };
265}
266
267opaque_token!(
268    TargetDatabaseIdentity,
269    "Opaque identity binding a proposal to one target database."
270);
271opaque_token!(
272    TargetStoreIdentity,
273    "Opaque identity routing one entity to a store in the target database."
274);
275opaque_token!(
276    ExpectedSchemaFingerprint,
277    "Opaque expected accepted-schema fingerprint."
278);
279opaque_token!(
280    SchemaProposalDigest,
281    "Canonical digest of one current-form schema proposal."
282);