Skip to main content

openentropy_core/
session.rs

1//! Session recording for entropy collection research.
2//!
3//! Records timestamped entropy samples from one or more sources, storing raw
4//! bytes, CSV metrics, and session metadata. Designed for offline analysis of
5//! how entropy sources behave under different conditions.
6//!
7//! # Storage Format
8//!
9//! Each session is a directory containing:
10//! - `session.json` — metadata (sources, timing, machine info, tags)
11//! - `samples.csv` — per-sample metrics (raw + conditioned entropy stats)
12//! - `raw.bin` — concatenated raw bytes
13//! - `raw_index.csv` — byte offset index into raw.bin
14//! - `conditioned.bin` — concatenated conditioned bytes
15//! - `conditioned_index.csv` — byte offset index into conditioned.bin
16
17use std::collections::{HashMap, HashSet, VecDeque};
18use std::fs::{self, File};
19use std::io::{BufWriter, Write};
20use std::path::{Path, PathBuf};
21use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
22
23use serde::{Deserialize, Serialize};
24use uuid::Uuid;
25
26use crate::analysis;
27use crate::conditioning::{ConditioningMode, quick_min_entropy, quick_shannon};
28#[cfg(test)]
29use crate::telemetry::{TelemetryMetric, TelemetryMetricDelta};
30use crate::telemetry::{
31    TelemetrySnapshot, TelemetryWindowReport, collect_telemetry_snapshot, collect_telemetry_window,
32};
33
34// ---------------------------------------------------------------------------
35// Machine info
36// ---------------------------------------------------------------------------
37
38/// Machine information captured at session start.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct MachineInfo {
41    pub os: String,
42    pub arch: String,
43    pub chip: String,
44    pub cores: usize,
45}
46
47/// Detect machine information (best-effort).
48pub fn detect_machine_info() -> MachineInfo {
49    let os = format!(
50        "{} {}",
51        std::env::consts::OS,
52        os_version().unwrap_or_default()
53    );
54    let arch = std::env::consts::ARCH.to_string();
55    let chip = detect_chip().unwrap_or_else(|| "unknown".to_string());
56    let cores = std::thread::available_parallelism()
57        .map(std::num::NonZero::get)
58        .unwrap_or(1);
59
60    MachineInfo {
61        os,
62        arch,
63        chip,
64        cores,
65    }
66}
67
68/// Get OS version string (best-effort).
69fn os_version() -> Option<String> {
70    #[cfg(target_os = "macos")]
71    {
72        let output = std::process::Command::new("sw_vers")
73            .arg("-productVersion")
74            .output()
75            .ok()?;
76        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
77    }
78    #[cfg(target_os = "linux")]
79    {
80        std::fs::read_to_string("/etc/os-release")
81            .ok()
82            .and_then(|s| {
83                s.lines().find(|l| l.starts_with("PRETTY_NAME=")).map(|l| {
84                    l.trim_start_matches("PRETTY_NAME=")
85                        .trim_matches('"')
86                        .to_string()
87                })
88            })
89    }
90    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
91    {
92        None
93    }
94}
95
96/// Detect chip/CPU name (best-effort).
97fn detect_chip() -> Option<String> {
98    #[cfg(target_os = "macos")]
99    {
100        let output = std::process::Command::new("/usr/sbin/sysctl")
101            .arg("-n")
102            .arg("machdep.cpu.brand_string")
103            .output()
104            .ok()?;
105        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
106        if s.is_empty() { None } else { Some(s) }
107    }
108    #[cfg(target_os = "linux")]
109    {
110        std::fs::read_to_string("/proc/cpuinfo").ok().and_then(|s| {
111            s.lines()
112                .find(|l| l.starts_with("model name"))
113                .map(|l| l.split(':').nth(1).unwrap_or("").trim().to_string())
114        })
115    }
116    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
117    {
118        None
119    }
120}
121
122// ---------------------------------------------------------------------------
123// Per-source analysis summary (embedded in session.json)
124// ---------------------------------------------------------------------------
125
126/// Compact analysis summary for a single source, embedded in session metadata.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct SessionSourceAnalysis {
129    pub autocorrelation_max: f64,
130    pub autocorrelation_violations: usize,
131    pub spectral_flatness: f64,
132    pub spectral_dominant_freq: f64,
133    pub bit_bias_max: f64,
134    pub bit_bias_has_significant: bool,
135    pub distribution_ks_p: f64,
136    pub distribution_mean: f64,
137    pub distribution_std: f64,
138    pub stationarity_f_stat: f64,
139    pub stationarity_is_stationary: bool,
140    pub runs_longest: usize,
141    pub runs_total: usize,
142}
143
144impl SessionSourceAnalysis {
145    /// Build a compact summary from a full `SourceAnalysis`.
146    fn from_full(sa: &analysis::SourceAnalysis) -> Self {
147        Self {
148            autocorrelation_max: sa.autocorrelation.max_abs_correlation,
149            autocorrelation_violations: sa.autocorrelation.violations,
150            spectral_flatness: sa.spectral.flatness,
151            spectral_dominant_freq: sa.spectral.dominant_frequency,
152            bit_bias_max: sa.bit_bias.overall_bias,
153            bit_bias_has_significant: sa.bit_bias.has_significant_bias,
154            distribution_ks_p: sa.distribution.ks_p_value,
155            distribution_mean: sa.distribution.mean,
156            distribution_std: sa.distribution.std_dev,
157            stationarity_f_stat: sa.stationarity.f_statistic,
158            stationarity_is_stationary: sa.stationarity.is_stationary,
159            runs_longest: sa.runs.longest_run,
160            runs_total: sa.runs.total_runs,
161        }
162    }
163}
164
165// ---------------------------------------------------------------------------
166// Analysis buffer (retains last N bytes per source for end-of-session analysis)
167// ---------------------------------------------------------------------------
168
169/// Circular buffer that retains the last `capacity` bytes per source.
170struct AnalysisBuffer {
171    data: HashMap<String, VecDeque<u8>>,
172    capacity: usize,
173}
174
175impl AnalysisBuffer {
176    fn new(sources: &[String], capacity: usize) -> Self {
177        let data = sources
178            .iter()
179            .map(|s| (s.clone(), VecDeque::with_capacity(capacity)))
180            .collect();
181        Self { data, capacity }
182    }
183
184    fn push(&mut self, source: &str, bytes: &[u8]) {
185        if self.capacity == 0 || bytes.is_empty() {
186            return;
187        }
188
189        let buf = self
190            .data
191            .entry(source.to_string())
192            .or_insert_with(|| VecDeque::with_capacity(self.capacity));
193
194        if bytes.len() >= self.capacity {
195            buf.clear();
196            buf.extend(bytes[bytes.len() - self.capacity..].iter().copied());
197            return;
198        }
199
200        let overflow = buf.len() + bytes.len();
201        if overflow > self.capacity {
202            let to_drop = overflow - self.capacity;
203            for _ in 0..to_drop {
204                let _ = buf.pop_front();
205            }
206        }
207
208        buf.extend(bytes.iter().copied());
209    }
210
211    /// Run analysis on each source buffer and return the summary map.
212    fn analyze(&self) -> HashMap<String, SessionSourceAnalysis> {
213        self.data
214            .iter()
215            .filter(|(_, buf)| buf.len() >= 100) // Need minimum data for meaningful analysis
216            .map(|(name, buf)| {
217                let contiguous: Vec<u8> = buf.iter().copied().collect();
218                let full = analysis::full_analysis(name, &contiguous);
219                (name.clone(), SessionSourceAnalysis::from_full(&full))
220            })
221            .collect()
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Session metadata (session.json)
227// ---------------------------------------------------------------------------
228
229/// Session metadata written to session.json at the end of recording.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct SessionMeta {
232    pub version: u32,
233    pub id: String,
234    pub started_at: String,
235    pub ended_at: String,
236    pub duration_ms: u64,
237    pub sources: Vec<String>,
238    pub conditioning: String,
239    pub interval_ms: Option<u64>,
240    pub total_samples: u64,
241    pub samples_per_source: HashMap<String, u64>,
242    pub machine: MachineInfo,
243    pub tags: HashMap<String, String>,
244    pub note: Option<String>,
245    pub openentropy_version: String,
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub analysis: Option<HashMap<String, SessionSourceAnalysis>>,
248    #[serde(default, skip_serializing_if = "Option::is_none", alias = "telemetry")]
249    pub telemetry_v1: Option<TelemetryWindowReport>,
250}
251
252// ---------------------------------------------------------------------------
253// Session config
254// ---------------------------------------------------------------------------
255
256/// Configuration for a recording session.
257#[derive(Debug, Clone)]
258pub struct SessionConfig {
259    pub sources: Vec<String>,
260    pub conditioning: ConditioningMode,
261    pub interval: Option<Duration>,
262    pub output_dir: PathBuf,
263    pub tags: HashMap<String, String>,
264    pub note: Option<String>,
265    pub duration: Option<Duration>,
266    pub sample_size: usize,
267    pub include_analysis: bool,
268    pub include_telemetry: bool,
269}
270
271impl Default for SessionConfig {
272    fn default() -> Self {
273        Self {
274            sources: Vec::new(),
275            conditioning: ConditioningMode::Raw,
276            interval: None,
277            output_dir: PathBuf::from("sessions"),
278            tags: HashMap::new(),
279            note: None,
280            duration: None,
281            sample_size: 1000,
282            include_analysis: false,
283            include_telemetry: false,
284        }
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Session writer
290// ---------------------------------------------------------------------------
291
292/// Number of samples between periodic flushes. Balances crash-safety
293/// (data written to disk) against performance (fewer syscalls).
294const FLUSH_INTERVAL: u64 = 64;
295
296/// Handles incremental file I/O for a recording session.
297///
298/// Implements `Drop` to flush buffers and write a best-effort session.json
299/// if `finish()` was never called (e.g., due to a panic or early exit).
300pub struct SessionWriter {
301    session_dir: PathBuf,
302    csv_writer: BufWriter<File>,
303    raw_writer: BufWriter<File>,
304    conditioned_writer: BufWriter<File>,
305    index_writer: BufWriter<File>,
306    conditioned_index_writer: BufWriter<File>,
307    raw_offset: u64,
308    conditioned_offset: u64,
309    total_samples: u64,
310    samples_per_source: HashMap<String, u64>,
311    started_at: SystemTime,
312    started_instant: Instant,
313    session_id: String,
314    config: SessionConfig,
315    machine: MachineInfo,
316    /// Retains last 128 KiB per source for optional end-of-session analysis.
317    analysis_buffer: Option<AnalysisBuffer>,
318    /// Optional telemetry snapshot captured at session start.
319    telemetry_start: Option<TelemetrySnapshot>,
320    /// Set to true after `finish()` succeeds so `Drop` doesn't double-write.
321    finished: bool,
322}
323
324impl SessionWriter {
325    /// Create a new session writer, creating the session directory and files.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if no sources were configured, or if the session
330    /// directory or any output files cannot be created.
331    pub fn new(config: SessionConfig) -> std::io::Result<Self> {
332        if config.sources.is_empty() {
333            return Err(std::io::Error::new(
334                std::io::ErrorKind::InvalidInput,
335                "at least one source is required for session recording",
336            ));
337        }
338        let mut seen = HashSet::new();
339        for source in &config.sources {
340            if !seen.insert(source.as_str()) {
341                return Err(std::io::Error::new(
342                    std::io::ErrorKind::InvalidInput,
343                    format!("duplicate source '{source}' in session configuration"),
344                ));
345            }
346        }
347
348        let machine = detect_machine_info();
349        let session_id = Uuid::new_v4().to_string();
350        let started_at = SystemTime::now();
351
352        // Build directory name: bounded and filesystem-safe to avoid ENAMETOOLONG
353        // when many sources are recorded.
354        let ts = started_at.duration_since(UNIX_EPOCH).unwrap_or_default();
355        let dt = format_iso8601_compact(ts);
356        let dir_name = build_session_dir_name(&dt, &config.sources, &session_id);
357
358        let session_dir = config.output_dir.join(&dir_name);
359        fs::create_dir_all(&session_dir)?;
360
361        // Create samples.csv with header
362        let csv_file = File::create(session_dir.join("samples.csv"))?;
363        let mut csv_writer = BufWriter::new(csv_file);
364        writeln!(
365            csv_writer,
366            "timestamp_ns,source,raw_hex,conditioned_hex,raw_shannon,raw_min_entropy,conditioned_shannon,conditioned_min_entropy"
367        )?;
368        csv_writer.flush()?;
369
370        // Create raw.bin
371        let raw_file = File::create(session_dir.join("raw.bin"))?;
372        let raw_writer = BufWriter::new(raw_file);
373
374        // Create conditioned.bin
375        let conditioned_file = File::create(session_dir.join("conditioned.bin"))?;
376        let conditioned_writer = BufWriter::new(conditioned_file);
377
378        // Create raw_index.csv with header
379        let index_file = File::create(session_dir.join("raw_index.csv"))?;
380        let mut index_writer = BufWriter::new(index_file);
381        writeln!(index_writer, "offset,length,timestamp_ns,source")?;
382        index_writer.flush()?;
383
384        // Create conditioned_index.csv with header
385        let conditioned_index_file = File::create(session_dir.join("conditioned_index.csv"))?;
386        let mut conditioned_index_writer = BufWriter::new(conditioned_index_file);
387        writeln!(
388            conditioned_index_writer,
389            "offset,length,timestamp_ns,source"
390        )?;
391        conditioned_index_writer.flush()?;
392
393        let samples_per_source: HashMap<String, u64> =
394            config.sources.iter().map(|s| (s.clone(), 0)).collect();
395        let analysis_buffer = if config.include_analysis {
396            Some(AnalysisBuffer::new(&config.sources, 128 * 1024))
397        } else {
398            None
399        };
400        let telemetry_start = config.include_telemetry.then(collect_telemetry_snapshot);
401
402        Ok(Self {
403            session_dir,
404            csv_writer,
405            raw_writer,
406            conditioned_writer,
407            index_writer,
408            conditioned_index_writer,
409            raw_offset: 0,
410            conditioned_offset: 0,
411            total_samples: 0,
412            samples_per_source,
413            started_at,
414            started_instant: Instant::now(),
415            session_id,
416            config,
417            machine,
418            analysis_buffer,
419            telemetry_start,
420            finished: false,
421        })
422    }
423
424    /// Record a single sample from a source.
425    ///
426    /// Buffers are flushed periodically (every [`FLUSH_INTERVAL`] samples)
427    /// rather than on every call, for performance. Data is still safe against
428    /// process crashes because `Drop` flushes and writes session.json.
429    ///
430    /// # Errors
431    ///
432    /// Returns an error if writing to any of the output files fails.
433    pub fn write_sample(
434        &mut self,
435        source: &str,
436        raw_bytes: &[u8],
437        conditioned_bytes: &[u8],
438    ) -> std::io::Result<()> {
439        if !self.samples_per_source.contains_key(source) {
440            return Err(std::io::Error::new(
441                std::io::ErrorKind::InvalidInput,
442                format!("source '{source}' was not declared for this session"),
443            ));
444        }
445        if raw_bytes.is_empty() {
446            return Ok(());
447        }
448
449        #[allow(clippy::cast_possible_truncation)] // ns won't overflow u64 until ~2554
450        let timestamp_ns = SystemTime::now()
451            .duration_since(UNIX_EPOCH)
452            .unwrap_or_default()
453            .as_nanos() as u64;
454
455        let raw_shannon = quick_shannon(raw_bytes);
456        // Clamp to 0.0 to avoid displaying "-0.00" in CSV
457        let raw_min_entropy = quick_min_entropy(raw_bytes).max(0.0);
458        let conditioned_shannon = quick_shannon(conditioned_bytes);
459        let conditioned_min_entropy = quick_min_entropy(conditioned_bytes).max(0.0);
460        let raw_hex = hex_encode(raw_bytes);
461        let conditioned_hex = hex_encode(conditioned_bytes);
462
463        // Write CSV row
464        writeln!(
465            self.csv_writer,
466            "{timestamp_ns},{source},{raw_hex},{conditioned_hex},{raw_shannon:.2},{raw_min_entropy:.2},{conditioned_shannon:.2},{conditioned_min_entropy:.2}",
467        )?;
468
469        // Write raw bytes
470        self.raw_writer.write_all(raw_bytes)?;
471        self.conditioned_writer.write_all(conditioned_bytes)?;
472
473        // Write index row
474        writeln!(
475            self.index_writer,
476            "{},{},{timestamp_ns},{source}",
477            self.raw_offset,
478            raw_bytes.len(),
479        )?;
480        writeln!(
481            self.conditioned_index_writer,
482            "{},{},{timestamp_ns},{source}",
483            self.conditioned_offset,
484            conditioned_bytes.len(),
485        )?;
486
487        self.raw_offset += raw_bytes.len() as u64;
488        self.conditioned_offset += conditioned_bytes.len() as u64;
489        self.total_samples += 1;
490        if let Some(buffer) = &mut self.analysis_buffer {
491            buffer.push(source, raw_bytes);
492        }
493        *self
494            .samples_per_source
495            .entry(source.to_string())
496            .or_insert(0) += 1;
497
498        // Periodic flush for crash-safety without per-sample syscall overhead
499        if self.total_samples.is_multiple_of(FLUSH_INTERVAL) {
500            self.flush_all()?;
501        }
502
503        Ok(())
504    }
505
506    /// Flush all buffered writers to disk.
507    fn flush_all(&mut self) -> std::io::Result<()> {
508        self.csv_writer.flush()?;
509        self.raw_writer.flush()?;
510        self.conditioned_writer.flush()?;
511        self.index_writer.flush()?;
512        self.conditioned_index_writer.flush()?;
513        Ok(())
514    }
515
516    /// Build the session metadata from current state.
517    #[allow(clippy::cast_possible_truncation)] // durations won't overflow u64 in practice
518    fn build_meta(&self) -> SessionMeta {
519        let ended_at = SystemTime::now();
520        let duration = self.started_instant.elapsed();
521
522        let analysis = self.analysis_buffer.as_ref().and_then(|buffer| {
523            let analysis_map = buffer.analyze();
524            if analysis_map.is_empty() {
525                None
526            } else {
527                Some(analysis_map)
528            }
529        });
530        let telemetry = self
531            .telemetry_start
532            .as_ref()
533            .cloned()
534            .map(collect_telemetry_window);
535
536        SessionMeta {
537            version: 2,
538            id: self.session_id.clone(),
539            started_at: format_iso8601(
540                self.started_at
541                    .duration_since(UNIX_EPOCH)
542                    .unwrap_or_default(),
543            ),
544            ended_at: format_iso8601(ended_at.duration_since(UNIX_EPOCH).unwrap_or_default()),
545            duration_ms: duration.as_millis() as u64,
546            sources: self.config.sources.clone(),
547            conditioning: self.config.conditioning.to_string(),
548            interval_ms: self.config.interval.map(|d| d.as_millis() as u64),
549            total_samples: self.total_samples,
550            samples_per_source: self.samples_per_source.clone(),
551            machine: self.machine.clone(),
552            tags: self.config.tags.clone(),
553            note: self.config.note.clone(),
554            openentropy_version: crate::VERSION.to_string(),
555            analysis,
556            telemetry_v1: telemetry,
557        }
558    }
559
560    /// Write session.json to disk.
561    fn write_session_json(&self, meta: &SessionMeta) -> std::io::Result<()> {
562        let json = serde_json::to_string_pretty(meta).map_err(std::io::Error::other)?;
563        fs::write(self.session_dir.join("session.json"), json)
564    }
565
566    /// Finalize the session, writing session.json. Call this on graceful shutdown.
567    ///
568    /// # Errors
569    ///
570    /// Returns an error if flushing buffers or writing session.json fails.
571    pub fn finish(mut self) -> std::io::Result<PathBuf> {
572        self.flush_all()?;
573        let meta = self.build_meta();
574        self.write_session_json(&meta)?;
575        self.finished = true;
576        Ok(self.session_dir.clone())
577    }
578
579    /// Get the session directory path.
580    #[must_use]
581    pub fn session_dir(&self) -> &Path {
582        &self.session_dir
583    }
584
585    /// Get total samples recorded so far.
586    #[must_use]
587    pub fn total_samples(&self) -> u64 {
588        self.total_samples
589    }
590
591    /// Get elapsed time since recording started.
592    #[must_use]
593    pub fn elapsed(&self) -> Duration {
594        self.started_instant.elapsed()
595    }
596
597    /// Get per-source sample counts.
598    #[must_use]
599    pub fn samples_per_source(&self) -> &HashMap<String, u64> {
600        &self.samples_per_source
601    }
602}
603
604impl Drop for SessionWriter {
605    fn drop(&mut self) {
606        if self.finished {
607            return;
608        }
609        // Best-effort: flush buffers and write session.json so data isn't lost
610        // on panic/early-exit. Errors are silently ignored since we're in Drop.
611        let _ = self.flush_all();
612        let meta = self.build_meta();
613        let _ = self.write_session_json(&meta);
614    }
615}
616
617// ---------------------------------------------------------------------------
618// Helpers
619// ---------------------------------------------------------------------------
620
621/// Hex-encode bytes without any separator.
622fn hex_encode(bytes: &[u8]) -> String {
623    use std::fmt::Write;
624    let mut s = String::with_capacity(bytes.len() * 2);
625    for &b in bytes {
626        write!(s, "{b:02x}").unwrap();
627    }
628    s
629}
630
631/// Format a duration-since-epoch as a compact ISO-8601 timestamp for directory names.
632/// Example: `2026-02-15T013000Z`
633fn format_iso8601_compact(since_epoch: Duration) -> String {
634    let secs = since_epoch.as_secs();
635    let (year, month, day, hour, min, sec) = secs_to_utc(secs);
636    format!("{year:04}-{month:02}-{day:02}T{hour:02}{min:02}{sec:02}Z")
637}
638
639/// Format a duration-since-epoch as a full ISO-8601 timestamp.
640/// Example: `2026-02-15T01:30:00Z`
641fn format_iso8601(since_epoch: Duration) -> String {
642    let secs = since_epoch.as_secs();
643    let (year, month, day, hour, min, sec) = secs_to_utc(secs);
644    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z")
645}
646
647/// Convert seconds since Unix epoch to (year, month, day, hour, minute, second) UTC.
648/// Simple implementation — no leap second handling.
649fn secs_to_utc(secs: u64) -> (u64, u64, u64, u64, u64, u64) {
650    let sec = secs % 60;
651    let min = (secs / 60) % 60;
652    let hour = (secs / 3600) % 24;
653
654    let mut days = secs / 86400;
655    let mut year = 1970u64;
656
657    loop {
658        let days_in_year = if is_leap(year) { 366 } else { 365 };
659        if days < days_in_year {
660            break;
661        }
662        days -= days_in_year;
663        year += 1;
664    }
665
666    let months_days: [u64; 12] = if is_leap(year) {
667        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
668    } else {
669        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
670    };
671
672    let mut month = 0u64;
673    for (i, &md) in months_days.iter().enumerate() {
674        if days < md {
675            month = i as u64 + 1;
676            break;
677        }
678        days -= md;
679    }
680    let day = days + 1;
681
682    (year, month, day, hour, min, sec)
683}
684
685fn is_leap(year: u64) -> bool {
686    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
687}
688
689/// Build a compact, filesystem-safe session directory name.
690///
691/// Format: `{timestamp}-{source-label}-{id8}`
692/// Examples:
693/// - `2026-02-17T193000Z-clock_jitter-a1b2c3d4`
694/// - `2026-02-17T193000Z-clock_jitter-plus34-a1b2c3d4`
695fn build_session_dir_name(timestamp: &str, sources: &[String], session_id: &str) -> String {
696    let first = sources.first().map(String::as_str).unwrap_or("unknown");
697    let first = sanitize_for_path(first);
698    let label = if sources.len() <= 1 {
699        truncate_for_path(&first, 48)
700    } else {
701        let base = truncate_for_path(&first, 36);
702        format!("{base}-plus{}", sources.len() - 1)
703    };
704    let id8 = session_id.chars().take(8).collect::<String>();
705    format!("{timestamp}-{label}-{id8}")
706}
707
708/// Replace non path-safe characters with `_`.
709fn sanitize_for_path(s: &str) -> String {
710    s.chars()
711        .map(|c| {
712            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
713                c
714            } else {
715                '_'
716            }
717        })
718        .collect()
719}
720
721/// Truncate by character count (ASCII-safe output from sanitize_for_path).
722fn truncate_for_path(s: &str, max_chars: usize) -> String {
723    s.chars().take(max_chars).collect()
724}
725
726// ---------------------------------------------------------------------------
727// Session listing and loading utilities
728// ---------------------------------------------------------------------------
729
730/// List all recorded sessions in a directory.
731///
732/// Scans `dir` for subdirectories containing a valid `session.json` file,
733/// deserializes each into [`SessionMeta`], and returns them sorted by
734/// `started_at` descending (newest first).
735///
736/// Returns `Ok(vec![])` if the directory does not exist. Subdirectories
737/// with missing or corrupt `session.json` files are silently skipped.
738pub fn list_sessions(dir: &Path) -> Result<Vec<(PathBuf, SessionMeta)>, std::io::Error> {
739    if !dir.exists() {
740        return Ok(Vec::new());
741    }
742
743    let mut sessions: Vec<(PathBuf, SessionMeta)> = Vec::new();
744
745    for entry in fs::read_dir(dir)? {
746        let entry = entry?;
747        let path = entry.path();
748        if !path.is_dir() {
749            continue;
750        }
751        let json_path = path.join("session.json");
752        if !json_path.exists() {
753            continue;
754        }
755        let contents = match fs::read_to_string(&json_path) {
756            Ok(c) => c,
757            Err(_) => continue,
758        };
759        match serde_json::from_str::<SessionMeta>(&contents) {
760            Ok(meta) => sessions.push((path, meta)),
761            Err(_) => continue,
762        }
763    }
764
765    // Sort by start time, newest first
766    sessions.sort_by(|a, b| b.1.started_at.cmp(&a.1.started_at));
767
768    Ok(sessions)
769}
770
771/// Load raw entropy data from a recorded session, grouped by source.
772///
773/// Reads `raw.bin` and `raw_index.csv` from `session_dir`, then groups
774/// the raw bytes by source name using the index entries.
775///
776/// # CSV format (`raw_index.csv`)
777///
778/// ```text
779/// offset,length,timestamp_ns,source
780/// 0,1000,1709234567890123456,clock_jitter
781/// 1000,1000,1709234567890234567,thermal_noise
782/// ```
783///
784/// Each row after the header contains four comma-separated fields:
785/// - `offset` — byte offset into `raw.bin`
786/// - `length` — number of bytes for this entry
787/// - `timestamp_ns` — nanosecond timestamp when the sample was recorded
788/// - `source` — name of the entropy source
789///
790/// Index entries where `offset + length` exceeds the size of `raw.bin`
791/// are silently skipped (bounds check).
792///
793/// # Errors
794///
795/// Returns [`std::io::ErrorKind::NotFound`] if either `raw.bin` or
796/// `raw_index.csv` is missing from `session_dir`.
797pub fn load_session_raw_data(
798    session_dir: &Path,
799) -> Result<HashMap<String, Vec<u8>>, std::io::Error> {
800    let raw_path = session_dir.join("raw.bin");
801    let index_path = session_dir.join("raw_index.csv");
802
803    if !raw_path.exists() || !index_path.exists() {
804        return Err(std::io::Error::new(
805            std::io::ErrorKind::NotFound,
806            "missing raw.bin or raw_index.csv",
807        ));
808    }
809
810    let raw_data = fs::read(&raw_path)?;
811    let index_csv = fs::read_to_string(&index_path)?;
812
813    let mut source_bytes: HashMap<String, Vec<u8>> = HashMap::new();
814
815    for line in index_csv.lines().skip(1) {
816        let parts: Vec<&str> = line.splitn(4, ',').collect();
817        if parts.len() < 4 {
818            continue;
819        }
820        let offset: usize = match parts[0].parse() {
821            Ok(v) => v,
822            Err(_) => continue,
823        };
824        let length: usize = match parts[1].parse() {
825            Ok(v) => v,
826            Err(_) => continue,
827        };
828        let source = parts[3].to_string();
829
830        if offset + length <= raw_data.len() {
831            source_bytes
832                .entry(source)
833                .or_default()
834                .extend_from_slice(&raw_data[offset..offset + length]);
835        }
836    }
837
838    Ok(source_bytes)
839}
840
841// ---------------------------------------------------------------------------
842// Tests
843// ---------------------------------------------------------------------------
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    // -----------------------------------------------------------------------
850    // Machine info tests
851    // -----------------------------------------------------------------------
852
853    #[test]
854    fn test_detect_machine_info() {
855        let info = detect_machine_info();
856        assert!(!info.os.is_empty());
857        assert!(!info.arch.is_empty());
858        assert!(info.cores > 0);
859    }
860
861    // -----------------------------------------------------------------------
862    // ISO-8601 formatting tests
863    // -----------------------------------------------------------------------
864
865    #[test]
866    fn test_format_iso8601_epoch() {
867        let s = format_iso8601(Duration::from_secs(0));
868        assert_eq!(s, "1970-01-01T00:00:00Z");
869    }
870
871    #[test]
872    fn test_format_iso8601_compact_epoch() {
873        let s = format_iso8601_compact(Duration::from_secs(0));
874        assert_eq!(s, "1970-01-01T000000Z");
875    }
876
877    #[test]
878    fn test_format_iso8601_known_date() {
879        // 2026-02-15 01:30:00 UTC = 1771030200 seconds since epoch
880        let s = format_iso8601(Duration::from_secs(1771030200));
881        assert!(s.starts_with("2026-"));
882    }
883
884    // -----------------------------------------------------------------------
885    // Hex encode tests
886    // -----------------------------------------------------------------------
887
888    #[test]
889    fn test_hex_encode_empty() {
890        assert_eq!(hex_encode(&[]), "");
891    }
892
893    #[test]
894    fn test_hex_encode_basic() {
895        assert_eq!(hex_encode(&[0xab, 0xcd, 0x01]), "abcd01");
896    }
897
898    // -----------------------------------------------------------------------
899    // SessionWriter tests
900    // -----------------------------------------------------------------------
901
902    #[test]
903    fn test_session_writer_creates_directory_and_files() {
904        let tmp = tempfile::tempdir().unwrap();
905        let config = SessionConfig {
906            sources: vec!["test_source".to_string()],
907            output_dir: tmp.path().to_path_buf(),
908            ..Default::default()
909        };
910
911        let writer = SessionWriter::new(config).unwrap();
912        let dir = writer.session_dir().to_path_buf();
913
914        assert!(dir.exists());
915        assert!(dir.join("samples.csv").exists());
916        assert!(dir.join("raw.bin").exists());
917        assert!(dir.join("raw_index.csv").exists());
918        assert!(dir.join("conditioned.bin").exists());
919        assert!(dir.join("conditioned_index.csv").exists());
920
921        // Finish and verify session.json
922        let result_dir = writer.finish().unwrap();
923        assert!(result_dir.join("session.json").exists());
924    }
925
926    #[test]
927    fn test_session_writer_rejects_empty_sources() {
928        let tmp = tempfile::tempdir().unwrap();
929        let config = SessionConfig {
930            output_dir: tmp.path().to_path_buf(),
931            ..Default::default()
932        };
933
934        let err = match SessionWriter::new(config) {
935            Ok(_) => panic!("empty sources should be rejected"),
936            Err(err) => err,
937        };
938        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
939        assert!(
940            err.to_string().contains("at least one source"),
941            "unexpected error: {err}"
942        );
943    }
944
945    #[test]
946    fn test_session_writer_rejects_duplicate_sources() {
947        let tmp = tempfile::tempdir().unwrap();
948        let config = SessionConfig {
949            sources: vec!["dup_source".to_string(), "dup_source".to_string()],
950            output_dir: tmp.path().to_path_buf(),
951            ..Default::default()
952        };
953
954        let err = match SessionWriter::new(config) {
955            Ok(_) => panic!("duplicate sources should be rejected"),
956            Err(err) => err,
957        };
958        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
959        assert!(
960            err.to_string().contains("duplicate source"),
961            "unexpected error: {err}"
962        );
963    }
964
965    #[test]
966    fn test_build_session_dir_name_is_compact() {
967        let sources: Vec<String> = (0..40)
968            .map(|i| format!("very_long_source_name_number_{i}_with_extra_detail"))
969            .collect();
970        let name = build_session_dir_name("2026-02-17T010203Z", &sources, "12345678-aaaa-bbbb");
971        assert!(name.len() < 128, "dir name too long: {} chars", name.len());
972        assert!(name.contains("plus39"));
973    }
974
975    #[test]
976    fn test_session_writer_with_many_sources_does_not_fail() {
977        let tmp = tempfile::tempdir().unwrap();
978        let sources: Vec<String> = (0..40)
979            .map(|i| format!("very_long_source_name_number_{i}_with_extra_detail"))
980            .collect();
981        let config = SessionConfig {
982            sources,
983            output_dir: tmp.path().to_path_buf(),
984            ..Default::default()
985        };
986        let writer = SessionWriter::new(config).expect("SessionWriter should handle many sources");
987        assert!(writer.session_dir().exists());
988    }
989
990    #[test]
991    fn test_session_writer_writes_valid_csv() {
992        let tmp = tempfile::tempdir().unwrap();
993        let config = SessionConfig {
994            sources: vec!["mock_source".to_string()],
995            output_dir: tmp.path().to_path_buf(),
996            ..Default::default()
997        };
998
999        let mut writer = SessionWriter::new(config).unwrap();
1000        let data = vec![0xAA; 100];
1001        writer.write_sample("mock_source", &data, &data).unwrap();
1002        writer.write_sample("mock_source", &data, &data).unwrap();
1003
1004        let dir = writer.session_dir().to_path_buf();
1005        let result_dir = writer.finish().unwrap();
1006
1007        // Check CSV
1008        let csv = std::fs::read_to_string(dir.join("samples.csv")).unwrap();
1009        let lines: Vec<&str> = csv.lines().collect();
1010        assert_eq!(
1011            lines[0],
1012            "timestamp_ns,source,raw_hex,conditioned_hex,raw_shannon,raw_min_entropy,conditioned_shannon,conditioned_min_entropy"
1013        );
1014        assert_eq!(lines.len(), 3); // header + 2 samples
1015        assert!(lines[1].contains("mock_source"));
1016
1017        // Check raw.bin size
1018        let raw = std::fs::read(dir.join("raw.bin")).unwrap();
1019        assert_eq!(raw.len(), 200); // 2 x 100 bytes
1020
1021        // Check raw_index.csv
1022        let index = std::fs::read_to_string(dir.join("raw_index.csv")).unwrap();
1023        let idx_lines: Vec<&str> = index.lines().collect();
1024        assert_eq!(idx_lines.len(), 3); // header + 2 entries
1025        assert!(idx_lines[1].starts_with("0,100,")); // first entry at offset 0
1026        assert!(idx_lines[2].starts_with("100,100,")); // second at offset 100
1027
1028        // Check conditioned.bin/index
1029        let conditioned = std::fs::read(dir.join("conditioned.bin")).unwrap();
1030        assert_eq!(conditioned.len(), 200);
1031        let conditioned_index = std::fs::read_to_string(dir.join("conditioned_index.csv")).unwrap();
1032        let cidx_lines: Vec<&str> = conditioned_index.lines().collect();
1033        assert_eq!(cidx_lines.len(), 3);
1034        assert!(cidx_lines[1].starts_with("0,100,"));
1035        assert!(cidx_lines[2].starts_with("100,100,"));
1036
1037        // Check session.json
1038        let json_str = std::fs::read_to_string(result_dir.join("session.json")).unwrap();
1039        let meta: SessionMeta = serde_json::from_str(&json_str).unwrap();
1040        assert_eq!(meta.version, 2);
1041        assert_eq!(meta.total_samples, 2);
1042        assert_eq!(meta.sources, vec!["mock_source"]);
1043        assert_eq!(*meta.samples_per_source.get("mock_source").unwrap(), 2);
1044        assert_eq!(meta.conditioning, "raw");
1045    }
1046
1047    #[test]
1048    fn test_session_writer_multiple_sources() {
1049        let tmp = tempfile::tempdir().unwrap();
1050        let config = SessionConfig {
1051            sources: vec!["source_a".to_string(), "source_b".to_string()],
1052            output_dir: tmp.path().to_path_buf(),
1053            ..Default::default()
1054        };
1055
1056        let mut writer = SessionWriter::new(config).unwrap();
1057        writer.write_sample("source_a", &[1; 50], &[4; 50]).unwrap();
1058        writer.write_sample("source_b", &[2; 75], &[5; 75]).unwrap();
1059        writer.write_sample("source_a", &[3; 50], &[6; 50]).unwrap();
1060
1061        assert_eq!(writer.total_samples(), 3);
1062        assert_eq!(*writer.samples_per_source().get("source_a").unwrap(), 2);
1063        assert_eq!(*writer.samples_per_source().get("source_b").unwrap(), 1);
1064
1065        let dir = writer.finish().unwrap();
1066        let meta: SessionMeta =
1067            serde_json::from_str(&std::fs::read_to_string(dir.join("session.json")).unwrap())
1068                .unwrap();
1069        assert_eq!(meta.total_samples, 3);
1070    }
1071
1072    #[test]
1073    fn test_session_writer_with_tags_and_note() {
1074        let tmp = tempfile::tempdir().unwrap();
1075        let mut tags = HashMap::new();
1076        tags.insert("crystal".to_string(), "quartz".to_string());
1077        tags.insert("distance".to_string(), "2cm".to_string());
1078
1079        let config = SessionConfig {
1080            sources: vec!["test".to_string()],
1081            output_dir: tmp.path().to_path_buf(),
1082            tags,
1083            note: Some("Testing quartz crystal".to_string()),
1084            ..Default::default()
1085        };
1086
1087        let writer = SessionWriter::new(config).unwrap();
1088        let dir = writer.finish().unwrap();
1089
1090        let meta: SessionMeta =
1091            serde_json::from_str(&std::fs::read_to_string(dir.join("session.json")).unwrap())
1092                .unwrap();
1093        assert_eq!(meta.tags.get("crystal").unwrap(), "quartz");
1094        assert_eq!(meta.tags.get("distance").unwrap(), "2cm");
1095        assert_eq!(meta.note.unwrap(), "Testing quartz crystal");
1096    }
1097
1098    #[test]
1099    fn test_session_meta_serialization_roundtrip() {
1100        let meta = SessionMeta {
1101            version: 2,
1102            id: "test-id".to_string(),
1103            started_at: "2026-01-01T00:00:00Z".to_string(),
1104            ended_at: "2026-01-01T00:05:00Z".to_string(),
1105            duration_ms: 300000,
1106            sources: vec!["clock_jitter".to_string()],
1107            conditioning: "raw".to_string(),
1108            interval_ms: Some(100),
1109            total_samples: 3000,
1110            samples_per_source: {
1111                let mut m = HashMap::new();
1112                m.insert("clock_jitter".to_string(), 3000);
1113                m
1114            },
1115            machine: MachineInfo {
1116                os: "macos 15.4".to_string(),
1117                arch: "aarch64".to_string(),
1118                chip: "Apple M4".to_string(),
1119                cores: 10,
1120            },
1121            tags: HashMap::new(),
1122            note: None,
1123            openentropy_version: env!("CARGO_PKG_VERSION").to_string(),
1124            analysis: None,
1125            telemetry_v1: None,
1126        };
1127
1128        let json = serde_json::to_string_pretty(&meta).unwrap();
1129        let parsed: SessionMeta = serde_json::from_str(&json).unwrap();
1130        assert_eq!(parsed.version, 2);
1131        assert_eq!(parsed.id, "test-id");
1132        assert_eq!(parsed.total_samples, 3000);
1133        assert_eq!(parsed.duration_ms, 300000);
1134    }
1135
1136    #[test]
1137    fn test_session_meta_accepts_legacy_telemetry_key() {
1138        let base = SessionMeta {
1139            version: 2,
1140            id: "test-id".to_string(),
1141            started_at: "2026-01-01T00:00:00Z".to_string(),
1142            ended_at: "2026-01-01T00:05:00Z".to_string(),
1143            duration_ms: 300000,
1144            sources: vec!["clock_jitter".to_string()],
1145            conditioning: "raw".to_string(),
1146            interval_ms: Some(100),
1147            total_samples: 3000,
1148            samples_per_source: {
1149                let mut m = HashMap::new();
1150                m.insert("clock_jitter".to_string(), 3000);
1151                m
1152            },
1153            machine: MachineInfo {
1154                os: "macos 15.4".to_string(),
1155                arch: "aarch64".to_string(),
1156                chip: "Apple M4".to_string(),
1157                cores: 10,
1158            },
1159            tags: HashMap::new(),
1160            note: None,
1161            openentropy_version: env!("CARGO_PKG_VERSION").to_string(),
1162            analysis: None,
1163            telemetry_v1: None,
1164        };
1165
1166        let window = TelemetryWindowReport {
1167            model_id: "telemetry_v1".to_string(),
1168            model_version: 1,
1169            elapsed_ms: 1234,
1170            start: TelemetrySnapshot {
1171                model_id: "telemetry_v1".to_string(),
1172                model_version: 1,
1173                collected_unix_ms: 1000,
1174                os: "macos".to_string(),
1175                arch: "aarch64".to_string(),
1176                cpu_count: 8,
1177                loadavg_1m: Some(1.0),
1178                loadavg_5m: Some(1.1),
1179                loadavg_15m: Some(1.2),
1180                metrics: vec![TelemetryMetric {
1181                    domain: "memory".to_string(),
1182                    name: "free_bytes".to_string(),
1183                    value: 100.0,
1184                    unit: "bytes".to_string(),
1185                    source: "test".to_string(),
1186                }],
1187            },
1188            end: TelemetrySnapshot {
1189                model_id: "telemetry_v1".to_string(),
1190                model_version: 1,
1191                collected_unix_ms: 2234,
1192                os: "macos".to_string(),
1193                arch: "aarch64".to_string(),
1194                cpu_count: 8,
1195                loadavg_1m: Some(1.3),
1196                loadavg_5m: Some(1.2),
1197                loadavg_15m: Some(1.1),
1198                metrics: vec![TelemetryMetric {
1199                    domain: "memory".to_string(),
1200                    name: "free_bytes".to_string(),
1201                    value: 80.0,
1202                    unit: "bytes".to_string(),
1203                    source: "test".to_string(),
1204                }],
1205            },
1206            deltas: vec![TelemetryMetricDelta {
1207                domain: "memory".to_string(),
1208                name: "free_bytes".to_string(),
1209                unit: "bytes".to_string(),
1210                source: "test".to_string(),
1211                start_value: 100.0,
1212                end_value: 80.0,
1213                delta_value: -20.0,
1214            }],
1215        };
1216
1217        let mut json = serde_json::to_value(base).unwrap();
1218        let obj = json.as_object_mut().expect("session meta should be object");
1219        obj.insert(
1220            "telemetry".to_string(),
1221            serde_json::to_value(window).unwrap(),
1222        );
1223
1224        let parsed: SessionMeta = serde_json::from_value(json).unwrap();
1225        assert!(parsed.telemetry_v1.is_some());
1226        assert_eq!(
1227            parsed
1228                .telemetry_v1
1229                .as_ref()
1230                .map(|t| t.model_id.as_str())
1231                .unwrap_or(""),
1232            "telemetry_v1"
1233        );
1234    }
1235
1236    // -----------------------------------------------------------------------
1237    // Drop safety tests
1238    // -----------------------------------------------------------------------
1239
1240    #[test]
1241    fn test_drop_writes_session_json_without_finish() {
1242        let tmp = tempfile::tempdir().unwrap();
1243        let config = SessionConfig {
1244            sources: vec!["drop_test".to_string()],
1245            output_dir: tmp.path().to_path_buf(),
1246            ..Default::default()
1247        };
1248
1249        let mut writer = SessionWriter::new(config).unwrap();
1250        let dir = writer.session_dir().to_path_buf();
1251        writer
1252            .write_sample("drop_test", &[42; 100], &[24; 100])
1253            .unwrap();
1254        // Drop without calling finish()
1255        drop(writer);
1256
1257        // session.json should still be written by Drop
1258        assert!(dir.join("session.json").exists());
1259        let meta: SessionMeta =
1260            serde_json::from_str(&std::fs::read_to_string(dir.join("session.json")).unwrap())
1261                .unwrap();
1262        assert_eq!(meta.total_samples, 1);
1263    }
1264
1265    #[test]
1266    fn test_finish_prevents_double_write_on_drop() {
1267        let tmp = tempfile::tempdir().unwrap();
1268        let config = SessionConfig {
1269            sources: vec!["test".to_string()],
1270            output_dir: tmp.path().to_path_buf(),
1271            ..Default::default()
1272        };
1273
1274        let writer = SessionWriter::new(config).unwrap();
1275        let dir = writer.session_dir().to_path_buf();
1276        let _ = writer.finish().unwrap();
1277
1278        // session.json should exist (from finish), and Drop should not error
1279        assert!(dir.join("session.json").exists());
1280    }
1281
1282    // -----------------------------------------------------------------------
1283    // Edge case tests
1284    // -----------------------------------------------------------------------
1285
1286    #[test]
1287    fn test_write_sample_skips_empty_bytes() {
1288        let tmp = tempfile::tempdir().unwrap();
1289        let config = SessionConfig {
1290            sources: vec!["test".to_string()],
1291            output_dir: tmp.path().to_path_buf(),
1292            ..Default::default()
1293        };
1294
1295        let mut writer = SessionWriter::new(config).unwrap();
1296        writer.write_sample("test", &[], &[]).unwrap();
1297        assert_eq!(writer.total_samples(), 0);
1298        let _ = writer.finish().unwrap();
1299    }
1300
1301    #[test]
1302    fn test_write_sample_rejects_undeclared_source() {
1303        let tmp = tempfile::tempdir().unwrap();
1304        let config = SessionConfig {
1305            sources: vec!["declared".to_string()],
1306            output_dir: tmp.path().to_path_buf(),
1307            ..Default::default()
1308        };
1309
1310        let mut writer = SessionWriter::new(config).unwrap();
1311        let err = writer
1312            .write_sample("undeclared", &[1, 2, 3], &[1, 2, 3])
1313            .unwrap_err();
1314        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1315        assert!(
1316            err.to_string().contains("was not declared"),
1317            "unexpected error: {err}"
1318        );
1319        assert_eq!(writer.total_samples(), 0);
1320        let _ = writer.finish().unwrap();
1321    }
1322
1323    #[test]
1324    fn test_min_entropy_not_negative_in_csv() {
1325        let tmp = tempfile::tempdir().unwrap();
1326        let config = SessionConfig {
1327            sources: vec!["test".to_string()],
1328            output_dir: tmp.path().to_path_buf(),
1329            ..Default::default()
1330        };
1331
1332        let mut writer = SessionWriter::new(config).unwrap();
1333        // All-same bytes produce near-zero min-entropy that could display as -0.00
1334        writer
1335            .write_sample("test", &[0xAA; 100], &[0xAA; 100])
1336            .unwrap();
1337        let dir = writer.session_dir().to_path_buf();
1338        let _ = writer.finish().unwrap();
1339
1340        let csv = std::fs::read_to_string(dir.join("samples.csv")).unwrap();
1341        for line in csv.lines().skip(1) {
1342            assert!(
1343                !line.contains("-0.00"),
1344                "CSV should not contain negative zero: {line}"
1345            );
1346        }
1347    }
1348
1349    // -----------------------------------------------------------------------
1350    // UTC conversion tests
1351    // -----------------------------------------------------------------------
1352
1353    #[test]
1354    fn test_secs_to_utc_epoch() {
1355        let (y, m, d, h, mi, s) = secs_to_utc(0);
1356        assert_eq!((y, m, d, h, mi, s), (1970, 1, 1, 0, 0, 0));
1357    }
1358
1359    #[test]
1360    fn test_secs_to_utc_known_date() {
1361        // 2000-01-01 00:00:00 UTC = 946684800
1362        let (y, m, d, h, mi, s) = secs_to_utc(946684800);
1363        assert_eq!((y, m, d, h, mi, s), (2000, 1, 1, 0, 0, 0));
1364    }
1365
1366    #[test]
1367    fn test_is_leap() {
1368        assert!(is_leap(2000));
1369        assert!(is_leap(2024));
1370        assert!(!is_leap(1900));
1371        assert!(!is_leap(2023));
1372    }
1373
1374    // -----------------------------------------------------------------------
1375    // list_sessions / load_session_raw_data tests
1376    // -----------------------------------------------------------------------
1377
1378    #[test]
1379    fn test_list_sessions_empty_dir() {
1380        let tmp = tempfile::tempdir().unwrap();
1381        let nonexistent = tmp.path().join("does_not_exist");
1382        let result = list_sessions(&nonexistent).unwrap();
1383        assert!(result.is_empty());
1384    }
1385
1386    #[test]
1387    fn test_list_sessions_corrupt_json() {
1388        let tmp = tempfile::tempdir().unwrap();
1389        let bad_session = tmp.path().join("bad-session");
1390        fs::create_dir_all(&bad_session).unwrap();
1391        fs::write(bad_session.join("session.json"), "not valid json {{{").unwrap();
1392
1393        let result = list_sessions(tmp.path()).unwrap();
1394        assert!(result.is_empty(), "corrupt session.json should be skipped");
1395    }
1396
1397    #[test]
1398    fn test_list_and_load_roundtrip() {
1399        let tmp = tempfile::tempdir().unwrap();
1400        let raw_bytes = vec![0xDE, 0xAD, 0xBE, 0xEF];
1401        let conditioned_bytes = vec![0xCA, 0xFE];
1402
1403        let config = SessionConfig {
1404            sources: vec!["test_src".to_string()],
1405            output_dir: tmp.path().to_path_buf(),
1406            ..Default::default()
1407        };
1408        let mut writer = SessionWriter::new(config).unwrap();
1409        writer
1410            .write_sample("test_src", &raw_bytes, &conditioned_bytes)
1411            .unwrap();
1412        let session_dir = writer.finish().unwrap();
1413
1414        // list_sessions should find it
1415        let sessions = list_sessions(tmp.path()).unwrap();
1416        assert_eq!(sessions.len(), 1);
1417        assert_eq!(sessions[0].0, session_dir);
1418        assert_eq!(sessions[0].1.total_samples, 1);
1419        assert_eq!(sessions[0].1.sources, vec!["test_src"]);
1420
1421        // load_session_raw_data should recover the bytes
1422        let loaded = load_session_raw_data(&session_dir).unwrap();
1423        assert_eq!(loaded.len(), 1);
1424        assert_eq!(loaded.get("test_src").unwrap(), &raw_bytes);
1425    }
1426}