Skip to main content

dbg_cli/session_db/
lifecycle.rs

1//! SessionDb lifecycle: create, open (with strict schema-version check),
2//! save-to, prune, session groups, raw-file paths.
3
4use 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
15/// An open SessionDb — either in-memory (fresh) or backed by a file.
16pub 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
39/// Parameters for `SessionDb::create`.
40pub struct CreateOptions<'a> {
41    pub kind: SessionKind,
42    pub target: &'a str,
43    pub target_class: TargetClass,
44    pub cwd: &'a Path,
45    /// `None` → in-memory DB. Call `save_to` later to persist.
46    pub db_path: Option<&'a Path>,
47    /// `None` → auto-generate `"{basename}-{yyyymmdd-hhmmss}"`.
48    pub label: Option<String>,
49    /// `None` → auto-compute `size:mtime_ns` if `target` is a file.
50    pub target_hash: Option<String>,
51}
52
53/// Whether `prune` deletes only auto-created sessions or everything over age.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum PrunePolicy {
56    AutoOnly,
57    All,
58}
59
60impl SessionDb {
61    /// Create a fresh SessionDb. Applies DDL, stamps `PRAGMA user_version`,
62    /// inserts the session row, and records session-group metadata.
63    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                // Creation is a claim on the pathname. Opening an existing
78                // SQLite file and inserting another owner leaves a database
79                // which `open` correctly rejects later. `create_new` makes
80                // that failure happen before any schema or owner mutation.
81                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(); // WAL unsupported in-memory — non-fatal.
98        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        // Record session-group key under meta so peers can be discovered
127        // by scanning `.dbg/sessions/*.db` and matching on this value.
128        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        // Include session kind and target class. A profile and a live debug
140        // session can use the same target and hash, but they have different
141        // lifecycle and grouping semantics and must not merge.
142        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    /// Open an existing SessionDb from disk. Refuses to open DBs with a
171    /// mismatched `PRAGMA user_version`: the caller must re-collect.
172    pub fn open(path: &Path) -> Result<Self> {
173        Self::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE)
174    }
175
176    /// Open an existing session without granting SQLite write access.
177    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        // A well-formed DB has exactly one session row (the owning session).
251        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    /// Copy the current in-memory (or on-disk) DB to `path` using the SQLite
281    /// online backup API. The source DB stays usable.
282    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        // Validate before creating anything. In particular, do not let
288        // create_dir_all follow an already-present symlink in an ancestor.
289        // The descriptor open below is the final authority and fails closed
290        // if a race installs one after this check.
291        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        // Back up to a newly-created sibling, then rename the completed
303        // object into place. SQLite must never reopen a pathname which the
304        // caller validated earlier: a swap at that point could redirect the
305        // backup into an external symlink target.
306        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    /// True iff the session has accumulated data worth keeping — either a
336    /// breakpoint hit was captured (debug track) or a data layer landed
337    /// (profile track). Used by the daemon shutdown path to decide whether
338    /// to persist or discard an auto session.
339    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        // Profile-mode sessions (dotnet-trace, perf, callgrind, import)
357        // stash their source content in meta.profile_raw rather than
358        // creating a layers row. Treat that as captured data so
359        // `persist_session_on_exit` actually saves the DB and
360        // `dbg replay <label>` can rehydrate the profile.
361        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    /// Promote this session so `prune` will never delete it.
378    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
886/// `<cwd>/.dbg/sessions/` — where saved session DBs live.
887pub fn sessions_dir(cwd: &Path) -> PathBuf {
888    cwd.join(".dbg").join("sessions")
889}
890
891/// `<cwd>/.dbg/sessions/<label>/raw/` — where raw captures (`perf.data`,
892/// `nsys-rep`, `.nettrace`, etc.) are preserved verbatim. Per principle 5
893/// (adaptation layer), these files are the durable artifact.
894pub fn raw_dir(cwd: &Path, label: &str) -> PathBuf {
895    sessions_dir(cwd).join(label).join("raw")
896}
897
898/// Deterministic session-group id derived from `(cwd, target_hash)`.
899/// Two sessions share a group iff they were launched against the same
900/// target from the same working directory.
901pub fn group_key(cwd: &Path, target_hash: &str) -> String {
902    format!("{}|{}", cwd.display(), target_hash)
903}
904
905/// Cheap target fingerprint: `"<size>:<mtime_ns>"`. Good enough to detect
906/// "target has been rebuilt since last session". Not a cryptographic hash.
907pub 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
919/// `"{basename}-{yyyymmdd-hhmmss}"` in the local-ish (system time) zone,
920/// computed from `SystemTime::now()` without pulling in chrono.
921pub 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    // Keep the readable second-resolution prefix, but add process-local and
931    // subsecond entropy. This covers concurrent callers in one process and
932    // separate daemon processes without making the database/raw directory
933    // names depend on a later collision check.
934    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
945/// UTC `yyyymmdd-hhmmss`. Proleptic Gregorian, no leap seconds —
946/// sufficient for session labels.
947fn 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    // Convert days-since-1970-01-01 to (year, month, day) — algorithm from
962    // Howard Hinnant's date library, public-domain.
963    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    // 16-byte randomblob → 32-hex-char id. SQLite's CSPRNG.
982    let id: String = conn.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
983    Ok(id)
984}
985
986/// Walk `sessions_dir`, delete `.db` files for auto sessions whose mtime
987/// is older than `older_than`. Returns the list of deleted paths.
988/// User-promoted sessions are never touched under `PrunePolicy::AutoOnly`.
989pub 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        // Inspect created_by without opening via SessionDb (which would
1032        // fail on version mismatch — intentional, but the prune path
1033        // should still be able to remove obsolete files).
1034        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        // Remove the DB plus any adjacent raw-capture directory named
1043        // after the label (file stem).
1044        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
1072/// Open a directory by resolving each component from a stable descriptor.
1073/// A pre-check of all pathnames is not race-safe because an ancestor can be
1074/// replaced before the final open. `openat` plus `O_NOFOLLOW` keeps the
1075/// resulting descriptor inside the originally selected directory tree.
1076fn 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
1135/// Open SQLite through a file descriptor which was opened with no-follow
1136/// semantics. This keeps a pathname swap from redirecting SQLite after the
1137/// caller has validated the destination. Linux exposes the descriptor through
1138/// procfs; other platforms retain the normal SQLite pathname operation.
1139fn 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        // A user-promoted DB must remain protected even when it needs a
1215        // migration. Unknown files without that marker remain pruneable.
1216        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        // Replace an ancestor after save_to has validated it and opened the
1323        // destination directory. The completed database must remain in the
1324        // directory inode which was opened, never in the replacement tree.
1325        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            // Pretend this DB was written by a far-future version.
1431            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        // Give the fs a tick so our ZERO-age cutoff sits at-or-after the
1541        // file mtimes (required on coarse-grained timestamp filesystems).
1542        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        // One-day cutoff should keep a just-created file.
1607        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); // yyyymmdd-hhmmss
1619        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}