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