1use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9use anyhow::{Context, Result, bail};
10use rusqlite::{Connection, OptionalExtension, params};
11
12use super::schema::{self, SCHEMA_VERSION};
13use super::{SessionKind, TargetClass};
14
15pub struct SessionDb {
17 conn: Connection,
18 session_id: String,
19 label: String,
20 kind: SessionKind,
21 target_class: TargetClass,
22 target: String,
23 db_path: Option<PathBuf>,
24}
25
26impl std::fmt::Debug for SessionDb {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.debug_struct("SessionDb")
29 .field("session_id", &self.session_id)
30 .field("label", &self.label)
31 .field("kind", &self.kind)
32 .field("target_class", &self.target_class)
33 .field("target", &self.target)
34 .field("db_path", &self.db_path)
35 .finish()
36 }
37}
38
39pub struct CreateOptions<'a> {
41 pub kind: SessionKind,
42 pub target: &'a str,
43 pub target_class: TargetClass,
44 pub cwd: &'a Path,
45 pub db_path: Option<&'a Path>,
47 pub label: Option<String>,
49 pub target_hash: Option<String>,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum PrunePolicy {
56 AutoOnly,
57 All,
58}
59
60impl SessionDb {
61 pub fn create(opts: CreateOptions<'_>) -> Result<Self> {
64 let conn = match opts.db_path {
65 Some(p) => {
66 #[cfg(not(target_os = "linux"))]
67 bail!(
68 "refusing descriptor-anchored session creation: this platform has no safe directory descriptor primitive"
69 );
70 let parent = p.parent().unwrap_or_else(|| Path::new("."));
71 reject_symlink_components(parent)?;
72 fs::create_dir_all(parent)?;
73 let (anchored_parent, _parent_guard) = open_directory_anchor(parent)?;
74 let name = p
75 .file_name()
76 .ok_or_else(|| anyhow::anyhow!("session DB path has no file name"))?;
77 let file = std::fs::OpenOptions::new()
82 .read(true)
83 .write(true)
84 .create_new(true)
85 .open(anchored_parent.join(name))
86 .with_context(|| format!("creating new session DB {}", p.display()))?;
87 open_sqlite_from_file(&file, p, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)?
88 }
89 None => Connection::open_in_memory()?,
90 };
91 conn.execute_batch("PRAGMA foreign_keys=ON;")?;
92 let foreign_keys: i64 = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
93 if foreign_keys != 1 {
94 bail!("SQLite foreign-key enforcement could not be enabled");
95 }
96 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
97 .ok(); schema::apply(&conn, opts.target_class)?;
99 conn.execute(&format!("PRAGMA user_version = {SCHEMA_VERSION}"), [])?;
100
101 let session_id = random_id(&conn)?;
102 let label = match opts.label {
103 Some(l) => l,
104 None => auto_label(opts.target)?,
105 };
106 let target_hash = match opts.target_hash {
107 Some(h) => Some(h),
108 None => compute_target_hash(Path::new(opts.target)).ok(),
109 };
110
111 conn.execute(
112 "INSERT INTO sessions
113 (id, kind, target, target_class, target_hash,
114 started_at, label, created_by)
115 VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), ?6, 'auto')",
116 params![
117 session_id,
118 opts.kind.as_str(),
119 opts.target,
120 opts.target_class.as_str(),
121 target_hash,
122 label,
123 ],
124 )?;
125
126 let cwd_canonical = opts
129 .cwd
130 .canonicalize()
131 .unwrap_or_else(|_| opts.cwd.to_path_buf());
132 let target_identity = Path::new(opts.target).canonicalize().unwrap_or_else(|_| {
133 if Path::new(opts.target).is_absolute() {
134 PathBuf::from(opts.target)
135 } else {
136 cwd_canonical.join(opts.target)
137 }
138 });
139 let group = format!(
143 "{}|{}|{}|{}|{}",
144 opts.kind.as_str(),
145 opts.target_class.as_str(),
146 cwd_canonical.display(),
147 target_identity.display(),
148 target_hash.as_deref().unwrap_or("")
149 );
150 conn.execute(
151 "INSERT INTO meta (session_id, key, value) VALUES (?1, 'session_group_key', ?2)",
152 params![session_id, group],
153 )?;
154 conn.execute(
155 "INSERT INTO meta (session_id, key, value) VALUES (?1, 'cwd', ?2)",
156 params![session_id, cwd_canonical.to_string_lossy().as_ref()],
157 )?;
158
159 Ok(SessionDb {
160 conn,
161 session_id,
162 label,
163 kind: opts.kind,
164 target_class: opts.target_class,
165 target: opts.target.to_string(),
166 db_path: opts.db_path.map(Path::to_path_buf),
167 })
168 }
169
170 pub fn open(path: &Path) -> Result<Self> {
173 Self::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)
174 }
175
176 pub fn open_read_only(path: &Path) -> Result<Self> {
178 Self::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
179 }
180
181 fn open_with_flags(path: &Path, flags: rusqlite::OpenFlags) -> Result<Self> {
182 if !path.exists() {
183 bail!("session DB not found: {}", path.display());
184 }
185 let conn = open_sqlite_path(path, flags)
186 .with_context(|| format!("opening session DB {}", path.display()))?;
187 conn.execute_batch("PRAGMA foreign_keys=ON;")
188 .with_context(|| format!("enabling SQLite foreign keys for {}", path.display()))?;
189 let foreign_keys: i64 = conn.query_row("PRAGMA foreign_keys", [], |r| r.get(0))?;
190 if foreign_keys != 1 {
191 bail!(
192 "SQLite foreign-key enforcement is disabled for {}",
193 path.display()
194 );
195 }
196
197 let found: i64 = conn
198 .query_row("PRAGMA user_version", [], |r| r.get(0))
199 .with_context(|| format!("reading user_version from {}", path.display()))?;
200 if found != SCHEMA_VERSION {
201 bail!(
202 "session DB schema_version={found}, expected {SCHEMA_VERSION} at {path}.\n\
203 No migration path. Re-collect against the raw files in {raw} \
204 (or delete the DB and re-run the originating command).",
205 found = found,
206 SCHEMA_VERSION = SCHEMA_VERSION,
207 path = path.display(),
208 raw = path
209 .parent()
210 .map(|p| p.join(path.file_stem().unwrap_or_default()).join("raw"))
211 .map(|p| p.display().to_string())
212 .unwrap_or_else(|| "(no raw dir)".into()),
213 );
214 }
215
216 validate_required_schema(&conn, path)?;
217 let owner_count: i64 = conn
218 .query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))
219 .with_context(|| format!("counting session owners in {}", path.display()))?;
220 if owner_count != 1 {
221 bail!(
222 "session DB {} must contain exactly one session owner row, found {}",
223 path.display(),
224 owner_count
225 );
226 }
227 let violations: Vec<String> = {
228 let mut foreign_key_check = conn.prepare("PRAGMA foreign_key_check")?;
229 foreign_key_check
230 .query_map([], |row| {
231 Ok(format!(
232 "table={}, rowid={}, parent={}, fk_index={}",
233 row.get::<_, String>(0)?,
234 row.get::<_, Option<i64>>(1)?
235 .map_or_else(|| "NULL".into(), |v| v.to_string()),
236 row.get::<_, String>(2)?,
237 row.get::<_, i64>(3)?,
238 ))
239 })?
240 .collect::<rusqlite::Result<_>>()?
241 };
242 if !violations.is_empty() {
243 bail!(
244 "session DB {} contains foreign-key violations: {}",
245 path.display(),
246 violations.join("; ")
247 );
248 }
249
250 let (session_id, label, kind_s, class_s, target): (String, String, String, String, String) =
252 conn.query_row(
253 "SELECT id, label, kind, target_class, target FROM sessions LIMIT 1",
254 [],
255 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
256 )
257 .with_context(|| format!("reading session row from {}", path.display()))?;
258
259 let kind = match kind_s.as_str() {
260 "debug" => SessionKind::Debug,
261 "profile" => SessionKind::Profile,
262 other => bail!("unknown session kind in DB: {other}"),
263 };
264 let target_class: TargetClass = class_s
265 .parse()
266 .with_context(|| format!("parsing target_class {class_s}"))?;
267 validate_class_schema(&conn, path, target_class)?;
268
269 Ok(SessionDb {
270 conn,
271 session_id,
272 label,
273 kind,
274 target_class,
275 target,
276 db_path: Some(path.to_path_buf()),
277 })
278 }
279
280 pub fn save_to(&self, path: &Path) -> Result<()> {
283 self.save_to_inner(path, || {})
284 }
285
286 fn save_to_inner(&self, path: &Path, after_anchor: impl FnOnce()) -> Result<()> {
287 reject_symlink_components(path)?;
292 if let Some(parent) = path.parent() {
293 fs::create_dir_all(parent)?;
294 }
295 if fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
296 bail!(
297 "refusing to save session through symbolic link {}",
298 path.display()
299 );
300 }
301
302 let parent = path.parent().unwrap_or_else(|| Path::new("."));
307 let (anchored_parent, _parent_guard) = open_directory_anchor(parent)?;
308 after_anchor();
309 let file_name = path.file_name().unwrap_or_default().to_string_lossy();
310 let anchored_path = anchored_parent.join(file_name.as_ref());
311 let tmp = anchored_parent.join(format!(
312 ".{file_name}.tmp-{}-{}",
313 std::process::id(),
314 unique_suffix()
315 ));
316 let guard = std::fs::OpenOptions::new()
317 .read(true)
318 .write(true)
319 .create_new(true)
320 .open(&tmp)
321 .with_context(|| format!("creating temporary session DB {}", tmp.display()))?;
322 let mut dst =
323 open_sqlite_from_file(&guard, &tmp, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)?;
324 let backup = rusqlite::backup::Backup::new(&self.conn, &mut dst)?;
325 backup.run_to_completion(128, Duration::from_millis(50), None)?;
326 drop(backup);
327 dst.execute(&format!("PRAGMA user_version = {SCHEMA_VERSION}"), [])?;
328 drop(dst);
329 drop(guard);
330 fs::rename(&tmp, &anchored_path)
331 .with_context(|| format!("installing session DB {}", path.display()))?;
332 Ok(())
333 }
334
335 pub fn has_captured_data(&self) -> Result<bool> {
340 let hits: i64 = self.conn.query_row(
341 "SELECT COUNT(*) FROM breakpoint_hits WHERE session_id = ?1",
342 params![self.session_id],
343 |r| r.get(0),
344 )?;
345 if hits > 0 {
346 return Ok(true);
347 }
348 let layers: i64 = self.conn.query_row(
349 "SELECT COUNT(*) FROM layers WHERE session_id = ?1",
350 params![self.session_id],
351 |r| r.get(0),
352 )?;
353 if layers > 0 {
354 return Ok(true);
355 }
356 let profile_raw: i64 = self.conn.query_row(
362 "SELECT COUNT(*) FROM meta WHERE session_id = ?1 AND key = 'profile_raw'",
363 params![self.session_id],
364 |r| r.get(0),
365 )?;
366 if profile_raw > 0 {
367 return Ok(true);
368 }
369 let disassembly: i64 = self.conn.query_row(
370 "SELECT COUNT(*) FROM disassembly WHERE session_id = ?1",
371 params![self.session_id],
372 |r| r.get(0),
373 )?;
374 Ok(disassembly > 0)
375 }
376
377 pub fn promote_to_user(&self) -> Result<()> {
379 self.conn.execute(
380 "UPDATE sessions SET created_by = 'user' WHERE id = ?1",
381 params![self.session_id],
382 )?;
383 Ok(())
384 }
385
386 pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
387 self.conn.execute(
388 "INSERT INTO meta (session_id, key, value) VALUES (?1, ?2, ?3)
389 ON CONFLICT(session_id, key) DO UPDATE SET value = excluded.value",
390 params![self.session_id, key, value],
391 )?;
392 Ok(())
393 }
394
395 pub fn add_failure(&self, phase: &str, error: &str) -> Result<()> {
396 self.conn.execute(
397 "INSERT INTO failures (session_id, phase, error) VALUES (?1, ?2, ?3)",
398 params![self.session_id, phase, error],
399 )?;
400 Ok(())
401 }
402
403 pub fn meta(&self, key: &str) -> Result<Option<String>> {
404 Ok(self
405 .conn
406 .query_row(
407 "SELECT value FROM meta WHERE session_id = ?1 AND key = ?2",
408 params![self.session_id, key],
409 |r| r.get(0),
410 )
411 .optional()?)
412 }
413
414 pub fn conn(&self) -> &Connection {
415 &self.conn
416 }
417 pub fn session_id(&self) -> &str {
418 &self.session_id
419 }
420 pub fn label(&self) -> &str {
421 &self.label
422 }
423 pub fn kind(&self) -> SessionKind {
424 self.kind
425 }
426 pub fn target_class(&self) -> TargetClass {
427 self.target_class
428 }
429 pub fn target(&self) -> &str {
430 &self.target
431 }
432 pub fn db_path(&self) -> Option<&Path> {
433 self.db_path.as_deref()
434 }
435}
436
437fn validate_required_schema(conn: &Connection, path: &Path) -> Result<()> {
438 let required: &[(&str, &[&str])] = &[
439 (
440 "sessions",
441 &[
442 "id",
443 "kind",
444 "target",
445 "target_class",
446 "target_hash",
447 "started_at",
448 "ended_at",
449 "label",
450 "created_by",
451 ],
452 ),
453 (
454 "layers",
455 &[
456 "id",
457 "session_id",
458 "source",
459 "file",
460 "collected_at",
461 "command_used",
462 "collection_secs",
463 "target_hash",
464 ],
465 ),
466 (
467 "symbols",
468 &[
469 "id",
470 "session_id",
471 "lang",
472 "fqn",
473 "file",
474 "line",
475 "demangled",
476 "raw",
477 "is_synthetic",
478 ],
479 ),
480 ("meta", &["session_id", "key", "value"]),
481 ("failures", &["session_id", "phase", "error"]),
482 (
483 "regions",
484 &[
485 "id",
486 "session_id",
487 "name",
488 "start_us",
489 "duration_us",
490 "thread",
491 "layer_id",
492 ],
493 ),
494 (
495 "allocations",
496 &[
497 "id",
498 "session_id",
499 "op",
500 "address",
501 "bytes",
502 "start_us",
503 "heap",
504 "thread",
505 "stack_json",
506 "layer_id",
507 ],
508 ),
509 (
510 "commands",
511 &[
512 "seq",
513 "session_id",
514 "input",
515 "output_head",
516 "output_file",
517 "output_bytes",
518 "ts",
519 "canonical_op",
520 ],
521 ),
522 (
523 "breakpoint_hits",
524 &[
525 "id",
526 "session_id",
527 "location_key",
528 "hit_seq",
529 "thread",
530 "ts",
531 "locals_json",
532 "stack_json",
533 ],
534 ),
535 (
536 "watch_evals",
537 &[
538 "id",
539 "session_id",
540 "hit_id",
541 "expr",
542 "value",
543 "type_name",
544 "ts",
545 ],
546 ),
547 (
548 "disassembly",
549 &[
550 "id",
551 "session_id",
552 "symbol_id",
553 "source",
554 "tier",
555 "code_bytes",
556 "asm_text",
557 "asm_lines_json",
558 "collected_at",
559 "trigger",
560 "layer_id",
561 ],
562 ),
563 (
564 "source_snapshots",
565 &[
566 "id",
567 "session_id",
568 "symbol_id",
569 "file",
570 "line_start",
571 "line_end",
572 "text",
573 "content_hash",
574 "collected_at",
575 ],
576 ),
577 (
578 "alloc_sites",
579 &[
580 "id",
581 "session_id",
582 "symbol_id",
583 "bytes_total",
584 "count",
585 "largest_bytes",
586 "collected_at",
587 "layer_id",
588 ],
589 ),
590 (
591 "insn_hits",
592 &[
593 "id",
594 "session_id",
595 "target",
596 "hit_count",
597 "sample_basis",
598 "sample_period",
599 "window_us",
600 "backend",
601 "collected_at",
602 "detail_json",
603 ],
604 ),
605 (
606 "insn_hit_details",
607 &["id", "insn_hit_id", "ts_us", "stack_json", "regs_json"],
608 ),
609 ];
610 for (table, columns) in required {
611 validate_table_columns(conn, table, columns, path)?;
612 }
613 Ok(())
614}
615
616fn validate_class_schema(conn: &Connection, path: &Path, class: TargetClass) -> Result<()> {
617 let required: &[(&str, &[&str])] = match class {
618 TargetClass::Gpu => &[
619 (
620 "launches",
621 &[
622 "id",
623 "session_id",
624 "kernel_name",
625 "duration_us",
626 "grid_x",
627 "grid_y",
628 "grid_z",
629 "block_x",
630 "block_y",
631 "block_z",
632 "stream_id",
633 "start_us",
634 "correlation_id",
635 "layer_id",
636 ],
637 ),
638 (
639 "metrics",
640 &[
641 "session_id",
642 "kernel_name",
643 "occupancy_pct",
644 "compute_throughput_pct",
645 "memory_throughput_pct",
646 "registers_per_thread",
647 "shared_mem_static_bytes",
648 "shared_mem_dynamic_bytes",
649 "l2_hit_rate_pct",
650 "achieved_bandwidth_gb_s",
651 "peak_bandwidth_gb_s",
652 "boundedness",
653 "layer_id",
654 ],
655 ),
656 (
657 "transfers",
658 &[
659 "id",
660 "session_id",
661 "kind",
662 "bytes",
663 "duration_us",
664 "start_us",
665 "stream_id",
666 "layer_id",
667 ],
668 ),
669 (
670 "ops",
671 &[
672 "id",
673 "session_id",
674 "name",
675 "module_path",
676 "cpu_time_us",
677 "gpu_time_us",
678 "input_shapes",
679 "layer_id",
680 ],
681 ),
682 ("op_kernel_map", &["session_id", "op_id", "kernel_name"]),
683 ],
684 TargetClass::ManagedDotnet | TargetClass::Jvm => &[
685 (
686 "samples",
687 &[
688 "id",
689 "session_id",
690 "symbol_id",
691 "thread",
692 "start_us",
693 "duration_us",
694 "cpu_ns",
695 "weight",
696 "stack_json",
697 "layer_id",
698 ],
699 ),
700 (
701 "counters",
702 &[
703 "id",
704 "session_id",
705 "name",
706 "symbol_id",
707 "value",
708 "unit",
709 "layer_id",
710 ],
711 ),
712 (
713 "gc_events",
714 &[
715 "id",
716 "session_id",
717 "kind",
718 "pause_us",
719 "start_us",
720 "heap_before_bytes",
721 "heap_after_bytes",
722 "reason",
723 "layer_id",
724 ],
725 ),
726 (
727 "jit_events",
728 &[
729 "id",
730 "session_id",
731 "symbol_id",
732 "compile_us",
733 "code_bytes",
734 "tier",
735 "start_us",
736 "layer_id",
737 ],
738 ),
739 ],
740 TargetClass::Python => &[
741 (
742 "samples",
743 &[
744 "id",
745 "session_id",
746 "symbol_id",
747 "thread",
748 "start_us",
749 "duration_us",
750 "cpu_ns",
751 "weight",
752 "stack_json",
753 "layer_id",
754 ],
755 ),
756 (
757 "counters",
758 &[
759 "id",
760 "session_id",
761 "name",
762 "symbol_id",
763 "value",
764 "unit",
765 "layer_id",
766 ],
767 ),
768 (
769 "gil_events",
770 &[
771 "id",
772 "session_id",
773 "kind",
774 "thread",
775 "start_us",
776 "duration_us",
777 "layer_id",
778 ],
779 ),
780 ],
781 TargetClass::JsNode => &[
782 (
783 "samples",
784 &[
785 "id",
786 "session_id",
787 "symbol_id",
788 "thread",
789 "start_us",
790 "duration_us",
791 "cpu_ns",
792 "weight",
793 "stack_json",
794 "layer_id",
795 ],
796 ),
797 (
798 "counters",
799 &[
800 "id",
801 "session_id",
802 "name",
803 "symbol_id",
804 "value",
805 "unit",
806 "layer_id",
807 ],
808 ),
809 (
810 "event_loop_lags",
811 &[
812 "id",
813 "session_id",
814 "lag_us",
815 "start_us",
816 "phase",
817 "layer_id",
818 ],
819 ),
820 ],
821 TargetClass::NativeCpu | TargetClass::Ruby | TargetClass::Php => &[
822 (
823 "samples",
824 &[
825 "id",
826 "session_id",
827 "symbol_id",
828 "thread",
829 "start_us",
830 "duration_us",
831 "cpu_ns",
832 "weight",
833 "stack_json",
834 "layer_id",
835 ],
836 ),
837 (
838 "counters",
839 &[
840 "id",
841 "session_id",
842 "name",
843 "symbol_id",
844 "value",
845 "unit",
846 "layer_id",
847 ],
848 ),
849 ],
850 };
851 for (table, columns) in required {
852 validate_table_columns(conn, table, columns, path)?;
853 }
854 Ok(())
855}
856
857fn validate_table_columns(
858 conn: &Connection,
859 table: &str,
860 required: &[&str],
861 path: &Path,
862) -> Result<()> {
863 let mut stmt = conn
864 .prepare(&format!("PRAGMA table_info({table})"))
865 .with_context(|| format!("checking required table {table} in {}", path.display()))?;
866 let actual: std::collections::HashSet<String> = stmt
867 .query_map([], |r| r.get::<_, String>(1))?
868 .collect::<rusqlite::Result<_>>()?;
869 if actual.is_empty() {
870 bail!(
871 "session DB {} is missing required table {table}",
872 path.display()
873 );
874 }
875 for column in required {
876 if !actual.contains(*column) {
877 bail!(
878 "session DB {} has malformed table {table}: missing column {column}",
879 path.display()
880 );
881 }
882 }
883 Ok(())
884}
885
886pub fn sessions_dir(cwd: &Path) -> PathBuf {
888 cwd.join(".dbg").join("sessions")
889}
890
891pub fn raw_dir(cwd: &Path, label: &str) -> PathBuf {
895 sessions_dir(cwd).join(label).join("raw")
896}
897
898pub fn group_key(cwd: &Path, target_hash: &str) -> String {
902 format!("{}|{}", cwd.display(), target_hash)
903}
904
905pub fn compute_target_hash(target: &Path) -> Result<String> {
908 let md = fs::metadata(target).with_context(|| format!("stat {}", target.display()))?;
909 let size = md.len();
910 let mtime = md
911 .modified()
912 .ok()
913 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
914 .map(|d| d.as_nanos())
915 .unwrap_or(0);
916 Ok(format!("{size}:{mtime}"))
917}
918
919pub fn auto_label(target: &str) -> Result<String> {
922 let basename = Path::new(target)
923 .file_name()
924 .and_then(|s| s.to_str())
925 .unwrap_or("session")
926 .to_string();
927 let now = SystemTime::now()
928 .duration_since(UNIX_EPOCH)
929 .unwrap_or_default();
930 static LABEL_SEQUENCE: AtomicU64 = AtomicU64::new(0);
935 let sequence = LABEL_SEQUENCE.fetch_add(1, Ordering::Relaxed);
936 Ok(format!(
937 "{basename}-{}-{:09x}-{}-{:x}",
938 format_timestamp(now.as_secs()),
939 now.subsec_nanos(),
940 std::process::id(),
941 sequence
942 ))
943}
944
945fn format_timestamp(secs: u64) -> String {
948 let (y, mo, d, h, mi, s) = decompose_utc(secs);
949 format!("{y:04}{mo:02}{d:02}-{h:02}{mi:02}{s:02}")
950}
951
952fn decompose_utc(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
953 let s = (secs % 60) as u32;
954 secs /= 60;
955 let mi = (secs % 60) as u32;
956 secs /= 60;
957 let h = (secs % 24) as u32;
958 secs /= 24;
959 let mut days = secs as i64;
960
961 days += 719_468;
964 let era = if days >= 0 {
965 days / 146_097
966 } else {
967 (days - 146_096) / 146_097
968 };
969 let doe = (days - era * 146_097) as u32;
970 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
971 let y = (yoe as i64) + era * 400;
972 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
973 let mp = (5 * doy + 2) / 153;
974 let d = doy - (153 * mp + 2) / 5 + 1;
975 let mo = if mp < 10 { mp + 3 } else { mp - 9 };
976 let y = if mo <= 2 { y + 1 } else { y };
977 (y as u32, mo, d, h, mi, s)
978}
979
980fn random_id(conn: &Connection) -> Result<String> {
981 let id: String = conn.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
983 Ok(id)
984}
985
986pub fn prune(
990 sessions_dir: &Path,
991 older_than: Duration,
992 policy: PrunePolicy,
993) -> Result<Vec<PathBuf>> {
994 let root_meta = match fs::symlink_metadata(sessions_dir) {
995 Ok(meta) => meta,
996 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
997 Err(error) => return Err(error.into()),
998 };
999 if root_meta.file_type().is_symlink() {
1000 bail!(
1001 "refusing to prune through symbolic-link sessions root {}",
1002 sessions_dir.display()
1003 );
1004 }
1005 if !root_meta.is_dir() {
1006 bail!(
1007 "sessions root is not a directory: {}",
1008 sessions_dir.display()
1009 );
1010 }
1011 let (scan_root, _root_guard) = open_prune_root(sessions_dir)?;
1012 let cutoff = SystemTime::now() - older_than;
1013 let mut deleted = vec![];
1014
1015 for entry in fs::read_dir(&scan_root)? {
1016 let entry = entry?;
1017 let path = entry.path();
1018 let public_path = sessions_dir.join(entry.file_name());
1019 if path.extension().and_then(|s| s.to_str()) != Some("db") {
1020 continue;
1021 }
1022 let md = match entry.metadata() {
1023 Ok(m) => m,
1024 Err(_) => continue,
1025 };
1026 let mtime = md.modified().unwrap_or(SystemTime::now());
1027 if mtime > cutoff {
1028 continue;
1029 }
1030
1031 let should_delete = match policy {
1035 PrunePolicy::All => true,
1036 PrunePolicy::AutoOnly => is_auto_session(&path).unwrap_or(false),
1037 };
1038 if !should_delete {
1039 continue;
1040 }
1041
1042 fs::remove_file(&path)
1045 .with_context(|| format!("deleting session DB {}", public_path.display()))?;
1046 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
1047 let raw = scan_root.join(stem);
1048 if fs::symlink_metadata(&raw).is_ok_and(|m| m.is_dir()) {
1049 fs::remove_dir_all(&raw)
1050 .with_context(|| format!("deleting raw capture {}", raw.display()))?;
1051 }
1052 }
1053 deleted.push(public_path);
1054 }
1055
1056 Ok(deleted)
1057}
1058
1059fn unique_suffix() -> u64 {
1060 static SUFFIX: AtomicU64 = AtomicU64::new(0);
1061 let now = SystemTime::now()
1062 .duration_since(UNIX_EPOCH)
1063 .unwrap_or_default()
1064 .as_nanos() as u64;
1065 now ^ (std::process::id() as u64) ^ SUFFIX.fetch_add(1, Ordering::Relaxed)
1066}
1067
1068fn open_prune_root(path: &Path) -> Result<(PathBuf, Option<std::fs::File>)> {
1069 open_directory_anchor(path)
1070}
1071
1072fn open_directory_anchor(path: &Path) -> Result<(PathBuf, Option<std::fs::File>)> {
1077 #[cfg(target_os = "linux")]
1078 {
1079 use std::ffi::CString;
1080 use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
1081 use std::os::unix::ffi::OsStrExt;
1082
1083 let absolute = if path.is_absolute() {
1084 path.to_path_buf()
1085 } else {
1086 std::env::current_dir()?.join(path)
1087 };
1088 let root = CString::new("/").expect("literal has no NUL");
1089 let root_fd = unsafe {
1090 nix::libc::open(
1091 root.as_ptr(),
1092 nix::libc::O_PATH | nix::libc::O_DIRECTORY | nix::libc::O_CLOEXEC,
1093 )
1094 };
1095 if root_fd < 0 {
1096 return Err(std::io::Error::last_os_error().into());
1097 }
1098 let mut current = unsafe { OwnedFd::from_raw_fd(root_fd) };
1099 for component in absolute.components() {
1100 let std::path::Component::Normal(name) = component else {
1101 if matches!(component, std::path::Component::ParentDir) {
1102 bail!("refusing parent component in path {}", path.display());
1103 }
1104 continue;
1105 };
1106 let name = CString::new(name.as_bytes())?;
1107 let fd = unsafe {
1108 nix::libc::openat(
1109 current.as_raw_fd(),
1110 name.as_ptr(),
1111 nix::libc::O_PATH
1112 | nix::libc::O_DIRECTORY
1113 | nix::libc::O_NOFOLLOW
1114 | nix::libc::O_CLOEXEC,
1115 )
1116 };
1117 if fd < 0 {
1118 return Err(std::io::Error::last_os_error().into());
1119 }
1120 current = unsafe { OwnedFd::from_raw_fd(fd) };
1121 }
1122 let guard = std::fs::File::from(current);
1123 let anchored = PathBuf::from(format!("/proc/self/fd/{}", guard.as_raw_fd()));
1124 return Ok((anchored, Some(guard)));
1125 }
1126 #[cfg(not(target_os = "linux"))]
1127 {
1128 let _ = path;
1129 bail!(
1130 "refusing descriptor-anchored session operation: this platform has no safe directory descriptor primitive"
1131 );
1132 }
1133}
1134
1135fn open_sqlite_path(path: &Path, flags: rusqlite::OpenFlags) -> Result<Connection> {
1140 #[cfg(target_os = "linux")]
1141 {
1142 use std::os::unix::fs::OpenOptionsExt;
1143 let parent = path.parent().unwrap_or_else(|| Path::new("."));
1144 let name = path
1145 .file_name()
1146 .ok_or_else(|| anyhow::anyhow!("session DB path has no file name"))?;
1147 let (anchored_parent, _parent_guard) = open_directory_anchor(parent)?;
1148 let mut options = std::fs::OpenOptions::new();
1149 if flags.contains(rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE) {
1150 options.read(true).write(true);
1151 } else {
1152 options.read(true);
1153 }
1154 let file = options
1155 .custom_flags(nix::libc::O_NOFOLLOW)
1156 .open(anchored_parent.join(name))?;
1157 return open_sqlite_from_file(&file, path, flags);
1158 }
1159 #[cfg(not(target_os = "linux"))]
1160 {
1161 let _ = (path, flags);
1162 bail!(
1163 "refusing descriptor-anchored SQLite operation: this platform has no safe descriptor primitive"
1164 )
1165 }
1166}
1167
1168fn open_sqlite_from_file(
1169 file: &std::fs::File,
1170 _fallback_path: &Path,
1171 flags: rusqlite::OpenFlags,
1172) -> Result<Connection> {
1173 #[cfg(target_os = "linux")]
1174 {
1175 use std::os::fd::AsRawFd;
1176 let fd_path = PathBuf::from(format!("/proc/self/fd/{}", file.as_raw_fd()));
1177 return Connection::open_with_flags(fd_path, flags).map_err(Into::into);
1178 }
1179 #[cfg(not(target_os = "linux"))]
1180 {
1181 let _ = (file, _fallback_path, flags);
1182 bail!(
1183 "refusing descriptor-anchored SQLite operation: this platform has no safe descriptor primitive"
1184 )
1185 }
1186}
1187
1188fn reject_symlink_components(path: &Path) -> Result<()> {
1189 for ancestor in path.ancestors() {
1190 match fs::symlink_metadata(ancestor) {
1191 Ok(meta) if meta.file_type().is_symlink() => {
1192 bail!(
1193 "refusing session path through symbolic link {}",
1194 ancestor.display()
1195 )
1196 }
1197 Ok(_) => {}
1198 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1199 Err(error) => return Err(error.into()),
1200 }
1201 }
1202 Ok(())
1203}
1204
1205fn is_auto_session(path: &Path) -> Result<bool> {
1206 let conn = Connection::open(path)?;
1207 let v: i64 = conn
1208 .query_row("PRAGMA user_version", [], |r| r.get(0))
1209 .unwrap_or(-1);
1210 let created_by: String = conn
1211 .query_row("SELECT created_by FROM sessions LIMIT 1", [], |r| r.get(0))
1212 .unwrap_or_else(|_| "auto".to_string());
1213 if v != SCHEMA_VERSION {
1214 return Ok(created_by != "user");
1217 }
1218 Ok(created_by == "auto")
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224 use tempfile::TempDir;
1225
1226 fn opts<'a>(cwd: &'a Path, target: &'a str) -> CreateOptions<'a> {
1227 CreateOptions {
1228 kind: SessionKind::Debug,
1229 target,
1230 target_class: TargetClass::NativeCpu,
1231 cwd,
1232 db_path: None,
1233 label: Some(format!("t-{target}")),
1234 target_hash: Some("test".into()),
1235 }
1236 }
1237
1238 #[test]
1239 fn create_inserts_session_row() {
1240 let tmp = TempDir::new().unwrap();
1241 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1242 assert_eq!(db.kind(), SessionKind::Debug);
1243 assert_eq!(db.target(), "/bin/ls");
1244 let (label, class, created_by): (String, String, String) = db
1245 .conn
1246 .query_row(
1247 "SELECT label, target_class, created_by FROM sessions",
1248 [],
1249 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1250 )
1251 .unwrap();
1252 assert_eq!(label, "t-/bin/ls");
1253 assert_eq!(class, "native-cpu");
1254 assert_eq!(created_by, "auto");
1255 }
1256
1257 #[test]
1258 fn save_and_open_roundtrip() {
1259 let tmp = TempDir::new().unwrap();
1260 let path = tmp.path().join("roundtrip.db");
1261 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1262 src.save_to(&path).unwrap();
1263
1264 let opened = SessionDb::open(&path).unwrap();
1265 assert_eq!(opened.session_id(), src.session_id());
1266 assert_eq!(opened.label(), src.label());
1267 assert_eq!(opened.target_class(), TargetClass::NativeCpu);
1268 }
1269
1270 #[test]
1271 fn create_refuses_existing_database_without_a_second_owner() {
1272 let tmp = TempDir::new().unwrap();
1273 let path = tmp.path().join("owned.db");
1274 let first = SessionDb::create(CreateOptions {
1275 db_path: Some(&path),
1276 ..opts(tmp.path(), "/bin/ls")
1277 })
1278 .unwrap();
1279 let error = SessionDb::create(CreateOptions {
1280 db_path: Some(&path),
1281 ..opts(tmp.path(), "/bin/ls")
1282 })
1283 .unwrap_err()
1284 .to_string();
1285 assert!(error.contains("creating new session DB"));
1286 let owners: i64 = first
1287 .conn()
1288 .query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
1289 .unwrap();
1290 assert_eq!(owners, 1);
1291 }
1292
1293 #[cfg(unix)]
1294 #[test]
1295 fn save_refuses_a_symlink_destination_without_touching_target() {
1296 let tmp = TempDir::new().unwrap();
1297 let external = tmp.path().join("external.db");
1298 std::fs::write(&external, b"do not overwrite").unwrap();
1299 let destination = tmp.path().join("saved.db");
1300 std::os::unix::fs::symlink(&external, &destination).unwrap();
1301 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1302 assert!(db.save_to(&destination).is_err());
1303 assert_eq!(std::fs::read(&external).unwrap(), b"do not overwrite");
1304 }
1305
1306 #[cfg(target_os = "linux")]
1307 #[test]
1308 fn save_refuses_a_symlinked_parent_directory() {
1309 let tmp = TempDir::new().unwrap();
1310 let external = TempDir::new().unwrap();
1311 let link = tmp.path().join("sessions");
1312 std::os::unix::fs::symlink(external.path(), &link).unwrap();
1313 let destination = link.join("saved.db");
1314 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1315 assert!(db.save_to(&destination).is_err());
1316 assert!(!external.path().join("saved.db").exists());
1317 }
1318
1319 #[cfg(target_os = "linux")]
1320 #[test]
1321 fn save_ancestor_swap() {
1322 let tmp = TempDir::new().unwrap();
1326 let root = tmp.path().join("root");
1327 let parent = root.join("sessions");
1328 std::fs::create_dir_all(&parent).unwrap();
1329 let destination = parent.join("saved.db");
1330 let replacement = root.with_file_name("replacement-root");
1331 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1332
1333 db.save_to_inner(&destination, || {
1334 std::fs::rename(&root, &replacement).unwrap();
1335 std::fs::create_dir_all(&parent).unwrap();
1336 })
1337 .unwrap();
1338
1339 assert!(replacement.join("sessions/saved.db").exists());
1340 assert!(!destination.exists());
1341 assert!(!parent.join("saved.db").exists());
1342 }
1343
1344 #[test]
1345 fn foreign_keys_are_enabled_for_created_and_opened_sessions() {
1346 let tmp = TempDir::new().unwrap();
1347 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1348 let fk: i64 = db
1349 .conn
1350 .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
1351 .unwrap();
1352 assert_eq!(fk, 1);
1353 assert!(
1354 db.conn
1355 .execute(
1356 "INSERT INTO layers (session_id, source) VALUES ('missing', 'bad')",
1357 [],
1358 )
1359 .is_err()
1360 );
1361
1362 let path = tmp.path().join("foreign-keys.db");
1363 db.save_to(&path).unwrap();
1364 let opened = SessionDb::open(&path).unwrap();
1365 let fk: i64 = opened
1366 .conn
1367 .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
1368 .unwrap();
1369 assert_eq!(fk, 1);
1370 assert!(
1371 opened
1372 .conn
1373 .execute(
1374 "INSERT INTO layers (session_id, source) VALUES ('missing', 'bad')",
1375 [],
1376 )
1377 .is_err()
1378 );
1379 }
1380
1381 #[test]
1382 fn sessiondb_open_rejects_foreign_key_violations() {
1383 let tmp = TempDir::new().unwrap();
1384 let source = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1385 let path = tmp.path().join("violated.db");
1386 source.save_to(&path).unwrap();
1387 {
1388 let conn = Connection::open(&path).unwrap();
1389 conn.execute_batch("PRAGMA foreign_keys=OFF;").unwrap();
1390 conn.execute(
1391 "INSERT INTO layers (session_id, source) VALUES ('missing-owner', 'hostile')",
1392 [],
1393 )
1394 .unwrap();
1395 }
1396 let error = SessionDb::open(&path).unwrap_err().to_string();
1397 assert!(error.contains("foreign-key violations"), "{error}");
1398 }
1399
1400 #[test]
1401 fn open_requires_exactly_one_owner_row_and_required_tables() {
1402 let tmp = TempDir::new().unwrap();
1403 let source = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1404 let path = tmp.path().join("malformed.db");
1405 source.save_to(&path).unwrap();
1406 let err = {
1407 let conn = Connection::open(&path).unwrap();
1408 conn.execute_batch("PRAGMA foreign_keys=OFF;").unwrap();
1409 conn.execute("DELETE FROM sessions", []).unwrap();
1410 SessionDb::open(&path).unwrap_err().to_string()
1411 };
1412 assert!(err.contains("exactly one session owner row"), "{err}");
1413
1414 let source = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1415 source.save_to(&path).unwrap();
1416 let err = {
1417 let conn = Connection::open(&path).unwrap();
1418 conn.execute("DROP TABLE samples", []).unwrap();
1419 SessionDb::open(&path).unwrap_err().to_string()
1420 };
1421 assert!(err.contains("missing required table samples"), "{err}");
1422 }
1423
1424 #[test]
1425 fn open_refuses_mismatched_version() {
1426 let tmp = TempDir::new().unwrap();
1427 let path = tmp.path().join("bad.db");
1428 {
1429 let conn = Connection::open(&path).unwrap();
1430 conn.execute("PRAGMA user_version = 9999", []).unwrap();
1432 }
1433 let err = SessionDb::open(&path).unwrap_err().to_string();
1434 assert!(
1435 err.contains("schema_version=9999") && err.contains("No migration path"),
1436 "error missing expected phrases: {err}"
1437 );
1438 }
1439
1440 #[test]
1441 fn has_captured_data_false_on_fresh_session() {
1442 let tmp = TempDir::new().unwrap();
1443 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1444 assert!(!db.has_captured_data().unwrap());
1445 }
1446
1447 #[test]
1448 fn has_captured_data_true_after_breakpoint_hit() {
1449 let tmp = TempDir::new().unwrap();
1450 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1451 db.conn
1452 .execute(
1453 "INSERT INTO breakpoint_hits
1454 (session_id, location_key, hit_seq, ts)
1455 VALUES (?1, 'main.c:42', 1, datetime('now'))",
1456 params![db.session_id()],
1457 )
1458 .unwrap();
1459 assert!(db.has_captured_data().unwrap());
1460 }
1461
1462 #[test]
1463 fn has_captured_data_true_after_layer() {
1464 let tmp = TempDir::new().unwrap();
1465 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1466 db.conn
1467 .execute(
1468 "INSERT INTO layers (session_id, source) VALUES (?1, 'perf')",
1469 params![db.session_id()],
1470 )
1471 .unwrap();
1472 assert!(db.has_captured_data().unwrap());
1473 }
1474
1475 #[test]
1476 fn promote_to_user_sticks_through_save() {
1477 let tmp = TempDir::new().unwrap();
1478 let path = tmp.path().join("user.db");
1479 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1480 src.promote_to_user().unwrap();
1481 src.save_to(&path).unwrap();
1482 let opened = SessionDb::open(&path).unwrap();
1483 let cb: String = opened
1484 .conn
1485 .query_row("SELECT created_by FROM sessions", [], |r| r.get(0))
1486 .unwrap();
1487 assert_eq!(cb, "user");
1488 }
1489
1490 #[test]
1491 fn read_only_open_rejects_mutation() {
1492 let tmp = TempDir::new().unwrap();
1493 let path = tmp.path().join("readonly.db");
1494 let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1495 src.save_to(&path).unwrap();
1496 let ro = SessionDb::open_read_only(&path).unwrap();
1497 assert!(ro.promote_to_user().is_err());
1498 }
1499
1500 #[test]
1501 fn set_and_get_meta() {
1502 let tmp = TempDir::new().unwrap();
1503 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1504 db.set_meta("custom", "hello").unwrap();
1505 assert_eq!(db.meta("custom").unwrap(), Some("hello".into()));
1506 db.set_meta("custom", "world").unwrap();
1507 assert_eq!(db.meta("custom").unwrap(), Some("world".into()));
1508 }
1509
1510 #[test]
1511 fn session_group_key_persisted_at_create() {
1512 let tmp = TempDir::new().unwrap();
1513 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1514 let g = db.meta("session_group_key").unwrap().unwrap();
1515 assert!(g.ends_with("|test"), "unexpected group key: {g}");
1516 }
1517
1518 #[test]
1519 fn prune_auto_only_keeps_user_sessions() {
1520 let tmp = TempDir::new().unwrap();
1521 let dir = tmp.path().to_path_buf();
1522
1523 let auto_path = dir.join("auto.db");
1524 let user_path = dir.join("user.db");
1525 let a = SessionDb::create(CreateOptions {
1526 db_path: None,
1527 ..opts(&dir, "/bin/ls")
1528 })
1529 .unwrap();
1530 a.save_to(&auto_path).unwrap();
1531
1532 let u = SessionDb::create(CreateOptions {
1533 db_path: None,
1534 ..opts(&dir, "/bin/ls")
1535 })
1536 .unwrap();
1537 u.promote_to_user().unwrap();
1538 u.save_to(&user_path).unwrap();
1539
1540 std::thread::sleep(Duration::from_millis(10));
1543
1544 let deleted = prune(&dir, Duration::ZERO, PrunePolicy::AutoOnly).unwrap();
1545 assert_eq!(deleted.len(), 1, "deleted: {deleted:?}");
1546 assert!(deleted[0].ends_with("auto.db"));
1547 assert!(user_path.exists());
1548 assert!(!auto_path.exists());
1549 }
1550
1551 #[test]
1552 fn prune_does_not_report_or_remove_raw_capture_after_db_failure() {
1553 let tmp = TempDir::new().unwrap();
1554 let root = tmp.path();
1555 let db_path = root.join("broken.db");
1556 std::fs::create_dir(&db_path).unwrap();
1557 let raw = root.join("broken");
1558 std::fs::create_dir(&raw).unwrap();
1559 std::fs::write(raw.join("capture.raw"), "evidence").unwrap();
1560
1561 let error = prune(root, Duration::ZERO, PrunePolicy::All).unwrap_err();
1562 assert!(error.to_string().contains("deleting session DB"));
1563 assert!(db_path.exists());
1564 assert!(raw.exists());
1565 }
1566
1567 #[cfg(unix)]
1568 #[test]
1569 fn prune_all_refuses_a_symlinked_sessions_root() {
1570 let tmp = TempDir::new().unwrap();
1571 let external = TempDir::new().unwrap();
1572 let outside_db = external.path().join("outside.db");
1573 std::fs::write(&outside_db, b"external").unwrap();
1574 let root = tmp.path().join("sessions");
1575 std::os::unix::fs::symlink(external.path(), &root).unwrap();
1576 assert!(prune(&root, Duration::ZERO, PrunePolicy::All).is_err());
1577 assert!(outside_db.exists());
1578 }
1579
1580 #[test]
1581 fn prune_auto_only_keeps_promoted_unknown_schema() {
1582 let tmp = TempDir::new().unwrap();
1583 let path = tmp.path().join("promoted.db");
1584 let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1585 db.promote_to_user().unwrap();
1586 db.save_to(&path).unwrap();
1587 let conn = Connection::open(&path).unwrap();
1588 conn.execute("PRAGMA user_version = 9999", []).unwrap();
1589 std::thread::sleep(Duration::from_millis(10));
1590 let deleted = prune(tmp.path(), Duration::ZERO, PrunePolicy::AutoOnly).unwrap();
1591 assert!(deleted.is_empty());
1592 assert!(path.exists());
1593 }
1594
1595 #[test]
1596 fn prune_skips_files_newer_than_cutoff() {
1597 let tmp = TempDir::new().unwrap();
1598 let dir = tmp.path().to_path_buf();
1599 let p = dir.join("fresh.db");
1600 let s = SessionDb::create(CreateOptions {
1601 db_path: None,
1602 ..opts(&dir, "/bin/ls")
1603 })
1604 .unwrap();
1605 s.save_to(&p).unwrap();
1606 let deleted = prune(&dir, Duration::from_secs(86_400), PrunePolicy::AutoOnly).unwrap();
1608 assert!(deleted.is_empty());
1609 assert!(p.exists());
1610 }
1611
1612 #[test]
1613 fn auto_label_has_expected_shape() {
1614 let lbl = auto_label("/usr/bin/ls").unwrap();
1615 assert!(lbl.starts_with("ls-"), "label: {lbl}");
1616 let suffix = lbl.trim_start_matches("ls-");
1617 let timestamp = &suffix[..15];
1618 assert_eq!(timestamp.len(), 15); let (date, time) = timestamp.split_once('-').unwrap();
1620 assert_eq!(date.len(), 8);
1621 assert_eq!(time.len(), 6);
1622 assert!(date.chars().all(|c| c.is_ascii_digit()));
1623 assert!(time.chars().all(|c| c.is_ascii_digit()));
1624 }
1625
1626 #[test]
1627 fn automatic_labels_are_unique_within_one_second() {
1628 let first = auto_label("/usr/bin/ls").unwrap();
1629 let second = auto_label("/usr/bin/ls").unwrap();
1630 assert_ne!(first, second);
1631 }
1632
1633 #[test]
1634 fn automatic_label_concurrent_same_second() {
1635 let tmp = TempDir::new().unwrap();
1636 let barrier = std::sync::Arc::new(std::sync::Barrier::new(16));
1637 let workers: Vec<_> = (0..16)
1638 .map(|_| {
1639 let barrier = barrier.clone();
1640 let cwd = tmp.path().to_path_buf();
1641 std::thread::spawn(move || {
1642 barrier.wait();
1643 SessionDb::create(CreateOptions {
1644 kind: SessionKind::Debug,
1645 target: "/bin/ls",
1646 target_class: TargetClass::NativeCpu,
1647 cwd: &cwd,
1648 db_path: None,
1649 label: None,
1650 target_hash: Some("same-second".into()),
1651 })
1652 .unwrap()
1653 .label()
1654 .to_string()
1655 })
1656 })
1657 .collect();
1658 let labels: std::collections::HashSet<_> = workers
1659 .into_iter()
1660 .map(|worker| worker.join().unwrap())
1661 .collect();
1662 assert_eq!(labels.len(), 16);
1663 }
1664
1665 #[test]
1666 fn raw_dir_is_sibling_of_label() {
1667 let d = raw_dir(Path::new("/tmp/proj"), "myapp-20260415-120000");
1668 assert_eq!(
1669 d,
1670 PathBuf::from("/tmp/proj/.dbg/sessions/myapp-20260415-120000/raw")
1671 );
1672 }
1673
1674 #[test]
1675 fn group_key_stable_across_create_calls() {
1676 let tmp = TempDir::new().unwrap();
1677 let a = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1678 let b = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1679 assert_eq!(
1680 a.meta("session_group_key").unwrap(),
1681 b.meta("session_group_key").unwrap()
1682 );
1683 }
1684
1685 #[test]
1686 fn group_key_keeps_distinct_unhashed_targets_separate() {
1687 let tmp = TempDir::new().unwrap();
1688 let mut a = opts(tmp.path(), "attach:100");
1689 a.target_hash = None;
1690 let mut b = opts(tmp.path(), "attach:200");
1691 b.target_hash = None;
1692 let a = SessionDb::create(a).unwrap();
1693 let b = SessionDb::create(b).unwrap();
1694 assert_ne!(
1695 a.meta("session_group_key").unwrap(),
1696 b.meta("session_group_key").unwrap()
1697 );
1698 }
1699
1700 #[test]
1701 fn group_key_keeps_session_kinds_separate() {
1702 let tmp = TempDir::new().unwrap();
1703 let debug = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
1704 let profile = SessionDb::create(CreateOptions {
1705 kind: SessionKind::Profile,
1706 ..opts(tmp.path(), "/bin/ls")
1707 })
1708 .unwrap();
1709 assert_ne!(
1710 debug.meta("session_group_key").unwrap(),
1711 profile.meta("session_group_key").unwrap()
1712 );
1713 }
1714}