Skip to main content

ruma_events/
room.rs

1//! Modules for events in the `m.room` namespace.
2//!
3//! This module also contains types shared by events in its child namespaces.
4
5use std::{
6    collections::{BTreeMap, btree_map},
7    fmt,
8    ops::Deref,
9};
10
11use as_variant::as_variant;
12use js_int::UInt;
13use ruma_common::{
14    OwnedMxcUri,
15    serde::{
16        Base64, JsonObject,
17        base64::{Standard, UrlSafe},
18    },
19};
20use ruma_macros::StringEnum;
21use serde::{Deserialize, Serialize, de};
22use zeroize::Zeroize;
23
24use crate::PrivOwnedStr;
25
26pub mod avatar;
27pub mod canonical_alias;
28pub mod create;
29pub mod encrypted;
30mod encrypted_file_serde;
31pub mod encryption;
32pub mod guest_access;
33pub mod history_visibility;
34pub mod join_rules;
35#[cfg(feature = "unstable-msc4334")]
36pub mod language;
37pub mod member;
38pub mod message;
39pub mod name;
40pub mod pinned_events;
41pub mod policy;
42pub mod power_levels;
43pub mod redaction;
44pub mod server_acl;
45pub mod third_party_invite;
46mod thumbnail_source_serde;
47pub mod tombstone;
48pub mod topic;
49
50/// The source of a media file.
51#[derive(Clone, Debug, Serialize)]
52#[allow(clippy::exhaustive_enums)]
53pub enum MediaSource {
54    /// The MXC URI to the unencrypted media file.
55    #[serde(rename = "url")]
56    Plain(OwnedMxcUri),
57
58    /// The encryption info of the encrypted media file.
59    #[serde(rename = "file")]
60    Encrypted(Box<EncryptedFile>),
61}
62
63// Custom implementation of `Deserialize`, because serde doesn't guarantee what variant will be
64// deserialized for "externally tagged"¹ enums where multiple "tag" fields exist.
65//
66// ¹ https://serde.rs/enum-representations.html
67impl<'de> Deserialize<'de> for MediaSource {
68    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
69    where
70        D: serde::Deserializer<'de>,
71    {
72        #[derive(Deserialize)]
73        struct MediaSourceJsonRepr {
74            url: Option<OwnedMxcUri>,
75            file: Option<Box<EncryptedFile>>,
76        }
77
78        match MediaSourceJsonRepr::deserialize(deserializer)? {
79            MediaSourceJsonRepr { url: None, file: None } => Err(de::Error::missing_field("url")),
80            // Prefer file if it is set
81            MediaSourceJsonRepr { file: Some(file), .. } => Ok(MediaSource::Encrypted(file)),
82            MediaSourceJsonRepr { url: Some(url), .. } => Ok(MediaSource::Plain(url)),
83        }
84    }
85}
86
87/// Metadata about an image.
88#[derive(Clone, Debug, Default, Deserialize, Serialize)]
89#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
90pub struct ImageInfo {
91    /// The height of the image in pixels.
92    #[serde(rename = "h", skip_serializing_if = "Option::is_none")]
93    pub height: Option<UInt>,
94
95    /// The width of the image in pixels.
96    #[serde(rename = "w", skip_serializing_if = "Option::is_none")]
97    pub width: Option<UInt>,
98
99    /// The MIME type of the image, e.g. "image/png."
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub mimetype: Option<String>,
102
103    /// The file size of the image in bytes.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub size: Option<UInt>,
106
107    /// Metadata about the image referred to in `thumbnail_source`.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub thumbnail_info: Option<Box<ThumbnailInfo>>,
110
111    /// The source of the thumbnail of the image.
112    #[serde(flatten, with = "thumbnail_source_serde", skip_serializing_if = "Option::is_none")]
113    pub thumbnail_source: Option<MediaSource>,
114
115    /// The [BlurHash](https://blurha.sh) for this image.
116    ///
117    /// This uses the unstable prefix in
118    /// [MSC2448](https://github.com/matrix-org/matrix-spec-proposals/pull/2448).
119    #[cfg(feature = "unstable-msc2448")]
120    #[serde(rename = "xyz.amorgan.blurhash", skip_serializing_if = "Option::is_none")]
121    pub blurhash: Option<String>,
122
123    /// The [ThumbHash](https://evanw.github.io/thumbhash/) for this image.
124    ///
125    /// This uses the unstable prefix in
126    /// [MSC2448](https://github.com/matrix-org/matrix-spec-proposals/pull/2448).
127    #[cfg(feature = "unstable-msc2448")]
128    #[serde(rename = "xyz.amorgan.thumbhash", skip_serializing_if = "Option::is_none")]
129    pub thumbhash: Option<Base64>,
130
131    /// If this flag is `true`, the original image SHOULD be assumed to be animated. If this flag
132    /// is `false`, the original image SHOULD be assumed to NOT be animated.
133    ///
134    /// If a sending client is unable to determine whether an image is animated, it SHOULD leave
135    /// the flag unset.
136    ///
137    /// Receiving clients MAY use this flag to optimize whether to download the original image
138    /// rather than a thumbnail if it is animated, but they SHOULD NOT trust this flag.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub is_animated: Option<bool>,
141}
142
143impl ImageInfo {
144    /// Creates an empty `ImageInfo`.
145    pub fn new() -> Self {
146        Self::default()
147    }
148}
149
150/// Metadata about a thumbnail.
151#[derive(Clone, Debug, Default, Deserialize, Serialize)]
152#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
153pub struct ThumbnailInfo {
154    /// The height of the thumbnail in pixels.
155    #[serde(rename = "h", skip_serializing_if = "Option::is_none")]
156    pub height: Option<UInt>,
157
158    /// The width of the thumbnail in pixels.
159    #[serde(rename = "w", skip_serializing_if = "Option::is_none")]
160    pub width: Option<UInt>,
161
162    /// The MIME type of the thumbnail, e.g. "image/png."
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub mimetype: Option<String>,
165
166    /// The file size of the thumbnail in bytes.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub size: Option<UInt>,
169}
170
171impl ThumbnailInfo {
172    /// Creates an empty `ThumbnailInfo`.
173    pub fn new() -> Self {
174        Self::default()
175    }
176}
177
178/// A file sent to a room with end-to-end encryption enabled.
179#[derive(Clone, Debug, Deserialize, Serialize)]
180#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
181pub struct EncryptedFile {
182    /// The URL to the file.
183    pub url: OwnedMxcUri,
184
185    /// Information about the encryption of the file.
186    #[serde(flatten)]
187    pub info: EncryptedFileInfo,
188
189    /// A map from an algorithm name to a hash of the ciphertext.
190    ///
191    /// Clients should support the SHA-256 hash.
192    pub hashes: EncryptedFileHashes,
193}
194
195impl EncryptedFile {
196    /// Construct a new `EncryptedFile` with the given URL, encryption info and hashes.
197    pub fn new(url: OwnedMxcUri, info: EncryptedFileInfo, hashes: EncryptedFileHashes) -> Self {
198        Self { url, info, hashes }
199    }
200}
201
202/// Information about the encryption of a file.
203#[derive(Debug, Clone, Deserialize, Serialize)]
204#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
205#[serde(tag = "v", rename_all = "lowercase")]
206pub enum EncryptedFileInfo {
207    /// Information about a file encrypted using version 2 of the attachment encryption protocol.
208    V2(V2EncryptedFileInfo),
209
210    #[doc(hidden)]
211    #[serde(untagged)]
212    _Custom(CustomEncryptedFileInfo),
213}
214
215impl EncryptedFileInfo {
216    /// Get the version of the attachment encryption protocol.
217    ///
218    /// This matches the `v` field in the serialized data.
219    pub fn version(&self) -> &str {
220        match self {
221            Self::V2(_) => "v2",
222            Self::_Custom(info) => &info.v,
223        }
224    }
225
226    /// Get the data of the attachment encryption protocol, if it doesn't match one of the known
227    /// variants.
228    pub fn custom_data(&self) -> Option<&JsonObject> {
229        as_variant!(self, Self::_Custom(info) => &info.data)
230    }
231}
232
233impl From<V2EncryptedFileInfo> for EncryptedFileInfo {
234    fn from(value: V2EncryptedFileInfo) -> Self {
235        Self::V2(value)
236    }
237}
238
239/// A file encrypted with the AES-CTR algorithm with a 256-bit key.
240#[derive(Clone)]
241#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
242pub struct V2EncryptedFileInfo {
243    /// The 256-bit key used to encrypt or decrypt the file.
244    pub k: Base64<UrlSafe, [u8; 32]>,
245
246    /// The 128-bit unique counter block used by AES-CTR.
247    pub iv: Base64<Standard, [u8; 16]>,
248}
249
250impl V2EncryptedFileInfo {
251    /// Construct a new `V2EncryptedFileInfo` with the given encoded key and initialization vector.
252    pub fn new(k: Base64<UrlSafe, [u8; 32]>, iv: Base64<Standard, [u8; 16]>) -> Self {
253        Self { k, iv }
254    }
255
256    /// Construct a new `V2EncryptedFileInfo` by base64-encoding the given key and initialization
257    /// vector bytes.
258    pub fn encode(k: [u8; 32], iv: [u8; 16]) -> Self {
259        Self::new(Base64::new(k), Base64::new(iv))
260    }
261}
262
263impl fmt::Debug for V2EncryptedFileInfo {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        f.debug_struct("V2EncryptedFileInfo").finish_non_exhaustive()
266    }
267}
268
269impl Drop for V2EncryptedFileInfo {
270    fn drop(&mut self) {
271        self.k.zeroize();
272    }
273}
274
275/// Information about a file encrypted using a custom version of the attachment encryption protocol.
276#[doc(hidden)]
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct CustomEncryptedFileInfo {
279    /// The version of the protocol.
280    v: String,
281
282    /// Extra data about the encryption.
283    #[serde(flatten)]
284    data: JsonObject,
285}
286
287/// A map of [`EncryptedFileHashAlgorithm`] to the associated [`EncryptedFileHash`].
288///
289/// This type is used to ensure that a supported [`EncryptedFileHash`] always matches the
290/// appropriate [`EncryptedFileHashAlgorithm`].
291#[derive(Clone, Debug, Default)]
292#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
293pub struct EncryptedFileHashes(BTreeMap<EncryptedFileHashAlgorithm, EncryptedFileHash>);
294
295impl EncryptedFileHashes {
296    /// Construct an empty `EncryptedFileHashes`.
297    pub fn new() -> Self {
298        Self::default()
299    }
300
301    /// Construct an `EncryptedFileHashes` that includes the given SHA-256 hash.
302    pub fn with_sha256(hash: [u8; 32]) -> Self {
303        std::iter::once(EncryptedFileHash::Sha256(Base64::new(hash))).collect()
304    }
305
306    /// Insert the given [`EncryptedFileHash`].
307    ///
308    /// If a map with the same [`EncryptedFileHashAlgorithm`] was already present, it is returned.
309    pub fn insert(&mut self, hash: EncryptedFileHash) -> Option<EncryptedFileHash> {
310        self.0.insert(hash.algorithm(), hash)
311    }
312}
313
314impl Deref for EncryptedFileHashes {
315    type Target = BTreeMap<EncryptedFileHashAlgorithm, EncryptedFileHash>;
316
317    fn deref(&self) -> &Self::Target {
318        &self.0
319    }
320}
321
322impl FromIterator<EncryptedFileHash> for EncryptedFileHashes {
323    fn from_iter<T: IntoIterator<Item = EncryptedFileHash>>(iter: T) -> Self {
324        Self(iter.into_iter().map(|hash| (hash.algorithm(), hash)).collect())
325    }
326}
327
328impl Extend<EncryptedFileHash> for EncryptedFileHashes {
329    fn extend<T: IntoIterator<Item = EncryptedFileHash>>(&mut self, iter: T) {
330        self.0.extend(iter.into_iter().map(|hash| (hash.algorithm(), hash)));
331    }
332}
333
334impl IntoIterator for EncryptedFileHashes {
335    type Item = EncryptedFileHash;
336    type IntoIter = btree_map::IntoValues<EncryptedFileHashAlgorithm, EncryptedFileHash>;
337
338    fn into_iter(self) -> Self::IntoIter {
339        self.0.into_values()
340    }
341}
342
343/// An algorithm used to generate the hash of an [`EncryptedFile`].
344#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
345#[derive(Clone, StringEnum)]
346#[ruma_enum(rename_all = "lowercase")]
347#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
348pub enum EncryptedFileHashAlgorithm {
349    /// The SHA-256 algorithm
350    Sha256,
351
352    #[doc(hidden)]
353    _Custom(PrivOwnedStr),
354}
355
356/// The hash of an encrypted file's ciphertext.
357#[derive(Clone, Debug)]
358#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
359pub enum EncryptedFileHash {
360    /// A hash computed with the SHA-256 algorithm.
361    Sha256(Base64<Standard, [u8; 32]>),
362
363    #[doc(hidden)]
364    _Custom(CustomEncryptedFileHash),
365}
366
367impl EncryptedFileHash {
368    /// The key that was used to group this map.
369    pub fn algorithm(&self) -> EncryptedFileHashAlgorithm {
370        match self {
371            Self::Sha256(_) => EncryptedFileHashAlgorithm::Sha256,
372            Self::_Custom(custom) => custom.algorithm.as_str().into(),
373        }
374    }
375
376    /// Get a reference to the decoded bytes of the hash.
377    pub fn as_bytes(&self) -> &[u8] {
378        match self {
379            Self::Sha256(hash) => hash.as_bytes(),
380            Self::_Custom(custom) => custom.hash.as_bytes(),
381        }
382    }
383
384    /// Get the decoded bytes of the hash.
385    pub fn into_bytes(self) -> Vec<u8> {
386        match self {
387            Self::Sha256(hash) => hash.into_inner().into(),
388            Self::_Custom(custom) => custom.hash.into_inner(),
389        }
390    }
391}
392
393/// A map of results grouped by custom key type.
394#[doc(hidden)]
395#[derive(Clone, Debug)]
396pub struct CustomEncryptedFileHash {
397    /// The algorithm that was used to generate the hash.
398    algorithm: String,
399
400    /// The hash.
401    hash: Base64,
402}
403
404#[cfg(test)]
405mod tests {
406    use assert_matches2::assert_matches;
407    use ruma_common::owned_mxc_uri;
408    use serde::Deserialize;
409    use serde_json::{from_value as from_json_value, json};
410
411    use super::{EncryptedFile, MediaSource, V2EncryptedFileInfo};
412    use crate::room::EncryptedFileHashes;
413
414    #[derive(Deserialize)]
415    struct MsgWithAttachment {
416        #[allow(dead_code)]
417        body: String,
418        #[serde(flatten)]
419        source: MediaSource,
420    }
421
422    #[test]
423    fn prefer_encrypted_attachment_over_plain() {
424        let msg: MsgWithAttachment = from_json_value(json!({
425            "body": "",
426            "file": EncryptedFile::new(
427                owned_mxc_uri!("mxc://localhost/encryptedfile"),
428                V2EncryptedFileInfo::encode([0;32], [1;16]).into(),
429                EncryptedFileHashes::new(),
430            ),
431            "url": "mxc://localhost/file",
432        }))
433        .unwrap();
434
435        assert_matches!(msg.source, MediaSource::Encrypted(_));
436    }
437}