Skip to main content

dpp/data_contract/config/moderation/
document_removal.rs

1use crate::data_contract::config::moderation::ContractModerationReason;
2use crate::data_contract::document_type::accessors::{
3    DocumentTypeV0Getters, DocumentTypeV2Getters,
4};
5use crate::data_contract::document_type::property_constraints::SystemProperty;
6use crate::data_contract::document_type::{
7    property_at_path, DocumentPropertyType, DocumentTypeRef,
8};
9use crate::data_contract::errors::DataContractError;
10use crate::document::{Document, DocumentV0Getters};
11use crate::identity::TimestampMillis;
12#[cfg(feature = "serde-conversion")]
13use crate::serialization::serde_bytes_var;
14use crate::ProtocolError;
15use bincode::{Decode, DecodeUntrusted, Encode};
16use byteorder::{BigEndian, ReadBytesExt};
17use platform_value::{Identifier, Value};
18use serde::{Deserialize, Serialize};
19use std::collections::{BTreeMap, BTreeSet};
20use std::io::{BufReader, Read};
21
22/// The record a contract keeps of a document one of its moderators deleted.
23///
24/// The document itself is gone; this is what is left to say that it was removed, not lost:
25/// whose it was, who removed it, why and when, a hash of what it was, and the values of the
26/// fields its type keeps public (`moderatorAbilities.deleteKeepsFields`). It is stored under
27/// the contract, by document type then document id, paid for by the moderator, and never
28/// deleted. A document id commits to the nonce of its create transition and is produced at
29/// most once, so the removed id can not be created again; what can bring the document back
30/// is a moderator's restore, within `SystemLimits::contract_document_restore_window_ms` of
31/// the removal, which marks the record restored and leaves it in place. A later deletion of
32/// the restored document replaces the record with a fresh one.
33#[derive(
34    Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
35)]
36#[serde(rename_all = "camelCase")]
37pub struct ContractDocumentRemoval {
38    /// The identity that owned the document when it was removed.
39    pub document_owner_id: Identifier,
40    /// The contract owner or moderator that removed it.
41    pub moderator_id: Identifier,
42    /// Why the moderator removed it. The code is a number the moderator sets and nothing
43    /// checks; the text may be empty.
44    pub reason: ContractModerationReason,
45    /// The time of the block that removed it, in milliseconds.
46    pub removed_at: TimestampMillis,
47    /// A double SHA-256 of the document as it was serialized under its document type at the
48    /// time of the removal (`Document::serialize`): what a restore must bring back, byte for
49    /// byte.
50    pub document_hash: [u8; 32],
51    /// Set once a moderator restored the document: it is live again, at its id, as it was.
52    pub restoration: Option<ContractDocumentRestoration>,
53    /// The values the record keeps of the document, the fields its type lists under
54    /// `moderatorAbilities.deleteKeepsFields`, as the record stores them: encoded as the
55    /// document encodes its properties (see [`encode_kept_fields`]), so read, as a document is,
56    /// under the document's type ([`ContractDocumentRemoval::kept_values`]). What of the
57    /// document stays public once it is gone. Empty on a type that lists none.
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    #[cfg_attr(feature = "serde-conversion", serde(with = "serde_bytes_var"))]
60    pub kept_fields: Vec<u8>,
61}
62
63/// The mark a restore leaves on a removal record: who brought the document back, and when.
64#[derive(
65    Debug, Clone, PartialEq, Eq, Default, Encode, Decode, DecodeUntrusted, Serialize, Deserialize,
66)]
67#[serde(rename_all = "camelCase")]
68pub struct ContractDocumentRestoration {
69    /// The contract owner or moderator that restored the document.
70    pub moderator_id: Identifier,
71    /// The time of the block that restored it, in milliseconds.
72    pub restored_at: TimestampMillis,
73}
74
75/// No value at the path.
76const KEPT_VALUE_ABSENT: u8 = 0;
77/// A value follows.
78const KEPT_VALUE_PRESENT: u8 = 1;
79
80/// Encodes the values a removal record keeps of `document`, a document of `document_type`
81/// (`moderatorAbilities.deleteKeepsFields`), the way the document encodes its properties: for
82/// each path the type lists, in the list's order, `0` when the document holds no value there,
83/// or `1` followed by the value as an optional property of its type is written
84/// (`DocumentPropertyType::encode_value_ref_with_size`), an object length-prefixed with its
85/// members in order, a time or a block height as eight big-endian bytes and a core block
86/// height as four. The paths themselves are not written: the type lists them and never changes
87/// the list, so a reader knows them from the type. Empty for a type that keeps none.
88pub fn encode_kept_fields(
89    document: &Document,
90    document_type: DocumentTypeRef,
91) -> Result<Vec<u8>, ProtocolError> {
92    let mut encoded = Vec::new();
93    for path in document_type.moderator_deletion_kept_fields() {
94        let value = match SystemProperty::from_name(path) {
95            Some(property) => system_property_bytes(document, property),
96            None => {
97                let property = kept_property_type(&document_type, path)?;
98                match document.get(path).filter(|value| !value.is_null()) {
99                    Some(value) => Some(property.encode_value_ref_with_size(value, false)?),
100                    None => None,
101                }
102            }
103        };
104        match value {
105            Some(value) => {
106                encoded.push(KEPT_VALUE_PRESENT);
107                encoded.extend(value);
108            }
109            None => encoded.push(KEPT_VALUE_ABSENT),
110        }
111    }
112    Ok(encoded)
113}
114
115/// Reads the values [`encode_kept_fields`] wrote, under `document_type`: each kept path to its
116/// value, typed as reading the document gives it, a path the document held no value at left
117/// out. Refuses bytes that end before the last path or run past it.
118pub fn decode_kept_fields(
119    encoded: &[u8],
120    document_type: DocumentTypeRef,
121) -> Result<BTreeMap<String, Value>, ProtocolError> {
122    let corrupted = |message: String| {
123        ProtocolError::DataContractError(DataContractError::CorruptedSerialization(message))
124    };
125    let mut buf = BufReader::new(encoded);
126    let mut values = BTreeMap::new();
127    for path in document_type.moderator_deletion_kept_fields() {
128        let value = match SystemProperty::from_name(path) {
129            Some(property) => {
130                let marker = buf
131                    .read_u8()
132                    .map_err(|_| corrupted(format!("kept fields end before \"{path}\"")))?;
133                match marker {
134                    KEPT_VALUE_ABSENT => None,
135                    KEPT_VALUE_PRESENT => Some(
136                        read_system_property(&mut buf, property)
137                            .map_err(|_| corrupted(format!("kept fields end inside \"{path}\"")))?,
138                    ),
139                    marker => {
140                        return Err(corrupted(format!(
141                            "kept field \"{path}\" has unknown marker {marker}"
142                        )))
143                    }
144                }
145            }
146            None => {
147                let (value, finished) = kept_property_type(&document_type, path)?
148                    .read_optionally_from(&mut buf, false)
149                    .map_err(ProtocolError::DataContractError)?;
150                if finished {
151                    return Err(corrupted(format!("kept fields end before \"{path}\"")));
152                }
153                value
154            }
155        };
156        if let Some(value) = value {
157            values.insert(path.clone(), value);
158        }
159    }
160    let mut trailing = [0u8; 1];
161    if buf
162        .read(&mut trailing)
163        .map_err(|error| corrupted(error.to_string()))?
164        > 0
165    {
166        return Err(corrupted(
167            "kept fields run past the last path their type keeps".to_string(),
168        ));
169    }
170    Ok(values)
171}
172
173/// The type of the property at `path`, one the parser admitted as kept on `document_type`.
174fn kept_property_type<'a>(
175    document_type: &'a DocumentTypeRef,
176    path: &str,
177) -> Result<&'a DocumentPropertyType, ProtocolError> {
178    property_at_path(document_type.properties(), path)
179        .map(|property| &property.property_type)
180        .ok_or_else(|| {
181            ProtocolError::CorruptedCodeExecution(format!(
182                "document type {} keeps \"{path}\", which it does not declare",
183                document_type.name()
184            ))
185        })
186}
187
188/// The bytes `document` holds for `property`, as the document stores its times and heights.
189fn system_property_bytes(document: &Document, property: SystemProperty) -> Option<Vec<u8>> {
190    let long = |value: Option<u64>| value.map(|value| value.to_be_bytes().to_vec());
191    let short = |value: Option<u32>| value.map(|value| value.to_be_bytes().to_vec());
192    match property {
193        SystemProperty::CreatedAt => long(document.created_at()),
194        SystemProperty::UpdatedAt => long(document.updated_at()),
195        SystemProperty::TransferredAt => long(document.transferred_at()),
196        SystemProperty::CreatedAtBlockHeight => long(document.created_at_block_height()),
197        SystemProperty::UpdatedAtBlockHeight => long(document.updated_at_block_height()),
198        SystemProperty::TransferredAtBlockHeight => long(document.transferred_at_block_height()),
199        SystemProperty::CreatedAtCoreBlockHeight => short(document.created_at_core_block_height()),
200        SystemProperty::UpdatedAtCoreBlockHeight => short(document.updated_at_core_block_height()),
201        SystemProperty::TransferredAtCoreBlockHeight => {
202            short(document.transferred_at_core_block_height())
203        }
204    }
205}
206
207/// Reads what [`system_property_bytes`] wrote for `property`: a `U64` for a time or a block
208/// height, a `U32` for a core block height.
209fn read_system_property(
210    buf: &mut BufReader<&[u8]>,
211    property: SystemProperty,
212) -> std::io::Result<Value> {
213    match property {
214        SystemProperty::CreatedAtCoreBlockHeight
215        | SystemProperty::UpdatedAtCoreBlockHeight
216        | SystemProperty::TransferredAtCoreBlockHeight => {
217            buf.read_u32::<BigEndian>().map(Value::U32)
218        }
219        SystemProperty::CreatedAt
220        | SystemProperty::UpdatedAt
221        | SystemProperty::TransferredAt
222        | SystemProperty::CreatedAtBlockHeight
223        | SystemProperty::UpdatedAtBlockHeight
224        | SystemProperty::TransferredAtBlockHeight => buf.read_u64::<BigEndian>().map(Value::U64),
225    }
226}
227
228/// What a removal record says of `property`, from the paths its document type keeps
229/// (`kept_paths`, its `moderatorAbilities.deleteKeepsFields`) and the values the record keeps
230/// (`kept_values`, [`ContractDocumentRemoval::kept_values`]): `None` when the record does not
231/// keep it, neither kept itself nor inside a kept object; otherwise the value, `Some(None)`
232/// when the document held none there. Kept paths never nest, so at most one covers it.
233pub fn kept_value_at(
234    kept_paths: &BTreeSet<String>,
235    kept_values: &BTreeMap<String, Value>,
236    property: &str,
237) -> Option<Option<Value>> {
238    if kept_paths.contains(property) {
239        return Some(kept_values.get(property).cloned());
240    }
241    let (kept, inner) = kept_paths.iter().find_map(|kept| {
242        let inner = property.strip_prefix(kept.as_str())?.strip_prefix('.')?;
243        Some((kept, inner))
244    })?;
245    // Down through the kept object, a member it lacks or a step through what is no object
246    // reading as absent, as a document's own path does
247    Some(
248        kept_values
249            .get(kept)
250            .and_then(|object| object.get_optional_value_at_path(inner).ok().flatten())
251            .filter(|value| !value.is_null())
252            .cloned(),
253    )
254}
255
256impl ContractDocumentRemoval {
257    /// Whether the document was restored: the record then describes a removal that was
258    /// undone, and the document is live again.
259    pub fn is_restored(&self) -> bool {
260        self.restoration.is_some()
261    }
262
263    /// The values the record keeps, read under `document_type`, the removed document's type,
264    /// as its documents are: each path the type keeps to its value, a path the document held
265    /// no value at left out (see [`decode_kept_fields`]). Empty when the record keeps none.
266    pub fn kept_values(
267        &self,
268        document_type: DocumentTypeRef,
269    ) -> Result<BTreeMap<String, Value>, ProtocolError> {
270        if self.kept_fields.is_empty() {
271            return Ok(BTreeMap::new());
272        }
273        decode_kept_fields(&self.kept_fields, document_type)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::data_contract::config::moderation::{ContractModerationConfig, ContractModerators};
281    use crate::data_contract::config::DataContractConfig;
282    use crate::data_contract::document_type::{is_path_listed, DocumentType};
283    use crate::document::DocumentV0;
284    use platform_value::platform_value;
285    use platform_version::version::PlatformVersion;
286
287    /// A post keeping a text it may lack, an integer, an identifier, a whole object, a member
288    /// of another object, and its creation time and core block height
289    fn post_type() -> DocumentType {
290        let platform_version = PlatformVersion::latest();
291        let config = DataContractConfig::default_for_version(platform_version)
292            .expect("expected a default config")
293            .with_moderation(Some(ContractModerationConfig {
294                banlist: false,
295                suspensions: false,
296                moderators: ContractModerators::ContractOwner,
297                warnings: false,
298            }));
299        DocumentType::try_from_schema(
300            Identifier::new([1; 32]),
301            1,
302            config.version(),
303            "post",
304            platform_value!({
305                "type": "object",
306                "properties": {
307                    "text": { "type": "string", "maxLength": 50, "position": 0 },
308                    "hashtag": { "type": "string", "maxLength": 61, "position": 1 },
309                    "score": { "type": "integer", "position": 2 },
310                    "author": {
311                        "type": "array",
312                        "byteArray": true,
313                        "minItems": 32,
314                        "maxItems": 32,
315                        "contentMediaType": "application/x.dash.dpp.identifier",
316                        "position": 3,
317                    },
318                    "meta": {
319                        "type": "object",
320                        "position": 4,
321                        "properties": {
322                            "tags": {
323                                "type": "array",
324                                "items": { "type": "string", "maxLength": 20 },
325                                "maxItems": 5,
326                                "position": 0,
327                            },
328                            "note": { "type": "string", "maxLength": 20, "position": 1 },
329                        },
330                        "additionalProperties": false,
331                    },
332                    "extra": {
333                        "type": "object",
334                        "position": 5,
335                        "properties": {
336                            "count": { "type": "integer", "position": 0 },
337                            "label": { "type": "string", "maxLength": 20, "position": 1 },
338                        },
339                        "additionalProperties": false,
340                    },
341                },
342                "required": ["$createdAt", "$createdAtCoreBlockHeight"],
343                "additionalProperties": false,
344                "moderatorAbilities": {
345                    "delete": true,
346                    "deleteKeepsFields": [
347                        "$createdAt",
348                        "$createdAtCoreBlockHeight",
349                        "author",
350                        "extra.count",
351                        "hashtag",
352                        "meta",
353                        "score",
354                        "text",
355                    ],
356                },
357            }),
358            None,
359            &BTreeMap::new(),
360            &config,
361            true,
362            &mut vec![],
363            platform_version,
364        )
365        .expect("expected the post type")
366    }
367
368    fn post(properties: BTreeMap<String, Value>) -> Document {
369        Document::V0(DocumentV0 {
370            id: Identifier::new([2; 32]),
371            owner_id: Identifier::new([3; 32]),
372            properties,
373            created_at: Some(1_700_000_000_000),
374            created_at_core_block_height: Some(55),
375            ..Default::default()
376        })
377    }
378
379    #[test]
380    fn should_keep_values_as_the_document_holds_them() {
381        let document_type = post_type();
382        let meta = Value::Map(vec![
383            (
384                Value::Text("tags".to_string()),
385                Value::Array(vec![
386                    Value::Text("privacy".to_string()),
387                    Value::Text("payments".to_string()),
388                ]),
389            ),
390            (
391                Value::Text("note".to_string()),
392                Value::Text("n".to_string()),
393            ),
394        ]);
395        let document = post(BTreeMap::from([
396            ("hashtag".to_string(), Value::Text("dash".to_string())),
397            ("score".to_string(), Value::I64(-3)),
398            ("author".to_string(), Value::Identifier([4; 32])),
399            ("meta".to_string(), meta.clone()),
400            (
401                "extra".to_string(),
402                Value::Map(vec![
403                    (Value::Text("count".to_string()), Value::I64(7)),
404                    (
405                        Value::Text("label".to_string()),
406                        Value::Text("not kept".to_string()),
407                    ),
408                ]),
409            ),
410        ]));
411
412        let encoded =
413            encode_kept_fields(&document, document_type.as_ref()).expect("expected to encode");
414        // One marker per kept path, in the list's order: `$createdAt` first, then its value
415        assert_eq!(encoded[0], KEPT_VALUE_PRESENT);
416        assert_eq!(&encoded[1..9], &1_700_000_000_000u64.to_be_bytes());
417        // `text`, last in the list, holds nothing
418        assert_eq!(encoded.last(), Some(&KEPT_VALUE_ABSENT));
419
420        let removal = ContractDocumentRemoval {
421            kept_fields: encoded,
422            ..Default::default()
423        };
424        assert_eq!(
425            removal
426                .kept_values(document_type.as_ref())
427                .expect("expected to decode"),
428            BTreeMap::from([
429                ("$createdAt".to_string(), Value::U64(1_700_000_000_000)),
430                ("$createdAtCoreBlockHeight".to_string(), Value::U32(55)),
431                ("author".to_string(), Value::Identifier([4; 32])),
432                ("extra.count".to_string(), Value::I64(7)),
433                ("hashtag".to_string(), Value::Text("dash".to_string())),
434                ("meta".to_string(), meta),
435                ("score".to_string(), Value::I64(-3)),
436            ])
437        );
438    }
439
440    #[test]
441    fn should_read_a_kept_path_or_a_path_inside_a_kept_object() {
442        let document_type = post_type();
443        let kept_paths = document_type.moderator_deletion_kept_fields();
444        let document = post(BTreeMap::from([
445            ("hashtag".to_string(), Value::Text("dash".to_string())),
446            (
447                "meta".to_string(),
448                Value::Map(vec![(
449                    Value::Text("tags".to_string()),
450                    Value::Array(vec![Value::Text("privacy".to_string())]),
451                )]),
452            ),
453            (
454                "extra".to_string(),
455                Value::Map(vec![(Value::Text("count".to_string()), Value::I64(7))]),
456            ),
457        ]));
458        let removal = ContractDocumentRemoval {
459            kept_fields: encode_kept_fields(&document, document_type.as_ref())
460                .expect("expected to encode"),
461            ..Default::default()
462        };
463        let kept_values = removal
464            .kept_values(document_type.as_ref())
465            .expect("expected to decode");
466        // Each as the document holds it, at the path the document holds it
467        for path in ["hashtag", "meta.tags", "extra.count"] {
468            let value = document.get(path).cloned();
469            assert!(value.is_some(), "{path}");
470            assert_eq!(
471                kept_value_at(kept_paths, &kept_values, path),
472                Some(value),
473                "{path}"
474            );
475        }
476        // Kept, but nothing where the document held nothing, inside a kept object or not
477        for path in ["meta.note", "text"] {
478            assert_eq!(
479                kept_value_at(kept_paths, &kept_values, path),
480                Some(None),
481                "{path}"
482            );
483        }
484        // Not kept: a member of an object only partly kept, the object itself, a name
485        // sharing a kept path's prefix, and the owner, which every record holds apart
486        for path in ["extra.label", "extra", "hashtagged", "$ownerId"] {
487            assert!(!is_path_listed(kept_paths, path), "{path}");
488            assert_eq!(
489                kept_value_at(kept_paths, &kept_values, path),
490                None,
491                "{path}"
492            );
493        }
494    }
495
496    #[test]
497    fn should_keep_nothing_of_a_document_that_holds_none_of_the_paths() {
498        let document_type = post_type();
499        let document = Document::V0(DocumentV0 {
500            id: Identifier::new([2; 32]),
501            owner_id: Identifier::new([3; 32]),
502            ..Default::default()
503        });
504        let encoded =
505            encode_kept_fields(&document, document_type.as_ref()).expect("expected to encode");
506        assert_eq!(encoded, vec![KEPT_VALUE_ABSENT; 8]);
507        assert_eq!(
508            decode_kept_fields(&encoded, document_type.as_ref()).expect("expected to decode"),
509            BTreeMap::new()
510        );
511    }
512
513    #[test]
514    fn should_refuse_kept_fields_cut_short_or_running_past_the_last_path() {
515        let document_type = post_type();
516        let document = post(BTreeMap::from([(
517            "hashtag".to_string(),
518            Value::Text("dash".to_string()),
519        )]));
520        let encoded =
521            encode_kept_fields(&document, document_type.as_ref()).expect("expected to encode");
522        decode_kept_fields(&encoded[..encoded.len() - 1], document_type.as_ref())
523            .expect_err("the last path's marker is missing");
524        decode_kept_fields(&encoded[..3], document_type.as_ref())
525            .expect_err("cut short inside a time");
526        let mut trailing = encoded.clone();
527        trailing.push(0);
528        decode_kept_fields(&trailing, document_type.as_ref()).expect_err("a byte past the end");
529        let mut unknown_marker = encoded;
530        unknown_marker[0] = 7;
531        decode_kept_fields(&unknown_marker, document_type.as_ref()).expect_err("an unknown marker");
532    }
533}