dpp/data_contract/config/moderation/
reason.rs1use crate::consensus::basic::contract_moderation::{
2 ContractModerationReasonTooLongError, InvalidContractModerationReasonDocumentsError,
3};
4#[cfg(feature = "json-conversion")]
5use crate::serialization::JsonSafeFields;
6use crate::validation::SimpleConsensusValidationResult;
7use bincode::{Decode, DecodeUntrusted, Encode};
8use platform_value::Identifier;
9use platform_version::version::PlatformVersion;
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeSet;
12
13const MAX_DOCUMENT_TYPE_NAME_LENGTH: usize = 64;
16
17#[derive(
19 Debug,
20 Clone,
21 PartialEq,
22 Eq,
23 PartialOrd,
24 Ord,
25 Default,
26 Encode,
27 Decode,
28 DecodeUntrusted,
29 Serialize,
30 Deserialize,
31)]
32#[serde(rename_all = "camelCase", deny_unknown_fields)]
33pub struct ContractModerationDocument {
34 pub document_type_name: String,
36 pub document_id: Identifier,
38}
39
40#[derive(
50 Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
51)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct ContractModerationReason {
54 #[serde(default)]
58 pub code: Option<u16>,
59 pub text: String,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
66 pub documents: Vec<ContractModerationDocument>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub reason_document_id: Option<Identifier>,
74}
75
76impl ContractModerationReason {
77 pub fn from_text(text: impl Into<String>) -> Self {
79 Self {
80 code: None,
81 text: text.into(),
82 documents: vec![],
83 reason_document_id: None,
84 }
85 }
86
87 pub fn with_reason_document(mut self, reason_document_id: Identifier) -> Self {
89 self.reason_document_id = Some(reason_document_id);
90 self
91 }
92
93 pub fn with_documents(mut self, documents: Vec<ContractModerationDocument>) -> Self {
95 self.documents = documents;
96 self
97 }
98
99 pub fn validate(&self, platform_version: &PlatformVersion) -> SimpleConsensusValidationResult {
103 let max_length = platform_version
104 .system_limits
105 .max_contract_moderation_reason_length;
106 if self.text.len() > max_length as usize {
107 return SimpleConsensusValidationResult::new_with_error(
108 ContractModerationReasonTooLongError::new(self.text.len() as u64, max_length)
109 .into(),
110 );
111 }
112 let max_documents = platform_version
113 .system_limits
114 .max_contract_moderation_reason_documents;
115 if self.documents.len() > usize::from(max_documents) {
116 return SimpleConsensusValidationResult::new_with_error(
117 InvalidContractModerationReasonDocumentsError::new(format!(
118 "{} documents cited, at most {} allowed",
119 self.documents.len(),
120 max_documents
121 ))
122 .into(),
123 );
124 }
125 let mut seen = BTreeSet::new();
126 for document in &self.documents {
127 let name_length = document.document_type_name.len();
128 if name_length == 0 || name_length > MAX_DOCUMENT_TYPE_NAME_LENGTH {
129 return SimpleConsensusValidationResult::new_with_error(
130 InvalidContractModerationReasonDocumentsError::new(format!(
131 "a cited document type name is {} bytes long, it must be 1 to {}",
132 name_length, MAX_DOCUMENT_TYPE_NAME_LENGTH
133 ))
134 .into(),
135 );
136 }
137 if !seen.insert(document) {
138 return SimpleConsensusValidationResult::new_with_error(
139 InvalidContractModerationReasonDocumentsError::new(format!(
140 "document {} of type {} is cited twice",
141 document.document_id, document.document_type_name
142 ))
143 .into(),
144 );
145 }
146 }
147 SimpleConsensusValidationResult::new()
148 }
149}
150
151#[cfg(feature = "json-conversion")]
152impl JsonSafeFields for ContractModerationDocument {}
153
154#[cfg(feature = "json-conversion")]
155impl JsonSafeFields for ContractModerationReason {}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::consensus::basic::BasicError;
161 use crate::consensus::ConsensusError;
162 use platform_value::string_encoding::Encoding;
163
164 #[test]
165 fn should_round_trip_through_json_with_and_without_a_code() {
166 let reason = ContractModerationReason {
167 code: Some(7),
168 text: "spam".to_string(),
169 documents: vec![],
170 reason_document_id: None,
171 };
172 let json = serde_json::to_value(&reason).expect("to json");
173 assert_eq!(json, serde_json::json!({"code": 7, "text": "spam"}));
174 assert_eq!(
175 serde_json::from_value::<ContractModerationReason>(json).expect("from json"),
176 reason
177 );
178
179 assert_eq!(
181 serde_json::from_value::<ContractModerationReason>(serde_json::json!({"text": ""}))
182 .expect("from json"),
183 ContractModerationReason::from_text("")
184 );
185 }
186
187 fn document(seed: u8) -> ContractModerationDocument {
188 ContractModerationDocument {
189 document_type_name: "post".to_string(),
190 document_id: Identifier::from([seed; 32]),
191 }
192 }
193
194 #[test]
195 fn should_round_trip_the_documents_through_json_and_leave_them_out_when_none() {
196 let reason = ContractModerationReason::from_text("spam").with_documents(vec![document(1)]);
197 let json = serde_json::to_value(&reason).expect("to json");
198 assert_eq!(json["documents"][0]["documentTypeName"], "post");
199 assert_eq!(
200 json["documents"][0]["documentId"],
201 Identifier::from([1; 32]).to_string(Encoding::Base58)
202 );
203 assert_eq!(
204 serde_json::from_value::<ContractModerationReason>(json).expect("from json"),
205 reason
206 );
207 let json =
209 serde_json::to_value(ContractModerationReason::from_text("spam")).expect("to json");
210 assert!(json.get("documents").is_none());
211 }
212
213 #[test]
214 fn should_bound_the_documents_cited_and_refuse_one_cited_twice() {
215 let platform_version = PlatformVersion::latest();
216 let max_documents = platform_version
217 .system_limits
218 .max_contract_moderation_reason_documents as u8;
219 let cited = |count: u8| {
220 ContractModerationReason::from_text("spam")
221 .with_documents((1..=count).map(document).collect())
222 };
223 assert!(cited(max_documents).validate(platform_version).is_valid());
224 let refused = |reason: ContractModerationReason| {
225 matches!(
226 reason.validate(platform_version).errors.as_slice(),
227 [ConsensusError::BasicError(
228 BasicError::InvalidContractModerationReasonDocumentsError(_)
229 )]
230 )
231 };
232 assert!(refused(cited(max_documents + 1)));
233 assert!(refused(
234 ContractModerationReason::from_text("spam")
235 .with_documents(vec![document(1), document(1)])
236 ));
237 let other_type = ContractModerationDocument {
239 document_type_name: "reply".to_string(),
240 ..document(1)
241 };
242 assert!(ContractModerationReason::from_text("spam")
243 .with_documents(vec![document(1), other_type])
244 .validate(platform_version)
245 .is_valid());
246 for name in ["", &"n".repeat(65)] {
247 assert!(refused(
248 ContractModerationReason::from_text("spam").with_documents(vec![
249 ContractModerationDocument {
250 document_type_name: name.to_string(),
251 document_id: Identifier::from([1; 32]),
252 }
253 ])
254 ));
255 }
256 assert!(cited(1).validate(platform_version).is_valid());
258 }
259
260 #[test]
261 fn should_round_trip_the_reason_document_through_json_and_leave_it_out_when_none() {
262 let reason = ContractModerationReason::from_text("spam")
263 .with_reason_document(Identifier::from([4; 32]));
264 let json = serde_json::to_value(&reason).expect("to json");
265 assert_eq!(
266 json["reasonDocumentId"],
267 Identifier::from([4; 32]).to_string(Encoding::Base58)
268 );
269 assert_eq!(
270 serde_json::from_value::<ContractModerationReason>(json).expect("from json"),
271 reason
272 );
273 let json =
274 serde_json::to_value(ContractModerationReason::from_text("spam")).expect("to json");
275 assert!(json.get("reasonDocumentId").is_none());
276 }
277
278 #[test]
279 fn should_refuse_an_unknown_field() {
280 serde_json::from_value::<ContractModerationReason>(
281 serde_json::json!({"text": "spam", "note": "x"}),
282 )
283 .expect_err("unknown field");
284 }
285
286 #[test]
287 fn should_accept_any_code_and_a_text_up_to_the_limit() {
288 let platform_version = PlatformVersion::latest();
289 let max_length = platform_version
290 .system_limits
291 .max_contract_moderation_reason_length as usize;
292
293 let reason = ContractModerationReason {
294 code: Some(u16::MAX),
295 text: "é".repeat(max_length / 2),
296 documents: vec![],
297 reason_document_id: None,
298 };
299 assert_eq!(reason.text.len(), max_length);
300 assert!(reason.validate(platform_version).is_valid());
301 assert!(ContractModerationReason::default()
302 .validate(platform_version)
303 .is_valid());
304 }
305
306 #[test]
307 fn should_refuse_a_text_over_the_limit_counted_in_bytes() {
308 let platform_version = PlatformVersion::latest();
309 let max_length = platform_version
310 .system_limits
311 .max_contract_moderation_reason_length;
312
313 let reason = ContractModerationReason::from_text("é".repeat(max_length as usize / 2 + 1));
315 let result = reason.validate(platform_version);
316 assert!(matches!(
317 result.errors.as_slice(),
318 [ConsensusError::BasicError(BasicError::ContractModerationReasonTooLongError(error))]
319 if error.length() == max_length as u64 + 2 && error.max_length() == max_length
320 ));
321 }
322}