Skip to main content

icydb_schema/
key.rs

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