Skip to main content

openentropy_core/sources/signal/
spotlight_timing.rs

1//! Novel entropy sources: Spotlight metadata query timing.
2
3use std::process::Command;
4use std::time::{Duration, Instant};
5
6use crate::source::{EntropySource, Platform, SourceCategory, SourceInfo};
7
8use crate::sources::helpers::extract_timing_entropy;
9
10// ---------------------------------------------------------------------------
11// SpotlightTimingSource
12// ---------------------------------------------------------------------------
13
14/// Files to query via mdls, cycling through them.
15const SPOTLIGHT_FILES: &[&str] = &[
16    "/usr/bin/true",
17    "/usr/bin/false",
18    "/usr/bin/env",
19    "/usr/bin/which",
20];
21
22/// Path to the mdls binary.
23const MDLS_PATH: &str = "/usr/bin/mdls";
24
25/// Timeout for mdls commands.
26const MDLS_TIMEOUT: Duration = Duration::from_millis(150);
27
28static SPOTLIGHT_TIMING_INFO: SourceInfo = SourceInfo {
29    name: "spotlight_timing",
30    description: "Spotlight metadata index query timing jitter via mdls",
31    physics: "Queries Spotlight\u{2019}s metadata index (mdls) and measures response time. \
32              The index is a complex B-tree/inverted index structure. Query timing depends \
33              on: index size, disk cache residency, concurrent indexing activity, and \
34              filesystem metadata state. When Spotlight is actively indexing new files, \
35              query latency becomes highly variable.",
36    category: SourceCategory::Signal,
37    platform: Platform::MacOS,
38    requirements: &[],
39    entropy_rate_estimate: 2.0,
40    composite: false,
41    is_fast: false,
42};
43
44/// Entropy source that harvests timing jitter from Spotlight metadata queries.
45pub struct SpotlightTimingSource;
46
47impl EntropySource for SpotlightTimingSource {
48    fn info(&self) -> &SourceInfo {
49        &SPOTLIGHT_TIMING_INFO
50    }
51
52    fn is_available(&self) -> bool {
53        std::path::Path::new(MDLS_PATH).exists()
54    }
55
56    fn collect(&self, n_samples: usize) -> Vec<u8> {
57        // Cap mdls calls aggressively. Each spawns a subprocess (~5-50ms
58        // each, 150ms timeout if stalled). With a 2s deadline this keeps
59        // total wall time well under the pool's 6s per-source budget.
60        let raw_count = (n_samples + 16).min(48);
61        let mut timings: Vec<u64> = Vec::with_capacity(raw_count);
62        let file_count = SPOTLIGHT_FILES.len();
63        let deadline = Instant::now() + Duration::from_secs(2);
64
65        for i in 0..raw_count {
66            if Instant::now() >= deadline {
67                break;
68            }
69            let file = SPOTLIGHT_FILES[i % file_count];
70
71            // Measure the time to query Spotlight metadata with a timeout.
72            // Even timeouts produce useful timing entropy.
73            let t0 = Instant::now();
74
75            let child = Command::new(MDLS_PATH)
76                .args(["-name", "kMDItemFSName", file])
77                .stdout(std::process::Stdio::null())
78                .stderr(std::process::Stdio::null())
79                .spawn();
80
81            if let Ok(mut child) = child {
82                let per_cmd_deadline = Instant::now() + MDLS_TIMEOUT;
83                loop {
84                    match child.try_wait() {
85                        Ok(Some(_)) => break,
86                        Ok(None) => {
87                            if Instant::now() >= per_cmd_deadline {
88                                let _ = child.kill();
89                                let _ = child.wait();
90                                break;
91                            }
92                            std::thread::sleep(Duration::from_millis(5));
93                        }
94                        Err(_) => break,
95                    }
96                }
97            }
98
99            // Always record timing — timeouts are just as entropic.
100            let elapsed_ns = t0.elapsed().as_nanos() as u64;
101            timings.push(elapsed_ns);
102        }
103
104        extract_timing_entropy(&timings, n_samples)
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use crate::sources::helpers::extract_lsbs_u64;
112
113    #[test]
114    fn spotlight_timing_info() {
115        let src = SpotlightTimingSource;
116        assert_eq!(src.name(), "spotlight_timing");
117        assert_eq!(src.info().category, SourceCategory::Signal);
118        assert!((src.info().entropy_rate_estimate - 2.0).abs() < f64::EPSILON);
119    }
120
121    #[test]
122    #[cfg(target_os = "macos")]
123    #[ignore] // Run with: cargo test -- --ignored
124    fn spotlight_timing_collects_bytes() {
125        let src = SpotlightTimingSource;
126        if src.is_available() {
127            let data = src.collect(32);
128            assert!(!data.is_empty());
129            assert!(data.len() <= 32);
130        }
131    }
132
133    #[test]
134    fn extract_lsbs_packing() {
135        let deltas = vec![1u64, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0];
136        let bytes = extract_lsbs_u64(&deltas);
137        assert_eq!(bytes.len(), 2);
138        // First 8 bits: 1,0,1,0,1,0,1,0 -> 0xAA
139        assert_eq!(bytes[0], 0xAA);
140        // Next 8 bits: 1,1,1,1,0,0,0,0 -> 0xF0
141        assert_eq!(bytes[1], 0xF0);
142    }
143}