1use serde::{Deserialize, Serialize};
2use serde_json::{json, Map, Value};
3
4const ATTACHMENT_MANIFEST_CONTENT_TYPE: &str = "application/anp-attachment-manifest+json";
5pub(crate) const OBJECT_ENCRYPTION_MODE_NONE: &str = "none";
6pub(crate) const OBJECT_ENCRYPTION_MODE_E2EE: &str = "object-e2ee";
7const DIGEST_ALG_SHA256: &str = "sha-256";
8
9#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
10pub struct PreparedAttachment {
11 pub filename: String,
12 pub mime_type: String,
13 pub size_bytes: u64,
14 pub size_string: String,
15 pub digest_b64u: String,
16 pub payload: Vec<u8>,
17 pub object_encryption_mode: String,
18 pub object_cipher: Option<String>,
19 pub plaintext_size_bytes: Option<u64>,
20 pub plaintext_size_string: Option<String>,
21}
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
24pub struct AttachmentDescriptor {
25 pub attachment_id: String,
26 pub filename: String,
27 pub mime_type: String,
28 pub size: String,
29 pub digest_b64u: String,
30 pub object_uri: String,
31 pub encryption_mode: String,
32 pub object_cipher: Option<String>,
33 pub plaintext_size: Option<String>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct AttachmentManifest {
38 pub attachments: Vec<AttachmentDescriptor>,
39 pub primary_attachment_id: String,
40 pub caption: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub mention_payload: Option<Value>,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) struct AttachmentGrantRef {
47 pub attachment_id: String,
48 pub object_uri: String,
49 pub size: String,
50 pub digest_b64u: String,
51 pub mime_type: String,
52 pub object_encryption_mode: String,
53 pub plaintext_size: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub(crate) struct ObjectE2eeAttachmentSecrets {
58 pub object_key_b64u: String,
59 pub nonce_b64u: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub(crate) struct PreparedObjectE2eeAttachment {
64 pub prepared: PreparedAttachment,
65 pub secrets: ObjectE2eeAttachmentSecrets,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub(crate) struct ParsedAttachmentManifest {
70 pub attachments: Vec<ParsedAttachmentDescriptor>,
71 pub primary_attachment_id: String,
72 pub caption: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub(crate) struct ParsedAttachmentDescriptor {
77 pub descriptor: AttachmentDescriptor,
78 pub object_key_b64u: Option<String>,
79 pub nonce_b64u: Option<String>,
80}
81
82impl From<crate::internal::attachment_runtime::digest::PreparedAttachmentPayload>
83 for PreparedAttachment
84{
85 fn from(value: crate::internal::attachment_runtime::digest::PreparedAttachmentPayload) -> Self {
86 Self {
87 filename: value.filename,
88 mime_type: value.mime_type,
89 size_bytes: value.size_bytes,
90 size_string: value.size_string,
91 digest_b64u: value.digest_b64u,
92 payload: value.payload,
93 object_encryption_mode: OBJECT_ENCRYPTION_MODE_NONE.to_string(),
94 object_cipher: None,
95 plaintext_size_bytes: None,
96 plaintext_size_string: None,
97 }
98 }
99}
100
101impl AttachmentDescriptor {
102 pub fn from_prepared(
103 prepared: &PreparedAttachment,
104 attachment_id: impl Into<String>,
105 object_uri: impl Into<String>,
106 ) -> Self {
107 Self {
108 attachment_id: attachment_id.into(),
109 filename: prepared.filename.clone(),
110 mime_type: prepared.mime_type.clone(),
111 size: prepared.size_string.clone(),
112 digest_b64u: prepared.digest_b64u.clone(),
113 object_uri: object_uri.into(),
114 encryption_mode: prepared.object_encryption_mode(),
115 object_cipher: prepared.object_cipher.clone(),
116 plaintext_size: prepared.plaintext_size_string.clone(),
117 }
118 }
119
120 pub(crate) fn object_encryption_mode(&self) -> String {
121 normalized_object_encryption_mode(&self.encryption_mode)
122 }
123}
124
125impl PreparedAttachment {
126 pub(crate) fn object_encryption_mode(&self) -> String {
127 normalized_object_encryption_mode(&self.object_encryption_mode)
128 }
129
130 pub(crate) fn is_object_e2ee(&self) -> bool {
131 self.object_encryption_mode() == OBJECT_ENCRYPTION_MODE_E2EE
132 }
133}
134
135impl AttachmentGrantRef {
136 pub(crate) fn from_descriptor(descriptor: &AttachmentDescriptor) -> crate::ImResult<Self> {
137 if descriptor.attachment_id.trim().is_empty() {
138 return Err(crate::ImError::invalid_input(
139 Some("attachment_id".to_string()),
140 "attachment_id is required",
141 ));
142 }
143 if descriptor.object_uri.trim().is_empty() {
144 return Err(crate::ImError::invalid_input(
145 Some("object_uri".to_string()),
146 "object_uri is required",
147 ));
148 }
149 let mode = descriptor.object_encryption_mode();
150 let plaintext_size = descriptor.plaintext_size.as_ref().and_then(|value| {
151 let value = value.trim();
152 (!value.is_empty()).then(|| value.to_string())
153 });
154 if mode == OBJECT_ENCRYPTION_MODE_E2EE && plaintext_size.is_none() {
155 return Err(crate::ImError::invalid_input(
156 Some("plaintext_size".to_string()),
157 "plaintext_size is required for object-e2ee attachments",
158 ));
159 }
160 Ok(Self {
161 attachment_id: descriptor.attachment_id.clone(),
162 object_uri: descriptor.object_uri.clone(),
163 size: descriptor.size.clone(),
164 digest_b64u: descriptor.digest_b64u.clone(),
165 mime_type: descriptor.mime_type.clone(),
166 object_encryption_mode: mode,
167 plaintext_size,
168 })
169 }
170
171 pub(crate) fn to_value(&self) -> Value {
172 let mut object = Map::new();
173 object.insert(
174 "attachment_id".to_string(),
175 Value::String(self.attachment_id.clone()),
176 );
177 object.insert(
178 "object_uri".to_string(),
179 Value::String(self.object_uri.clone()),
180 );
181 object.insert("size".to_string(), Value::String(self.size.clone()));
182 object.insert(
183 "digest".to_string(),
184 json!({
185 "alg": DIGEST_ALG_SHA256,
186 "value_b64u": self.digest_b64u,
187 }),
188 );
189 object.insert(
190 "mime_type".to_string(),
191 Value::String(self.mime_type.clone()),
192 );
193 object.insert(
194 "object_encryption_mode".to_string(),
195 Value::String(self.object_encryption_mode.clone()),
196 );
197 if let Some(plaintext_size) = self.plaintext_size.as_ref() {
198 object.insert(
199 "plaintext_size".to_string(),
200 Value::String(plaintext_size.clone()),
201 );
202 }
203 Value::Object(object)
204 }
205}
206
207pub fn attachment_manifest_content_type() -> &'static str {
208 ATTACHMENT_MANIFEST_CONTENT_TYPE
209}
210
211pub(crate) fn prepare_attachment_payload_from_path(
212 path: &std::path::Path,
213 mime_override: &str,
214 payload: Vec<u8>,
215) -> crate::ImResult<PreparedAttachment> {
216 crate::internal::attachment_runtime::digest::prepare_attachment_payload_from_path(
217 path,
218 mime_override,
219 payload,
220 )
221 .map(PreparedAttachment::from)
222}
223
224pub(crate) fn prepare_attachment_payload(
225 filename: &str,
226 mime_override: &str,
227 payload: Vec<u8>,
228) -> crate::ImResult<PreparedAttachment> {
229 crate::internal::attachment_runtime::digest::prepare_attachment_payload(
230 filename,
231 mime_override,
232 payload,
233 )
234 .map(PreparedAttachment::from)
235}
236
237pub(crate) fn prepare_object_e2ee_attachment_payload(
238 filename: &str,
239 mime_override: &str,
240 plaintext: Vec<u8>,
241) -> crate::ImResult<PreparedObjectE2eeAttachment> {
242 let plain = crate::internal::attachment_runtime::digest::prepare_attachment_payload(
243 filename,
244 mime_override,
245 plaintext,
246 )?;
247 let encrypted =
248 crate::internal::attachment_runtime::object_crypto::encrypt_object_e2ee(&plain.payload)?;
249 Ok(PreparedObjectE2eeAttachment {
250 secrets: ObjectE2eeAttachmentSecrets {
251 object_key_b64u: encrypted.crypto.object_key_b64u,
252 nonce_b64u: encrypted.crypto.nonce_b64u,
253 },
254 prepared: PreparedAttachment {
255 filename: plain.filename,
256 mime_type: plain.mime_type,
257 size_bytes: encrypted.ciphertext_size_bytes,
258 size_string: encrypted.ciphertext_size_string,
259 digest_b64u: encrypted.ciphertext_digest_b64u,
260 payload: encrypted.ciphertext,
261 object_encryption_mode: OBJECT_ENCRYPTION_MODE_E2EE.to_string(),
262 object_cipher: Some(encrypted.crypto.object_cipher),
263 plaintext_size_bytes: Some(encrypted.plaintext_size_bytes),
264 plaintext_size_string: Some(encrypted.plaintext_size_string),
265 },
266 })
267}
268
269pub(crate) async fn prepare_attachment_metadata_from_path(
270 path: &std::path::Path,
271 mime_override: &str,
272) -> crate::ImResult<PreparedAttachment> {
273 crate::internal::attachment_runtime::digest::prepare_attachment_metadata_from_path(
274 path,
275 mime_override,
276 )
277 .await
278 .map(PreparedAttachment::from)
279}
280
281pub(crate) fn build_attachment_manifest(
282 descriptor: &AttachmentDescriptor,
283 caption: &str,
284) -> crate::ImResult<Value> {
285 Ok(redact_attachment_manifest(
286 &build_attachment_manifest_internal(descriptor, caption)?,
287 ))
288}
289
290pub(crate) fn build_attachment_manifest_with_mention_payload(
291 descriptor: &AttachmentDescriptor,
292 caption: &str,
293 mention_payload: Option<&Value>,
294) -> crate::ImResult<Value> {
295 Ok(redact_attachment_manifest(
296 &build_attachment_manifest_internal_with_mention_payload(
297 descriptor,
298 caption,
299 mention_payload,
300 )?,
301 ))
302}
303
304pub(crate) fn build_attachment_manifest_internal(
305 descriptor: &AttachmentDescriptor,
306 caption: &str,
307) -> crate::ImResult<Value> {
308 build_attachment_manifest_internal_with_mention_payload(descriptor, caption, None)
309}
310
311pub(crate) fn build_attachment_manifest_internal_with_mention_payload(
312 descriptor: &AttachmentDescriptor,
313 caption: &str,
314 mention_payload: Option<&Value>,
315) -> crate::ImResult<Value> {
316 build_attachment_manifest_value(descriptor, caption, mention_payload, None)
317}
318
319pub(crate) fn build_attachment_manifest_with_object_e2ee_secrets(
320 descriptor: &AttachmentDescriptor,
321 caption: &str,
322 secrets: &ObjectE2eeAttachmentSecrets,
323) -> crate::ImResult<Value> {
324 build_attachment_manifest_value(descriptor, caption, None, Some(secrets))
325}
326
327pub(crate) fn build_attachment_manifest_with_object_e2ee_secrets_and_mention_payload(
328 descriptor: &AttachmentDescriptor,
329 caption: &str,
330 secrets: &ObjectE2eeAttachmentSecrets,
331 mention_payload: Option<&Value>,
332) -> crate::ImResult<Value> {
333 build_attachment_manifest_value(descriptor, caption, mention_payload, Some(secrets))
334}
335
336fn build_attachment_manifest_value(
337 descriptor: &AttachmentDescriptor,
338 caption: &str,
339 mention_payload: Option<&Value>,
340 secrets: Option<&ObjectE2eeAttachmentSecrets>,
341) -> crate::ImResult<Value> {
342 let mention_payload = mention_payload
343 .map(|payload| {
344 crate::messages::validate_message_mention_payload(payload)?;
345 Ok::<Value, crate::ImError>(payload.clone())
346 })
347 .transpose()?;
348 let manifest = AttachmentManifest {
349 attachments: vec![descriptor.clone()],
350 primary_attachment_id: descriptor.attachment_id.clone(),
351 caption: if caption.trim().is_empty() {
352 None
353 } else {
354 Some(caption.to_string())
355 },
356 mention_payload,
357 };
358 Ok(manifest_to_value(&manifest, secrets))
359}
360
361pub fn manifest_content_string(manifest: &Value) -> String {
362 serde_json::to_string(manifest).unwrap_or_default()
363}
364
365pub(crate) fn redact_attachment_manifest(manifest: &Value) -> Value {
366 let mut redacted = manifest.clone();
367 if let Some(attachments) = redacted
368 .get_mut("attachments")
369 .and_then(Value::as_array_mut)
370 {
371 for attachment in attachments {
372 if let Some(encryption) = attachment
373 .get_mut("encryption_info")
374 .and_then(Value::as_object_mut)
375 {
376 encryption.remove("object_key_b64u");
377 encryption.remove("nonce_b64u");
378 }
379 }
380 }
381 redacted
382}
383
384pub(crate) fn build_attachment_grant_ref(
385 descriptor: &AttachmentDescriptor,
386) -> crate::ImResult<Value> {
387 AttachmentGrantRef::from_descriptor(descriptor).map(|grant| grant.to_value())
388}
389
390pub(crate) fn parse_attachment_manifest(value: &Value) -> crate::ImResult<AttachmentManifest> {
391 let parsed = parse_attachment_manifest_internal(value)?;
392 Ok(AttachmentManifest {
393 attachments: parsed
394 .attachments
395 .into_iter()
396 .map(|attachment| attachment.descriptor)
397 .collect(),
398 primary_attachment_id: parsed.primary_attachment_id,
399 caption: parsed.caption,
400 mention_payload: None,
401 })
402}
403
404pub(crate) fn parse_attachment_manifest_internal(
405 value: &Value,
406) -> crate::ImResult<ParsedAttachmentManifest> {
407 let object = value.as_object().ok_or_else(|| {
408 crate::ImError::invalid_input(
409 Some("manifest".to_string()),
410 "attachment manifest must be a JSON object",
411 )
412 })?;
413 let attachments = object
414 .get("attachments")
415 .and_then(Value::as_array)
416 .ok_or_else(|| {
417 crate::ImError::invalid_input(
418 Some("attachments".to_string()),
419 "attachment manifest attachments must be an array",
420 )
421 })?
422 .iter()
423 .map(parse_attachment_descriptor_internal)
424 .collect::<crate::ImResult<Vec<_>>>()?;
425 Ok(ParsedAttachmentManifest {
426 attachments,
427 primary_attachment_id: string_value(object.get("primary_attachment_id")),
428 caption: optional_string_value(object.get("caption")),
429 })
430}
431
432fn manifest_to_value(
433 manifest: &AttachmentManifest,
434 secrets: Option<&ObjectE2eeAttachmentSecrets>,
435) -> Value {
436 let mut value = Map::new();
437 value.insert(
438 "attachments".to_string(),
439 Value::Array(
440 manifest
441 .attachments
442 .iter()
443 .map(|descriptor| attachment_descriptor_to_value(descriptor, secrets))
444 .collect(),
445 ),
446 );
447 value.insert(
448 "primary_attachment_id".to_string(),
449 Value::String(manifest.primary_attachment_id.clone()),
450 );
451 if let Some(caption) = manifest.caption.as_ref() {
452 value.insert("caption".to_string(), Value::String(caption.clone()));
453 }
454 if let Some(mention_payload) = manifest.mention_payload.as_ref() {
455 if let Some(text) = mention_payload.get("text") {
456 value.insert("text".to_string(), text.clone());
457 }
458 if let Some(mentions) = mention_payload.get("mentions") {
459 value.insert("mentions".to_string(), mentions.clone());
460 }
461 if let Some(annotations) = mention_payload.get("annotations") {
462 value.insert("annotations".to_string(), annotations.clone());
463 }
464 }
465 Value::Object(value)
466}
467
468fn attachment_descriptor_to_value(
469 descriptor: &AttachmentDescriptor,
470 secrets: Option<&ObjectE2eeAttachmentSecrets>,
471) -> Value {
472 let mut encryption_info = Map::new();
473 let mode = descriptor.object_encryption_mode();
474 encryption_info.insert("mode".to_string(), Value::String(mode.clone()));
475 if mode == OBJECT_ENCRYPTION_MODE_E2EE {
476 encryption_info.insert(
477 "object_cipher".to_string(),
478 Value::String(descriptor.object_cipher.clone().unwrap_or_else(|| {
479 crate::internal::attachment_runtime::object_crypto::OBJECT_E2EE_CIPHER.to_string()
480 })),
481 );
482 if let Some(secrets) = secrets {
483 encryption_info.insert(
484 "object_key_b64u".to_string(),
485 Value::String(secrets.object_key_b64u.clone()),
486 );
487 encryption_info.insert(
488 "nonce_b64u".to_string(),
489 Value::String(secrets.nonce_b64u.clone()),
490 );
491 }
492 if let Some(plaintext_size) = descriptor.plaintext_size.as_ref() {
493 encryption_info.insert(
494 "plaintext_size".to_string(),
495 Value::String(plaintext_size.clone()),
496 );
497 }
498 }
499
500 json!({
501 "attachment_id": descriptor.attachment_id,
502 "filename": descriptor.filename,
503 "mime_type": descriptor.mime_type,
504 "size": descriptor.size,
505 "digest": {
506 "alg": DIGEST_ALG_SHA256,
507 "value_b64u": descriptor.digest_b64u,
508 },
509 "access_info": {
510 "object_uri": descriptor.object_uri,
511 },
512 "encryption_info": Value::Object(encryption_info),
513 })
514}
515
516fn parse_attachment_descriptor_internal(
517 value: &Value,
518) -> crate::ImResult<ParsedAttachmentDescriptor> {
519 let object = value.as_object().ok_or_else(|| {
520 crate::ImError::invalid_input(
521 Some("attachments".to_string()),
522 "attachment entry must be a JSON object",
523 )
524 })?;
525 let digest = object.get("digest").and_then(Value::as_object);
526 let access_info = object.get("access_info").and_then(Value::as_object);
527 let encryption_info = object.get("encryption_info").and_then(Value::as_object);
528 let mode = encryption_info
529 .and_then(|value| value.get("mode"))
530 .and_then(Value::as_str)
531 .unwrap_or(OBJECT_ENCRYPTION_MODE_NONE);
532 let descriptor = AttachmentDescriptor {
533 attachment_id: string_value(object.get("attachment_id")),
534 filename: string_value(object.get("filename")),
535 mime_type: string_value(object.get("mime_type")),
536 size: string_value(object.get("size")),
537 digest_b64u: digest
538 .and_then(|value| value.get("value_b64u"))
539 .and_then(Value::as_str)
540 .unwrap_or_default()
541 .to_string(),
542 object_uri: access_info
543 .and_then(|value| value.get("object_uri"))
544 .and_then(Value::as_str)
545 .unwrap_or_default()
546 .to_string(),
547 encryption_mode: normalized_object_encryption_mode(mode),
548 object_cipher: encryption_info
549 .and_then(|value| value.get("object_cipher"))
550 .and_then(Value::as_str)
551 .map(ToOwned::to_owned),
552 plaintext_size: encryption_info
553 .and_then(|value| value.get("plaintext_size"))
554 .and_then(Value::as_str)
555 .map(ToOwned::to_owned),
556 };
557 Ok(ParsedAttachmentDescriptor {
558 descriptor,
559 object_key_b64u: encryption_info
560 .and_then(|value| value.get("object_key_b64u"))
561 .and_then(Value::as_str)
562 .map(ToOwned::to_owned),
563 nonce_b64u: encryption_info
564 .and_then(|value| value.get("nonce_b64u"))
565 .and_then(Value::as_str)
566 .map(ToOwned::to_owned),
567 })
568}
569
570fn normalized_object_encryption_mode(value: &str) -> String {
571 let value = value.trim();
572 if value.is_empty() {
573 OBJECT_ENCRYPTION_MODE_NONE.to_string()
574 } else {
575 value.to_string()
576 }
577}
578
579fn string_value(value: Option<&Value>) -> String {
580 value
581 .and_then(Value::as_str)
582 .unwrap_or_default()
583 .to_string()
584}
585
586fn optional_string_value(value: Option<&Value>) -> Option<String> {
587 value
588 .and_then(Value::as_str)
589 .map(str::trim)
590 .filter(|value| !value.is_empty())
591 .map(ToOwned::to_owned)
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn attachment_manifest_internal_full_redacted_and_parsed_secrets() {
600 let e2ee = prepare_object_e2ee_attachment_payload(
601 "secret.pdf",
602 "application/pdf",
603 b"plaintext report".to_vec(),
604 )
605 .expect("encrypted attachment");
606 let descriptor = AttachmentDescriptor::from_prepared(
607 &e2ee.prepared,
608 "att-e2ee-1",
609 "https://objects.example/att-e2ee-1",
610 );
611
612 let full = build_attachment_manifest_with_object_e2ee_secrets(
613 &descriptor,
614 "secret",
615 &e2ee.secrets,
616 )
617 .expect("full manifest");
618 assert_eq!(
619 full["attachments"][0]["encryption_info"]["object_key_b64u"],
620 e2ee.secrets.object_key_b64u
621 );
622 assert_eq!(
623 full["attachments"][0]["encryption_info"]["nonce_b64u"],
624 e2ee.secrets.nonce_b64u
625 );
626
627 let redacted = redact_attachment_manifest(&full);
628 assert_eq!(
629 redacted["attachments"][0]["encryption_info"]["mode"],
630 OBJECT_ENCRYPTION_MODE_E2EE
631 );
632 assert_eq!(
633 redacted["attachments"][0]["encryption_info"]["plaintext_size"],
634 "16"
635 );
636 assert_eq!(
637 redacted["attachments"][0]["encryption_info"].get("object_key_b64u"),
638 None
639 );
640 assert_eq!(
641 redacted["attachments"][0]["encryption_info"].get("nonce_b64u"),
642 None
643 );
644 assert_eq!(
645 build_attachment_manifest(&descriptor, "secret").expect("redacted manifest"),
646 redacted
647 );
648
649 let parsed = parse_attachment_manifest_internal(&full).expect("full manifest parses");
650 let parsed_attachment = &parsed.attachments[0];
651 assert_eq!(
652 parsed_attachment.object_key_b64u.as_deref(),
653 Some(e2ee.secrets.object_key_b64u.as_str())
654 );
655 assert_eq!(
656 parsed_attachment.nonce_b64u.as_deref(),
657 Some(e2ee.secrets.nonce_b64u.as_str())
658 );
659 assert_eq!(
660 parsed_attachment.descriptor.object_encryption_mode(),
661 OBJECT_ENCRYPTION_MODE_E2EE
662 );
663 }
664
665 #[test]
666 fn attachment_manifest_can_carry_structured_mention_payload() {
667 let prepared =
668 prepare_attachment_payload("report.md", "text/markdown", b"# Report".to_vec())
669 .expect("prepared attachment");
670 let descriptor = AttachmentDescriptor::from_prepared(
671 &prepared,
672 "att-mention-1",
673 "https://objects.example/att-mention-1",
674 );
675 let mention_payload = json!({
676 "text": "@Hermes 看看这个文件",
677 "mentions": [{
678 "id": "men_agent",
679 "range": {"start": 0, "end": 7, "unit": "unicode_code_point"},
680 "target": {"kind": "agent", "did": "did:agent:hermes"},
681 "mention_role": "addressee"
682 }]
683 });
684
685 let manifest = build_attachment_manifest_with_mention_payload(
686 &descriptor,
687 "@Hermes 看看这个文件",
688 Some(&mention_payload),
689 )
690 .expect("mention attachment manifest");
691
692 assert_eq!(manifest["caption"], "@Hermes 看看这个文件");
693 assert_eq!(manifest["text"], "@Hermes 看看这个文件");
694 assert_eq!(manifest["mentions"][0]["id"], "men_agent");
695 assert_eq!(manifest["attachments"][0]["attachment_id"], "att-mention-1");
696 crate::messages::parse_message_mention_payload(&manifest)
697 .expect("attachment manifest is also a valid mention payload");
698 }
699
700 #[test]
701 fn attachment_manifest_rejects_invalid_mention_payload() {
702 let prepared =
703 prepare_attachment_payload("report.md", "text/markdown", b"# Report".to_vec())
704 .expect("prepared attachment");
705 let descriptor = AttachmentDescriptor::from_prepared(
706 &prepared,
707 "att-mention-1",
708 "https://objects.example/att-mention-1",
709 );
710 let invalid = json!({
711 "text": "@Hermes",
712 "mentions": [{
713 "id": "men_agent",
714 "range": {"start": 0, "end": 100, "unit": "unicode_code_point"},
715 "target": {"kind": "agent", "did": "did:agent:hermes"}
716 }]
717 });
718
719 assert!(build_attachment_manifest_with_mention_payload(
720 &descriptor,
721 "@Hermes",
722 Some(&invalid)
723 )
724 .is_err());
725 }
726}