Skip to main content

openentropy_core/sources/
vmstat.rs

1//! VmstatSource — Runs macOS `vm_stat`, parses counter output, takes multiple
2//! snapshots, and extracts entropy from the deltas of changing counters.
3
4use std::collections::HashMap;
5use std::thread;
6use std::time::Duration;
7
8use crate::source::{EntropySource, Platform, SourceCategory, SourceInfo};
9
10use super::helpers::{extract_delta_bytes_i64, run_command};
11
12/// Delay between consecutive vm_stat snapshots.
13const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
14
15/// Number of snapshot rounds to collect.
16const NUM_ROUNDS: usize = 4;
17
18/// Entropy source that samples macOS `vm_stat` counters and extracts entropy
19/// from memory management deltas (page faults, pageins, compressions, etc.).
20///
21/// No tunable parameters — the source reads all vm_stat counters and
22/// automatically extracts deltas from those that change between snapshots.
23pub struct VmstatSource;
24
25static VMSTAT_INFO: SourceInfo = SourceInfo {
26    name: "vmstat_deltas",
27    description: "Samples macOS vm_stat counters and extracts entropy from memory management deltas",
28    physics: "Samples macOS vm_stat counters (page faults, pageins, pageouts, \
29              compressions, decompressions, swap activity). These track physical memory \
30              management \u{2014} each counter changes when hardware page table walks, TLB \
31              misses, or memory pressure triggers compressor/swap.",
32    category: SourceCategory::System,
33    platform: Platform::MacOS,
34    requirements: &[],
35    entropy_rate_estimate: 1000.0,
36    composite: false,
37};
38
39impl VmstatSource {
40    pub fn new() -> Self {
41        Self
42    }
43}
44
45impl Default for VmstatSource {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51/// Locate the `vm_stat` binary. Checks the standard macOS path first, then PATH.
52fn vm_stat_path() -> Option<String> {
53    let standard = "/usr/bin/vm_stat";
54    if std::path::Path::new(standard).exists() {
55        return Some(standard.to_string());
56    }
57
58    // Fall back to searching PATH via `which`
59    let path = run_command("which", &["vm_stat"])?;
60    let path = path.trim().to_string();
61    if !path.is_empty() {
62        return Some(path);
63    }
64
65    None
66}
67
68/// Run `vm_stat` and parse output into a map of counter names to values.
69///
70/// vm_stat output looks like:
71/// ```text
72/// Mach Virtual Memory Statistics: (page size of 16384 bytes)
73/// Pages free:                               12345.
74/// Pages active:                             67890.
75/// ```
76///
77/// We strip the trailing period and parse the integer.
78fn snapshot_vmstat(path: &str) -> Option<HashMap<String, i64>> {
79    let stdout = run_command(path, &[])?;
80    let mut map = HashMap::new();
81
82    for line in stdout.lines() {
83        // Skip the header line
84        if line.starts_with("Mach") || line.is_empty() {
85            continue;
86        }
87
88        // Lines look like: "Pages active:                             67890."
89        if let Some(colon_idx) = line.rfind(':') {
90            let key = line[..colon_idx].trim().to_string();
91            let val_str = line[colon_idx + 1..].trim().trim_end_matches('.');
92
93            if let Ok(v) = val_str.parse::<i64>() {
94                map.insert(key, v);
95            }
96        }
97    }
98
99    Some(map)
100}
101
102impl EntropySource for VmstatSource {
103    fn info(&self) -> &SourceInfo {
104        &VMSTAT_INFO
105    }
106
107    fn is_available(&self) -> bool {
108        vm_stat_path().is_some()
109    }
110
111    fn collect(&self, n_samples: usize) -> Vec<u8> {
112        let path = match vm_stat_path() {
113            Some(p) => p,
114            None => return Vec::new(),
115        };
116
117        // Take NUM_ROUNDS snapshots with delays between them
118        let mut snapshots: Vec<HashMap<String, i64>> = Vec::with_capacity(NUM_ROUNDS);
119
120        for i in 0..NUM_ROUNDS {
121            if i > 0 {
122                thread::sleep(SNAPSHOT_DELAY);
123            }
124            match snapshot_vmstat(&path) {
125                Some(snap) => snapshots.push(snap),
126                None => return Vec::new(),
127            }
128        }
129
130        // Compute deltas between consecutive rounds
131        let mut all_deltas: Vec<i64> = Vec::new();
132
133        for pair in snapshots.windows(2) {
134            let prev = &pair[0];
135            let curr = &pair[1];
136
137            for (key, curr_val) in curr {
138                if let Some(prev_val) = prev.get(key) {
139                    let delta = curr_val.wrapping_sub(*prev_val);
140                    if delta != 0 {
141                        all_deltas.push(delta);
142                    }
143                }
144            }
145        }
146
147        extract_delta_bytes_i64(&all_deltas, n_samples)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn vmstat_info() {
157        let src = VmstatSource::new();
158        assert_eq!(src.name(), "vmstat_deltas");
159        assert_eq!(src.info().category, SourceCategory::System);
160        assert!(!src.info().composite);
161    }
162
163    #[test]
164    #[cfg(target_os = "macos")]
165    #[ignore] // Requires vm_stat binary
166    fn vmstat_collects_bytes() {
167        let src = VmstatSource::new();
168        if src.is_available() {
169            let data = src.collect(64);
170            assert!(!data.is_empty());
171            assert!(data.len() <= 64);
172        }
173    }
174}