1use 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#[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
47pub 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
68fn 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
96fn 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#[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 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
165struct 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 fn analyze(&self) -> HashMap<String, SessionSourceAnalysis> {
213 self.data
214 .iter()
215 .filter(|(_, buf)| buf.len() >= 100) .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#[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#[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
288const FLUSH_INTERVAL: u64 = 64;
295
296pub 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 analysis_buffer: Option<AnalysisBuffer>,
318 telemetry_start: Option<TelemetrySnapshot>,
320 finished: bool,
322}
323
324impl SessionWriter {
325 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 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 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 let raw_file = File::create(session_dir.join("raw.bin"))?;
372 let raw_writer = BufWriter::new(raw_file);
373
374 let conditioned_file = File::create(session_dir.join("conditioned.bin"))?;
376 let conditioned_writer = BufWriter::new(conditioned_file);
377
378 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 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 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)] 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 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 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 self.raw_writer.write_all(raw_bytes)?;
471 self.conditioned_writer.write_all(conditioned_bytes)?;
472
473 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 if self.total_samples.is_multiple_of(FLUSH_INTERVAL) {
500 self.flush_all()?;
501 }
502
503 Ok(())
504 }
505
506 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 #[allow(clippy::cast_possible_truncation)] 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 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 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 #[must_use]
581 pub fn session_dir(&self) -> &Path {
582 &self.session_dir
583 }
584
585 #[must_use]
587 pub fn total_samples(&self) -> u64 {
588 self.total_samples
589 }
590
591 #[must_use]
593 pub fn elapsed(&self) -> Duration {
594 self.started_instant.elapsed()
595 }
596
597 #[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 let _ = self.flush_all();
612 let meta = self.build_meta();
613 let _ = self.write_session_json(&meta);
614 }
615}
616
617fn 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
631fn 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
639fn 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
647fn 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
689fn 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
708fn 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
721fn truncate_for_path(s: &str, max_chars: usize) -> String {
723 s.chars().take(max_chars).collect()
724}
725
726pub 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 sessions.sort_by(|a, b| b.1.started_at.cmp(&a.1.started_at));
767
768 Ok(sessions)
769}
770
771pub 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#[cfg(test)]
846mod tests {
847 use super::*;
848
849 #[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 #[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 let s = format_iso8601(Duration::from_secs(1771030200));
881 assert!(s.starts_with("2026-"));
882 }
883
884 #[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 #[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 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 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); assert!(lines[1].contains("mock_source"));
1016
1017 let raw = std::fs::read(dir.join("raw.bin")).unwrap();
1019 assert_eq!(raw.len(), 200); 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); assert!(idx_lines[1].starts_with("0,100,")); assert!(idx_lines[2].starts_with("100,100,")); 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 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 #[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(writer);
1256
1257 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 assert!(dir.join("session.json").exists());
1280 }
1281
1282 #[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 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 #[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 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 #[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 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 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}