helix-im 0.1.11

基于 Helix Core 的确定性 MessageV3 IM 业务模块
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
use super::{MediaInput, PlannedMedia, UploadPlan, UploadTarget};
use crate::ImError;
use serde_json::{json, Map, Value};

const MEDIA_INPUT_FIELDS: [&str; 5] = ["localPath", "fileName", "contentType", "size", "sha256"];
const MAX_MEDIA_SIZE: u64 = 64 << 20;
const MAX_RICH_MEDIA: usize = 10;
const TRANSIENT_REFERENCE_FIELDS: [&str; 10] = [
    "uploadId",
    "uploadToken",
    "method",
    "url",
    "publicUrl",
    "headers",
    "expiresAt",
    "status",
    "state",
    "etag",
];
const SERVER_OWNED_FIELDS: [&str; 19] = [
    "bucket",
    "id",
    "uri",
    "uploadId",
    "uploadToken",
    "fileId",
    "method",
    "url",
    "publicUrl",
    "stablePath",
    "objectKey",
    "headers",
    "expiresAt",
    "contentLength",
    "status",
    "state",
    "userId",
    "teamId",
    "etag",
];

/// 根据消息类型构造上传计划;稳定对象引用保持零上传。
pub fn build_upload_plan(
    msg_type: &str,
    message: &str,
    mut props: Value,
) -> Result<UploadPlan, ImError> {
    let media = match msg_type {
        "rich" | "RICH" | "IMAGE" => build_rich_plan(&mut props)?,
        "file" | "FILE" | "AUDIO" | "VIDEO" => build_file_plan(message, &mut props)?,
        "TEMPLATE" => build_template_plan(&mut props)?,
        _ => Vec::new(),
    };
    let targets = media.iter().map(|item| item.target.clone()).collect();
    // 消息级进度是**按总字节加权**的 O(n) 单趟投影,只在规划这一次边界算一次;
    // 后续单文件推进走 O(1) 的 `apply_upload_state`,不在此重算(T076)。
    if !media.is_empty() {
        let weighted = UploadTarget::message_upload_progress(&props)?;
        props
            .as_object_mut()
            .ok_or_else(|| ImError::Parse("props must be object".to_string()))?
            .insert("uploadProgress".to_string(), json!(weighted));
    }
    Ok(UploadPlan {
        props,
        targets,
        media,
    })
}

/// 校验版本化图文布局,附件索引在上传前后都绑定同一有序数组。
fn validate_rich_markdown(props: &Value) -> Result<(), ImError> {
    let Some(rich) = props.get("richText") else {
        return Ok(());
    };
    if rich.get("version").and_then(Value::as_u64) != Some(1) {
        return Err(ImError::Parse("unsupported richText version".into()));
    }
    let source = rich
        .get("markdown")
        .and_then(Value::as_str)
        .ok_or_else(|| ImError::Parse("richText.markdown must be string".into()))?;
    if source.len() > 32_000 {
        return Err(ImError::Parse("richText.markdown exceeds limit".into()));
    }
    let count = props
        .get("files")
        .and_then(Value::as_array)
        .map(Vec::len)
        .unwrap_or(0);
    for event in pulldown_cmark::Parser::new(source) {
        if let pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image { dest_url, .. }) = event {
            if let Some(index) = dest_url.strip_prefix("cses-media:") {
                if index.is_empty()
                    || !index.bytes().all(|byte| byte.is_ascii_digit())
                    || index.parse::<usize>().map_or(true, |index| index >= count)
                {
                    return Err(ImError::Parse(
                        "richText media index is out of range".into(),
                    ));
                }
            } else if dest_url.starts_with("blob:") || dest_url.starts_with("file:") {
                return Err(ImError::Parse(
                    "richText must not contain local resource URLs".into(),
                ));
            }
        }
    }
    Ok(())
}

