Skip to main content

matrix_sdk_contentscanner/
lib.rs

1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{
16    fmt::{Debug, Formatter},
17    sync::Arc,
18};
19
20use api::{
21    download::{
22        encrypted::DownloadAndScanEncryptedMediaRequest, unencrypted::DownloadAndScanMediaRequest,
23    },
24    public_server_key::PublicServerKeyRequest,
25};
26use matrix_sdk::{
27    BoxFuture, Client, Error, IdParseError,
28    encryption::vodozemac::pk_encryption::Message,
29    locks::Mutex,
30    media::{MediaFetcher, MediaRequestParameters},
31    ruma::events::room::MediaSource,
32};
33use matrix_sdk_crypto::olm::Curve25519PublicKey;
34use ruma::{
35    events::room::EncryptedFile,
36    serde::{Base64, base64::Standard},
37};
38use serde::{Deserialize, Serialize};
39use tracing::trace;
40
41#[cfg(feature = "uniffi")]
42uniffi::setup_scaffolding!();
43
44pub use crate::api::scan::MediaScanResponse;
45use crate::api::{
46    DownloadAndScanMediaResponse,
47    scan::{encrypted::EncryptedMediaScanRequest, unencrypted::MediaScanRequest},
48};
49
50mod api;
51
52/// A helper component to download and scan media from a content scanner server.
53#[derive(Debug)]
54pub struct ContentScanner {
55    scanner_url: String,
56    public_server_key: Arc<Mutex<Option<String>>>,
57}
58
59impl ContentScanner {
60    /// Instantiate a new [`ContentScanner`] using the `scanner_url`.
61    pub fn new(scanner_url: impl Into<String>) -> Self {
62        Self { scanner_url: scanner_url.into(), public_server_key: Arc::new(Mutex::new(None)) }
63    }
64
65    pub(crate) async fn fetch_public_server_key(&self, client: &Client) -> Result<String, Error> {
66        let response = client.send(PublicServerKeyRequest::new(self.scanner_url.clone())).await?;
67        Ok(response.public_key)
68    }
69
70    async fn get_or_fetch_public_server_key(&self, client: &Client) -> Option<Curve25519PublicKey> {
71        let public_server_key =
72            if let Some(public_server_key) = (*self.public_server_key.lock()).clone() {
73                trace!("Using cached public server key");
74                Some(public_server_key)
75            } else {
76                trace!("Using cached public server key");
77                let ret = self.fetch_public_server_key(client).await.ok();
78
79                if let Some(public_server_key) = &ret {
80                    trace!("Saved new public server key");
81                    let mut guard = self.public_server_key.lock();
82                    let _ = guard.insert(public_server_key.clone());
83                }
84
85                ret
86            };
87
88        public_server_key.and_then(|key| Curve25519PublicKey::from_base64(&key).ok())
89    }
90
91    pub(crate) async fn get_media(
92        &self,
93        client: &Client,
94        media_source: &MediaSource,
95    ) -> Result<DownloadAndScanMediaResponse, Error> {
96        match &media_source {
97            MediaSource::Encrypted(encrypted) => {
98                // Get the public server key if we don't have it yet.
99                let public_server_key = self.get_or_fetch_public_server_key(client).await;
100
101                Ok(client
102                    .send(DownloadAndScanEncryptedMediaRequest::new(
103                        self.scanner_url.clone(),
104                        public_server_key,
105                        *encrypted.clone(),
106                    ))
107                    .await?)
108            }
109            MediaSource::Plain(mxc) => {
110                let (server_name, media_id) =
111                    mxc.parts().map_err(|e| Error::Identifier(IdParseError::InvalidMxcUri(e)))?;
112                Ok(client
113                    .send(DownloadAndScanMediaRequest::new(
114                        &self.scanner_url,
115                        server_name.as_str(),
116                        media_id,
117                    ))
118                    .await?)
119            }
120        }
121    }
122
123    /// Scan a media source, returning a [`MediaScanResponse`] with the scan
124    /// result, or an error if something failed when trying to scan the media.
125    pub async fn scan(
126        &self,
127        client: &Client,
128        media_source: &MediaSource,
129    ) -> Result<MediaScanResponse, Error> {
130        match &media_source {
131            MediaSource::Encrypted(encrypted) => {
132                // Get the public server key if we don't have it yet.
133                let public_server_key = self.get_or_fetch_public_server_key(client).await;
134
135                Ok(client
136                    .send(EncryptedMediaScanRequest::new(
137                        self.scanner_url.clone(),
138                        public_server_key,
139                        *encrypted.clone(),
140                    ))
141                    .await?)
142            }
143            MediaSource::Plain(mxc) => {
144                let (server_name, media_id) =
145                    mxc.parts().map_err(|e| Error::Identifier(IdParseError::InvalidMxcUri(e)))?;
146                Ok(client
147                    .send(MediaScanRequest::new(
148                        self.scanner_url.clone(),
149                        server_name.to_string(),
150                        media_id.to_owned(),
151                    ))
152                    .await?)
153            }
154        }
155    }
156}
157
158#[derive(Debug, Clone, Serialize)]
159struct EncryptedBody {
160    ciphertext: String,
161    mac: String,
162    ephemeral: String,
163}
164
165impl From<Message> for EncryptedBody {
166    fn from(value: Message) -> Self {
167        Self {
168            ciphertext: Base64::<Standard>::new(value.ciphertext).to_string(),
169            mac: Base64::<Standard>::new(value.mac).to_string(),
170            ephemeral: value.ephemeral_key.to_base64(),
171        }
172    }
173}
174
175#[derive(Debug, Clone, Serialize)]
176pub(crate) struct EncryptedFileRequest {
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub file: Option<EncryptedFile>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub encrypted_body: Option<EncryptedBody>,
181}
182
183impl EncryptedFileRequest {
184    pub(crate) fn from_file_info(file_info: EncryptedFile) -> Self {
185        Self { file: Some(file_info), encrypted_body: None }
186    }
187
188    pub(crate) fn from_encrypted_body(encrypted_body: EncryptedBody) -> Self {
189        Self { file: None, encrypted_body: Some(encrypted_body) }
190    }
191}
192
193/// A media fetcher that uses the content scanner to download and scan media.
194pub struct ContentScannerMediaFetcher {
195    pub content_scanner: Arc<ContentScanner>,
196}
197
198impl ContentScannerMediaFetcher {
199    /// Instantiate a new [`MediaFetcher`] using the provided `scanner_url`.
200    pub fn new(scanner_url: impl Into<String>) -> Self {
201        Self { content_scanner: Arc::new(ContentScanner::new(scanner_url.into())) }
202    }
203
204    pub fn with_content_scanner(content_scanner: Arc<ContentScanner>) -> Self {
205        Self { content_scanner }
206    }
207}
208
209impl MediaFetcher for ContentScannerMediaFetcher {
210    fn fetch_media_content<'a>(
211        &'a self,
212        client: &'a Client,
213        request: &'a MediaRequestParameters,
214    ) -> BoxFuture<'a, matrix_sdk::Result<Vec<u8>, Error>> {
215        Box::pin(async move {
216            Ok(self.content_scanner.get_media(client, &request.source).await?.content)
217        })
218    }
219}
220
221impl Debug for ContentScannerMediaFetcher {
222    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
223        f.write_str("ContentScannerMediaFetcher")
224    }
225}
226
227/// A content scanner error.
228#[derive(Debug, Deserialize)]
229pub struct ContentScannerError {
230    pub info: String,
231    pub reason: ErrorReason,
232}
233
234/// The reason for the content scanner error.
235#[allow(non_camel_case_types)]
236#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
237#[derive(Clone, Debug, Deserialize)]
238pub enum ErrorReason {
239    /// The JSON file is malformed.
240    MCS_MALFORMED_JSON,
241    /// The media could not be decrypted.
242    MCS_MEDIA_FAILED_TO_DECRYPT,
243    /// No access token was provided.
244    M_MISSING_TOKEN,
245    /// The access token provided is invalid.
246    M_UNKNOWN_TOKEN,
247    /// The media was not found.
248    M_NOT_FOUND,
249    /// The media has some potentially dangerous content.
250    MCS_MEDIA_NOT_CLEAN,
251    /// The media has been blocked by the server because of its mime type.
252    MCS_MIME_TYPE_FORBIDDEN,
253    /// The used public key is wrong.
254    MCS_BAD_DECRYPTION,
255    /// An unknown error occurred.
256    M_UNKNOWN,
257    /// The server failed to request media from the media repo.
258    MCS_MEDIA_REQUEST_FAILED,
259}
260
261#[cfg(test)]
262mod tests {
263    use std::ops::Not;
264
265    use assert_matches2::assert_matches;
266    use matrix_sdk::{HttpError, RumaApiError, test_utils::mocks::MatrixMockServer};
267    use matrix_sdk_test::async_test;
268    use ruma::{
269        api::{
270            MatrixVersion,
271            error::{ErrorBody, FromHttpResponseError},
272        },
273        events::room::{
274            EncryptedFile, EncryptedFileHash, EncryptedFileHashes, EncryptedFileInfo, MediaSource,
275            V2EncryptedFileInfo,
276        },
277        exports::{http::StatusCode, serde_json::json},
278        owned_mxc_uri,
279        serde::Base64,
280    };
281    use serde::Deserialize;
282    use wiremock::{
283        Mock, MockServer, ResponseTemplate,
284        matchers::{header_exists, method, path, path_regex},
285    };
286
287    use crate::{ContentScanner, ContentScannerError, ErrorReason};
288
289    #[async_test]
290    async fn test_fetch_public_key() {
291        let server = MatrixMockServer::new().await;
292        let client =
293            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
294
295        let content_scanner_server = MockServer::start().await;
296        Mock::given(method("GET"))
297            .and(path("/_matrix/media_proxy/unstable/public_key"))
298            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
299                "public_key": "1234567890"
300            })))
301            .mount(&content_scanner_server)
302            .await;
303
304        let content_scanner = ContentScanner::new(content_scanner_server.uri());
305        content_scanner.fetch_public_server_key(&client).await.expect("Load public key");
306    }
307
308    #[async_test]
309    async fn test_get_media() {
310        let server = MatrixMockServer::new().await;
311        let client =
312            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
313
314        let content_scanner_server = MockServer::start().await;
315        Mock::given(method("GET"))
316            .and(path_regex(r"/_matrix/media_proxy/unstable/download/.+/.+"))
317            .and(header_exists("Authorization"))
318            .respond_with(
319                ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]),
320            )
321            .mount(&content_scanner_server)
322            .await;
323
324        let content_scanner = ContentScanner::new(content_scanner_server.uri());
325        let media_source =
326            MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/RhfpOXOzAwzkuqcmbgMwQUrJ"));
327        content_scanner.get_media(&client, &media_source).await.expect("Get media");
328    }
329
330    #[async_test]
331    async fn test_get_media_unsupported() {
332        let server = MatrixMockServer::new().await;
333        let client =
334            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
335
336        let content_scanner_server = MockServer::start().await;
337        Mock::given(method("GET"))
338            .and(path_regex(r"/_matrix/media_proxy/unstable/download/.+/.+"))
339            .and(header_exists("Authorization"))
340            .respond_with(ResponseTemplate::new(403).set_body_json(json!({
341                "reason": "MCS_MIME_TYPE_FORBIDDEN",
342                "info": "File type: application/octet-stream not allowed",
343            })))
344            .mount(&content_scanner_server)
345            .await;
346
347        let content_scanner = ContentScanner::new(content_scanner_server.uri());
348        let media_source =
349            MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/ckTaStcNnFXLzKApkBmgRDoC"));
350        let err =
351            content_scanner.get_media(&client, &media_source).await.expect_err("Get media error");
352        let client_error = err.as_client_api_error().expect("Get client error");
353        assert_eq!(client_error.status_code, StatusCode::FORBIDDEN);
354        assert_eq!(
355            client_error.to_string(),
356            "[403] {\"info\":\"File type: application/octet-stream not allowed\",\"reason\":\"MCS_MIME_TYPE_FORBIDDEN\"}"
357        );
358    }
359
360    #[async_test]
361    async fn test_get_encrypted_media() {
362        let server = MatrixMockServer::new().await;
363        let client =
364            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
365
366        let content_scanner_server = MockServer::start().await;
367        Mock::given(method("POST"))
368            .and(path("/_matrix/media_proxy/unstable/download_encrypted"))
369            .and(header_exists("Authorization"))
370            .respond_with(
371                ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3, 4, 5, 6, 7, 8, 9]),
372            )
373            .mount(&content_scanner_server)
374            .await;
375
376        let content_scanner = ContentScanner::new(content_scanner_server.uri());
377        let file_info = EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
378            Base64::parse("9lpOscZyMOZRCF3v867nPPo3WPNMZt9JXMsuYiWRszc".as_bytes()).expect("k"),
379            Base64::parse("czvdfKSjfLEAAAAAAAAAAA".as_bytes()).expect("iv"),
380        ));
381        let mut hashes = EncryptedFileHashes::new();
382        hashes.insert(EncryptedFileHash::Sha256(
383            Base64::parse("SBbJ3hINT2LgwXK8ev82enjnhubUy5UuKGDF3SezAhs".as_bytes()).expect("hash"),
384        ));
385        let media_source = MediaSource::Encrypted(Box::new(EncryptedFile::new(
386            owned_mxc_uri!(
387                "mxc://element.io/b50f38aa8ae820c75992370e4e944a045481e3932057062074730676224"
388            ),
389            file_info,
390            hashes,
391        )));
392        content_scanner.get_media(&client, &media_source).await.expect("Get media");
393    }
394
395    #[async_test]
396    async fn test_get_encrypted_media_unsupported() {
397        let server = MatrixMockServer::new().await;
398        let client =
399            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
400
401        let content_scanner_server = MockServer::start().await;
402
403        Mock::given(method("POST"))
404            .and(path("/_matrix/media_proxy/unstable/download_encrypted"))
405            .and(header_exists("Authorization"))
406            .respond_with(ResponseTemplate::new(403).set_body_json(json!({
407                "reason": "MCS_MIME_TYPE_FORBIDDEN",
408                "info": "File type: application/octet-stream not allowed",
409            })))
410            .mount(&content_scanner_server)
411            .await;
412
413        let content_scanner = ContentScanner::new(content_scanner_server.uri());
414        let file_info = EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
415            Base64::parse("tdHdCI5mc-g29IYfhYx2wkA5o-bILP9-nXY6Np1uSnM".as_bytes()).expect("k"),
416            Base64::parse("IBFdH65KqhoAAAAAAAAAAA".as_bytes()).expect("iv"),
417        ));
418        let mut hashes = EncryptedFileHashes::new();
419        hashes.insert(EncryptedFileHash::Sha256(
420            Base64::parse("HSkkamvMSvF3Q30HInorh0ccPrxjgu+wp1vyUOmov/8".as_bytes()).expect("hash"),
421        ));
422        let media_source = MediaSource::Encrypted(Box::new(EncryptedFile::new(
423            owned_mxc_uri!("mxc://matrix.org/WlfuejQQdpvWiWVpAGwfIKJL"),
424            file_info,
425            hashes,
426        )));
427        let err = content_scanner
428            .get_media(&client, &media_source)
429            .await
430            .expect_err("Invalid type error");
431        let client_error = err.as_client_api_error().expect("Invalid error");
432        assert_eq!(client_error.status_code, StatusCode::FORBIDDEN);
433        assert_eq!(
434            client_error.to_string(),
435            "[403] {\"info\":\"File type: application/octet-stream not allowed\",\"reason\":\"MCS_MIME_TYPE_FORBIDDEN\"}"
436        );
437    }
438
439    #[async_test]
440    async fn test_scan_media() {
441        let server = MatrixMockServer::new().await;
442        let client =
443            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
444
445        let content_scanner_server = MockServer::start().await;
446        Mock::given(method("GET"))
447            .and(path_regex(r"/_matrix/media_proxy/unstable/scan/.+/.+"))
448            .and(header_exists("Authorization"))
449            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
450                "clean": true,
451                "info": "All clear!"
452            })))
453            .mount(&content_scanner_server)
454            .await;
455
456        let content_scanner = ContentScanner::new(content_scanner_server.uri());
457        let media_source =
458            MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/RhfpOXOzAwzkuqcmbgMwQUrJ"));
459        let response = content_scanner.scan(&client, &media_source).await.expect("Get media");
460        assert!(response.clean);
461    }
462
463    #[async_test]
464    async fn test_scan_encrypted_media() {
465        let server = MatrixMockServer::new().await;
466        let client =
467            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
468
469        let content_scanner_server = MockServer::start().await;
470        Mock::given(method("POST"))
471            .and(path("/_matrix/media_proxy/unstable/scan_encrypted"))
472            .and(header_exists("Authorization"))
473            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
474                "clean": true,
475                "info": "All clear!"
476            })))
477            .mount(&content_scanner_server)
478            .await;
479
480        let content_scanner = ContentScanner::new(content_scanner_server.uri());
481        let file_info = EncryptedFileInfo::V2(V2EncryptedFileInfo::new(
482            Base64::parse("9lpOscZyMOZRCF3v867nPPo3WPNMZt9JXMsuYiWRszc".as_bytes()).expect("k"),
483            Base64::parse("czvdfKSjfLEAAAAAAAAAAA".as_bytes()).expect("iv"),
484        ));
485        let mut hashes = EncryptedFileHashes::new();
486        hashes.insert(EncryptedFileHash::Sha256(
487            Base64::parse("SBbJ3hINT2LgwXK8ev82enjnhubUy5UuKGDF3SezAhs".as_bytes()).expect("hash"),
488        ));
489        let media_source = MediaSource::Encrypted(Box::new(EncryptedFile::new(
490            owned_mxc_uri!(
491                "mxc://element.io/b50f38aa8ae820c75992370e4e944a045481e3932057062074730676224"
492            ),
493            file_info,
494            hashes,
495        )));
496        let response = content_scanner.scan(&client, &media_source).await.expect("Get media");
497        assert!(response.clean);
498    }
499
500    #[async_test]
501    async fn test_scan_media_unsupported() {
502        let server = MatrixMockServer::new().await;
503        let client =
504            server.client_builder().server_versions(vec![MatrixVersion::V1_11]).build().await;
505
506        let content_scanner_server = MockServer::start().await;
507        Mock::given(method("GET"))
508            .and(path_regex(r"/_matrix/media_proxy/unstable/scan/.+/.+"))
509            .and(header_exists("Authorization"))
510            .respond_with(
511                // This always returns a 200 status code for scan results, even for failures
512                ResponseTemplate::new(200).set_body_json(json!({
513                    "clean": false,
514                    "info": "***VIRUS DETECTED***"
515                })),
516            )
517            .mount(&content_scanner_server)
518            .await;
519
520        let content_scanner = ContentScanner::new(content_scanner_server.uri());
521        let media_source =
522            MediaSource::Plain(owned_mxc_uri!("mxc://matrix.org/RhfpOXOzAwzkuqcmbgMwQUrJ"));
523        let response = content_scanner.scan(&client, &media_source).await.expect("Get media");
524        assert!(response.clean.not());
525    }
526
527    #[test]
528    fn test_error_mapping() {
529        let error = HttpError::Api(Box::new(FromHttpResponseError::Server(
530            RumaApiError::MatrixError(ruma::api::error::Error::new(
531                StatusCode::FORBIDDEN,
532                ErrorBody::Json(json!({
533                    "info": "***VIRUS DETECTED***",
534                    "reason": "MCS_MEDIA_NOT_CLEAN"
535                })),
536            )),
537        )));
538        let api_error = error.as_client_api_error().expect("error as api error");
539        assert_eq!(
540            api_error.to_string(),
541            "[403] {\"info\":\"***VIRUS DETECTED***\",\"reason\":\"MCS_MEDIA_NOT_CLEAN\"}"
542        );
543        assert_matches!(&api_error.body, ErrorBody::Json(json_body));
544        let content_scanner_error =
545            ContentScannerError::deserialize(json_body).expect("deserialize");
546        assert_eq!(content_scanner_error.info, "***VIRUS DETECTED***");
547        assert_matches!(content_scanner_error.reason, ErrorReason::MCS_MEDIA_NOT_CLEAN);
548    }
549}