Skip to main content

dpp/data_contract/config/moderation/
reason.rs

1use 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
13/// The longest document type name a contract admits, in bytes: the bound the document type
14/// parser enforces on a schema, applied here to a name a reason cites.
15const MAX_DOCUMENT_TYPE_NAME_LENGTH: usize = 64;
16
17/// A document a moderation reason is about, named by its document type and its id.
18#[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    /// The document type of the document, on the moderated contract.
35    pub document_type_name: String,
36    /// The document's id.
37    pub document_id: Identifier,
38}
39
40/// Why a moderator banned, suspended or warned an identity, or deleted a document. Every such
41/// action carries one, and it is stored with the entry or the record, so whoever reads the
42/// list reads the reason.
43///
44/// Nothing checks what a moderator writes: the text is free, so is the code, and the documents
45/// a reason cites are not looked up. A cited document may have been deleted since, by its
46/// author or by a moderator (whose deletion left a record), or may never have existed. The
47/// one exception is the reason document a seated elected team names: it must be one its
48/// proposal lists.
49#[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    /// Reserved for the ban codes a contract may declare in a later protocol version. No
55    /// contract declares any today, so this is expected to be `None`. A value is accepted and
56    /// stored as written, and is not checked against anything.
57    #[serde(default)]
58    pub code: Option<u16>,
59    /// Free text, at most `SystemLimits::max_contract_moderation_reason_length` bytes of
60    /// UTF-8. May be empty.
61    pub text: String,
62    /// The documents the reason is about: the posts a warning or a ban is for, at most
63    /// `SystemLimits::max_contract_moderation_reason_documents`, none twice. Left out of the
64    /// JSON when there are none.
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub documents: Vec<ContractModerationDocument>,
67    /// The `reason` document of the moderation charters system contract the action is taken
68    /// on (protocol version 14). A seated elected team's ban, suspension, warning or document
69    /// deletion must name one its proposal lists (`ModerationReasonNotListedError`); for every
70    /// other moderator it is stored as written and checked against nothing. Last, so that a
71    /// reason written before it existed decodes; left out of the JSON when there is none.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub reason_document_id: Option<Identifier>,
74}
75
76impl ContractModerationReason {
77    /// A reason without a code, about no document, naming no reason document.
78    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    /// The same reason, naming the reason document `reason_document_id`.
88    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    /// The same reason, about `documents`.
94    pub fn with_documents(mut self, documents: Vec<ContractModerationDocument>) -> Self {
95        self.documents = documents;
96        self
97    }
98
99    /// Checks the length of the text and the documents cited: at most the limit, each with a
100    /// document type name a contract could admit (one to 64 bytes), and none twice. The code
101    /// is not checked, and neither is whether a cited document, or its type, exists.
102    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        // The code may be left out
180        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        // A reason about no document has no `documents` key, as before documents existed.
208        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        // The same id under another type is another document.
238        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        // Nothing checks whether the document exists.
257        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        // One character under the limit in characters, over it in bytes
314        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}