/// 线性扫描富媒体项;正式 RICH 接受图片和视频,旧 IMAGE 输入只作为同义入站处理。
fn build_rich_plan(props: &mut Value) -> Result<Vec<PlannedMedia>, ImError> {
    validate_rich_markdown(props)?;
    if props.get("file").is_some() {
        return Err(ImError::Parse(
            "rich message must not contain props.file".to_string(),
        ));
    }
    if props.get("images").is_some() || props.get("textPosition").is_some() {
        return Err(ImError::Parse(
            "rich message must use props.files and imagePosition".to_string(),
        ));
    }
    match props.get("imagePosition").and_then(Value::as_str) {
        Some("top" | "bottom") => {}
        _ => {
            return Err(ImError::Parse(
                "rich message requires imagePosition top/bottom".to_string(),
            ))
        }
    }
    let files = props
        .get_mut("files")
        .and_then(Value::as_array_mut)
        .ok_or_else(|| ImError::Parse("rich message requires props.files".to_string()))?;
    if files.is_empty() {
        return Err(ImError::Parse(
            "rich message requires at least one media item".to_string(),
        ));
    }
    if files.len() > MAX_RICH_MEDIA {
        return Err(ImError::Parse(format!(
            "rich message supports at most {MAX_RICH_MEDIA} media items"
        )));
    }

    let mut media = Vec::with_capacity(files.len());
    for (index, item) in files.iter_mut().enumerate() {
        let target = rich_target(item, index)?;
        if is_stable_object_reference(item, "rich media")? {
            // 已落对象沿用服务端签发的历史 bucket;MIME bucket 规则只约束本次新上传。
            continue;
        }
        media.push(PlannedMedia {
            target,
            input: extract_media(item, "rich media")?,
        });
    }
    Ok(media)
}

/// 以受校验的 MIME 主类型选择 RICH 的稳定 bucket 与上传目标。
fn rich_target(node: &Value, index: usize) -> Result<UploadTarget, ImError> {
    let content_type = node
        .pointer("/mediaInput/contentType")
        .or_else(|| node.get("contentType"))
        .and_then(Value::as_str)
        .map(str::trim)
        .ok_or_else(|| ImError::Parse("rich media requires contentType".to_string()))?;
    if content_type.starts_with("image/") {
        return Ok(UploadTarget::RichImage { index });
    }
    if content_type.starts_with("video/") {
        return Ok(UploadTarget::RichVideo { index });
    }
    Err(ImError::Parse(
        "rich media contentType must be image/* or video/*".to_string(),
    ))
}

/// 构造单文件计划;已有稳定对象引用不进入上传链。
fn build_file_plan(_message: &str, props: &mut Value) -> Result<Vec<PlannedMedia>, ImError> {
    if props.get("files").is_some() {
        return Err(ImError::Parse(
            "file message must not contain props.files".to_string(),
        ));
    }
    let file = props
        .get_mut("file")
        .ok_or_else(|| ImError::Parse("file message requires props.file".to_string()))?;
    if is_stable_object_reference(file, "file")? {
        return Ok(Vec::new());
    }
    let input = extract_media(file, "file")?;
    Ok(vec![PlannedMedia {
        target: UploadTarget::File,
        input,
    }])
}

/// 构造 TEMPLATE/IMAGE 的嵌套上传计划;纯文本模板保持零媒体 Effect。
fn build_template_plan(props: &mut Value) -> Result<Vec<PlannedMedia>, ImError> {
    let template = props
        .get_mut("template")
        .and_then(Value::as_object_mut)
        .ok_or_else(|| ImError::Parse("TEMPLATE requires props.template".to_string()))?;
    match template.get("type").and_then(Value::as_str) {
        Some("TEXT") => return Ok(Vec::new()),
        Some("IMAGE") => {}
        _ => {
            return Err(ImError::Parse(
                "TEMPLATE requires type TEXT or IMAGE".to_string(),
            ))
        }
    }
    let file = template
        .get_mut("file")
        .ok_or_else(|| ImError::Parse("TEMPLATE/IMAGE requires props.template.file".to_string()))?;
    if is_stable_object_reference(file, "template image")? {
        return Ok(Vec::new());
    }
    Ok(vec![PlannedMedia {
        target: UploadTarget::TemplateImage,
        input: extract_media(file, "template image")?,
    }])
}

