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}
61
62impl HostCapabilitiesReport {
63    pub const SCHEMA: u64 = 1;
64
65    pub fn probe() -> Self {
66        Self {
67            schema: Self::SCHEMA,
68            host_capabilities: probe_host_capabilities(),
69        }
70    }
71
72    pub fn validate(&self) -> Result<(), String> {
73        if self.schema != Self::SCHEMA {
74            return Err(format!(
75                "host-capabilities schema must be {}, got {}",
76                Self::SCHEMA,
77                self.schema
78            ));
79        }
80        for capability in HostCapability::ALL {
81            let verdict = self
82                .host_capabilities
83                .get(&capability)
84                .ok_or_else(|| format!("host-capabilities missing {}", capability.value()))?;
85            if verdict.evidence.trim().is_empty() {
86                return Err(format!(
87                    "host-capabilities {} evidence must be nonempty",
88                    capability.value()
89                ));
90            }
91        }
92        if self.host_capabilities.len() != HostCapability::ALL.len() {
93            return Err("host-capabilities must contain the complete closed set".into());
94        }
95        Ok(())
96    }
97}
98
99pub const ASSUME_PRESENT_ENV: &str = "HERMIT_VALIDATE_HOST_CAPABILITY_PRESENT";
100
101pub fn probe_host_capability(capability: HostCapability) -> CapabilityVerdict {
102    let forced = std::env::var(ASSUME_PRESENT_ENV).unwrap_or_default();
103    if forced
104        .split(',')
105        .map(str::trim)
106        .any(|candidate| candidate == capability.value())
107    {
108        return CapabilityVerdict {
109            present: true,
110            evidence: format!(
111                "{ASSUME_PRESENT_ENV} names {}; assumed PRESENT without probing (this override can only ADD capabilities)",
112                capability.value()
113            ),
114        };
115    }
116    match capability {
117        HostCapability::CpuidFaulting => probe_cpuid_faulting(),
118        HostCapability::Kvm => probe_kvm(),
119    }
120}
121
122pub fn probe_host_capabilities() -> HostCapabilities {
123    HostCapability::ALL
124        .into_iter()
125        .map(|capability| (capability, probe_host_capability(capability)))
126        .collect()
127}
128
129pub fn cpuid_faulting_absent(syscall: Result<(), i32>, advertised: Option<bool>) -> bool {
130    syscall == Err(libc::ENODEV) && advertised == Some(false)
131}
132
133fn probe_cpuid_faulting() -> CapabilityVerdict {
134    let syscall = arch_prctl_set_cpuid_off();
135    let advertised = cpuinfo_advertises_cpuid_fault();
136    let syscall_text = match syscall {
137        Ok(()) => "arch_prctl(ARCH_SET_CPUID, 0) = 0".to_string(),
138        Err(0) => "arch_prctl(ARCH_SET_CPUID, 0) probe could not be completed".to_string(),
139        Err(errno) => format!("arch_prctl(ARCH_SET_CPUID, 0) = -1 errno={errno}"),
140    };
141    let cpuinfo_text = match advertised {
142        Some(true) => "/proc/cpuinfo advertises cpuid_fault",
143        Some(false) => "/proc/cpuinfo does not advertise cpuid_fault",
144        None => "/proc/cpuinfo could not be read",
145    };
146    CapabilityVerdict {
147        present: !cpuid_faulting_absent(syscall, advertised),
148        evidence: format!("{syscall_text}; {cpuinfo_text}"),
149    }
150}
151
152fn arch_prctl_set_cpuid_off() -> Result<(), i32> {
153    const ARCH_SET_CPUID: libc::c_int = 0x1012;
154    // SAFETY: the child performs one syscall and `_exit`s; it never returns into Rust.
155    let child = unsafe { libc::fork() };
156    if child < 0 {
157        return Err(0);
158    }
159    if child == 0 {
160        let result = unsafe { libc::syscall(libc::SYS_arch_prctl, ARCH_SET_CPUID, 0) };
161        let code = if result == 0 {
162            0
163        } else {
164            // SAFETY: reading thread-local errno immediately after the failed syscall.
165            let errno = unsafe { *libc::__errno_location() };
166            errno.clamp(1, 255)
167        };
168        // SAFETY: this is the forked probe child and it must not run Rust destructors.
169        unsafe { libc::_exit(code) };
170    }
171    let mut status = 0;
172    if unsafe { libc::waitpid(child, &mut status, 0) } != child || !libc::WIFEXITED(status) {
173        return Err(0);
174    }
175    match libc::WEXITSTATUS(status) {
176        0 => Ok(()),
177        errno => Err(errno),
178    }
179}
180
181fn cpuinfo_advertises_cpuid_fault() -> Option<bool> {
182    let text = std::fs::read_to_string("/proc/cpuinfo").ok()?;
183    Some(text.split_whitespace().any(|word| word == "cpuid_fault"))
184}
185
186/// KVM is absent when `/dev/kvm` does not exist. That is proof on its own: a
187/// CPU that advertises vmx or svm still cannot run a KVM guest without the
188/// device (an RE worker or a container shows exactly that shape). Any other
189/// failure to open it -- a permissions or sandbox refusal -- is doubt about the
190/// probe, not proof, so the capability stays present and the cell runs.
191pub fn kvm_absent(open: Result<(), i32>) -> bool {
192    open == Err(libc::ENOENT)
193}
194
195fn probe_kvm() -> CapabilityVerdict {
196    let open = open_dev_kvm();
197    let advertised = cpuinfo_advertises_virtualization();
198    let open_text = match open {
199        Ok(()) => "open(/dev/kvm, O_RDWR) = ok".to_string(),
200        Err(errno) => format!("open(/dev/kvm, O_RDWR) = -1 errno={errno}"),
201    };
202    let cpuinfo_text = match advertised {
203        Some(true) => "/proc/cpuinfo advertises vmx or svm",
204        Some(false) => "/proc/cpuinfo advertises neither vmx nor svm",
205        None => "/proc/cpuinfo could not be read",
206    };
207    CapabilityVerdict {
208        present: !kvm_absent(open),
209        evidence: format!("{open_text}; {cpuinfo_text}"),
210    }
211}
212
213fn open_dev_kvm() -> Result<(), i32> {
214    let path = std::ffi::CString::new("/dev/kvm").expect("static path has no NUL");
215    // SAFETY: `path` is valid and the descriptor is closed immediately on success.
216    let fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
217    if fd >= 0 {
218        // SAFETY: `fd` came from the successful open above.
219        unsafe { libc::close(fd) };
220        return Ok(());
221    }
222    // SAFETY: reading thread-local errno immediately after the failed open.
223    let errno = unsafe { *libc::__errno_location() };
224    Err(errno.clamp(1, 255))
225}
226
227fn cpuinfo_advertises_virtualization() -> Option<bool> {
228    let text = std::fs::read_to_string("/proc/cpuinfo").ok()?;
229    Some(
230        text.split_whitespace()
231            .any(|word| word == "vmx" || word == "svm"),
232    )
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn complete_probe_records_every_closed_capability_with_evidence() {
241        let report = HostCapabilitiesReport::probe();
242        report.validate().unwrap();
243        assert_eq!(report.host_capabilities.len(), HostCapability::ALL.len());
244    }
245
246    /// A missing `/dev/kvm` is absence even when /proc/cpuinfo advertises
247    /// vmx or svm; only a refused open is doubt.
248    #[test]
249    fn a_missing_kvm_device_is_proof_of_absence() {
250        assert!(kvm_absent(Err(libc::ENOENT)));
251        for doubt in [
252            Ok(()),
253            Err(libc::EACCES),
254            Err(libc::EPERM),
255            Err(libc::EBUSY),
256        ] {
257            assert!(!kvm_absent(doubt), "{doubt:?} must not read as absent");
258        }
259    }
260
261    #[test]
262    fn incomplete_report_refuses_by_capability_name() {
263        let mut report = HostCapabilitiesReport::probe();
264        report
265            .host_capabilities
266            .remove(&HostCapability::CpuidFaulting);
267        assert_eq!(
268            report.validate().unwrap_err(),
269            "host-capabilities missing cpuid-faulting"
270        );
271    }
272}