Skip to main content

im_core/attachments/
manifest.rs

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}