Skip to main content

xmtp_content_types/
remote_attachment.rs

1use std::collections::HashMap;
2
3use crate::{
4    CodecError, ContentCodec,
5    attachment::{Attachment, AttachmentCodec},
6    encryption::{self, EncryptionKeys},
7};
8
9use xmtp_proto::xmtp::mls::message_contents::{
10    ContentTypeId, EncodedContent, content_types::RemoteAttachmentInfo,
11};
12
13pub struct RemoteAttachmentCodec {}
14
15/// Check a content URL without DNS or transfer policy.
16pub fn content_url_scheme(raw: &str) -> Result<&'static str, &'static str> {
17    let url = url::Url::parse(raw).map_err(|_| "attachment URL is invalid")?;
18    let host = url.host().ok_or("attachment URL has no host")?;
19    let loopback = match host {
20        url::Host::Domain(name) => is_loopback_name(name),
21        url::Host::Ipv4(address) => address.is_loopback(),
22        url::Host::Ipv6(address) => address.is_loopback(),
23    };
24    match url.scheme() {
25        "https" => Ok("https://"),
26        "http" if loopback => Ok("http://"),
27        _ => Err("attachment URL requires HTTPS or loopback HTTP"),
28    }
29}
30
31pub fn is_loopback_name(name: &str) -> bool {
32    name.strip_suffix('.')
33        .unwrap_or(name)
34        .eq_ignore_ascii_case("localhost")
35}
36
37// implements: CTYPE-014
38pub(crate) fn normalize_decoded_scheme(value: &mut RemoteAttachment) {
39    if matches!(value.scheme.as_str(), "https" | "http") {
40        value.scheme.push_str("://");
41    }
42}
43
44// implements: CTYPE-014
45pub(crate) fn validate_remote_attachment(value: &RemoteAttachment) -> Result<(), &'static str> {
46    use crate::encryption::{AES_GCM_NONCE_SIZE, HKDF_SALT_SIZE, SECRET_SIZE};
47    const SHA256_HEX_LENGTH: usize = 64;
48    if value.content_digest.len() != SHA256_HEX_LENGTH
49        || !value
50            .content_digest
51            .bytes()
52            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
53    {
54        return Err("contentDigest requires 64 lowercase hexadecimal digits");
55    }
56    if value.secret.len() != SECRET_SIZE {
57        return Err("secret requires 32 bytes");
58    }
59    if value.salt.len() != HKDF_SALT_SIZE {
60        return Err("salt requires 32 bytes");
61    }
62    if value.nonce.len() != AES_GCM_NONCE_SIZE {
63        return Err("nonce requires 12 bytes");
64    }
65    if value.scheme != content_url_scheme(&value.url)? {
66        return Err("scheme does not match the permitted URL scheme");
67    }
68    Ok(())
69}
70
71/// Result of encrypting an attachment for remote storage.
72///
73/// Contains the encrypted bytes to upload and all metadata needed to create a `RemoteAttachment`.
74#[derive(Clone)]
75pub struct EncryptedAttachment {
76    /// The encrypted bytes to upload to the remote server
77    pub payload: Vec<u8>,
78    /// SHA-256 digest of the encrypted bytes (hex-encoded)
79    pub content_digest: String,
80    /// The 32-byte secret key needed for decryption
81    pub secret: Vec<u8>,
82    /// The 32-byte salt used in key derivation
83    pub salt: Vec<u8>,
84    /// The 12-byte nonce used in encryption
85    pub nonce: Vec<u8>,
86    /// The length of the encrypted content
87    pub content_length: u32,
88    /// The filename of the attachment
89    pub filename: Option<String>,
90}
91
92impl std::fmt::Debug for EncryptedAttachment {
93    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        formatter
95            .debug_struct("EncryptedAttachment")
96            .field("payload", &self.payload)
97            .field("content_digest", &self.content_digest)
98            .field("secret", &"<redacted>")
99            .field("salt", &self.salt)
100            .field("nonce", &self.nonce)
101            .field("content_length", &self.content_length)
102            .field("filename", &self.filename)
103            .finish()
104    }
105}
106
107/// Encrypts an attachment for storage as a remote attachment.
108// implements: CTYPE-015
109pub fn encrypt_attachment(attachment: Attachment) -> Result<EncryptedAttachment, CodecError> {
110    let filename = attachment.filename.clone();
111
112    // Encode the Attachment to EncodedContent
113    let encoded_content = AttachmentCodec::encode(attachment)?;
114
115    let encrypted = encryption::encrypt_encoded_content(encoded_content)?;
116    let content_length = u32::try_from(encrypted.keys.length).map_err(|_| {
117        CodecError::Encode(format!(
118            "attachment size {} exceeds maximum of {} bytes",
119            encrypted.keys.length,
120            u32::MAX
121        ))
122    })?;
123
124    Ok(EncryptedAttachment {
125        content_length,
126        payload: encrypted.ciphertext,
127        content_digest: encrypted.keys.digest,
128        secret: encrypted.keys.secret,
129        salt: encrypted.keys.salt,
130        nonce: encrypted.keys.nonce,
131        filename,
132    })
133}
134
135/// Decrypts an attachment that was encrypted with [`encrypt_attachment`].
136// implements: CTYPE-015
137pub fn decrypt_attachment(
138    encrypted_bytes: &[u8],
139    remote_attachment: &RemoteAttachment,
140) -> Result<Attachment, CodecError> {
141    let keys = EncryptionKeys {
142        secret: remote_attachment.secret.clone(),
143        salt: remote_attachment.salt.clone(),
144        nonce: remote_attachment.nonce.clone(),
145        digest: remote_attachment.content_digest.clone(),
146        length: encrypted_bytes.len() as u64,
147    };
148    let encoded_content = encryption::decrypt_encoded_content(encrypted_bytes, &keys)?;
149
150    // Decode the Attachment
151    AttachmentCodec::decode(encoded_content)
152}
153
154/// Legacy content type id at <https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-remote-attachment/src/RemoteAttachment.ts>
155impl RemoteAttachmentCodec {
156    const AUTHORITY_ID: &'static str = "xmtp.org";
157    pub const TYPE_ID: &'static str = "remoteStaticAttachment";
158    pub const MAJOR_VERSION: u32 = 1;
159    pub const MINOR_VERSION: u32 = 0;
160}
161
162impl RemoteAttachmentCodec {
163    fn fallback(content: &RemoteAttachment) -> Option<String> {
164        Some(format!(
165            "Can't display {}. This app doesn't support remote attachments.",
166            content
167                .filename
168                .clone()
169                .unwrap_or("this content".to_string())
170        ))
171    }
172}
173
174impl ContentCodec<RemoteAttachment> for RemoteAttachmentCodec {
175    fn content_type() -> ContentTypeId {
176        ContentTypeId {
177            authority_id: Self::AUTHORITY_ID.to_string(),
178            type_id: Self::TYPE_ID.to_string(),
179            version_major: RemoteAttachmentCodec::MAJOR_VERSION,
180            version_minor: RemoteAttachmentCodec::MINOR_VERSION,
181        }
182    }
183
184    fn encode(data: RemoteAttachment) -> Result<EncodedContent, CodecError> {
185        validate_remote_attachment(&data).map_err(|reason| CodecError::Encode(reason.into()))?;
186        let fallback = Self::fallback(&data);
187        let mut parameters = [
188            ("contentDigest", data.content_digest),
189            ("salt", hex::encode(data.salt)),
190            ("nonce", hex::encode(data.nonce)),
191            ("secret", hex::encode(data.secret)),
192            ("scheme", data.scheme),
193        ]
194        .into_iter()
195        .map(|(k, v)| (k.to_string(), v))
196        .collect::<HashMap<_, _>>();
197
198        if let Some(content_length) = data.content_length {
199            parameters.insert("contentLength".to_string(), content_length.to_string());
200        }
201
202        if let Some(filename) = data.filename {
203            parameters.insert("filename".to_string(), filename);
204        }
205
206        Ok(EncodedContent {
207            r#type: Some(Self::content_type()),
208            parameters,
209            fallback,
210            compression: None,
211            content: data.url.into_bytes(),
212        })
213    }
214
215    fn decode(encoded: EncodedContent) -> Result<RemoteAttachment, CodecError> {
216        // Extract parameters
217        let parameters: &HashMap<String, String> = &encoded.parameters;
218        let required = |name| {
219            parameters
220                .get(name)
221                .ok_or_else(|| CodecError::Decode(format!("missing {name} parameter")))
222        };
223
224        let content_digest = required("contentDigest")?.clone();
225        let salt = hex::decode(required("salt")?)
226            .map_err(|e| CodecError::Decode(format!("invalid hex in salt parameter: {e}")))?;
227        let nonce = hex::decode(required("nonce")?)
228            .map_err(|e| CodecError::Decode(format!("invalid hex in nonce parameter: {e}")))?;
229        let secret = hex::decode(required("secret")?)
230            .map_err(|e| CodecError::Decode(format!("invalid hex in secret parameter: {e}")))?;
231        let scheme = required("scheme")?.clone();
232        for name in ["secret", "salt", "nonce"] {
233            if !required(name)?
234                .bytes()
235                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
236            {
237                return Err(CodecError::Decode(format!(
238                    "{name} requires lowercase hexadecimal digits"
239                )));
240            }
241        }
242        let content_length = parameters
243            .get("contentLength")
244            .map(|s| {
245                if s.is_empty() || !s.bytes().all(|byte| byte.is_ascii_digit()) {
246                    return Err(CodecError::Decode(
247                        "contentLength requires decimal digits".into(),
248                    ));
249                }
250                s.parse().map_err(|e| {
251                    CodecError::Decode(format!("invalid contentLength parameter: {e}"))
252                })
253            })
254            .transpose()?;
255
256        let filename = parameters.get("filename").cloned();
257
258        let url =
259            String::from_utf8(encoded.content).map_err(|e| CodecError::Decode(e.to_string()))?;
260
261        let mut attachment = RemoteAttachment {
262            filename,
263            url,
264            content_digest,
265            secret,
266            nonce,
267            salt,
268            scheme,
269            content_length,
270        };
271        normalize_decoded_scheme(&mut attachment);
272        validate_remote_attachment(&attachment)
273            .map_err(|reason| CodecError::Decode(reason.into()))?;
274        Ok(attachment)
275    }
276
277    fn should_push() -> bool {
278        true
279    }
280}
281
282/// The main content type for remote attachments.
283///
284/// This is a type alias for [`RemoteAttachmentInfo`] from the proto definitions,
285/// allowing the same type to be used for both single remote attachments and
286/// entries in [`MultiRemoteAttachment`].
287pub type RemoteAttachment = RemoteAttachmentInfo;
288
289#[cfg(test)]
290pub(crate) mod tests {
291    use super::*;
292    use crate::encryption::SECRET_SIZE;
293
294    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
295    #[cfg_attr(not(target_arch = "wasm32"), test)]
296    // verifies: CTYPE-007
297    fn test_encode_decode_remote_attachment() {
298        let remote_attachment = RemoteAttachment {
299            filename: Some("test.pdf".to_string()),
300            content_length: Some(1024),
301            url: "https://example.com/file.pdf".to_string(),
302            content_digest: "ab".repeat(32),
303            secret: vec![1; 32],
304            nonce: vec![2; 12],
305            salt: vec![3; 32],
306            scheme: "https://".to_string(),
307        };
308
309        let encoded = RemoteAttachmentCodec::encode(remote_attachment.clone()).unwrap();
310        let decoded = RemoteAttachmentCodec::decode(encoded).unwrap();
311
312        assert_eq!(decoded.filename, remote_attachment.filename);
313        assert_eq!(decoded.scheme, remote_attachment.scheme);
314        assert_eq!(decoded.content_length, remote_attachment.content_length);
315        assert_eq!(decoded.url, remote_attachment.url);
316        assert_eq!(decoded.content_digest, remote_attachment.content_digest);
317        assert_eq!(decoded.secret, remote_attachment.secret);
318        assert_eq!(decoded.nonce, remote_attachment.nonce);
319        assert_eq!(decoded.salt, remote_attachment.salt);
320    }
321
322    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
323    #[cfg_attr(not(target_arch = "wasm32"), test)]
324    // verifies: CTYPE-015
325    fn test_encrypt_decrypt_attachment_roundtrip() {
326        let original_content = b"This is a test attachment content";
327        let filename = Some("test.txt".to_string());
328        let mime_type = "text/plain".to_string();
329
330        let attachment = Attachment {
331            filename: filename.clone(),
332            mime_type: mime_type.clone(),
333            content: original_content.to_vec(),
334        };
335
336        // Encrypt the attachment
337        let encrypted = encrypt_attachment(attachment).unwrap();
338
339        // Verify filename is preserved
340        assert_eq!(encrypted.filename, filename);
341
342        // Create a RemoteAttachment with the encryption metadata
343        let remote_attachment = RemoteAttachment {
344            filename: encrypted.filename.clone(),
345            url: "https://example.com/file.txt".to_string(),
346            content_digest: encrypted.content_digest,
347            secret: encrypted.secret,
348            salt: encrypted.salt,
349            nonce: encrypted.nonce,
350            scheme: "https://".to_string(),
351            content_length: Some(encrypted.content_length),
352        };
353
354        // Decrypt the attachment
355        let decrypted = decrypt_attachment(&encrypted.payload, &remote_attachment).unwrap();
356
357        assert_eq!(original_content.as_slice(), decrypted.content.as_slice());
358        assert_eq!(decrypted.filename, filename);
359        assert_eq!(decrypted.mime_type, mime_type);
360    }
361
362    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
363    #[cfg_attr(not(target_arch = "wasm32"), test)]
364    // verifies: CTYPE-015
365    fn test_decrypt_with_wrong_digest_fails() {
366        let attachment = Attachment {
367            filename: None,
368            mime_type: "application/octet-stream".to_string(),
369            content: b"Test content".to_vec(),
370        };
371        let encrypted = encrypt_attachment(attachment).unwrap();
372
373        let remote_attachment = RemoteAttachment {
374            filename: None,
375            url: "https://example.com/file".to_string(),
376            content_digest: "wrong_digest".to_string(), // Wrong digest
377            secret: encrypted.secret,
378            salt: encrypted.salt,
379            nonce: encrypted.nonce,
380            scheme: "https://".to_string(),
381            content_length: Some(encrypted.content_length),
382        };
383
384        let result = decrypt_attachment(&encrypted.payload, &remote_attachment);
385        assert!(result.is_err());
386        assert!(
387            result
388                .unwrap_err()
389                .to_string()
390                .contains("content digest mismatch")
391        );
392    }
393
394    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
395    #[cfg_attr(not(target_arch = "wasm32"), test)]
396    // verifies: CTYPE-015
397    fn test_decrypt_with_wrong_secret_fails() {
398        let attachment = Attachment {
399            filename: None,
400            mime_type: "application/octet-stream".to_string(),
401            content: b"Test content".to_vec(),
402        };
403        let encrypted = encrypt_attachment(attachment).unwrap();
404
405        let wrong_secret: [u8; SECRET_SIZE] = xmtp_common::rand_array();
406        let remote_attachment = RemoteAttachment {
407            filename: None,
408            url: "https://example.com/file".to_string(),
409            content_digest: encrypted.content_digest,
410            secret: wrong_secret.to_vec(), // Wrong secret
411            salt: encrypted.salt,
412            nonce: encrypted.nonce,
413            scheme: "https://".to_string(),
414            content_length: Some(encrypted.content_length),
415        };
416
417        let result = decrypt_attachment(&encrypted.payload, &remote_attachment);
418        assert!(result.is_err());
419    }
420
421    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
422    #[cfg_attr(not(target_arch = "wasm32"), test)]
423    // verifies: CTYPE-014
424    fn test_decode_with_invalid_salt_hex() {
425        let encoded = EncodedContent {
426            r#type: Some(RemoteAttachmentCodec::content_type()),
427            parameters: [
428                (
429                    "contentDigest",
430                    "abababababababababababababababababababababababababababababababab",
431                ),
432                ("salt", "not_valid_hex"),
433                ("nonce", "030303030303030303030303"),
434                (
435                    "secret",
436                    "0101010101010101010101010101010101010101010101010101010101010101",
437                ),
438                ("scheme", "https://"),
439                ("contentLength", "100"),
440            ]
441            .into_iter()
442            .map(|(k, v)| (k.to_string(), v.to_string()))
443            .collect(),
444            fallback: None,
445            compression: None,
446            content: b"https://example.com/file".to_vec(),
447        };
448
449        let result = RemoteAttachmentCodec::decode(encoded);
450        assert!(result.is_err());
451        assert!(
452            result
453                .unwrap_err()
454                .to_string()
455                .contains("invalid hex in salt")
456        );
457    }
458
459    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
460    #[cfg_attr(not(target_arch = "wasm32"), test)]
461    fn test_decode_with_invalid_nonce_hex() {
462        let encoded = EncodedContent {
463            r#type: Some(RemoteAttachmentCodec::content_type()),
464            parameters: [
465                (
466                    "contentDigest",
467                    "abababababababababababababababababababababababababababababababab",
468                ),
469                (
470                    "salt",
471                    "0202020202020202020202020202020202020202020202020202020202020202",
472                ),
473                ("nonce", "not_valid_hex"),
474                (
475                    "secret",
476                    "0101010101010101010101010101010101010101010101010101010101010101",
477                ),
478                ("scheme", "https://"),
479                ("contentLength", "100"),
480            ]
481            .into_iter()
482            .map(|(k, v)| (k.to_string(), v.to_string()))
483            .collect(),
484            fallback: None,
485            compression: None,
486            content: b"https://example.com/file".to_vec(),
487        };
488
489        let result = RemoteAttachmentCodec::decode(encoded);
490        assert!(result.is_err());
491        assert!(
492            result
493                .unwrap_err()
494                .to_string()
495                .contains("invalid hex in nonce")
496        );
497    }
498
499    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
500    #[cfg_attr(not(target_arch = "wasm32"), test)]
501    fn test_decode_with_invalid_secret_hex() {
502        let encoded = EncodedContent {
503            r#type: Some(RemoteAttachmentCodec::content_type()),
504            parameters: [
505                (
506                    "contentDigest",
507                    "abababababababababababababababababababababababababababababababab",
508                ),
509                (
510                    "salt",
511                    "0202020202020202020202020202020202020202020202020202020202020202",
512                ),
513                ("nonce", "030303030303030303030303"),
514                ("secret", "not_valid_hex"),
515                ("scheme", "https://"),
516                ("contentLength", "100"),
517            ]
518            .into_iter()
519            .map(|(k, v)| (k.to_string(), v.to_string()))
520            .collect(),
521            fallback: None,
522            compression: None,
523            content: b"https://example.com/file".to_vec(),
524        };
525
526        let result = RemoteAttachmentCodec::decode(encoded);
527        assert!(result.is_err());
528        assert!(
529            result
530                .unwrap_err()
531                .to_string()
532                .contains("invalid hex in secret")
533        );
534    }
535
536    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
537    #[cfg_attr(not(target_arch = "wasm32"), test)]
538    // verifies: CTYPE-014
539    fn test_decode_with_invalid_content_length() {
540        let encoded = EncodedContent {
541            r#type: Some(RemoteAttachmentCodec::content_type()),
542            parameters: [
543                (
544                    "contentDigest",
545                    "abababababababababababababababababababababababababababababababab",
546                ),
547                (
548                    "salt",
549                    "0202020202020202020202020202020202020202020202020202020202020202",
550                ),
551                ("nonce", "030303030303030303030303"),
552                (
553                    "secret",
554                    "0101010101010101010101010101010101010101010101010101010101010101",
555                ),
556                ("scheme", "https://"),
557                ("contentLength", "not_a_number"),
558            ]
559            .into_iter()
560            .map(|(k, v)| (k.to_string(), v.to_string()))
561            .collect(),
562            fallback: None,
563            compression: None,
564            content: b"https://example.com/file".to_vec(),
565        };
566
567        let result = RemoteAttachmentCodec::decode(encoded);
568        assert!(result.is_err());
569        assert!(result.unwrap_err().to_string().contains("contentLength"));
570    }
571
572    // verifies: CTYPE-014
573    #[xmtp_common::test(unwrap_try = true)]
574    fn catalogue_values_are_checked_on_single_and_multi_encode_and_decode() {
575        use crate::multi_remote_attachment::MultiRemoteAttachmentCodec;
576        use prost::Message;
577        use xmtp_proto::xmtp::mls::message_contents::content_types::MultiRemoteAttachment;
578        let sample = || RemoteAttachment {
579            url: "https://example.com/file?token=value".into(),
580            content_digest: "ab".repeat(32),
581            secret: vec![1; 32],
582            salt: vec![2; 32],
583            nonce: vec![3; 12],
584            scheme: "https://".into(),
585            content_length: Some(0),
586            filename: None,
587        };
588        let raw = |value: &RemoteAttachment| EncodedContent {
589            r#type: Some(RemoteAttachmentCodec::content_type()),
590            parameters: HashMap::from([
591                ("contentDigest".into(), value.content_digest.clone()),
592                ("secret".into(), hex::encode(&value.secret)),
593                ("salt".into(), hex::encode(&value.salt)),
594                ("nonce".into(), hex::encode(&value.nonce)),
595                ("scheme".into(), value.scheme.clone()),
596                (
597                    "contentLength".into(),
598                    value.content_length.unwrap_or(0).to_string(),
599                ),
600            ]),
601            content: value.url.as_bytes().to_vec(),
602            ..Default::default()
603        };
604        for url in [
605            "https://example.com/file?token=value",
606            "http://localhost/file",
607            "http://127.0.0.1/file",
608            "http://[::1]/file",
609        ] {
610            for length in [Some(0), Some(u32::MAX), None] {
611                let mut value = sample();
612                value.url = url.into();
613                value.scheme = if url.starts_with("https") {
614                    "https://"
615                } else {
616                    "http://"
617                }
618                .into();
619                value.content_length = length;
620                let mut encoded = RemoteAttachmentCodec::encode(value.clone())?;
621                encoded
622                    .parameters
623                    .insert("ignored".into(), "invalid".into());
624                assert_eq!(RemoteAttachmentCodec::decode(encoded)?, value);
625                let multi = MultiRemoteAttachment {
626                    attachments: vec![value],
627                };
628                assert_eq!(
629                    MultiRemoteAttachmentCodec::decode(MultiRemoteAttachmentCodec::encode(
630                        multi.clone()
631                    )?)?,
632                    multi
633                );
634            }
635        }
636        let mut invalid = Vec::new();
637        for digest in [
638            String::new(),
639            "ab".repeat(31),
640            "ab".repeat(33),
641            "AB".repeat(32),
642            "gg".repeat(32),
643        ] {
644            let mut value = sample();
645            value.content_digest = digest;
646            invalid.push(value);
647        }
648        for (field, size) in [("secret", 32), ("salt", 32), ("nonce", 12)] {
649            for length in [0, size - 1, size + 1] {
650                let mut value = sample();
651                match field {
652                    "secret" => value.secret = vec![1; length],
653                    "salt" => value.salt = vec![2; length],
654                    _ => value.nonce = vec![3; length],
655                }
656                invalid.push(value);
657            }
658        }
659        for (url, scheme) in [
660            ("not a URL", "https://"),
661            ("http://example.com/file", "http://"),
662            ("ftp://localhost/file", "ftp://"),
663            ("https://example.com/file", "http://"),
664        ] {
665            let mut value = sample();
666            value.url = url.into();
667            value.scheme = scheme.into();
668            invalid.push(value);
669        }
670        for value in invalid {
671            assert!(matches!(
672                RemoteAttachmentCodec::encode(value.clone()),
673                Err(CodecError::Encode(_))
674            ));
675            assert!(matches!(
676                RemoteAttachmentCodec::decode(raw(&value)),
677                Err(CodecError::Decode(_))
678            ));
679            let multi = MultiRemoteAttachment {
680                attachments: vec![sample(), value],
681            };
682            assert!(matches!(
683                MultiRemoteAttachmentCodec::encode(multi.clone()),
684                Err(CodecError::Encode(_))
685            ));
686            assert!(matches!(
687                MultiRemoteAttachmentCodec::decode(EncodedContent {
688                    r#type: Some(MultiRemoteAttachmentCodec::content_type()),
689                    content: multi.encode_to_vec(),
690                    ..Default::default()
691                }),
692                Err(CodecError::Decode(_))
693            ));
694        }
695        for (field, text) in [
696            ("secret", "AB".repeat(32)),
697            ("salt", "AB".repeat(32)),
698            ("nonce", "AB".repeat(12)),
699            ("contentLength", "-1".into()),
700            ("contentLength", "+1".into()),
701            ("contentLength", "4294967296".into()),
702            ("contentLength", "1.0".into()),
703            ("contentLength", "".into()),
704        ] {
705            let mut encoded = raw(&sample());
706            encoded.parameters.insert(field.into(), text);
707            assert!(
708                matches!(
709                    RemoteAttachmentCodec::decode(encoded),
710                    Err(CodecError::Decode(_))
711                ),
712                "{field}"
713            );
714        }
715    }
716
717    // verifies: CTYPE-014
718    #[xmtp_common::test(unwrap_try = true)]
719    fn legacy_scheme_reads_normalize_without_changing_the_wire() {
720        use crate::multi_remote_attachment::MultiRemoteAttachmentCodec;
721        use base64::{Engine as _, engine::general_purpose::STANDARD};
722        use prost::Message;
723        use xmtp_proto::xmtp::mls::message_contents::content_types::MultiRemoteAttachment;
724        // Original wire fixture from commit 334dd7f45b915da51dfc4e7a2c66c01145241442.
725        let original = STANDARD.decode("CiQKCHhtdHAub3JnEhZyZW1vdGVTdGF0aWNBdHRhY2htZW50GAESUQoNY29udGVudERpZ2VzdBJAYWJjZDEyMzQ1Njc4OTBhYmNkMTIzNDU2Nzg5MGFiY2QxMjM0NTY3ODkwYWJjZDEyMzQ1Njc4OTBhYmNkMTIzNBJICgRzYWx0EkAwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyMDIwMjAyEiEKBW5vbmNlEhgwMzAzMDMwMzAzMDMwMzAzMDMwMzAzMDMSSgoGc2VjcmV0EkAwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxMDEwMTAxEg8KBnNjaGVtZRIFaHR0cHMSFQoNY29udGVudExlbmd0aBIEMTAyNBIKCghmaWxlbmFtZSIcaHR0cHM6Ly9leGFtcGxlLmNvbS9maWxlLnBkZg==")?;
726        let legacy = EncodedContent::decode(original.as_slice())?;
727        assert_eq!(legacy.parameters["scheme"], "https");
728        let decoded = RemoteAttachmentCodec::decode(legacy.clone())?;
729        assert_eq!(decoded.scheme, "https://");
730        assert_eq!(decoded.url, "https://example.com/file.pdf");
731        assert_eq!(legacy.parameters["scheme"], "https");
732        assert_eq!(
733            RemoteAttachmentCodec::encode(decoded)?.parameters["scheme"],
734            "https://"
735        );
736        for (url, bare, canonical) in [
737            ("https://example.com/file", "https", "https://"),
738            ("http://localhost/file", "http", "http://"),
739            ("http://127.0.0.1/file", "http", "http://"),
740            ("http://[::1]/file", "http", "http://"),
741        ] {
742            let value = RemoteAttachment {
743                url: url.into(),
744                content_digest: "ab".repeat(32),
745                secret: vec![1; 32],
746                salt: vec![2; 32],
747                nonce: vec![3; 12],
748                scheme: canonical.into(),
749                ..Default::default()
750            };
751            let mut legacy = RemoteAttachmentCodec::encode(value.clone())?;
752            legacy.parameters.insert("scheme".into(), bare.into());
753            let original = legacy.encode_to_vec();
754            let decoded = RemoteAttachmentCodec::decode(legacy.clone())?;
755            assert_eq!(decoded, value);
756            assert_eq!(legacy.encode_to_vec(), original);
757            assert_eq!(legacy.parameters["scheme"], bare);
758            assert_eq!(
759                RemoteAttachmentCodec::encode(decoded)?.parameters["scheme"],
760                canonical
761            );
762            let mut legacy_value = value.clone();
763            legacy_value.scheme = bare.into();
764            assert!(matches!(
765                RemoteAttachmentCodec::encode(legacy_value.clone()),
766                Err(CodecError::Encode(_))
767            ));
768            let multi = MultiRemoteAttachment {
769                attachments: vec![legacy_value],
770            };
771            assert!(matches!(
772                MultiRemoteAttachmentCodec::encode(multi.clone()),
773                Err(CodecError::Encode(_))
774            ));
775            let outer = EncodedContent {
776                r#type: Some(MultiRemoteAttachmentCodec::content_type()),
777                content: multi.encode_to_vec(),
778                ..Default::default()
779            };
780            let decoded = MultiRemoteAttachmentCodec::decode(outer.clone())?;
781            assert_eq!(decoded.attachments[0], value);
782            assert_eq!(outer.content, multi.encode_to_vec());
783            assert_eq!(
784                MultiRemoteAttachmentCodec::decode(MultiRemoteAttachmentCodec::encode(decoded)?)?
785                    .attachments[0]
786                    .scheme,
787                canonical
788            );
789        }
790        let value = RemoteAttachment {
791            url: "http://example.com/file".into(),
792            content_digest: "ab".repeat(32),
793            secret: vec![1; 32],
794            salt: vec![2; 32],
795            nonce: vec![3; 12],
796            scheme: "http".into(),
797            ..Default::default()
798        };
799        let valid = RemoteAttachment {
800            url: "https://example.com/file".into(),
801            scheme: "https://".into(),
802            ..value.clone()
803        };
804        let mut legacy = RemoteAttachmentCodec::encode(valid)?;
805        legacy.content = value.url.as_bytes().to_vec();
806        legacy.parameters.insert("scheme".into(), "http".into());
807        assert!(matches!(
808            RemoteAttachmentCodec::decode(legacy),
809            Err(CodecError::Decode(_))
810        ));
811        let multi = MultiRemoteAttachment {
812            attachments: vec![value],
813        };
814        assert!(matches!(
815            MultiRemoteAttachmentCodec::decode(EncodedContent {
816                r#type: Some(MultiRemoteAttachmentCodec::content_type()),
817                content: multi.encode_to_vec(),
818                ..Default::default()
819            }),
820            Err(CodecError::Decode(_))
821        ));
822    }
823}