1use crate::{
2 DOCUMENT_MUTATION_SEMANTIC_DIGEST_DOMAIN, DOCUMENT_MUTATION_SEMANTIC_DIGEST_FORMAT_VERSION, Digest,
3 DocumentMutationCondition,
4};
5
6const MAXIMUM_DOCUMENT_ID_BYTES: usize = 512;
7const MAXIMUM_SOURCE_BYTES: usize = 2 * 1024 * 1024;
8const MAXIMUM_ROUTING_BYTES: usize = 1_024;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum DocumentMutationSemanticDigestError {
12 EmptyDocumentId,
13 DocumentIdTooLong(usize),
14 InvalidOperation,
15 SourceRequired,
16 SourceForbidden,
17 SourceTooLarge(usize),
18 InvalidSourceJson,
19 RoutingTooLong(usize),
20 InvalidSequenceNumber,
21 InvalidPrimaryTerm,
22}
23
24impl std::fmt::Display for DocumentMutationSemanticDigestError {
25 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 write!(
27 formatter,
28 "invalid Lodestone document mutation semantic digest input: {self:?}"
29 )
30 }
31}
32
33impl std::error::Error for DocumentMutationSemanticDigestError {}
34
35pub fn document_mutation_semantic_digest(
43 document_id: &str,
44 operation: &str,
45 source: Option<&[u8]>,
46 routing: Option<&str>,
47 condition: Option<&DocumentMutationCondition>,
48) -> Result<Digest, DocumentMutationSemanticDigestError> {
49 let document_id = document_id.as_bytes();
50 if document_id.is_empty() {
51 return Err(DocumentMutationSemanticDigestError::EmptyDocumentId);
52 }
53 if document_id.len() > MAXIMUM_DOCUMENT_ID_BYTES {
54 return Err(DocumentMutationSemanticDigestError::DocumentIdTooLong(
55 document_id.len(),
56 ));
57 }
58 if let Some(routing) = routing
59 && routing.len() > MAXIMUM_ROUTING_BYTES
60 {
61 return Err(DocumentMutationSemanticDigestError::RoutingTooLong(routing.len()));
62 }
63
64 let (operation_tag, source) = match (operation, source) {
65 ("put", Some(source)) => (1_u8, source),
66 ("update", Some(source)) => (2_u8, source),
67 ("delete", None) => (3_u8, &[][..]),
68 ("put" | "update", None) => return Err(DocumentMutationSemanticDigestError::SourceRequired),
69 ("delete", Some(_)) => return Err(DocumentMutationSemanticDigestError::SourceForbidden),
70 _ => return Err(DocumentMutationSemanticDigestError::InvalidOperation),
71 };
72 if source.len() > MAXIMUM_SOURCE_BYTES {
73 return Err(DocumentMutationSemanticDigestError::SourceTooLarge(source.len()));
74 }
75 if operation_tag != 3 && serde_json::from_slice::<&serde_json::value::RawValue>(source).is_err() {
76 return Err(DocumentMutationSemanticDigestError::InvalidSourceJson);
77 }
78
79 let condition = canonical_condition(condition)?;
80 let mut hasher = blake3::Hasher::new();
81 hasher.update(DOCUMENT_MUTATION_SEMANTIC_DIGEST_DOMAIN);
82 hasher.update(&DOCUMENT_MUTATION_SEMANTIC_DIGEST_FORMAT_VERSION.to_be_bytes());
83 hasher.update(&[operation_tag]);
84 update_bounded(&mut hasher, document_id);
85 match routing {
86 Some(value) => {
87 hasher.update(&[1]);
88 update_bounded(&mut hasher, value.as_bytes());
89 }
90 None => {
91 hasher.update(&[0]);
92 }
93 }
94 update_bounded(&mut hasher, source);
95 update_bounded(&mut hasher, condition.as_bytes());
96 Ok(Digest {
97 value: hasher.finalize().to_hex().to_string(),
98 })
99}
100
101fn canonical_condition(
102 condition: Option<&DocumentMutationCondition>,
103) -> Result<String, DocumentMutationSemanticDigestError> {
104 match condition {
105 None | Some(DocumentMutationCondition::Any) => Ok("any".to_owned()),
106 Some(DocumentMutationCondition::MustNotExist) => Ok("must_not_exist".to_owned()),
107 Some(DocumentMutationCondition::IfSeqNo { seq_no }) => {
108 seq_no
109 .validate()
110 .map_err(|_| DocumentMutationSemanticDigestError::InvalidSequenceNumber)?;
111 Ok(format!("if_seq_no={}", seq_no.value))
112 }
113 Some(DocumentMutationCondition::IfSeqNoAndPrimaryTerm { seq_no, primary_term }) => {
114 seq_no
115 .validate()
116 .map_err(|_| DocumentMutationSemanticDigestError::InvalidSequenceNumber)?;
117 primary_term
118 .validate()
119 .map_err(|_| DocumentMutationSemanticDigestError::InvalidPrimaryTerm)?;
120 if primary_term.value == "0" {
121 return Err(DocumentMutationSemanticDigestError::InvalidPrimaryTerm);
122 }
123 Ok(format!(
124 "if_seq_no={};if_primary_term={}",
125 seq_no.value, primary_term.value
126 ))
127 }
128 }
129}
130
131fn update_bounded(hasher: &mut blake3::Hasher, value: &[u8]) {
132 hasher.update(&(value.len() as u64).to_be_bytes());
133 hasher.update(value);
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::U64;
140
141 #[test]
142 fn digest_preserves_exact_source_lexemes_and_condition() -> Result<(), Box<dyn std::error::Error>> {
143 let condition = DocumentMutationCondition::IfSeqNoAndPrimaryTerm {
144 seq_no: U64 { value: "9".to_owned() },
145 primary_term: U64 { value: "3".to_owned() },
146 };
147 let first = document_mutation_semantic_digest(
148 "document-1",
149 "update",
150 Some(br#"{"n":9007199254740993,"d":1.2300}"#),
151 Some("tenant-a"),
152 Some(&condition),
153 )?;
154 let second = document_mutation_semantic_digest(
155 "document-1",
156 "update",
157 Some(br#"{"n":9007199254740993,"d":1.23}"#),
158 Some("tenant-a"),
159 Some(&condition),
160 )?;
161 assert_ne!(first, second);
162 Ok(())
163 }
164
165 #[test]
166 fn digest_rejects_invalid_source_operation_and_primary_term() {
167 assert_eq!(
168 document_mutation_semantic_digest("document-1", "put", None, None, None),
169 Err(DocumentMutationSemanticDigestError::SourceRequired)
170 );
171 assert_eq!(
172 document_mutation_semantic_digest("document-1", "delete", Some(b"{}"), None, None),
173 Err(DocumentMutationSemanticDigestError::SourceForbidden)
174 );
175 assert_eq!(
176 document_mutation_semantic_digest("document-1", "put", Some(b"not-json"), None, None),
177 Err(DocumentMutationSemanticDigestError::InvalidSourceJson)
178 );
179 assert_eq!(
180 document_mutation_semantic_digest(
181 "document-1",
182 "update",
183 Some(b"{}"),
184 None,
185 Some(&DocumentMutationCondition::IfSeqNoAndPrimaryTerm {
186 seq_no: U64 { value: "0".to_owned() },
187 primary_term: U64 { value: "0".to_owned() },
188 }),
189 ),
190 Err(DocumentMutationSemanticDigestError::InvalidPrimaryTerm)
191 );
192 }
193}