1use 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 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 #[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 _ty() -> candid::types::Type {
76 <String as CandidType>::_ty()
77 }
78
79 fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
80 where
81 S: candid::types::Serializer,
82 {
83 serializer.serialize_text(self.as_str())
84 }
85 }
86
87 impl<'de> Deserialize<'de> for $name {
88 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
89 where
90 D: Deserializer<'de>,
91 {
92 let value = String::deserialize(deserializer)?;
93 Self::try_new(value).map_err(D::Error::custom)
94 }
95 }
96 };
97}
98
99source_key!(EntitySourceKey);
100source_key!(FieldSourceKey);
101source_key!(TypeSourceKey);
102source_key!(ConstraintSourceKey);
103source_key!(IndexSourceKey);
104source_key!(RelationSourceKey);
105source_key!(RuleSourceKey);
106
107impl ConstraintSourceKey {
108 #[must_use]
113 pub fn for_targeted_field_rule(
114 field: &FieldSourceKey,
115 target_type: &TypeSourceKey,
116 rule: &RuleSourceKey,
117 ) -> Self {
118 let mut hasher = Sha256::new();
119 hasher.update(b"icydb:constraint-source:targeted-field-rule:v1");
120 hash_bounded_part(&mut hasher, field.as_str());
121 hash_bounded_part(&mut hasher, target_type.as_str());
122 hash_bounded_part(&mut hasher, rule.as_str());
123 let digest = hasher.finalize();
124 let mut value = String::with_capacity(5 + (digest.len() * 2));
125 value.push_str("rule:");
126 for byte in digest {
127 value.push(HEX_DIGITS[usize::from(byte >> 4)] as char);
128 value.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char);
129 }
130 Self(value)
131 }
132}
133
134const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
135
136fn hash_bounded_part(hasher: &mut Sha256, value: &str) {
137 hasher.update(u32::try_from(value.len()).unwrap_or(u32::MAX).to_be_bytes());
138 hasher.update(value.as_bytes());
139}
140
141#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
143pub struct SchemaName(String);
144
145impl SchemaName {
146 pub fn try_new(value: impl Into<String>) -> Result<Self, SchemaContractError> {
153 let value = value.into();
154 validate_source_key(&value)?;
155 Ok(Self(value))
156 }
157
158 #[must_use]
160 pub const fn as_str(&self) -> &str {
161 self.0.as_str()
162 }
163
164 pub(crate) fn for_targeted_rule(source_key: &ConstraintSourceKey) -> Self {
165 Self(format!("__icydb_{}", source_key.as_str().replace(':', "_")))
166 }
167}
168
169impl<'de> Deserialize<'de> for SchemaName {
170 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
171 where
172 D: Deserializer<'de>,
173 {
174 Self::try_new(String::deserialize(deserializer)?).map_err(D::Error::custom)
175 }
176}
177
178impl CandidType for SchemaName {
179 fn ty() -> candid::types::Type {
180 <String as CandidType>::ty()
181 }
182
183 fn _ty() -> candid::types::Type {
184 <String as CandidType>::_ty()
185 }
186
187 fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
188 where
189 S: candid::types::Serializer,
190 {
191 serializer.serialize_text(self.as_str())
192 }
193}
194
195#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
197pub struct SchemaSubmissionKey(String);
198
199impl SchemaSubmissionKey {
200 pub fn try_new(value: impl Into<String>) -> Result<Self, SchemaContractError> {
206 let value = value.into();
207 validate_bounded_identity(&value, MAX_SCHEMA_SUBMISSION_KEY_BYTES)?;
208 Ok(Self(value))
209 }
210
211 #[must_use]
213 pub const fn as_str(&self) -> &str {
214 self.0.as_str()
215 }
216}
217
218impl<'de> Deserialize<'de> for SchemaSubmissionKey {
219 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
220 where
221 D: Deserializer<'de>,
222 {
223 Self::try_new(String::deserialize(deserializer)?).map_err(D::Error::custom)
224 }
225}
226
227impl CandidType for SchemaSubmissionKey {
228 fn ty() -> candid::types::Type {
229 <String as CandidType>::ty()
230 }
231
232 fn _ty() -> candid::types::Type {
233 <String as CandidType>::_ty()
234 }
235
236 fn idl_serialize<S>(&self, serializer: S) -> Result<(), S::Error>
237 where
238 S: candid::types::Serializer,
239 {
240 serializer.serialize_text(self.as_str())
241 }
242}
243
244macro_rules! opaque_token {
245 ($name:ident, $doc:literal) => {
246 #[doc = $doc]
247 #[derive(
248 CandidType,
249 Clone,
250 Copy,
251 Debug,
252 Deserialize,
253 Eq,
254 Hash,
255 Ord,
256 PartialEq,
257 PartialOrd,
258 Serialize,
259 )]
260 #[serde(transparent)]
261 pub struct $name([u8; 32]);
262
263 impl $name {
264 #[must_use]
266 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
267 Self(bytes)
268 }
269
270 #[must_use]
272 pub const fn to_bytes(self) -> [u8; 32] {
273 self.0
274 }
275 }
276 };
277}
278
279opaque_token!(
280 TargetDatabaseIdentity,
281 "Opaque identity binding a proposal to one target database."
282);
283opaque_token!(
284 TargetStoreIdentity,
285 "Opaque identity routing one entity to a store in the target database."
286);
287opaque_token!(
288 ExpectedSchemaFingerprint,
289 "Opaque expected accepted-schema fingerprint."
290);
291opaque_token!(
292 SchemaProposalDigest,
293 "Canonical digest of one current-form schema proposal."
294);
295opaque_token!(
296 SchemaMigrationPlanDigest,
297 "Canonical digest of one current-form coordinated schema migration plan."
298);
299opaque_token!(
300 EntitySourceDigest,
301 "Canonical generated-source meaning digest for one current entity."
302);
303
304#[cfg(test)]
305mod tests {
306 use candid::CandidType;
307
308 use super::{
309 ConstraintSourceKey, EntitySourceKey, FieldSourceKey, IndexSourceKey, RelationSourceKey,
310 RuleSourceKey, SchemaName, SchemaSubmissionKey, TypeSourceKey,
311 };
312
313 #[test]
314 fn text_backed_keys_delegate_their_candid_type() {
315 let text = String::ty();
316
317 assert_eq!(EntitySourceKey::ty(), text);
318 assert_eq!(FieldSourceKey::ty(), text);
319 assert_eq!(TypeSourceKey::ty(), text);
320 assert_eq!(ConstraintSourceKey::ty(), text);
321 assert_eq!(IndexSourceKey::ty(), text);
322 assert_eq!(RelationSourceKey::ty(), text);
323 assert_eq!(RuleSourceKey::ty(), text);
324 assert_eq!(SchemaName::ty(), text);
325 assert_eq!(SchemaSubmissionKey::ty(), text);
326 }
327}