/// 识别不可伪造临时凭据的稳定对象描述符,并拒绝半完整引用。
fn is_stable_object_reference(node: &Value, label: &str) -> Result<bool, ImError> {
    let object = node
        .as_object()
        .ok_or_else(|| ImError::Parse(format!("{label} must be object")))?;
    if object.contains_key("mediaInput") {
        return Ok(false);
    }
    let has_reference_field = ["bucket", "id", "uri"]
        .iter()
        .any(|field| object.contains_key(*field));
    if !has_reference_field {
        return Ok(false);
    }
    if let Some(field) = TRANSIENT_REFERENCE_FIELDS
        .iter()
        .find(|field| object.contains_key(**field))
    {
        return Err(ImError::Parse(format!(
            "{label} stable reference must not contain transient field {field}"
        )));
    }
    let bucket = stable_reference_string(object, "bucket", label)?;
    let id = stable_reference_string(object, "id", label)?;
    let uri = stable_reference_string(object, "uri", label)?;
    if bucket.len() > 64
        || id.len() > 255
        || !uri.starts_with('/')
        || uri.starts_with("//")
        || uri.contains("..")
        || uri.contains(['?', '#', '\\'])
    {
        return Err(ImError::Parse(format!(
            "{label} stable object reference is invalid"
        )));
    }
    Ok(true)
}

/// 读取稳定引用的必填非空字符串字段。
fn stable_reference_string<'a>(
    object: &'a Map<String, Value>,
    field: &str,
    label: &str,
) -> Result<&'a str, ImError> {
    object
        .get(field)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| {
            ImError::Parse(format!(
                "{label} stable reference requires non-empty {field}"
            ))
        })
}

/// 提取并清除 Host 暂存输入,只保留可持久化媒体元数据。
fn extract_media(node: &mut Value, label: &str) -> Result<MediaInput, ImError> {
    reject_server_owned_fields(node, label)?;
    let object = node
        .as_object_mut()
        .ok_or_else(|| ImError::Parse(format!("{label} must be object")))?;
    let media_input = object
        .remove("mediaInput")
        .ok_or_else(|| ImError::Parse(format!("{label} requires mediaInput")))?;
    let input = media_input
        .as_object()
        .ok_or_else(|| ImError::Parse(format!("{label} mediaInput must be object")))?;
    validate_media_input_keys(input, label)?;

    let local_path = required_string(input, "localPath", label)?;
    let file_name = validate_file_name(input, label)?;
    let content_type = validate_content_type(input, label)?;
    let size = input
        .get("size")
        .and_then(Value::as_u64)
        .filter(|value| (1..=MAX_MEDIA_SIZE).contains(value))
        .ok_or_else(|| {
            ImError::Parse(format!(
                "{label} mediaInput.size must be u64 in 1..={MAX_MEDIA_SIZE}"
            ))
        })?;
    let sha256 = validate_sha256(input, label)?;

    // mediaInput 与本地路径别名不进入公开 props;保留前端渲染发送中/失败卡片所需的
    // 非敏感 metadata。bucket 是现网 getFilePath 的必填合同,固定为 Java file bucket。
    for field in ["localUrl", "local_url", "localPath", "local_path"] {
        object.remove(field);
    }
    object.insert("bucket".to_string(), json!("file"));
    object.insert("name".to_string(), json!(&file_name));
    object.insert("contentType".to_string(), json!(&content_type));
    object.insert("size".to_string(), json!(size));
    object.insert("upload".to_string(), json!({"status": "preparing"}));
    // 逐文件绝对进度必须在乐观落库/首帧 Sending 之前就位(INV-01 / T076)。
    super::state::init_upload_state(object);
    Ok(MediaInput {
        local_path,
        file_name,
        content_type,
        size,
        sha256,
    })
}

fn reject_server_owned_fields(value: &Value, label: &str) -> Result<(), ImError> {
    match value {
        Value::Object(object) => {
            for (key, child) in object {
                if SERVER_OWNED_FIELDS.contains(&key.as_str()) {
                    return Err(ImError::Parse(format!(
                        "{label} must not supply server-owned field {key}"
                    )));
                }
                reject_server_owned_fields(child, label)?;
            }
        }
        Value::Array(items) => {
            for item in items {
                reject_server_owned_fields(item, label)?;
            }
        }
        _ => {}
    }
    Ok(())
}

