Skip to main content

observer_storage/
bundle.rs

1//! bundle 契约 + 会话读/列举/导入导出。
2//!
3//! bundle 是平台「会话跨进程/跨机迁移的唯一契约」(见 docs/架构/bundle-规范.md):
4//! 本地文件分享 / 本地 server 实时流 / 云端上传三路共用。本模块提供纯逻辑,
5//! 供 console Tauri command 与 [`observer_server`](../../observer-server) HTTP 路由共用。
6
7use std::fs;
8use std::path::Path;
9
10use serde_json::{json, Value};
11
12use crate::annotations::read_annotations;
13use crate::storage::{
14    now_ms, read_segment_events, read_segment_first_line, read_segment_last_line, read_segment_text,
15    segment_id_from_filename, segment_path,
16};
17
18/// bundle 契约标识与版本(与 TS 侧 buildBundle/parseBundle 对齐,见 docs/架构/bundle-规范.md)。
19pub const BUNDLE_FORMAT: &str = "prism-session";
20pub const BUNDLE_VERSION: i64 = 1;
21
22/// segmentId 合法性:`<label>#<n>`,label 仅 [A-Za-z0-9_-]。
23/// segment key 会成为文件名(segments/<key>.jsonl),此校验是路径穿越防护的核心。
24pub fn validate_segment_id(id: &str) -> bool {
25    let Some((label, n)) = id.split_once('#') else {
26        return false;
27    };
28    if label.is_empty() || n.is_empty() {
29        return false;
30    }
31    if !label
32        .bytes()
33        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
34    {
35        return false;
36    }
37    n.bytes().all(|b| b.is_ascii_digit())
38}
39
40/// session ID 合法性:纯数字(与 [`unique_id`] 生成器一致)。
41/// read API 的 `/sessions/:id` 直接 `root.join(id)`,未校验可被 `../` 跨租户逃逸。
42/// 多租户(P9)下这是必校验项;单租户也应校验。
43pub fn validate_session_id(id: &str) -> bool {
44    !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())
45}
46
47/// 分配不与本地已有会话冲突的新 id(毫秒时间戳,冲突则自增)。
48pub fn unique_id(root: &Path) -> String {
49    let mut id = now_ms();
50    loop {
51        let s = id.to_string();
52        if !root.join(&s).exists() {
53            return s;
54        }
55        id += 1;
56    }
57}
58
59/// 读会话目录组装成回放数据(session + windows + segments + annotations)。
60/// 供 console `read_session` 命令与 server `GET /sessions/:id` 共用。
61/// segments 读路径 gzip 感知(`.jsonl` / `.jsonl.gz` 均可),见 [`read_segment_events`]。
62pub fn read_session(dir: &Path) -> Result<Value, String> {
63    let session = serde_json::from_str::<Value>(
64        &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
65    )
66    .map_err(|e| e.to_string())?;
67
68    // filter_map:单行损坏只跳过该行(与 read_session_meta / build_export_bundle 一致)
69    let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
70        .ok()
71        .map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
72        .unwrap_or_default();
73
74    let segments = read_segments_dir(dir);
75    let annotations = read_annotations(dir);
76    Ok(json!({ "session": session, "windows": windows, "segments": segments, "annotations": annotations }))
77}
78
79/// 列出所有会话 meta(按 startedAt 倒序)。
80pub fn list_sessions(root: &Path) -> Vec<Value> {
81    let mut out = Vec::new();
82    if let Ok(entries) = fs::read_dir(root) {
83        for e in entries.flatten() {
84            if let Ok(meta) = fs::read_to_string(e.path().join("session.json")) {
85                if let Ok(v) = serde_json::from_str::<Value>(&meta) {
86                    out.push(v);
87                }
88            }
89        }
90    }
91    out.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
92    out
93}
94
95/// 读 segments 目录所有段(gzip 感知),返回 segmentId -> events 映射。
96fn read_segments_dir(dir: &Path) -> Value {
97    let mut segments = serde_json::Map::new();
98    let seg_dir = dir.join("segments");
99    if let Ok(entries) = fs::read_dir(&seg_dir) {
100        for e in entries.flatten() {
101            let path = e.path();
102            let name = path.file_name().unwrap().to_string_lossy().to_string();
103            let seg_id = segment_id_from_filename(&name).to_string();
104            let events = read_segment_events(&path);
105            segments.insert(seg_id, Value::Array(events));
106        }
107    }
108    Value::Object(segments)
109}
110
111/// 直拼路径的廉价残行校验:括号配平(感知字符串与转义)。
112///
113/// 单靠「首尾是大括号」会漏掉一种截断(进程恰好在某个 `}` 之后被杀):那类行能通过
114/// 首尾检查,却让整份直拼结果不可解析。逐字节扫一遍比 `serde_json` 全量 parse 便宜
115/// 一个数量级,且不需要解析器就能排除残行。
116fn json_balanced(line: &str) -> bool {
117    let mut depth: i32 = 0;
118    let mut in_str = false;
119    let mut escaped = false;
120    for b in line.bytes() {
121        if in_str {
122            if escaped {
123                escaped = false;
124            } else if b == b'\\' {
125                escaped = true;
126            } else if b == b'"' {
127                in_str = false;
128            }
129            continue;
130        }
131        match b {
132            b'"' => in_str = true,
133            b'{' | b'[' => depth += 1,
134            b'}' | b']' => {
135                depth -= 1;
136                if depth < 0 {
137                    return false;
138                }
139            }
140            _ => {}
141        }
142    }
143    depth == 0 && !in_str
144}
145
146/// 段索引:`[{ segmentId, bytes, width, height }]`,O(段数) 读取。
147///
148/// 只读每段首行(Meta 事件含录制视口宽高)+ 文件字节数,**不碰段内事件**——
149/// 这是「元信息先到、段按需拉」的取数基础(P19 D1)。首行缺失/非 Meta 时宽高为 null,
150/// 回放侧退化为不缩放。
151fn segment_index(dir: &Path) -> Vec<Value> {
152    let mut out = Vec::new();
153    let Ok(entries) = fs::read_dir(dir.join("segments")) else {
154        return out;
155    };
156    for e in entries.flatten() {
157        let path = e.path();
158        let name = path.file_name().unwrap().to_string_lossy().to_string();
159        let seg_id = segment_id_from_filename(&name).to_string();
160        let bytes = e.metadata().map(|m| m.len()).unwrap_or(0);
161        let mut width = Value::Null;
162        let mut height = Value::Null;
163        let mut first_ts = Value::Null;
164        if let Some(line) = read_segment_first_line(&path) {
165            if let Ok(v) = serde_json::from_str::<Value>(&line) {
166                if let Some(w) = v["data"]["width"].as_u64() {
167                    width = json!(w);
168                }
169                if let Some(h) = v["data"]["height"].as_u64() {
170                    height = json!(h);
171                }
172                if let Some(t) = v["timestamp"].as_i64() {
173                    first_ts = json!(t);
174                }
175            }
176        }
177        // 尾事件时间戳:会话缺 endedAt / 段缺 hidden 时的时长兜底(外部应用导出的
178        // bundle 常见——实测某 172MB 会话 27 段零 hidden、无 endedAt)
179        let mut last_ts = Value::Null;
180        if let Some(line) = read_segment_last_line(&path) {
181            if let Ok(v) = serde_json::from_str::<Value>(&line) {
182                if let Some(t) = v["timestamp"].as_i64() {
183                    last_ts = json!(t);
184                }
185            }
186        }
187        out.push(json!({
188            "segmentId": seg_id,
189            "bytes": bytes,
190            "width": width,
191            "height": height,
192            "firstTs": first_ts,
193            "lastTs": last_ts,
194        }));
195    }
196    out.sort_by(|a, b| a["segmentId"].as_str().cmp(&b["segmentId"].as_str()));
197    out
198}
199
200/// 元信息 + 段索引(**不含段内事件**):回放首屏所需的最小数据集合。
201/// 供 console `read_session_meta` 与 server `GET /sessions/:id/meta` 共用。
202pub fn read_session_meta(dir: &Path) -> Result<Value, String> {
203    let session = serde_json::from_str::<Value>(
204        &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
205    )
206    .map_err(|e| e.to_string())?;
207    // filter_map:单行损坏只跳过该行。旧写法(map + collect::<Option<Vec<_>>>)会让
208    // 一行坏数据把整个 windows 数组变成空——而首屏的 label/hiddenAt 全靠它。
209    let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
210        .ok()
211        .map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
212        .unwrap_or_default();
213    let annotations = read_annotations(dir);
214    Ok(json!({
215        "session": session,
216        "windows": windows,
217        "annotations": annotations,
218        "segments": segment_index(dir),
219    }))
220}
221
222/// 单段事件直拼为 JSON 数组文本:jsonl 每行本身即合法 JSON,串接即可,
223/// **不做 `serde_json::Value` 往返**(P19 D2)——省掉实测 2.9GB 的中间树与二次序列化。
224/// 调用方经 raw IPC 返回字节,JS 侧一次 `JSON.parse`。
225///
226/// 只做「首尾必须是大括号」的廉价校验:跳过被截断的残行(进程中途退出的典型损坏),
227/// 不做逐行 parse(那正是本函数要避免的成本)。
228pub fn read_segment_json(dir: &Path, segment_id: &str) -> Result<String, String> {
229    if !validate_segment_id(segment_id) {
230        return Err(format!("invalid segment id: {segment_id}"));
231    }
232    let path = segment_path(dir, segment_id)
233        .ok_or_else(|| format!("segment not found: {segment_id}"))?;
234    let raw = read_segment_text(&path).ok_or_else(|| format!("读取失败:{}", path.display()))?;
235    let mut out = String::with_capacity(raw.len() + 2);
236    out.push('[');
237    let mut first = true;
238    for line in raw.lines() {
239        let l = line.trim();
240        if !l.starts_with('{') || !l.ends_with('}') || !json_balanced(l) {
241            continue;
242        }
243        if !first {
244            out.push(',');
245        }
246        out.push_str(l);
247        first = false;
248    }
249    out.push(']');
250    Ok(out)
251}
252
253/// 全库诊断信号(`type:6`)直拼为 JSON 数组文本。
254///
255/// 只做廉价子串筛选后直拼,**不 parse 任何事件**:DOM 事件占总量约 99%,逐个 parse
256/// 正是旧 `read_session` 的主要成本。误纳入的行由 JS 侧按解析后的 `type` 过滤(多传的
257/// 量极小);判据是「包含」而非精确匹配,故不会漏判。
258/// 用途:诊断面板首屏即可拿到完整信号流,不必等各段按需加载(P19 回归修复)。
259pub fn read_session_signals_json(dir: &Path) -> Result<String, String> {
260    let seg_dir = dir.join("segments");
261    let mut files: Vec<std::path::PathBuf> = Vec::new();
262    if let Ok(entries) = fs::read_dir(&seg_dir) {
263        for e in entries.flatten() {
264            let p = e.path();
265            if p.is_file() {
266                files.push(p);
267            }
268        }
269    }
270    files.sort();
271    let mut out = String::new();
272    out.push('[');
273    let mut first = true;
274    for path in files {
275        let Some(text) = read_segment_text(&path) else {
276            continue;
277        };
278        for line in text.lines() {
279            let l = line.trim();
280            if !l.starts_with('{')
281                || !l.ends_with('}')
282                || !l.contains("\"type\":6")
283                || !json_balanced(l)
284            {
285                continue;
286            }
287            if !first {
288                out.push(',');
289            }
290            out.push_str(l);
291            first = false;
292        }
293    }
294    out.push(']');
295    Ok(out)
296}
297
298/// 读会话目录组装成 export bundle(不依赖 AppHandle,便于测试)。
299pub fn build_export_bundle(dir: &Path) -> Result<Value, String> {
300    let session = serde_json::from_str::<Value>(
301        &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
302    )
303    .map_err(|e| e.to_string())?;
304    let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
305        .ok()
306        .map(|s| s.lines().filter_map(|l| serde_json::from_str(l).ok()).collect())
307        .unwrap_or_default();
308    let segments = read_segments_dir(dir);
309    let annotations = read_annotations(dir);
310    Ok(json!({
311        "format": BUNDLE_FORMAT,
312        "version": BUNDLE_VERSION,
313        "exportedAt": now_ms(),
314        "session": session,
315        "windows": windows,
316        "segments": segments,
317        "annotations": annotations,
318    }))
319}
320
321/// 合并 meta 到 session.json(空串/null 删除字段,否则覆盖),返回写回后的 session。
322pub fn merge_session_meta(path: &Path, meta: &Value) -> Result<Value, String> {
323    let mut v = serde_json::from_str::<Value>(
324        &fs::read_to_string(path).map_err(|e| e.to_string())?,
325    )
326    .map_err(|e| e.to_string())?;
327    if let (Some(obj), Some(m)) = (v.as_object_mut(), meta.as_object()) {
328        for (k, val) in m {
329            // 空字符串 / null = 删除该字段(清理),否则覆盖
330            if (val.is_string() && val.as_str() == Some("")) || val.is_null() {
331                obj.remove(k);
332            } else {
333                obj.insert(k.clone(), val.clone());
334            }
335        }
336    }
337    fs::write(path, v.to_string()).map_err(|e| e.to_string())?;
338    Ok(v)
339}
340
341/// 把 bundle 重建为目标目录的文件结构(session.id 替换为 new_id,标记 importedAt)。
342pub fn write_import_bundle(dir: &Path, bundle: &Value, new_id: &str) -> Result<(), String> {
343    fs::create_dir_all(dir.join("segments")).map_err(|e| e.to_string())?;
344    // session.json:保留原始 meta,替换 id 并标记导入时间
345    let mut session = bundle["session"].clone();
346    if let Some(obj) = session.as_object_mut() {
347        obj.insert("id".into(), json!(new_id));
348        obj.insert("importedAt".into(), json!(now_ms()));
349    }
350    fs::write(dir.join("session.json"), session.to_string()).map_err(|e| e.to_string())?;
351    // windows.jsonl
352    if let Some(wins) = bundle["windows"].as_array() {
353        let mut body = String::new();
354        for w in wins {
355            body.push_str(&w.to_string());
356            body.push('\n');
357        }
358        fs::write(dir.join("windows.jsonl"), body).map_err(|e| e.to_string())?;
359    }
360    // segments/<name>.jsonl -- name 来自 bundle,必须校验防路径穿越(B1)
361    if let Some(segs) = bundle["segments"].as_object() {
362        for (name, events) in segs {
363            if !validate_segment_id(name) {
364                return Err(format!("非法 segmentId(拒绝以防路径穿越):{name}"));
365            }
366            if let Some(arr) = events.as_array() {
367                let mut body = String::new();
368                for e in arr {
369                    body.push_str(&e.to_string());
370                    body.push('\n');
371                }
372                fs::write(dir.join("segments").join(format!("{name}.jsonl")), body)
373                    .map_err(|e| e.to_string())?;
374            }
375        }
376    }
377    // annotations.jsonl
378    if let Some(annos) = bundle["annotations"].as_array() {
379        crate::annotations::write_annotations(dir, annos)?;
380    }
381    Ok(())
382}
383
384/// 校验 bundle 格式与版本,返回解析后的 Value。
385pub fn parse_bundle(content: &str) -> Result<Value, String> {
386    let v = serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?;
387    if v["format"].as_str() != Some(BUNDLE_FORMAT) {
388        return Err(format!("不是有效的会话文件(缺少 format: {BUNDLE_FORMAT})"));
389    }
390    let version = v["version"].as_i64().unwrap_or(1);
391    if version > BUNDLE_VERSION {
392        return Err(format!(
393            "不支持的 bundle 版本:{version}(当前支持 ≤{BUNDLE_VERSION})"
394        ));
395    }
396    Ok(v)
397}
398
399/// 导入已校验的 bundle 到 root 下:分配新 id、原子写(.tmp -> rename)。返回新 id。
400pub fn import_bundle(root: &Path, bundle: &Value) -> Result<String, String> {
401    let new_id = unique_id(root);
402    let dir = root.join(&new_id);
403    let tmp = root.join(format!("{new_id}.tmp"));
404    // 原子写:先写临时目录,成功后 rename;任一步失败清理 tmp,不污染 recordings/
405    if tmp.exists() {
406        let _ = fs::remove_dir_all(&tmp);
407    }
408    match write_import_bundle(&tmp, bundle, &new_id) {
409        Ok(()) => fs::rename(&tmp, &dir).map_err(|e| {
410            let _ = fs::remove_dir_all(&tmp);
411            e.to_string()
412        })?,
413        Err(e) => {
414            let _ = fs::remove_dir_all(&tmp);
415            return Err(e);
416        }
417    }
418    Ok(new_id)
419}
420
421/// 导入 bundle(从 JSON 字符串):校验 + 原子写。返回新 id。
422/// 供 console `import_session(content)` 与 server `POST /sessions/import` 共用。
423pub fn import_bundle_content(root: &Path, content: &str) -> Result<String, String> {
424    let v = parse_bundle(content)?;
425    import_bundle(root, &v)
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use tempfile::tempdir;
432
433    fn write_jsonl(path: &Path, rows: &[Value]) {
434        let mut body = String::new();
435        for r in rows {
436            body.push_str(&r.to_string());
437            body.push('\n');
438        }
439        fs::write(path, body).unwrap();
440    }
441
442    /// 导出 -> 导入闭环:建一个完整会话目录,导出为 bundle,再导入到新目录,
443    /// 验证 session id 替换、原字段保留、windows/segments/annotations 均还原。
444    #[test]
445    fn export_import_roundtrip() {
446        let root = tempdir().unwrap();
447        let src = root.path().join("session-001");
448        fs::create_dir_all(src.join("segments")).unwrap();
449        fs::write(
450            src.join("session.json"),
451            json!({"id":"session-001","source":"self","startedAt":1000,"endedAt":2000})
452                .to_string(),
453        )
454        .unwrap();
455        write_jsonl(
456            &src.join("windows.jsonl"),
457            &[
458                json!({"type":"shown","label":"main","segmentId":"main#0","t":1000}),
459                json!({"type":"hidden","label":"main","segmentId":"main#0","t":2000}),
460            ],
461        );
462        write_jsonl(
463            &src.join("segments/main#0.jsonl"),
464            &[
465                json!({"type":2,"timestamp":1000}),
466                json!({"type":6,"timestamp":1100,"data":{"plugin":"console"}}),
467            ],
468        );
469        crate::annotations::write_annotations(
470            &src,
471            &[json!({"id":"a1","t":500,"text":"note here","author":"local","createdAt":1200})],
472        )
473        .unwrap();
474
475        // 导出
476        let bundle = build_export_bundle(&src).unwrap();
477        assert_eq!(bundle["format"], "prism-session");
478        assert_eq!(bundle["session"]["id"], "session-001");
479        assert_eq!(bundle["session"]["source"], "self");
480        assert_eq!(bundle["windows"].as_array().unwrap().len(), 2);
481        assert_eq!(bundle["segments"]["main#0"].as_array().unwrap().len(), 2);
482        assert_eq!(bundle["annotations"].as_array().unwrap().len(), 1);
483        assert_eq!(bundle["annotations"][0]["text"], "note here");
484
485        // 导入到新目录
486        let dst = root.path().join("session-002");
487        write_import_bundle(&dst, &bundle, "session-002").unwrap();
488
489        // session id 替换、原字段保留、importedAt 标记
490        let session: Value =
491            serde_json::from_str(&fs::read_to_string(dst.join("session.json")).unwrap()).unwrap();
492        assert_eq!(session["id"], "session-002");
493        assert_eq!(session["source"], "self");
494        assert_eq!(session["startedAt"], 1000);
495        assert_eq!(session["endedAt"], 2000);
496        assert!(session["importedAt"].as_i64().unwrap() > 0);
497
498        // windows / segments / annotations 还原
499        assert_eq!(
500            fs::read_to_string(dst.join("windows.jsonl"))
501                .unwrap()
502                .lines()
503                .count(),
504            2
505        );
506        assert_eq!(
507            fs::read_to_string(dst.join("segments/main#0.jsonl"))
508                .unwrap()
509                .lines()
510                .count(),
511            2
512        );
513        let annos = read_annotations(&dst);
514        assert_eq!(annos.len(), 1);
515        assert_eq!(annos[0]["text"], "note here");
516        assert_eq!(annos[0]["author"], "local");
517    }
518
519    /// 元信息合并:覆盖已有字段、新增字段、空串删除字段。
520    #[test]
521    fn merge_session_meta_overrides_adds_and_clears() {
522        let root = tempdir().unwrap();
523        let path = root.path().join("session.json");
524        fs::write(
525            &path,
526            json!({"id":"s1","startedAt":100,"name":"old"}).to_string(),
527        )
528        .unwrap();
529
530        // 覆盖 name + 新增 note/tags
531        let v = merge_session_meta(
532            &path,
533            &json!({"name":"new name","note":"a note","tags":["bug","login"]}),
534        )
535        .unwrap();
536        assert_eq!(v["name"], "new name");
537        assert_eq!(v["note"], "a note");
538        assert_eq!(v["tags"].as_array().unwrap().len(), 2);
539        assert_eq!(v["startedAt"], 100); // 原字段不受影响
540
541        // 空串 = 删除字段
542        let v2 = merge_session_meta(&path, &json!({"name":""})).unwrap();
543        assert!(v2.get("name").is_none());
544        assert!(v2.get("note").is_some()); // 其他字段保留
545    }
546
547    /// segmentId 校验:合法的通过,含路径分隔符/`..` 的拒绝(路径穿越防护)。
548    #[test]
549    fn segment_id_validation() {
550        assert!(validate_segment_id("main#0"));
551        assert!(validate_segment_id("web#1"));
552        assert!(validate_segment_id("settings#12"));
553        assert!(validate_segment_id("a-b_c#9"));
554        // 非法:路径穿越企图
555        assert!(!validate_segment_id("../etc#0"));
556        assert!(!validate_segment_id("a/..#1"));
557        assert!(!validate_segment_id("a\\b#1"));
558        // 非法:其他形态
559        assert!(!validate_segment_id("a#b")); // n 非数字
560        assert!(!validate_segment_id("#1")); // label 空
561        assert!(!validate_segment_id("a#")); // n 空
562        assert!(!validate_segment_id("a")); // 无 #
563    }
564
565    /// session ID 校验:纯数字通过,含 `..`/`/`/字母 被拒(read API 路径穿越防护)。
566    #[test]
567    fn session_id_validation() {
568        assert!(validate_session_id("1750000000000"));
569        assert!(validate_session_id("0"));
570        assert!(validate_session_id("123456789"));
571        // 非法:路径穿越企图
572        assert!(!validate_session_id("../1"));
573        assert!(!validate_session_id("1/2"));
574        assert!(!validate_session_id(".."));
575        // 非法:非纯数字
576        assert!(!validate_session_id("abc"));
577        assert!(!validate_session_id("1a"));
578        assert!(!validate_session_id(""));
579        assert!(!validate_session_id("1-2"));
580    }
581
582    /// 含恶意 segment key 的 bundle 必须被拒绝,恶意文件不得落盘(路径穿越防护)。
583    #[test]
584    fn import_rejects_path_traversal() {
585        let root = tempdir().unwrap();
586        let dir = root.path().join("dst");
587        let bundle = json!({
588            "format": BUNDLE_FORMAT,
589            "version": BUNDLE_VERSION,
590            "session": {"id":"x","startedAt":1},
591            "windows": [],
592            "segments": { "../evil#0": [{"type":2,"timestamp":1}] },
593            "annotations": [],
594        });
595        let err = write_import_bundle(&dir, &bundle, "dst").unwrap_err();
596        assert!(err.contains("路径穿越"), "got: {err}");
597        // 未写穿:traversal 目标文件不应存在(无校验时会落到 dst/evil#0.jsonl)
598        assert!(
599            !dir.join("evil#0.jsonl").exists(),
600            "恶意 segment 不得落盘到段目录之外"
601        );
602    }
603
604    /// parse_bundle:合法 bundle 通过,缺 format / 版本过高被拒。
605    #[test]
606    fn parse_bundle_validates() {
607        let ok = json!({
608            "format": BUNDLE_FORMAT, "version": 1,
609            "session": {"id":"x","startedAt":1}, "windows": [], "segments": {}, "annotations": [],
610        })
611        .to_string();
612        assert!(parse_bundle(&ok).is_ok());
613
614        let no_format = json!({"version": 1, "session": {}}).to_string();
615        assert!(parse_bundle(&no_format).is_err());
616
617        let too_new = json!({"format": BUNDLE_FORMAT, "version": 99, "session": {}}).to_string();
618        let err = parse_bundle(&too_new).unwrap_err();
619        assert!(err.contains("版本"));
620    }
621
622    /// import_bundle_content:完整闭环,分配新 id 并原子落盘。
623    #[test]
624    fn import_bundle_content_roundtrip() {
625        let root = tempdir().unwrap();
626        let bundle = json!({
627            "format": BUNDLE_FORMAT, "version": 1,
628            "session": {"id":"orig","source":"web","startedAt":100},
629            "windows": [json!({"type":"shown","label":"web","segmentId":"web#1","t":100})],
630            "segments": {"web#1": [json!({"type":2,"timestamp":100})]},
631            "annotations": [],
632        })
633        .to_string();
634        let new_id = import_bundle_content(root.path(), &bundle).unwrap();
635        let dir = root.path().join(&new_id);
636        assert!(dir.join("session.json").exists());
637        let session: Value =
638            serde_json::from_str(&fs::read_to_string(dir.join("session.json")).unwrap()).unwrap();
639        assert_eq!(session["id"], new_id);
640        assert_eq!(session["source"], "web");
641    }
642
643    /// read_session 支持 gzip segment 文件(混合 plain + gz 目录)。
644    #[test]
645    fn read_session_with_gzip_segments() {
646        let root = tempdir().unwrap();
647        let dir = root.path().join("s1");
648        fs::create_dir_all(dir.join("segments")).unwrap();
649        fs::write(
650            &dir.join("session.json"),
651            json!({"id":"s1","startedAt":1}).to_string(),
652        )
653        .unwrap();
654        // 一个 plain segment + 一个 gzip segment
655        crate::storage::append_events_file(
656            &dir,
657            "plain#0",
658            &[json!({"type":2,"timestamp":1})],
659        )
660        .unwrap();
661        crate::storage::append_events_file_with(
662            &dir,
663            "gz#0",
664            &[json!({"type":2,"timestamp":2}), json!({"type":6,"timestamp":3})],
665            &crate::storage::WriteOpts::gzip(),
666        )
667        .unwrap();
668
669        let data = read_session(&dir).unwrap();
670        let segs = data["segments"].as_object().unwrap();
671        assert_eq!(segs.len(), 2);
672        assert_eq!(segs["plain#0"].as_array().unwrap().len(), 1);
673        assert_eq!(segs["gz#0"].as_array().unwrap().len(), 2);
674    }
675
676    /// P19 D1:段索引只读首行——宽高取自 Meta(type 4),bytes 为文件大小,不含事件正文。
677    #[test]
678    fn session_meta_index_reads_first_line() {
679        let root = tempdir().unwrap();
680        let dir = root.path().join("s1");
681        fs::create_dir_all(dir.join("segments")).unwrap();
682        fs::write(
683            &dir.join("session.json"),
684            json!({"id":"s1","startedAt":1}).to_string(),
685        )
686        .unwrap();
687        crate::storage::append_events_file(
688            &dir,
689            "main#0",
690            &[
691                json!({"data":{"width":1300,"height":800},"timestamp":1,"type":4}),
692                json!({"type":2,"timestamp":2}),
693            ],
694        )
695        .unwrap();
696
697        let meta = read_session_meta(&dir).unwrap();
698        assert_eq!(meta["session"]["id"], "s1");
699        let idx = meta["segments"].as_array().unwrap();
700        assert_eq!(idx.len(), 1);
701        assert_eq!(idx[0]["segmentId"], "main#0");
702        assert_eq!(idx[0]["width"], 1300);
703        assert_eq!(idx[0]["height"], 800);
704        assert_eq!(idx[0]["firstTs"], 1, "首行 Meta 的 timestamp");
705        assert_eq!(idx[0]["lastTs"], 2, "尾行事件的 timestamp(时长兜底)");
706        assert!(idx[0]["bytes"].as_u64().unwrap() > 0);
707        assert!(idx[0].get("events").is_none(), "段索引不含事件正文");
708    }
709
710    /// 尾行读取:文件以换行结尾(写侧惯例),应取最后一个**非空**行。
711    #[test]
712    fn segment_last_line_handles_trailing_newline() {
713        let root = tempdir().unwrap();
714        let dir = root.path().join("s1");
715        fs::create_dir_all(dir.join("segments")).unwrap();
716        crate::storage::append_events_file(
717            &dir,
718            "main#0",
719            &[json!({"type":4,"timestamp":11}), json!({"type":2,"timestamp":22})],
720        )
721        .unwrap();
722        let last = crate::storage::read_segment_last_line(
723            &dir.join("segments").join("main#0.jsonl"),
724        )
725        .unwrap();
726        assert_eq!(serde_json::from_str::<Value>(&last).unwrap()["timestamp"], 22);
727    }
728
729    /// 段索引对 gz 段同样给出 firstTs/lastTs(gz 无法逆序解压,走全文解压路径)。
730    #[test]
731    fn session_meta_index_covers_gzip_segments() {
732        let root = tempdir().unwrap();
733        let dir = root.path().join("s1");
734        fs::create_dir_all(dir.join("segments")).unwrap();
735        fs::write(
736            &dir.join("session.json"),
737            json!({"id":"s1","startedAt":1}).to_string(),
738        )
739        .unwrap();
740        crate::storage::append_events_file_with(
741            &dir,
742            "gz#0",
743            &[json!({"type":4,"timestamp":7}), json!({"type":2,"timestamp":9})],
744            &crate::storage::WriteOpts::gzip(),
745        )
746        .unwrap();
747
748        let idx = read_session_meta(&dir).unwrap()["segments"].clone();
749        assert_eq!(idx[0]["firstTs"], 7);
750        assert_eq!(idx[0]["lastTs"], 9);
751    }
752
753    /// 首行非 Meta / 缺宽高时仍产出条目,宽高为 null(回放侧退化为不缩放)。
754    #[test]
755    fn session_meta_index_tolerates_missing_meta() {
756        let root = tempdir().unwrap();
757        let dir = root.path().join("s1");
758        fs::create_dir_all(dir.join("segments")).unwrap();
759        fs::write(
760            &dir.join("session.json"),
761            json!({"id":"s1","startedAt":1}).to_string(),
762        )
763        .unwrap();
764        crate::storage::append_events_file(&dir, "main#0", &[json!({"type":2,"timestamp":2})])
765            .unwrap();
766
767        let idx = read_session_meta(&dir).unwrap()["segments"].clone();
768        assert_eq!(idx[0]["width"], Value::Null);
769        assert_eq!(idx[0]["height"], Value::Null);
770    }
771
772    /// P19 D2:直拼的段 JSON 与源 jsonl 语义一致(plain 与 gz 两路都走)。
773    #[test]
774    fn read_segment_json_roundtrips_plain_and_gzip() {
775        let root = tempdir().unwrap();
776        let dir = root.path().join("s1");
777        fs::create_dir_all(dir.join("segments")).unwrap();
778        let rows = [
779            json!({"data":{"width":800,"height":600},"timestamp":1,"type":4}),
780            json!({"type":3,"timestamp":2}),
781        ];
782        crate::storage::append_events_file(&dir, "main#0", &rows).unwrap();
783        crate::storage::append_events_file_with(
784            &dir,
785            "gz#0",
786            &rows,
787            &crate::storage::WriteOpts::gzip(),
788        )
789        .unwrap();
790
791        for seg in ["main#0", "gz#0"] {
792            let txt = read_segment_json(&dir, seg).unwrap();
793            let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
794            assert_eq!(arr.len(), 2, "{seg} 直拼结果应可被 JSON 解析且条数一致");
795            assert_eq!(arr[0]["data"]["width"], 800);
796        }
797    }
798
799    /// 直拼路径的入参闸门:segmentId 校验(路径穿越)+ 段不存在。
800    #[test]
801    fn read_segment_json_rejects_bad_id_and_missing() {
802        let root = tempdir().unwrap();
803        let dir = root.path().join("s1");
804        fs::create_dir_all(dir.join("segments")).unwrap();
805        assert!(read_segment_json(&dir, "../etc/passwd").is_err());
806        assert!(read_segment_json(&dir, "nope#9").is_err());
807    }
808
809    /// P19 回归修复:全库信号只回 type:6 行(含 plain 与 gz 两路),DOM 事件不入选。
810    #[test]
811    fn session_signals_returns_only_plugin_events() {
812        let root = tempdir().unwrap();
813        let dir = root.path().join("s1");
814        fs::create_dir_all(dir.join("segments")).unwrap();
815        crate::storage::append_events_file(
816            &dir,
817            "main#0",
818            &[
819                json!({"data":{"width":800,"height":600},"timestamp":1,"type":4}),
820                json!({"data":{"plugin":"console","payload":{"level":"log","args":["hi"]}},
821                        "timestamp":2,"type":6}),
822                json!({"data":{"source":0,"adds":[]},"timestamp":3,"type":3}),
823            ],
824        )
825        .unwrap();
826        crate::storage::append_events_file_with(
827            &dir,
828            "gz#0",
829            &[
830                json!({"data":{"plugin":"error","payload":{"message":"boom","kind":"error"}},
831                        "timestamp":4,"type":6}),
832                json!({"data":{"source":1,"positions":[]},"timestamp":5,"type":3}),
833            ],
834            &crate::storage::WriteOpts::gzip(),
835        )
836        .unwrap();
837
838        let txt = read_session_signals_json(&dir).unwrap();
839        let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
840        assert_eq!(arr.len(), 2, "两段各一条 type:6");
841        assert!(arr.iter().all(|e| e["type"] == 6));
842        let plugins: Vec<&str> = arr
843            .iter()
844            .map(|e| e["data"]["plugin"].as_str().unwrap())
845            .collect();
846        assert!(plugins.contains(&"console") && plugins.contains(&"error"));
847    }
848
849    /// 配平校验不得误伤:字符串值里出现未配对的括号是合法 JSON。
850    #[test]
851    fn json_balanced_tolerates_braces_in_strings() {
852        assert!(json_balanced(r#"{"a":"{ not a brace","b":1}"#));
853        assert!(json_balanced(r#"{"a":"}","b":[1,2]}"#));
854        assert!(json_balanced(r#"{"a":"esc \" quote {","b":1}"#));
855        assert!(!json_balanced(r#"{"a":{"b":1}"#), "缺右括号");
856        assert!(!json_balanced(r#"{"a":"unterminated}"#), "字符串未闭合");
857        assert!(!json_balanced(r#"{"a":1}}"#), "多余右括号");
858    }
859
860    /// 截断点恰好落在 `}` 之后(能通过首尾检查但括号不配平)同样被排除——
861    /// 否则整份直拼结果不可解析(独立复核实测到的损坏模式)。
862    #[test]
863    fn read_segment_json_skips_truncation_ending_with_brace() {
864        let root = tempdir().unwrap();
865        let dir = root.path().join("s1");
866        fs::create_dir_all(dir.join("segments")).unwrap();
867        fs::write(
868            dir.join("segments").join("main#0.jsonl"),
869            "{\"type\":4,\"timestamp\":1}\n{\"type\":6,\"timestamp\":2,\"data\":{\"plugin\":\"console\"}\n",
870        )
871        .unwrap();
872        let txt = read_segment_json(&dir, "main#0").unwrap();
873        let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
874        assert_eq!(arr.len(), 1, "括号不配平的残行被排除,其余仍可解析");
875    }
876
877    /// windows.jsonl 单行损坏只跳过该行——旧写法(map + collect::<Option<Vec<_>>>)会让
878    /// 整个数组变空,而首屏的 label/hiddenAt 全靠它。
879    #[test]
880    fn session_windows_tolerates_bad_line() {
881        let root = tempdir().unwrap();
882        let dir = root.path().join("s1");
883        fs::create_dir_all(dir.join("segments")).unwrap();
884        fs::write(
885            &dir.join("session.json"),
886            json!({"id":"s1","startedAt":1}).to_string(),
887        )
888        .unwrap();
889        fs::write(
890            dir.join("windows.jsonl"),
891            "{not json}\n{\"type\":\"shown\",\"label\":\"main\",\"segmentId\":\"main#0\",\"t\":1}\n",
892        )
893        .unwrap();
894
895        for data in [read_session_meta(&dir).unwrap(), read_session(&dir).unwrap()] {
896            assert_eq!(data["windows"].as_array().unwrap().len(), 1);
897        }
898    }
899
900    /// 截断残行(进程中途退出的典型损坏)被跳过,不产出非法 JSON。
901    #[test]
902    fn read_segment_json_skips_truncated_line() {
903        let root = tempdir().unwrap();
904        let dir = root.path().join("s1");
905        fs::create_dir_all(dir.join("segments")).unwrap();
906        fs::write(
907            dir.join("segments").join("main#0.jsonl"),
908            "{\"type\":4,\"timestamp\":1}\n{\"type\":2,\"timesta",
909        )
910        .unwrap();
911
912        let txt = read_segment_json(&dir, "main#0").unwrap();
913        let arr: Vec<Value> = serde_json::from_str(&txt).unwrap();
914        assert_eq!(arr.len(), 1, "残行被跳过,其余行可解析");
915    }
916}