1use 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
15pub const BUNDLE_FORMAT: &str = "prism-session";
17pub const BUNDLE_VERSION: i64 = 1;
18
19pub 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
37pub fn validate_session_id(id: &str) -> bool {
41 !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit())
42}
43
44pub 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
56pub 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
75pub 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
91fn 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
107pub 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
130pub 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 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
150pub 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 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 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 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 if let Some(annos) = bundle["annotations"].as_array() {
188 crate::annotations::write_annotations(dir, annos)?;
189 }
190 Ok(())
191}
192
193pub 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
208pub 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 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
230pub 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 #[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 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 let dst = root.path().join("session-002");
296 write_import_bundle(&dst, &bundle, "session-002").unwrap();
297
298 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 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 #[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 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); let v2 = merge_session_meta(&path, &json!({"name":""})).unwrap();
352 assert!(v2.get("name").is_none());
353 assert!(v2.get("note").is_some()); }
355
356 #[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 assert!(!validate_segment_id("../etc#0"));
365 assert!(!validate_segment_id("a/..#1"));
366 assert!(!validate_segment_id("a\\b#1"));
367 assert!(!validate_segment_id("a#b")); assert!(!validate_segment_id("#1")); assert!(!validate_segment_id("a#")); assert!(!validate_segment_id("a")); }
373
374 #[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 assert!(!validate_session_id("../1"));
382 assert!(!validate_session_id("1/2"));
383 assert!(!validate_session_id(".."));
384 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 #[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 assert!(
408 !dir.join("evil#0.jsonl").exists(),
409 "恶意 segment 不得落盘到段目录之外"
410 );
411 }
412
413 #[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 #[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 #[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 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}