fn validate_media_input_keys(input: &Map<String, Value>, label: &str) -> Result<(), ImError> {
    if let Some(key) = input
        .keys()
        .find(|key| !MEDIA_INPUT_FIELDS.contains(&key.as_str()))
    {
        return Err(ImError::Parse(format!(
            "{label} mediaInput contains unsupported field {key}"
        )));
    }
    Ok(())
}

fn required_string(input: &Map<String, Value>, key: &str, label: &str) -> Result<String, ImError> {
    input
        .get(key)
        .and_then(Value::as_str)
        .filter(|value| !value.is_empty())
        .map(str::to_string)
        .ok_or_else(|| ImError::Parse(format!("{label} mediaInput.{key} must be non-empty string")))
}

fn validate_file_name(input: &Map<String, Value>, label: &str) -> Result<String, ImError> {
    let value = input
        .get("fileName")
        .and_then(Value::as_str)
        .ok_or_else(|| ImError::Parse(format!("{label} mediaInput.fileName must be string")))?
        .trim();
    if value.is_empty()
        || value.len() > 255
        || value
            .as_bytes()
            .iter()
            .any(|byte| matches!(byte, b'/' | b'\\' | 0))
    {
        return Err(ImError::Parse(format!(
            "{label} mediaInput.fileName is invalid"
        )));
    }
    Ok(value.to_string())
}

fn validate_content_type(input: &Map<String, Value>, label: &str) -> Result<String, ImError> {
    let value = input
        .get("contentType")
        .and_then(Value::as_str)
        .ok_or_else(|| ImError::Parse(format!("{label} mediaInput.contentType must be string")))?
        .trim();
    if value.len() > 127 || !is_valid_media_type(value) {
        return Err(ImError::Parse(format!(
            "{label} mediaInput.contentType is invalid"
        )));
    }
    Ok(value.to_string())
}

fn validate_sha256(input: &Map<String, Value>, label: &str) -> Result<String, ImError> {
    let value = input
        .get("sha256")
        .and_then(Value::as_str)
        .ok_or_else(|| ImError::Parse(format!("{label} mediaInput.sha256 must be string")))?;
    if value.len() != 64
        || !value
            .as_bytes()
            .iter()
            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
    {
        return Err(ImError::Parse(format!(
            "{label} mediaInput.sha256 must be 64 lowercase hex characters"
        )));
    }
    Ok(value.to_string())
}

// Java mediaUpload 的 HMAC 与 OSS metadata 都绑定规范化前的精确 Content-Type。
// v1 只接受无参数的 type/subtype,避免 Rust 接受 `; charset=...` 后被 Java 拒绝。
fn is_valid_media_type(value: &str) -> bool {
    let Some((media_type, subtype)) = value.split_once('/') else {
        return false;
    };
    !media_type.is_empty()
        && !subtype.is_empty()
        && !subtype.contains('/')
        && media_type
            .as_bytes()
            .iter()
            .all(|byte| is_media_token(*byte))
        && subtype.as_bytes().iter().all(|byte| is_media_token(*byte))
}

fn is_media_token(byte: u8) -> bool {
    byte.is_ascii_alphanumeric()
        || matches!(
            byte,
            b'!' | b'#' | b'$' | b'&' | b'^' | b'_' | b'.' | b'+' | b'-'
        )
}

#[cfg(test)]
mod tests {
    use super::*;

    const VALID_SHA256: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";

    fn file_props() -> Value {
        json!({
            "file": {
                "localUrl": "file:///tmp/report.pdf",
                "local_path": "/tmp/report.pdf",
                "mediaInput": {
                    "localPath": "/tmp/report.pdf",
                    "fileName": "report.pdf",
                    "contentType": "application/pdf",
                    "size": 9,
                    "sha256": VALID_SHA256
                }
            }
        })
    }

    #[test]
    fn file_plan_preserves_optional_text() {
        let plan = build_upload_plan("FILE", "quarterly report", file_props())
            .expect("FILE optional text should not block upload planning");

        assert_eq!(plan.targets, vec![UploadTarget::File]);
    }

    #[test]
    fn file_plan_rejects_image_files_collection() {
        let mut props = file_props();
        props["files"] = json!([]);

        let error = build_upload_plan("FILE", "", props)
            .expect_err("FILE must keep the single props.file contract");
        assert!(error.to_string().contains("must not contain props.files"));
    }

