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
15pub 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
37pub(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
44pub(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#[derive(Clone)]
75pub struct EncryptedAttachment {
76 pub payload: Vec<u8>,
78 pub content_digest: String,
80 pub secret: Vec<u8>,
82 pub salt: Vec<u8>,
84 pub nonce: Vec<u8>,
86 pub content_length: u32,
88 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
107pub fn encrypt_attachment(attachment: Attachment) -> Result<EncryptedAttachment, CodecError> {
110 let filename = attachment.filename.clone();
111
112 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
135pub 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 AttachmentCodec::decode(encoded_content)
152}
153
154impl 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 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
282pub 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 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 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 let encrypted = encrypt_attachment(attachment).unwrap();
338
339 assert_eq!(encrypted.filename, filename);
341
342 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 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 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(), 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 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(), 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 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 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 #[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 #[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 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}