Skip to main content

detcore_model/
host_capability.rs

1//! Closed host-capability vocabulary and probes shared by validation records.
2
3use std::collections::BTreeMap;
4
5use serde::Deserialize;
6use serde::Serialize;
7
8#[derive(
9    Clone,
10    Copy,
11    Debug,
12    Deserialize,
13    Eq,
14    Ord,
15    PartialEq,
16    PartialOrd,
17    Serialize
18)]
19pub enum HostCapability {
20    #[serde(rename = "cpuid-faulting")]
21    CpuidFaulting,
22    #[serde(rename = "kvm")]
23    Kvm,
24}
25
26impl HostCapability {
27    pub const ALL: [Self; 2] = [Self::CpuidFaulting, Self::Kvm];
28
29    pub fn value(self) -> &'static str {
30        match self {
31            Self::CpuidFaulting => "cpuid-faulting",
32            Self::Kvm => "kvm",
33        }
34    }
35
36    pub fn from_value(text: &str) -> Option<Self> {
37        match text {
38            "cpuid-faulting" => Some(Self::CpuidFaulting),
39            "kvm" => Some(Self::Kvm),
40            _ => None,
41        }
42    }
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
46#[serde(deny_unknown_fields)]
47pub struct CapabilityVerdict {
48    pub present: bool,
49    pub evidence: String,
50}
51
52pub type HostCapabilities = BTreeMap<HostCapability, CapabilityVerdict>;
53
54/// Complete machine-readable output from `hermit host-capabilities --json`.
55#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(deny_unknown_fields)]
57pub struct HostCapabilitiesReport {
58    pub schema: u64,
59    pub host_capabilities: HostCapabilities,
60    /// Whether this host's retired-conditional-branch counter, which drives
61    /// Hermit's virtual clock on the ptrace and e9patch backends, passes
62    /// Reverie's performance-counter validation. When it is absent,
63    /// `hermit run --strict` refuses those backends, and the E2E harness
64    /// reports their cells HOST-INAPPLICABLE.
65    ///
66    /// It is not one of [`HostCapability`]: only the hermit binary, which links
67    /// Reverie's ptrace backend, can run that validation, so no other process
68    /// can probe it, and adding a member to that closed set changes every
69    /// result and stress-series row. See
70    /// <https://github.com/rrnewton/hermit/issues/3794>.
71    pub exact_branch_counter: CapabilityVerdict,
72}
73
74impl HostCapabilitiesReport {
75    pub const SCHEMA: u64 = 2;
76
77    /// Probe the closed set and record `exact_branch_counter`, which the
78    /// caller probes because this crate cannot.
79    pub fn probe(exact_branch_counter: CapabilityVerdict) -> Self {
80        Self {
81            schema: Self::SCHEMA,
82            host_capabilities: probe_host_capabilities(),
83            exact_branch_counter,
84        }
85    }
86
87    pub fn validate(&self) -> Result<(), String> {
88        if self.schema != Self::SCHEMA {
89            return Err(format!(
90                "host-capabilities schema must be {}, got {}",
91                Self::SCHEMA,
92                self.schema
93            ));
94        }
95        for capability in HostCapability::ALL {
96            let verdict = self
97                .host_capabilities
98                .get(&capability)
99                .ok_or_else(|| format!("host-capabilities missing {}", capability.value()))?;
100            if verdict.evidence.trim().is_empty() {
101                return Err(format!(
102                    "host-capabilities {} evidence must be nonempty",
103                    capability.value()
104                ));
105            }
106        }
107        if self.host_capabilities.len() != HostCapability::ALL.len() {
108            return Err("host-capabilities must contain the complete closed set".into());
109        }
110        if self.exact_branch_counter.evidence.trim().is_empty() {
111            return Err("host-capabilities exact_branch_counter evidence must be nonempty".into());
112        }
113        Ok(())
114    }
115}
116
117pub const ASSUME_PRESENT_ENV: &str = "HERMIT_VALIDATE_HOST_CAPABILITY_PRESENT";
118
119pub fn probe_host_capability(capability: HostCapability) -> CapabilityVerdict {
120    let forced = std::env::var(ASSUME_PRESENT_ENV).unwrap_or_default();
121    if forced
122        .split(',')
123        .map(str::trim)
124        .any(|candidate| candidate == capability.value())
125    {
126        return CapabilityVerdict {
127            present: true,
128            evidence: format!(
129                "{ASSUME_PRESENT_ENV} names {}; assumed PRESENT without probing (this override can only ADD capabilities)",
130                capability.value()
131            ),
132        };
133    }
134    match capability {
135        HostCapability::CpuidFaulting => probe_cpuid_faulting(),
136        HostCapability::Kvm => probe_kvm(),
137    }
138}
139
140pub fn probe_host_capabilities() -> HostCapabilities {
141    HostCapability::ALL
142        .into_iter()
143        .map(|capability| (capability, probe_host_capability(capability)))
144        .collect()
145}
146
147pub fn cpuid_faulting_absent(syscall: Result<(), i32>, advertised: Option<bool>) -> bool {
148    syscall == Err(libc::ENODEV) && advertised == Some(false)
149}
150
151/// Only x86 has a CPUID instruction for the kernel to fault, so CPUID faulting
152/// is absent everywhere else.
153#[cfg(not(target_arch = "x86_64"))]
154fn probe_cpuid_faulting() -> CapabilityVerdict {
155    CapabilityVerdict {
156        present: false,
157        evidence: format!(
158            "{} has no CPUID instruction to fault",
159            std::env::consts::ARCH
160        ),
161    }
162}
163
164#[cfg(target_arch = "x86_64")]
165fn probe_cpuid_faulting() -> CapabilityVerdict {
166    let syscall = arch_prctl_set_cpuid_off();
167    let advertised = cpuinfo_advertises_cpuid_fault();
168    let syscall_text = match syscall {
169        Ok(()) => "arch_prctl(ARCH_SET_CPUID, 0) = 0".to_string(),
170        Err(0) => "arch_prctl(ARCH_SET_CPUID, 0) probe could not be completed".to_string(),
171        Err(errno) => format!("arch_prctl(ARCH_SET_CPUID, 0) = -1 errno={errno}"),
172    };
173    let cpuinfo_text = match advertised {
174        Some(true) => "/proc/cpuinfo advertises cpuid_fault",
175        Some(false) => "/proc/cpuinfo does not advertise cpuid_fault",
176        None => "/proc/cpuinfo could not be read",
177    };
178    CapabilityVerdict {
179        present: !cpuid_faulting_absent(syscall, advertised),
180        evidence: format!("{syscall_text}; {cpuinfo_text}"),
181    }
182}
183
184#[cfg(target_arch = "x86_64")]
185fn arch_prctl_set_cpuid_off() -> Result<(), i32> {
186    const ARCH_SET_CPUID: libc::c_int = 0x1012;
187    // SAFETY: the child performs one syscall and `_exit`s; it never returns into Rust.
188    let child = unsafe { libc::fork() };
189    if child < 0 {
190        return Err(0);
191    }
192    if child == 0 {
193        let result = unsafe { libc::syscall(libc::SYS_arch_prctl, ARCH_SET_CPUID, 0) };
194        let code = if result == 0 {
195            0
196        } else {
197            // SAFETY: reading thread-local errno immediately after the failed syscall.
198            let errno = unsafe { *libc::__errno_location() };
199            errno.clamp(1, 255)
200        };
201        // SAFETY: this is the forked probe child and it must not run Rust destructors.
202        unsafe { libc::_exit(code) };
203    }
204    let mut status = 0;
205    if unsafe { libc::waitpid(child, &mut status, 0) } != child || !libc::WIFEXITED(status) {
206        return Err(0);
207    }
208    match libc::WEXITSTATUS(status) {
209        0 => Ok(()),
210        errno => Err(errno),
211    }
212}
213
214#[cfg(target_arch = "x86_64")]
215fn cpuinfo_advertises_cpuid_fault() -> Option<bool> {
216    let text = std::fs::read_to_string("/proc/cpuinfo").ok()?;
217    Some(text.split_whitespace().any(|word| word == "cpuid_fault"))
218}
219
220/// KVM is absent when `/dev/kvm` does not exist. That is proof on its own: a
221/// CPU that advertises vmx or svm still cannot run a KVM guest without the
222/// device (an RE worker or a container shows exactly that shape). Any other
223/// failure to open it -- a permissions or sandbox refusal -- is doubt about the
224/// probe, not proof, so the capability stays present and the cell runs.
225pub fn kvm_absent(open: Result<(), i32>) -> bool {
226    open == Err(libc::ENOENT)
227}
228
229fn probe_kvm() -> CapabilityVerdict {
230    let open = open_dev_kvm();
231    let advertised = cpuinfo_advertises_virtualization();
232    let open_text = match open {
233        Ok(()) => "open(/dev/kvm, O_RDWR) = ok".to_string(),
234        Err(errno) => format!("open(/dev/kvm, O_RDWR) = -1 errno={errno}"),
235    };
236    let cpuinfo_text = match advertised {
237        Some(true) => "/proc/cpuinfo advertises vmx or svm",
238        Some(false) => "/proc/cpuinfo advertises neither vmx nor svm",
239        None => "/proc/cpuinfo could not be read",
240    };
241    CapabilityVerdict {
242        present: !kvm_absent(open),
243        evidence: format!("{open_text}; {cpuinfo_text}"),
244    }
245}
246
247fn open_dev_kvm() -> Result<(), i32> {
248    let path = std::ffi::CString::new("/dev/kvm").expect("static path has no NUL");
249    // SAFETY: `path` is valid and the descriptor is closed immediately on success.
250    let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
251    if fd >= 0 {
252        // SAFETY: `fd` came from the successful open above.
253        unsafe { libc::close(fd) };
254        return Ok(());
255    }
256    // SAFETY: reading thread-local errno immediately after the failed open.
257    let errno = unsafe { *libc::__errno_location() };
258    Err(errno.clamp(1, 255))
259}
260
261fn cpuinfo_advertises_virtualization() -> Option<bool> {
262    let text = std::fs::read_to_string("/proc/cpuinfo").ok()?;
263    Some(
264        text.split_whitespace()
265            .any(|word| word == "vmx" || word == "svm"),
266    )
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn exact_branch_counter(evidence: &str) -> CapabilityVerdict {
274        CapabilityVerdict {
275            present: true,
276            evidence: evidence.into(),
277        }
278    }
279
280    #[test]
281    fn complete_probe_records_every_closed_capability_with_evidence() {
282        let report = HostCapabilitiesReport::probe(exact_branch_counter("fixture"));
283        report.validate().unwrap();
284        assert_eq!(report.host_capabilities.len(), HostCapability::ALL.len());
285    }
286
287    #[test]
288    fn exact_branch_counter_needs_evidence() {
289        let report = HostCapabilitiesReport::probe(exact_branch_counter(" "));
290        assert_eq!(
291            report.validate().unwrap_err(),
292            "host-capabilities exact_branch_counter evidence must be nonempty"
293        );
294    }
295
296    /// A missing `/dev/kvm` is absence even when /proc/cpuinfo advertises
297    /// vmx or svm; only a refused open is doubt.
298    #[test]
299    fn a_missing_kvm_device_is_proof_of_absence() {
300        assert!(kvm_absent(Err(libc::ENOENT)));
301        for doubt in [
302            Ok(()),
303            Err(libc::EACCES),
304            Err(libc::EPERM),
305            Err(libc::EBUSY),
306        ] {
307            assert!(!kvm_absent(doubt), "{doubt:?} must not read as absent");
308        }
309    }
310
311    #[test]
312    fn incomplete_report_refuses_by_capability_name() {
313        let mut report = HostCapabilitiesReport::probe(exact_branch_counter("fixture"));
314        report
315            .host_capabilities
316            .remove(&HostCapability::CpuidFaulting);
317        assert_eq!(
318            report.validate().unwrap_err(),
319            "host-capabilities missing cpuid-faulting"
320        );
321    }
322}