    #[test]
    fn invalid_server_metadata_boundaries_are_rejected_before_planning() {
        let invalid = [
            ("fileName", json!("../report.pdf")),
            ("fileName", json!("report\\2026.pdf")),
            ("contentType", json!("not a mime")),
            ("contentType", json!("application/pdf; charset=utf-8")),
            ("size", json!((64_u64 << 20) + 1)),
            (
                "sha256",
                json!("ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"),
            ),
            ("sha256", json!("short")),
        ];

        for (field, value) in invalid {
            let mut props = file_props();
            props["file"]["mediaInput"][field] = value;
            let error = build_upload_plan("FILE", "", props)
                .expect_err(&format!("invalid {field} must fail closed"));
            assert!(error.to_string().contains(field), "{field}: {error}");
        }
    }

    #[test]
    fn server_metadata_trimming_and_upper_size_boundary_are_preserved() {
        let mut props = file_props();
        props["file"]["mediaInput"]["fileName"] = json!(" report.pdf ");
        props["file"]["mediaInput"]["contentType"] = json!(" application/pdf ");
        props["file"]["mediaInput"]["size"] = json!(64_u64 << 20);

        let plan = build_upload_plan("FILE", "caption", props).expect("valid upper boundary");
        assert_eq!(plan.media[0].input.file_name, "report.pdf");
        assert_eq!(plan.media[0].input.content_type, "application/pdf");
        assert_eq!(plan.media[0].input.size, 64_u64 << 20);
    }

    #[test]
    fn client_cannot_inject_trusted_media_reference_fields() {
        for (field, value) in [
            ("id", json!("attacker-id")),
            ("uri", json!("https://evil.example/file")),
            ("stablePath", json!("/oss/media/object/attacker")),
            ("expiresAt", json!(u64::MAX)),
            ("contentLength", json!(9)),
        ] {
            let mut props = file_props();
            props["file"][field] = value;
            let error = build_upload_plan("FILE", "", props)
                .expect_err(&format!("{field} must remain Java/Helix owned"));
            assert!(
                error.to_string().contains(field),
                "unexpected {field} error: {error}"
            );
        }
    }

    #[test]
    fn uppercase_file_extracts_opaque_media_input() {
        let plan = build_upload_plan("FILE", "", file_props())
            .expect("uppercase FILE should produce a prepare plan");

        assert_eq!(plan.targets, vec![UploadTarget::File]);
        assert_eq!(plan.media[0].input.local_path, "/tmp/report.pdf");
        assert!(plan.props["file"].get("mediaInput").is_none());
        assert!(plan.props["file"].get("localUrl").is_none());
        assert!(plan.props["file"].get("local_path").is_none());
        assert_eq!(plan.props["file"]["upload"]["status"], "preparing");
    }

    /// AUDIO 和 VIDEO 与单文件共用真实 prepare/PUT/complete 状态机。
    #[test]
    fn audio_and_video_build_single_file_upload_plans() {
        for message_type in ["AUDIO", "VIDEO"] {
            let plan = build_upload_plan(message_type, "", file_props())
                .expect("audio/video must produce a prepare plan");
            assert_eq!(plan.targets, vec![UploadTarget::File]);
            assert!(plan.props["file"].get("mediaInput").is_none());
        }
    }

    /// RICH 混合媒体按 MIME 分流到 picture/attachment,且仍属于同一上传计划。
    #[test]
    fn rich_image_and_video_use_distinct_stable_buckets() {
        let props = json!({
            "imagePosition": "bottom",
            "files": [
                {"mediaInput": {
                    "localPath": "/tmp/a.png", "fileName": "a.png",
                    "contentType": "image/png", "size": 3, "sha256": VALID_SHA256
                }},
                {"mediaInput": {
                    "localPath": "/tmp/b.mp4", "fileName": "b.mp4",
                    "contentType": "video/mp4", "size": 9, "sha256": VALID_SHA256
                }}
            ]
        });

        let plan = build_upload_plan("RICH", "caption", props).expect("mixed rich plan");

        assert_eq!(
            plan.targets,
            vec![
                UploadTarget::RichImage { index: 0 },
                UploadTarget::RichVideo { index: 1 }
            ]
        );
        assert_eq!(plan.targets[0].bucket(), "picture");
        assert_eq!(plan.targets[1].bucket(), "attachment");
    }

