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::{now_ms, read_segment_events, segment_id_from_filename};
14
15/// bundle 契约标识与版本(与 TS 侧 buildBundle/parseBundle 对齐,见 docs/架构/bundle-规范.md)。
16pub const BUNDLE_FORMAT: &str = "prism-session";
17pub const BUNDLE_VERSION: i64 = 1;
18
19/// segmentId 合法性:`<label>#<n>`,label 仅 [A-Za-z0-9_-]。
20/// segment key 会成为文件名(segments/<key>.jsonl),此校验是路径穿越防护的核心。
21pub fn validate_segment_id(id: &str) -> bool {
22    let Some((label, n)) = id.split_once('#') else {
23        return false;
24    };
25    if label.is_empty() || n.is_empty() {
26        return false;
27    }
28    if !label
29        .bytes()
30        .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
31    {
32        return false;
33    }
34    n.bytes().all(|b| b.is_ascii_digit())
35}
36
37/// session ID 合法性:纯数字(与 [`unique_id`] 生成器一致)。
38/// read API 的 `/sessions/:id` 直接 `root.join(id)`,未校验可被 `../` 跨租户逃逸。
39/// 多租户(P9)下这是必校验项;单租户也应校验。
40pub fn validate_session_id(id: &str) -> bool {
41    !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())
42}
43
44/// 分配不与本地已有会话冲突的新 id(毫秒时间戳,冲突则自增)。
45pub fn unique_id(root: &Path) -> String {
46    let mut id = now_ms();
47    loop {
48        let s = id.to_string();
49        if !root.join(&s).exists() {
50            return s;
51        }
52        id += 1;
53    }
54}
55
56/// 读会话目录组装成回放数据(session + windows + segments + annotations)。
57/// 供 console `read_session` 命令与 server `GET /sessions/:id` 共用。
58/// segments 读路径 gzip 感知(`.jsonl` / `.jsonl.gz` 均可),见 [`read_segment_events`]。
59pub fn read_session(dir: &Path) -> Result<Value, String> {
60    let session = serde_json::from_str::<Value>(
61        &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
62    )
63    .map_err(|e| e.to_string())?;
64
65    let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
66        .ok()
67        .and_then(|s| s.lines().map(|l| serde_json::from_str(l).ok()).collect())
68        .unwrap_or_default();
69
70    let segments = read_segments_dir(dir);
71    let annotations = read_annotations(dir);
72    Ok(json!({ "session": session, "windows": windows, "segments": segments, "annotations": annotations }))
73}
74
75/// 列出所有会话 meta(按 startedAt 倒序)。
76pub fn list_sessions(root: &Path) -> Vec<Value> {
77    let mut out = Vec::new();
78    if let Ok(entries) = fs::read_dir(root) {
79        for e in entries.flatten() {
80            if let Ok(meta) = fs::read_to_string(e.path().join("session.json")) {
81                if let Ok(v) = serde_json::from_str::<Value>(&meta) {
82                    out.push(v);
83                }
84            }
85        }
86    }
87    out.sort_by(|a, b| b["startedAt"].as_i64().cmp(&a["startedAt"].as_i64()));
88    out
89}
90
91/// 读 segments 目录所有段(gzip 感知),返回 segmentId -> events 映射。
92fn read_segments_dir(dir: &Path) -> Value {
93    let mut segments = serde_json::Map::new();
94    let seg_dir = dir.join("segments");
95    if let Ok(entries) = fs::read_dir(&seg_dir) {
96        for e in entries.flatten() {
97            let path = e.path();
98            let name = path.file_name().unwrap().to_string_lossy().to_string();
99            let seg_id = segment_id_from_filename(&name).to_string();
100            let events = read_segment_events(&path);
101            segments.insert(seg_id, Value::Array(events));
102        }
103    }
104    Value::Object(segments)
105}
106
107/// 读会话目录组装成 export bundle(不依赖 AppHandle,便于测试)。
108pub fn build_export_bundle(dir: &Path) -> Result<Value, String> {
109    let session = serde_json::from_str::<Value>(
110        &fs::read_to_string(dir.join("session.json")).map_err(|e| e.to_string())?,
111    )
112    .map_err(|e| e.to_string())?;
113    let windows: Vec<Value> = fs::read_to_string(dir.join("windows.jsonl"))
114        .ok()
115        .and_then(|s| s.lines().map(|l| serde_json::from_str(l).ok()).collect())
116        .unwrap_or_default();
117    let segments = read_segments_dir(dir);
118    let annotations = read_annotations(dir);
119    Ok(json!({
120        "format": BUNDLE_FORMAT,
121        "version": BUNDLE_VERSION,
122        "exportedAt": now_ms(),
123        "session": session,
124        "windows": windows,
125        "segments": segments,
126        "annotations": annotations,
127    }))
128}
129
130/// 合并 meta 到 session.json(空串/null 删除字段,否则覆盖),返回写回后的 session。
131pub fn merge_session_meta(path: &Path, meta: &Value) -> Result<Value, String> {
132    let mut v = serde_json::from_str::<Value>(
133        &fs::read_to_string(path).map_err(|e| e.to_string())?,
134    )
135    .map_err(|e| e.to_string())?;
136    if let (Some(obj), Some(m)) = (v.as_object_mut(), meta.as_object()) {
137        for (k, val) in m {
138            // 空字符串 / null = 删除该字段(清理),否则覆盖
139            if (val.is_string() && val.as_str() == Some("")) || val.is_null() {
140                obj.remove(k);
141            } else {
142                obj.insert(k.clone(), val.clone());
143            }
144        }
145    }
146    fs::write(path, v.to_string()).map_err(|e| e.to_string())?;
147    Ok(v)
148}
149
150/// 把 bundle 重建为目标目录的文件结构(session.id 替换为 new_id,标记 importedAt)。
151pub fn write_import_bundle(dir: &Path, bundle: &Value, new_id: &str) -> Result<(), String> {
152    fs::create_dir_all(dir.join("segments")).map_err(|e| e.to_string())?;
153    // session.json:保留原始 meta,替换 id 并标记导入时间
154    let mut session = bundle["session"].clone();
155    if let Some(obj) = session.as_object_mut() {
156        obj.insert("id".into(), json!(new_id));
157        obj.insert("importedAt".into(), json!(now_ms()));
158    }
159    fs::write(dir.join("session.json"), session.to_string()).map_err(|e| e.to_string())?;
160    // windows.jsonl
161    if let Some(wins) = bundle["windows"].as_array() {
162        let mut body = String::new();
163        for w in wins {
164            body.push_str(&w.to_string());
165            body.push('\n');
166        }
167        fs::write(dir.join("windows.jsonl"), body).map_err(|e| e.to_string())?;
168    }
169    // segments/<name>.jsonl -- name 来自 bundle,必须校验防路径穿越(B1)
170    if let Some(segs) = bundle["segments"].as_object() {
171        for (name, events) in segs {
172            if !validate_segment_id(name) {
173                return Err(format!("非法 segmentId(拒绝以防路径穿越):{name}"));
174            }
175            if let Some(arr) = events.as_array() {
176                let mut body = String::new();
177                for e in arr {
178                    body.push_str(&e.to_string());
179                    body.push('\n');
180                }
181                fs::write(dir.join("segments").join(format!("{name}.jsonl")), body)
182                    .map_err(|e| e.to_string())?;
183            }
184        }
185    }
186    // annotations.jsonl
187    if let Some(annos) = bundle["annotations"].as_array() {
188        crate::annotations::write_annotations(dir, annos)?;
189    }
190    Ok(())
191}
192
193/// 校验 bundle 格式与版本,返回解析后的 Value。
194pub fn parse_bundle(content: &str) -> Result<Value, String> {
195    let v = serde_json::from_str::<Value>(content).map_err(|e| e.to_string())?;
196    if v["format"].as_str() != Some(BUNDLE_FORMAT) {
197        return Err(format!("不是有效的会话文件(缺少 format: {BUNDLE_FORMAT})"));
198    }
199    let version = v["version"].as_i64().unwrap_or(1);
200    if version > BUNDLE_VERSION {
201        return Err(format!(
202            "不支持的 bundle 版本:{version}(当前支持 ≤{BUNDLE_VERSION})"
203        ));
204    }
205    Ok(v)
206}
207
208/// 导入已校验的 bundle 到 root 下:分配新 id、原子写(.tmp -> rename)。返回新 id。
209pub fn import_bundle(root: &Path, bundle: &Value) -> Result<String, String> {
210    let new_id = unique_id(root);
211    let dir = root.join(&new_id);
212    let tmp = root.join(format!("{new_id}.tmp"));
213    // 原子写:先写临时目录,成功后 rename;任一步失败清理 tmp,不污染 recordings/
214    if tmp.exists() {
215        let _ = fs::remove_dir_all(&tmp);
216    }
217    match write_import_bundle(&tmp, bundle, &new_id) {
218        Ok(()) => fs::rename(&tmp, &dir).map_err(|e| {
219            let _ = fs::remove_dir_all(&tmp);
220            e.to_string()
221        })?,
222        Err(e) => {
223            let _ = fs::remove_dir_all(&tmp);
224            return Err(e);
225        }
226    }
227    Ok(new_id)
228}
229
230/// 导入 bundle(从 JSON 字符串):校验 + 原子写。返回新 id。
231/// 供 console `import_session(content)` 与 server `POST /sessions/import` 共用。
232pub fn import_bundle_content(root: &Path, content: &str) -> Result<String, String> {
233    let v = parse_bundle(content)?;
234    import_bundle(root, &v)
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use tempfile::tempdir;
241
242    fn write_jsonl(path: &Path, rows: &[Value]) {
243        let mut body = String::new();
244        for r in rows {
245            body.push_str(&r.to_string());
246            body.push('\n');
247        }
248        fs::write(path, body).unwrap();
249    }
250
251    /// 导出 -> 导入闭环:建一个完整会话目录,导出为 bundle,再导入到新目录,
252    /// 验证 session id 替换、原字段保留、windows/segments/annotations 均还原。
253    #[test]
254    fn export_import_roundtrip() {
255        let root = tempdir().unwrap();
256        let src = root.path().join("session-001");
257        fs::create_dir_all(src.join("segments")).unwrap();
258        fs::write(
259            src.join("session.json"),
260            json!({"id":"session-001","source":"self","startedAt":1000,"endedAt":2000})
261                .to_string(),
262        )
263        .unwrap();
264        write_jsonl(
265            &src.join("windows.jsonl"),
266            &[
267                json!({"type":"shown","label":"main","segmentId":"main#0","t":1000}),
268                json!({"type":"hidden","label":"main","segmentId":"main#0","t":2000}),
269            ],
270        );
271        write_jsonl(
272            &src.join("segments/main#0.jsonl"),
273            &[
274                json!({"type":2,"timestamp":1000}),
275                json!({"type":6,"timestamp":1100,"data":{"plugin":"console"}}),
276            ],
277        );
278        crate::annotations::write_annotations(
279            &src,
280            &[json!({"id":"a1","t":500,"text":"note here","author":"local","createdAt":1200})],
281        )
282        .unwrap();
283
284        // 导出
285        let bundle = build_export_bundle(&src).unwrap();
286        assert_eq!(bundle["format"], "prism-session");
287        assert_eq!(bundle["session"]["id"], "session-001");
288        assert_eq!(bundle["session"]["source"], "self");
289        assert_eq!(bundle["windows"].as_array().unwrap().len(), 2);
290        assert_eq!(bundle["segments"]["main#0"].as_array().unwrap().len(), 2);
291        assert_eq!(bundle["annotations"].as_array().unwrap().len(), 1);
292        assert_eq!(bundle["annotations"][0]["text"], "note here");
293
294        // 导入到新目录
295        let dst = root.path().join("session-002");
296        write_import_bundle(&dst, &bundle, "session-002").unwrap();
297
298        // session id 替换、原字段保留、importedAt 标记
299        let session: Value =
300            serde_json::from_str(&fs::read_to_string(dst.join("session.json")).unwrap()).unwrap();
301        assert_eq!(session["id"], "session-002");
302        assert_eq!(session["source"], "self");
303        assert_eq!(session["startedAt"], 1000);
304        assert_eq!(session["endedAt"], 2000);
305        assert!(session["importedAt"].as_i64().unwrap() > 0);
306
307        // windows / segments / annotations 还原
308        assert_eq!(
309            fs::read_to_string(dst.join("windows.jsonl"))
310                .unwrap()
311                .lines()
312                .count(),
313            2
314        );
315        assert_eq!(
316            fs::read_to_string(dst.join("segments/main#0.jsonl"))
317                .unwrap()
318                .lines()
319                .count(),
320            2
321        );
322        let annos = read_annotations(&dst);
323        assert_eq!(annos.len(), 1);
324        assert_eq!(annos[0]["text"], "note here");
325        assert_eq!(annos[0]["author"], "local");
326    }
327
328    /// 元信息合并:覆盖已有字段、新增字段、空串删除字段。
329    #[test]
330    fn merge_session_meta_overrides_adds_and_clears() {
331        let root = tempdir().unwrap();
332        let path = root.path().join("session.json");
333        fs::write(
334            &path,
335            json!({"id":"s1","startedAt":100,"name":"old"}).to_string(),
336        )
337        .unwrap();
338
339        // 覆盖 name + 新增 note/tags
340        let v = merge_session_meta(
341            &path,
342            &json!({"name":"new name","note":"a note","tags":["bug","login"]}),
343        )
344        .unwrap();
345        assert_eq!(v["name"], "new name");
346        assert_eq!(v["note"], "a note");
347        assert_eq!(v["tags"].as_array().unwrap().len(), 2);
348        assert_eq!(v["startedAt"], 100); // 原字段不受影响
349
350        // 空串 = 删除字段
351        let v2 = merge_session_meta(&path, &json!({"name":""})).unwrap();
352        assert!(v2.get("name").is_none());
353        assert!(v2.get("note").is_some()); // 其他字段保留
354    }
355
356    /// segmentId 校验:合法的通过,含路径分隔符/`..` 的拒绝(路径穿越防护)。
357    #[test]
358    fn segment_id_validation() {
359        assert!(validate_segment_id("main#0"));
360        assert!(validate_segment_id("web#1"));
361        assert!(validate_segment_id("settings#12"));
362        assert!(validate_segment_id("a-b_c#9"));
363        // 非法:路径穿越企图
364        assert!(!validate_segment_id("../etc#0"));
365        assert!(!validate_segment_id("a/..#1"));
366        assert!(!validate_segment_id("a\\b#1"));
367        // 非法:其他形态
368        assert!(!validate_segment_id("a#b")); // n 非数字
369        assert!(!validate_segment_id("#1")); // label 空
370        assert!(!validate_segment_id("a#")); // n 空
371        assert!(!validate_segment_id("a")); // 无 #
372    }
373
374    /// session ID 校验:纯数字通过,含 `..`/`/`/字母 被拒(read API 路径穿越防护)。
375    #[test]
376    fn session_id_validation() {
377        assert!(validate_session_id("1750000000000"));
378        assert!(validate_session_id("0"));
379        assert!(validate_session_id("123456789"));
380        // 非法:路径穿越企图
381        assert!(!validate_session_id("../1"));
382        assert!(!validate_session_id("1/2"));
383        assert!(!validate_session_id(".."));
384        // 非法:非纯数字
385        assert!(!validate_session_id("abc"));
386        assert!(!validate_session_id("1a"));
387        assert!(!validate_session_id(""));
388        assert!(!validate_session_id("1-2"));
389    }
390
391    /// 含恶意 segment key 的 bundle 必须被拒绝,恶意文件不得落盘(路径穿越防护)。
392    #[test]
393    fn import_rejects_path_traversal() {
394        let root = tempdir().unwrap();
395        let dir = root.path().join("dst");
396        let bundle = json!({
397            "format": BUNDLE_FORMAT,
398            "version": BUNDLE_VERSION,
399            "session": {"id":"x","startedAt":1},
400            "windows": [],
401            "segments": { "../evil#0": [{"type":2,"timestamp":1}] },
402            "annotations": [],
403        });
404        let err = write_import_bundle(&dir, &bundle, "dst").unwrap_err();
405        assert!(err.contains("路径穿越"), "got: {err}");
406        // 未写穿:traversal 目标文件不应存在(无校验时会落到 dst/evil#0.jsonl)
407        assert!(
408            !dir.join("evil#0.jsonl").exists(),
409            "恶意 segment 不得落盘到段目录之外"
410        );
411    }
412
413    /// parse_bundle:合法 bundle 通过,缺 format / 版本过高被拒。
414    #[test]
415    fn parse_bundle_validates() {
416        let ok = json!({
417            "format": BUNDLE_FORMAT, "version": 1,
418            "session": {"id":"x","startedAt":1}, "windows": [], "segments": {}, "annotations": [],
419        })
420        .to_string();
421        assert!(parse_bundle(&ok).is_ok());
422
423        let no_format = json!({"version": 1, "session": {}}).to_string();
424        assert!(parse_bundle(&no_format).is_err());
425
426        let too_new = json!({"format": BUNDLE_FORMAT, "version": 99, "session": {}}).to_string();
427        let err = parse_bundle(&too_new).unwrap_err();
428        assert!(err.contains("版本"));
429    }
430
431    /// import_bundle_content:完整闭环,分配新 id 并原子落盘。
432    #[test]
433    fn import_bundle_content_roundtrip() {
434        let root = tempdir().unwrap();
435        let bundle = json!({
436            "format": BUNDLE_FORMAT, "version": 1,
437            "session": {"id":"orig","source":"web","startedAt":100},
438            "windows": [json!({"type":"shown","label":"web","segmentId":"web#1","t":100})],
439            "segments": {"web#1": [json!({"type":2,"timestamp":100})]},
440            "annotations": [],
441        })
442        .to_string();
443        let new_id = import_bundle_content(root.path(), &bundle).unwrap();
444        let dir = root.path().join(&new_id);
445        assert!(dir.join("session.json").exists());
446        let session: Value =
447            serde_json::from_str(&fs::read_to_string(dir.join("session.json")).unwrap()).unwrap();
448        assert_eq!(session["id"], new_id);
449        assert_eq!(session["source"], "web");
450    }
451
452    /// read_session 支持 gzip segment 文件(混合 plain + gz 目录)。
453    #[test]
454    fn read_session_with_gzip_segments() {
455        let root = tempdir().unwrap();
456        let dir = root.path().join("s1");
457        fs::create_dir_all(dir.join("segments")).unwrap();
458        fs::write(
459            &dir.join("session.json"),
460            json!({"id":"s1","startedAt":1}).to_string(),
461        )
462        .unwrap();
463        // 一个 plain segment + 一个 gzip segment
464        crate::storage::append_events_file(
465            &dir,
466            "plain#0",
467            &[json!({"type":2,"timestamp":1})],
468        )
469        .unwrap();
470        crate::storage::append_events_file_with(
471            &dir,
472            "gz#0",
473            &[json!({"type":2,"timestamp":2}), json!({"type":6,"timestamp":3})],
474            &crate::storage::WriteOpts::gzip(),
475        )
476        .unwrap();
477
478        let data = read_session(&dir).unwrap();
479        let segs = data["segments"].as_object().unwrap();
480        assert_eq!(segs.len(), 2);
481        assert_eq!(segs["plain#0"].as_array().unwrap().len(), 1);
482        assert_eq!(segs["gz#0"].as_array().unwrap().len(), 2);
483    }
484}