Skip to main content

openentropy_core/sources/system/
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 crate::sources::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: 2.0,
36    composite: false,
37    is_fast: false,
38};
39
40impl VmstatSource {
41    pub fn new() -> Self {
42        Self
43    }
44}
45
46impl Default for VmstatSource {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52/// Locate the `vm_stat` binary. Checks the standard macOS path first, then PATH.
53fn vm_stat_path() -> Option<String> {
54    let standard = "/usr/bin/vm_stat";
55    if std::path::Path::new(standard).exists() {
56        return Some(standard.to_string());
57    }
58
59    // Fall back to searching PATH via `which`
60    let path = run_command("which", &["vm_stat"])?;
61    let path = path.trim().to_string();
62    if !path.is_empty() {
63        return Some(path);
64    }
65
66    None
67}
68
69/// Run `vm_stat` and parse output into a map of counter names to values.
70///
71/// vm_stat output looks like:
72/// ```text
73/// Mach Virtual Memory Statistics: (page size of 16384 bytes)
74/// Pages free:                               12345.
75/// Pages active:                             67890.
76/// ```
77///
78/// We strip the trailing period and parse the integer.
79fn snapshot_vmstat(path: &str) -> Option<HashMap<String, i64>> {
80    let stdout = run_command(path, &[])?;
81    let mut map = HashMap::new();
82
83    for line in stdout.lines() {
84        // Skip the header line
85        if line.starts_with("Mach") || line.is_empty() {
86            continue;
87        }
88
89        // Lines look like: "Pages active:                             67890."
90        if let Some(colon_idx) = line.rfind(':') {
91            let key = line[..colon_idx].trim().to_string();
92            let val_str = line[colon_idx + 1..].trim().trim_end_matches('.');
93
94            if let Ok(v) = val_str.parse::<i64>() {
95                map.insert(key, v);
96            }
97        }
98    }
99
100    Some(map)
101}
102
103impl EntropySource for VmstatSource {
104    fn info(&self) -> &SourceInfo {
105        &VMSTAT_INFO
106    }
107
108    fn is_available(&self) -> bool {
109        vm_stat_path().is_some()
110    }
111
112    fn collect(&self, n_samples: usize) -> Vec<u8> {
113        let path = match vm_stat_path() {
114            Some(p) => p,
115            None => return Vec::new(),
116        };
117
118        // Take NUM_ROUNDS snapshots with delays between them
119        let mut snapshots: Vec<HashMap<String, i64>> = Vec::with_capacity(NUM_ROUNDS);
120
121        for i in 0..NUM_ROUNDS {
122            if i > 0 {
123                thread::sleep(SNAPSHOT_DELAY);
124            }
125            match snapshot_vmstat(&path) {
126                Some(snap) => snapshots.push(snap),
127                None => return Vec::new(),
128            }
129        }
130
131        // Compute deltas between consecutive rounds
132        let mut all_deltas: Vec<i64> = Vec::new();
133
134        for pair in snapshots.windows(2) {
135            let prev = &pair[0];
136            let curr = &pair[1];
137
138            for (key, curr_val) in curr {
139                if let Some(prev_val) = prev.get(key) {
140                    let delta = curr_val.wrapping_sub(*prev_val);
141                    if delta != 0 {
142                        all_deltas.push(delta);
143                    }
144                }
145            }
146        }
147
148        extract_delta_bytes_i64(&all_deltas, n_samples)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn vmstat_info() {
158        let src = VmstatSource::new();
159        assert_eq!(src.name(), "vmstat_deltas");
160        assert_eq!(src.info().category, SourceCategory::System);
161        assert!(!src.info().composite);
162    }
163
164    #[test]
165    #[cfg(target_os = "macos")]
166    #[ignore] // Requires vm_stat binary
167    fn vmstat_collects_bytes() {
168        let src = VmstatSource::new();
169        if src.is_available() {
170            let data = src.collect(64);
171            assert!(!data.is_empty());
172            assert!(data.len() <= 64);
173        }
174    }
175}