    /// 兼容 IMAGE 不接受视频,避免旧图片消息悄然改变 wire 语义。
    #[test]
    fn legacy_image_input_uses_rich_video_plan() {
        let props = json!({
            "imagePosition": "bottom",
            "files": [{"mediaInput": {
                "localPath": "/tmp/b.mp4", "fileName": "b.mp4",
                "contentType": "video/mp4", "size": 9, "sha256": VALID_SHA256
            }}]
        });

        let plan = build_upload_plan("IMAGE", "", props).expect("legacy IMAGE uses RICH plan");
        assert_eq!(plan.targets, vec![UploadTarget::RichVideo { index: 0 }]);
    }

    /// TEMPLATE/IMAGE 规划嵌套文件,TEMPLATE/TEXT 保持零媒体上传。
    #[test]
    fn template_image_builds_nested_upload_plan_but_text_does_not() {
        let file = file_props()["file"].clone();
        let image = build_upload_plan(
            "TEMPLATE",
            "template image",
            json!({"template":{"type":"IMAGE","file":file}}),
        )
        .expect("template image must produce a prepare plan");
        assert_eq!(image.targets, vec![UploadTarget::TemplateImage]);
        assert!(image.props["template"]["file"].get("mediaInput").is_none());

        let text = build_upload_plan(
            "TEMPLATE",
            "template text",
            json!({"template":{"type":"TEXT","text":"hello"}}),
        )
        .expect("text template must remain upload-free");
        assert!(text.media.is_empty());
    }

    #[test]
    fn rich_image_count_is_bounded_to_ten() {
        let image = || {
            json!({
                "mediaInput": {
                    "localPath": "/tmp/a.png",
                    "fileName": "a.png",
                    "contentType": "image/png",
                    "size": 3,
                    "sha256": VALID_SHA256
                }
            })
        };
        let ten = Value::Array((0..10).map(|_| image()).collect());
        let plan = build_upload_plan(
            "RICH",
            "caption",
            json!({"imagePosition":"bottom", "files":ten}),
        )
        .expect("ten images are accepted");
        assert_eq!(plan.media.len(), 10);
        assert_eq!(plan.props["imagePosition"], "bottom");
        assert!(plan.props.get("images").is_none());
        assert!(plan.props.get("textPosition").is_none());

        let legacy_error = build_upload_plan(
            "RICH",
            "caption",
            json!({
                "imagePosition": "bottom",
                "files": [image()],
                "textPosition": "bottom",
                "images": [image()]
            }),
        )
        .expect_err("legacy image fields must not be persisted beside canonical fields");
        assert!(legacy_error
            .to_string()
            .contains("must use props.files and imagePosition"));

        let eleven = Value::Array((0..11).map(|_| image()).collect());
        let error = build_upload_plan(
            "RICH",
            "caption",
            json!({"imagePosition":"bottom", "files":eleven}),
        )
        .expect_err("eleven images must fail closed");
        assert!(error.to_string().contains("at most 10"));
    }
    /// 图文引用按附件数组校验,禁止保存悬空索引或未知版本。
    #[test]
    fn rich_markdown_rejects_invalid_references() {
        let props = json!({"imagePosition":"bottom", "files":[{"mediaInput":{"localPath":"/tmp/a.png","fileName":"a.png","contentType":"image/png","size":3,"sha256":VALID_SHA256}}], "richText":{"version":1,"markdown":"前文 ![图](cses-media:9) 后文"}});
        assert!(build_upload_plan("RICH", "前文 后文", props.clone()).is_err());
        let mut valid = props.clone();
        valid["richText"]["markdown"] = json!("前文 ![图](cses-media:0) 后文");
        let plan = build_upload_plan("RICH", "前文 后文", valid.clone()).unwrap();
        assert_eq!(plan.props["richText"], valid["richText"]);
        valid["richText"]["version"] = json!(2);
        assert!(build_upload_plan("RICH", "前文 后文", valid).is_err());
    }
}