Skip to main content

hermit/
cpu_profile_admission.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Admission for intercepted CPU profiles: v3 binds qualified host XSTATE
10//! facts; host-no-rdrand requires CPUID faulting without the v3 qualification.
11//!
12//! Every class the v3 startup qualification cannot cover is refused by name.
13//! Neither nondefault profile silently falls back to v2 or native CPUID.
14
15use detcore_model::cpu_profile::CpuProfile;
16use detcore_model::cpu_profile::HostXstate;
17use nix::sched::CpuSet;
18use nix::sched::sched_getaffinity;
19use nix::sched::sched_setaffinity;
20use nix::unistd::Pid;
21
22#[cfg(target_arch = "x86_64")]
23use crate::xstate_probe;
24
25/// What a host qualification yields: the XSTATE probe's report and the host
26/// facts a v3 run binds.
27#[cfg(target_arch = "x86_64")]
28pub type Qualification = (xstate_probe::ProbeReport, HostXstate);
29/// What a host qualification yields. The XSTATE probe runs x86 instructions,
30/// so on other architectures no qualification exists.
31#[cfg(not(target_arch = "x86_64"))]
32pub type Qualification = (std::convert::Infallible, HostXstate);
33
34/// Why a profile is refused, after `--cpu-profile <profile> refused: `.
35pub type AdmissionRefusal = String;
36
37/// A refused CPU profile, carried as an error so that it
38/// crosses the container boundary as a policy refusal (exit 122).
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct CpuProfileRefusal {
41    pub profile: CpuProfile,
42    pub reason: AdmissionRefusal,
43}
44
45impl CpuProfileRefusal {
46    pub fn new(profile: CpuProfile, reason: impl Into<String>) -> Self {
47        Self {
48            profile,
49            reason: reason.into(),
50        }
51    }
52}
53
54impl std::fmt::Display for CpuProfileRefusal {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "--cpu-profile {} refused: {}", self.profile, self.reason)
57    }
58}
59
60impl std::error::Error for CpuProfileRefusal {}
61
62/// The admission request of a recording or replay, which always runs on the
63/// ptrace backend with virtual CPUID.
64pub fn record_or_replay_request(gdbserver: bool) -> AdmissionRequest<'static> {
65    AdmissionRequest {
66        backend: "ptrace",
67        ptrace: true,
68        virtualize_cpuid: true,
69        virtualize_time: true,
70        strace_only: false,
71        gdbserver,
72        namespace_only: false,
73    }
74}
75
76/// The first host fact in which `replay` differs from `recorded`, by name, or
77/// `None` when they are equal.
78pub fn first_difference(recorded: &HostXstate, replay: &HostXstate) -> Option<&'static str> {
79    if recorded.vendor != replay.vendor {
80        Some("the CPU vendor")
81    } else if recorded.signature != replay.signature {
82        Some("the CPU family, model or stepping")
83    } else if recorded.xcr0 != replay.xcr0 {
84        Some("XCR0")
85    } else if recorded.xsave_leaves != replay.xsave_leaves {
86        Some("the XSAVE layout (CPUID leaf 0xD)")
87    } else if (
88        recorded.xcomp_supp,
89        recorded.xcomp_perm,
90        recorded.xcomp_guest_perm,
91    ) != (
92        replay.xcomp_supp,
93        replay.xcomp_perm,
94        replay.xcomp_guest_perm,
95    ) {
96        Some("the ARCH_GET_XCOMP masks")
97    } else if recorded.xfeature_max != replay.xfeature_max {
98        Some("XFEATURE_MAX")
99    } else if (recorded.probe_cases, recorded.probe_forced_exit)
100        != (replay.probe_cases, replay.probe_forced_exit)
101    {
102        Some("the qualification it passed")
103    } else {
104        None
105    }
106}
107
108/// What the run asks for, as far as admission is concerned.
109#[derive(Clone, Copy, Debug)]
110pub struct AdmissionRequest<'a> {
111    /// The backend's command-line name, and whether it is the ptrace backend.
112    pub backend: &'a str,
113    pub ptrace: bool,
114    pub virtualize_cpuid: bool,
115    pub virtualize_time: bool,
116    pub strace_only: bool,
117    pub gdbserver: bool,
118    /// `--namespace-only`, which runs the guest with no Detcore at all.
119    pub namespace_only: bool,
120}
121
122/// The refusals that need no host probe, in the order they are checked.
123pub fn check_request(request: &AdmissionRequest<'_>) -> Result<(), AdmissionRefusal> {
124    if !request.ptrace {
125        let why = match request.backend {
126            "kvm" => {
127                "the KVM backend switches XCR0 between the guest's and the host's on every VM \
128                 exit, a transition the startup qualification does not cover"
129            }
130            "dbt" => {
131                "Detcore answers DBT's CPUID, but the DBT runtime's configuration carries no \
132                 CPU profile, so it answers only the x86-64-v2 table"
133            }
134            "sabre" | "liteinst" | "in-guest-trap" => {
135                "this backend does not trap CPUID, so the guest would see the host's"
136            }
137            _ => "only the ptrace backend is qualified",
138        };
139        return Err(format!("--backend={}: {why}", request.backend));
140    }
141    if request.namespace_only {
142        return Err(
143            "--namespace-only runs the guest without Detcore, so nothing answers its CPUID".into(),
144        );
145    }
146    if !request.virtualize_cpuid || request.strace_only {
147        return Err(
148            "the profile is a virtual CPUID table, and this run does not virtualize CPUID".into(),
149        );
150    }
151    if request.gdbserver {
152        return Err(
153            "--gdbserver writes the guest's FP registers, and the kernel's regset write forces \
154             XSAVE header bits the qualification binds"
155                .into(),
156        );
157    }
158    Ok(())
159}
160
161/// The host profile must intercept CPUID and TSC on ptrace. Unlike v3, it
162/// retains host XSAVE behavior and does not claim the XSTATE qualification.
163pub fn check_host_no_rdrand_request(
164    request: &AdmissionRequest<'_>,
165) -> Result<(), AdmissionRefusal> {
166    if !request.ptrace {
167        return Err(format!(
168            "--backend={} cannot provide this profile's required CPUID faulting; \
169             use --backend=ptrace",
170            request.backend
171        ));
172    }
173    if request.namespace_only {
174        return Err(
175            "--namespace-only runs without CPUID interception; remove --namespace-only".into(),
176        );
177    }
178    if request.strace_only {
179        return Err(
180            "--strace-only runs without CPUID and TSC interception; remove --strace-only".into(),
181        );
182    }
183    if !request.virtualize_cpuid {
184        return Err("CPUID must remain intercepted; remove --no-virtualize-cpuid".into());
185    }
186    if !request.virtualize_time {
187        return Err(
188            "the host's RDTSC/RDTSCP features require Hermit's virtual time; \
189             remove --no-virtualize-time"
190                .into(),
191        );
192    }
193    Ok(())
194}
195
196#[cfg(target_arch = "x86_64")]
197const CPUID_FAULTING_REMEDY: &str = "use a CPU and Linux kernel with CPUID faulting support (capable AMD CPUs need \
198     Linux 6.17 or its CPUID-faulting backport)";
199
200/// The source-tree script that loads a module turning on CPUID faulting in a
201/// KVM guest whose hypervisor offers it but whose pre-6.17 kernel probes it
202/// only on Intel vCPU models.
203pub const CPUID_FAULTING_VM_SCRIPT: &str = "tools/cpuid-fault-kmod/enable-cpuid-faulting.sh";
204
205/// A pointer to [`CPUID_FAULTING_VM_SCRIPT`] when `host` reports a hypervisor
206/// (CPUID leaf 1 ECX bit 31) and `release` is older than Linux 6.17, the only
207/// hosts the module can help; `None` otherwise.
208pub fn cpuid_faulting_vm_pointer(host: &dyn HostCpuid, release: &str) -> Option<String> {
209    let hypervisor = host.cpuid(1, 0)[2] & (1 << 31) != 0;
210    let before_6_17 = kernel_version(release).is_some_and(|version| version < (6, 17));
211    (hypervisor && before_6_17).then(|| {
212        format!(
213            "this host is a virtual machine on Linux {release}; if its hypervisor offers CPUID \
214             faulting, {CPUID_FAULTING_VM_SCRIPT} in the Hermit source tree can enable it"
215        )
216    })
217}
218
219#[cfg(target_arch = "x86_64")]
220fn native_cpuid_faulting_vm_pointer() -> Option<String> {
221    let uname = nix::sys::utsname::uname().ok()?;
222    cpuid_faulting_vm_pointer(&NativeCpuid, &uname.release().to_string_lossy())
223}
224
225/// The note to print after an error whose chain reports missing CPUID
226/// faulting, on a host where the script may help, unless a message already
227/// carries the pointer.
228pub fn missing_cpuid_faulting_note(messages: &[String]) -> Option<String> {
229    let missing = messages.iter().any(|message| {
230        message.contains("ptrace-cpuid-faulting-unavailable")
231            || message.contains("CPUID faulting is unavailable")
232    });
233    let pointed = messages
234        .iter()
235        .any(|message| message.contains(CPUID_FAULTING_VM_SCRIPT));
236    if missing && !pointed {
237        native_cpuid_faulting_vm_pointer()
238    } else {
239        None
240    }
241}
242
243#[cfg(target_arch = "x86_64")]
244fn check_host_no_rdrand_cpuid(host: &dyn HostCpuid) -> Result<(), AdmissionRefusal> {
245    let intel_hybrid = host.cpuid(0, 0)[0] >= 7 && host.cpuid(7, 0)[3] & (1 << 15) != 0;
246    // AMD advertises heterogeneous cores independently of Intel's hybrid bit:
247    // Linux identifies X86_FEATURE_AMD_HTR_CORES through this extended leaf.
248    let amd_heterogeneous = host.cpuid(0x8000_0000, 0)[0] >= 0x8000_0026
249        && host.cpuid(0x8000_0026, 0)[0] & (1 << 30) != 0;
250    if intel_hybrid || amd_heterogeneous {
251        return Err(
252            "this is a hybrid CPU whose CPUID features can change between core types; \
253             use a homogeneous CPU host for --cpu-profile=host-no-rdrand"
254                .into(),
255        );
256    }
257    Ok(())
258}
259
260/// Probe the Linux CPUID-faulting API in a child. Disabling CPUID in the
261/// tracer would also fault Rust's and libc's own feature detection, so only
262/// the forked child changes its state. After fork the child uses only libc
263/// syscalls and `_exit`, without allocation, locks or unwinding.
264#[cfg(target_arch = "x86_64")]
265pub fn qualify_host_no_rdrand() -> Result<(), AdmissionRefusal> {
266    const ARCH_GET_CPUID: libc::c_int = 0x1011;
267    const ARCH_SET_CPUID: libc::c_int = 0x1012;
268    // SAFETY: the child calls only async-signal-safe operations below.
269    let child = unsafe { libc::fork() };
270    if child < 0 {
271        return Err(format!(
272            "cannot probe CPUID faulting: fork failed ({}); resolve the process-resource \
273             failure and retry",
274            std::io::Error::last_os_error()
275        ));
276    }
277    if child == 0 {
278        // SAFETY: these arch_prctl operations take integer arguments only.
279        unsafe {
280            let initial = libc::syscall(libc::SYS_arch_prctl, ARCH_GET_CPUID, 0);
281            if initial != 0 && initial != 1 {
282                libc::_exit(1);
283            }
284            if libc::syscall(libc::SYS_arch_prctl, ARCH_SET_CPUID, 0) != 0 {
285                libc::_exit(2);
286            }
287            if libc::syscall(libc::SYS_arch_prctl, ARCH_GET_CPUID, 0) != 0 {
288                libc::_exit(3);
289            }
290            libc::_exit(0);
291        }
292    }
293    let mut status = 0;
294    loop {
295        // SAFETY: child is our live child PID and status points to one int.
296        let waited = unsafe { libc::waitpid(child, &mut status, 0) };
297        if waited == child {
298            break;
299        }
300        let error = std::io::Error::last_os_error();
301        if error.raw_os_error() != Some(libc::EINTR) {
302            return Err(format!(
303                "cannot probe CPUID faulting: waitpid failed ({error}); resolve the child \
304                 process failure and retry"
305            ));
306        }
307    }
308    let failure = if libc::WIFEXITED(status) {
309        match libc::WEXITSTATUS(status) {
310            0 => return check_host_no_rdrand_cpuid(&NativeCpuid),
311            1 => "ARCH_GET_CPUID did not return a valid initial state",
312            2 => "ARCH_SET_CPUID(0) failed",
313            3 => "ARCH_GET_CPUID did not confirm that CPUID was disabled",
314            _ => "the CPUID-faulting probe exited unexpectedly",
315        }
316    } else {
317        "the CPUID-faulting probe did not exit normally"
318    };
319    let vm_pointer = native_cpuid_faulting_vm_pointer()
320        .map(|pointer| format!("; {pointer}"))
321        .unwrap_or_default();
322    Err(format!(
323        "CPUID faulting is unavailable: {failure}; {CPUID_FAULTING_REMEDY}{vm_pointer}"
324    ))
325}
326
327// Each bit records a source CPU whose faulting and host-profile checks passed.
328// Atomics survive the CLI's fork without a possibly held mutex. A Config also
329// carries its chosen CPU, so concurrent library admissions cannot overwrite
330// another run's source or authorize an unpinned tracer.
331static HOST_NO_RDRAND_CPUS: [std::sync::atomic::AtomicBool; libc::CPU_SETSIZE as usize] =
332    [const { std::sync::atomic::AtomicBool::new(false) }; libc::CPU_SETSIZE as usize];
333
334fn affinity_cpus(affinity: &CpuSet) -> Vec<usize> {
335    (0..CpuSet::count())
336        .filter(|cpu| affinity.is_set(*cpu).unwrap_or(false))
337        .collect()
338}
339
340fn host_profile_affinity() -> Result<CpuSet, AdmissionRefusal> {
341    sched_getaffinity(Pid::from_raw(0))
342        .map_err(|error| format!("cannot read the CPUID source thread's CPU affinity: {error}"))
343}
344
345/// The actual tracer is pinned before it constructs the guest, including a
346/// library's cached tracer worker. Guest/worker threads inherit this singleton.
347/// Every leaf and arbitrarily high subleaf therefore has one native source.
348fn pin_host_cpuid_source(cpu: usize) -> Result<(), AdmissionRefusal> {
349    let mut selected = CpuSet::new();
350    selected.set(cpu).map_err(|error| error.to_string())?;
351    sched_setaffinity(Pid::from_raw(0), &selected)
352        .map_err(|error| format!("cannot pin the CPUID source thread to CPU {cpu}: {error}"))?;
353    check_host_cpuid_source(cpu)
354}
355
356fn check_host_cpuid_source(cpu: usize) -> Result<(), AdmissionRefusal> {
357    if affinity_cpus(&host_profile_affinity()?) != [cpu] {
358        return Err(format!(
359            "the tracer's CPU affinity changed after host CPUID admission; \
360             keep the tracer pinned to its admitted CPUID source CPU {cpu}"
361        ));
362    }
363    Ok(())
364}
365
366/// Select the lowest allowed CPU before the CLI's container can randomly
367/// narrow affinity. Probe that CPU, restore the caller's mask, and return the
368/// source to carry in Config::cpu_profile_source. The launch guard pins the
369/// actual tracer thread; this also works with a pre-existing library worker.
370/// Repeat runs require the same allowed CPU set and host configuration.
371pub fn admit_host_no_rdrand(request: &AdmissionRequest<'_>) -> Result<usize, AdmissionRefusal> {
372    admit_host_no_rdrand_with(request, qualify_host_no_rdrand)
373}
374
375fn admit_host_no_rdrand_with(
376    request: &AdmissionRequest<'_>,
377    probe: impl FnOnce() -> Result<(), AdmissionRefusal>,
378) -> Result<usize, AdmissionRefusal> {
379    check_host_no_rdrand_request(request)?;
380    let original = host_profile_affinity()?;
381    let cpu = affinity_cpus(&original)
382        .first()
383        .copied()
384        .ok_or("the CPUID source thread has no allowed CPU")?;
385    let admission = pin_host_cpuid_source(cpu).and_then(|()| probe());
386    // Restore on success as well as refusal: a library caller's other work
387    // must not inherit a placement constraint intended for its tracer.
388    sched_setaffinity(Pid::from_raw(0), &original).map_err(|error| {
389        format!("cannot restore the caller's CPU affinity after CPUID admission: {error}; probe result: {admission:?}")
390    })?;
391    admission?;
392    HOST_NO_RDRAND_CPUS[cpu].store(true, std::sync::atomic::Ordering::Release);
393    Ok(cpu)
394}
395
396/// A recording currently binds only the v3 host facts, not a complete native
397/// CPUID snapshot. Refuse host mode before writing metadata or launching a
398/// guest rather than accidentally treating it as v3.
399pub fn check_record_or_replay(profile: CpuProfile) -> Result<(), CpuProfileRefusal> {
400    if profile == CpuProfile::HostNoRdrand {
401        return Err(CpuProfileRefusal::new(
402            profile,
403            "record/replay is not supported because recording metadata does not bind \
404             the host CPUID state; use `hermit run --cpu-profile=host-no-rdrand --verify` \
405             for repeated runs, or record with --cpu-profile=x86-64-v2 or x86-64-v3",
406        ));
407    }
408    Ok(())
409}
410
411/// Linux's version as (major, minor), from a `uname -r` release string.
412pub fn kernel_version(release: &str) -> Option<(u32, u32)> {
413    let mut parts = release.split(['.', '-']);
414    let major = parts.next()?.parse().ok()?;
415    let minor = parts.next()?.parse().ok()?;
416    Some((major, minor))
417}
418
419/// Linux 5.16 is the first with the `ARCH_*_XCOMP_*` queries the profile
420/// answers from the host; before it they return EINVAL.
421pub fn check_kernel(release: &str) -> Result<(), AdmissionRefusal> {
422    match kernel_version(release) {
423        Some(version) if version >= (5, 16) => Ok(()),
424        Some((major, minor)) => Err(format!(
425            "Linux {major}.{minor} predates the 5.16 xstate queries the profile answers from"
426        )),
427        None => Err(format!("cannot read the kernel version from {release:?}")),
428    }
429}
430
431/// A host CPUID reader, so tests can supply a host.
432pub trait HostCpuid {
433    fn cpuid(&self, leaf: u32, subleaf: u32) -> [u32; 4];
434}
435
436/// The CPUID instruction of the host this process runs on.
437pub struct NativeCpuid;
438
439#[cfg(target_arch = "x86_64")]
440impl HostCpuid for NativeCpuid {
441    fn cpuid(&self, leaf: u32, subleaf: u32) -> [u32; 4] {
442        let r = std::arch::x86_64::__cpuid_count(leaf, subleaf);
443        [r.eax, r.ebx, r.ecx, r.edx]
444    }
445}
446
447const REGISTER_NAMES: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"];
448
449/// The host checks that need only CPUID, in the order they are checked:
450/// every advertised v3 feature bit natively, OSXSAVE, not a hybrid part, and
451/// not an AMD part without XSAVEERPTR (where Linux runs `fnclex; emms; fildl`
452/// before every FPU restore, a path no host here can exercise).
453pub fn check_host_cpuid(
454    host: &dyn HostCpuid,
455    advertised: &[(u32, u32, usize, u32)],
456) -> Result<(), AdmissionRefusal> {
457    let max_basic = host.cpuid(0, 0)[0];
458    let max_extended = host.cpuid(0x8000_0000, 0)[0];
459    let mut missing = Vec::new();
460    for &(leaf, subleaf, register, bits) in advertised {
461        let supported = if leaf >= 0x8000_0000 {
462            leaf <= max_extended
463        } else {
464            leaf <= max_basic
465        };
466        let native = if supported {
467            host.cpuid(leaf, subleaf)[register]
468        } else {
469            0
470        };
471        let absent = bits & !native;
472        for bit in 0..32 {
473            if absent & (1 << bit) != 0 {
474                missing.push(format!(
475                    "leaf {leaf:#x}.{subleaf} {} bit {bit}",
476                    REGISTER_NAMES[register]
477                ));
478            }
479        }
480    }
481    if !missing.is_empty() {
482        return Err(format!(
483            "the host lacks feature bits the profile advertises: {}",
484            missing.join(", ")
485        ));
486    }
487    if host.cpuid(1, 0)[2] & (1 << 27) == 0 {
488        return Err("the host kernel has not enabled XSAVE (OSXSAVE is clear)".into());
489    }
490    if max_basic >= 7 && host.cpuid(7, 0)[3] & (1 << 15) != 0 {
491        return Err(
492            "this is a hybrid part (leaf 7 EDX bit 15), whose core types the qualification \
493             would each have to sample"
494                .into(),
495        );
496    }
497    let vendor = host.cpuid(0, 0);
498    let amd = vendor[1] == 0x6874_7541;
499    let xsaveerptr = max_extended >= 0x8000_0008 && host.cpuid(0x8000_0008, 0)[1] & (1 << 2) != 0;
500    if amd && !xsaveerptr {
501        return Err(
502            "this AMD part lacks XSAVEERPTR, so Linux runs `fnclex; emms; fildl` before every \
503             FPU restore, which the qualification cannot exercise"
504                .into(),
505        );
506    }
507    Ok(())
508}
509
510/// The user-dynamic xstate component (AMX TILEDATA): Linux enables it in XCR0
511/// while withholding process permission, so a permission request changes
512/// process state and the three XCOMP answers differ.
513#[cfg(target_arch = "x86_64")]
514const XFEATURE_TILEDATA: u64 = 1 << 18;
515
516#[cfg(target_arch = "x86_64")]
517const ARCH_GET_XCOMP_SUPP: libc::c_int = 0x1021;
518#[cfg(target_arch = "x86_64")]
519const ARCH_GET_XCOMP_PERM: libc::c_int = 0x1022;
520#[cfg(target_arch = "x86_64")]
521const ARCH_REQ_XCOMP_PERM: libc::c_int = 0x1023;
522#[cfg(target_arch = "x86_64")]
523const ARCH_GET_XCOMP_GUEST_PERM: libc::c_int = 0x1024;
524
525#[cfg(target_arch = "x86_64")]
526fn arch_prctl_mask(cmd: libc::c_int) -> Result<u64, AdmissionRefusal> {
527    let mut mask = 0u64;
528    // SAFETY: the GET commands write one u64 through the pointer.
529    let rc = unsafe { libc::syscall(libc::SYS_arch_prctl, cmd, &mut mask as *mut u64) };
530    if rc != 0 {
531        return Err(format!(
532            "arch_prctl({cmd:#x}) failed: {}",
533            std::io::Error::last_os_error()
534        ));
535    }
536    Ok(mask)
537}
538
539/// Linux's XFEATURE_MAX as ARCH_REQ_XCOMP_PERM shows it: the first component
540/// index it answers EINVAL for. Every index below must answer EOPNOTSUPP; an
541/// index that is granted would mean a dynamic feature, which is refused
542/// before this runs.
543#[cfg(target_arch = "x86_64")]
544fn xfeature_max() -> Result<u32, AdmissionRefusal> {
545    for index in 0..64u64 {
546        // SAFETY: the request takes the component index by value.
547        let rc = unsafe { libc::syscall(libc::SYS_arch_prctl, ARCH_REQ_XCOMP_PERM, index) };
548        let errno = std::io::Error::last_os_error().raw_os_error();
549        match (rc, errno) {
550            (-1, Some(libc::EINVAL)) => return Ok(index as u32),
551            (-1, Some(libc::EOPNOTSUPP)) => {}
552            _ => {
553                return Err(format!(
554                    "ARCH_REQ_XCOMP_PERM for component {index} answered {rc} ({errno:?}), \
555                     not EOPNOTSUPP or EINVAL"
556                ));
557            }
558        }
559    }
560    Err("ARCH_REQ_XCOMP_PERM never answered EINVAL for indices 0-63".into())
561}
562
563/// The host facts a qualified v3 run binds.
564#[cfg(target_arch = "x86_64")]
565fn capture(probe: &xstate_probe::ProbeReport) -> Result<HostXstate, AdmissionRefusal> {
566    let host = host_facts(probe.cases as u32, probe.forced_exit)?;
567    if host.xcr0 != probe.xcr0 {
568        return Err(format!(
569            "XCR0 changed during admission ({:#x}, then {:#x})",
570            probe.xcr0, host.xcr0
571        ));
572    }
573    Ok(host)
574}
575
576/// This host's facts, with the qualification fields given rather than probed.
577/// The caller has checked OSXSAVE.
578#[cfg(target_arch = "x86_64")]
579fn host_facts(probe_cases: u32, probe_forced_exit: bool) -> Result<HostXstate, AdmissionRefusal> {
580    let host = NativeCpuid;
581    let leaf0 = host.cpuid(0, 0);
582    // SAFETY: XGETBV with ECX 0 is available because OSXSAVE was checked.
583    let xcr0 = unsafe { std::arch::x86_64::_xgetbv(0) };
584    let mut xsave_leaves = Vec::new();
585    for subleaf in std::iter::once(0).chain(2..64) {
586        if subleaf == 0 || xcr0 & (1 << subleaf) != 0 {
587            let [eax, ebx, ecx, edx] = host.cpuid(0xd, subleaf);
588            xsave_leaves.push([subleaf, eax, ebx, ecx, edx]);
589        }
590    }
591    let xcomp_supp = arch_prctl_mask(ARCH_GET_XCOMP_SUPP)?;
592    refuse_dynamic_features(xcr0, xcomp_supp)?;
593    Ok(HostXstate {
594        vendor: [leaf0[1], leaf0[3], leaf0[2]],
595        signature: host.cpuid(1, 0)[0],
596        xcr0,
597        xsave_leaves,
598        xcomp_supp,
599        xcomp_perm: arch_prctl_mask(ARCH_GET_XCOMP_PERM)?,
600        xcomp_guest_perm: arch_prctl_mask(ARCH_GET_XCOMP_GUEST_PERM)?,
601        xfeature_max: xfeature_max()?,
602        probe_cases,
603        probe_forced_exit,
604    })
605}
606
607/// Refuses a v3 replay before the qualification probe runs when the recording
608/// carries no host facts, when this host differs in one of the facts it bound
609/// (the qualification itself aside), or when its CPUID table digest is not the
610/// one this replayer computes from them. The probe costs seconds of CPU, and a
611/// replay on a different CPU, or of an altered recording, needs none of it to
612/// be refused. Returns the recorded facts.
613#[cfg(target_arch = "x86_64")]
614pub fn precheck_replay(
615    profile: CpuProfile,
616    recorded: Option<&HostXstate>,
617    recorded_digest: Option<&str>,
618) -> Result<HostXstate, CpuProfileRefusal> {
619    check_record_or_replay(profile)?;
620    let refuse = |why: String| CpuProfileRefusal::new(profile, why);
621    let recorded = recorded.cloned().ok_or_else(|| {
622        refuse("the recording names the profile but carries no host facts".into())
623    })?;
624    check_host_cpuid(&NativeCpuid, &detcore::v3_feature_words()).map_err(refuse)?;
625    let host = host_facts(recorded.probe_cases, recorded.probe_forced_exit).map_err(refuse)?;
626    if let Some(fact) = first_difference(&recorded, &host) {
627        return Err(refuse(format!(
628            "this host differs from the recording's in {fact}, so the guest would see a \
629             different CPU"
630        )));
631    }
632    let config = detcore::Config {
633        cpu_profile: profile,
634        cpu_profile_host: Some(recorded.clone()),
635        ..Default::default()
636    };
637    let digest = crate::record::hex_digest(&detcore::virtual_cpuid_digest(&config));
638    if recorded_digest != Some(digest.as_str()) {
639        return Err(refuse(format!(
640            "the recording's CPUID table digest {recorded_digest:?} is not this replayer's \
641             {digest}"
642        )));
643    }
644    Ok(recorded)
645}
646
647/// This process's successful qualification. A recording or replay qualifies
648/// in the CLI process, before its container child is forked, so the child
649/// reuses this result instead of probing again from inside the container,
650/// whose CPU affinity can be one busy CPU.
651static ADMITTED: std::sync::OnceLock<Qualification> = std::sync::OnceLock::new();
652
653/// Admits a v3 run on this host, or names why not. On success the probe report
654/// and the host facts to bind are returned, and this process's ptrace
655/// floating-point register writes are refused from then on. The request's own
656/// refusals are checked every time; the host qualification runs once per
657/// process.
658pub fn admit(request: &AdmissionRequest<'_>) -> Result<Qualification, AdmissionRefusal> {
659    admit_with(request, qualify_host)
660}
661
662/// [`admit`] with its host qualification as a parameter, so that a test can
663/// admit without probing.
664fn admit_with(
665    request: &AdmissionRequest<'_>,
666    qualify: impl FnOnce() -> Result<Qualification, AdmissionRefusal>,
667) -> Result<Qualification, AdmissionRefusal> {
668    check_request(request)?;
669    if let Some(admitted) = ADMITTED.get() {
670        return Ok(admitted.clone());
671    }
672    let qualified = qualify()?;
673    // The qualification covers the XSTATE_BV a guest's own instructions
674    // produce. A tracer write of its floating-point registers (PTRACE_SETFPREGS
675    // or PTRACE_SETREGSET NT_X86_XSTATE) sets that header from the tracer's
676    // buffer instead, which nothing checked, so it fails with EPERM. The flag
677    // is process-wide and set before the container child is forked, so the
678    // tracer inherits it. It is set before the admission becomes visible in
679    // [`ADMITTED`], so an admitted launch always runs with it.
680    safeptrace::forbid_fpu_register_writes();
681    Ok(ADMITTED.get_or_init(|| qualified).clone())
682}
683
684/// Refuses a nondefault profile launch that this process did not admit.
685/// Hermit's library launch functions call this before any backend starts.
686/// Host mode requires its faulting proof and interception options; v3 also
687/// requires exactly the host facts [`admit`] bound after qualification and
688/// its floating-point register write ban.
689pub fn refuse_unadmitted_launch(
690    config: &detcore::Config,
691    ptrace: bool,
692) -> Result<(), CpuProfileRefusal> {
693    if config.cpu_profile.is_default() {
694        return Ok(());
695    }
696    let refuse = |why: &str| Err(CpuProfileRefusal::new(config.cpu_profile, why));
697    if config.cpu_profile == CpuProfile::HostNoRdrand {
698        // Library callers can configure the network engine directly, without
699        // passing through RunOpts or its early artifact-creation guard.
700        if config.network_trace.mode != detcore_model::network_trace::NetworkTraceMode::Off {
701            check_record_or_replay(config.cpu_profile)?;
702        }
703        let request = AdmissionRequest {
704            backend: if ptrace { "ptrace" } else { "non-ptrace" },
705            ptrace,
706            virtualize_cpuid: config.virtualize_cpuid,
707            virtualize_time: config.virtualize_time,
708            strace_only: false,
709            gdbserver: config.gdbserver,
710            namespace_only: false,
711        };
712        check_host_no_rdrand_request(&request)
713            .map_err(|why| CpuProfileRefusal::new(config.cpu_profile, why))?;
714        let cpu = config.cpu_profile_source.filter(|cpu| {
715            HOST_NO_RDRAND_CPUS
716                .get(*cpu)
717                .is_some_and(|admitted| admitted.load(std::sync::atomic::Ordering::Acquire))
718        });
719        let Some(cpu) = cpu else {
720            return refuse(
721                "this process did not verify CPUID faulting on the configuration's source CPU; \
722                 call admit_host_no_rdrand and retain its source in cpu_profile_source",
723            );
724        };
725        return pin_host_cpuid_source(cpu)
726            .map_err(|reason| CpuProfileRefusal::new(config.cpu_profile, reason));
727    }
728    if !ptrace {
729        return refuse("only the ptrace backend is qualified");
730    }
731    if !config.virtualize_cpuid || config.gdbserver {
732        return refuse("the launch does not virtualize CPUID, or serves a gdbserver");
733    }
734    match (&config.cpu_profile_host, ADMITTED.get()) {
735        (None, _) => return refuse("the configuration carries no qualified host facts"),
736        (Some(_), None) => return refuse("this process did not qualify the host"),
737        (Some(facts), Some((_, admitted))) if facts != admitted => {
738            return refuse(
739                "the configuration's host facts are not the ones this process qualified",
740            );
741        }
742        _ => {}
743    }
744    if !safeptrace::fpu_register_writes_forbidden() {
745        return refuse("ptrace floating-point register writes are not forbidden");
746    }
747    Ok(())
748}
749
750/// The host half of [`admit`], run once per process: the kernel, the
751/// every-bit CPUID check, the dynamic-feature refusal, the probe and the host
752/// facts. `hermit host-capabilities --cpu-profile x86-64-v3` runs it alone.
753#[cfg(target_arch = "x86_64")]
754pub fn qualify_host() -> Result<Qualification, AdmissionRefusal> {
755    let release = nix::sys::utsname::uname()
756        .map_err(|e| format!("uname failed: {e}"))?
757        .release()
758        .to_string_lossy()
759        .into_owned();
760    check_kernel(&release)?;
761    check_host_cpuid(&NativeCpuid, &detcore::v3_feature_words())?;
762    // SAFETY: OSXSAVE was checked above.
763    let xcr0 = unsafe { std::arch::x86_64::_xgetbv(0) };
764    refuse_dynamic_features(xcr0, 0)?;
765    let probe = xstate_probe::qualify(xstate_probe::ProbeOptions::default())?;
766    let host = capture(&probe)?;
767    Ok((probe, host))
768}
769
770/// Refuses a host with a user-dynamic xstate feature (AMX TILEDATA, bit 18) in
771/// XCR0 or in ARCH_GET_XCOMP_SUPP: a guest's permission request for it changes
772/// process state, which the profile cannot bind.
773#[cfg(target_arch = "x86_64")]
774fn refuse_dynamic_features(xcr0: u64, xcomp_supp: u64) -> Result<(), AdmissionRefusal> {
775    if (xcr0 | xcomp_supp) & XFEATURE_TILEDATA != 0 {
776        return Err(
777            "the host has a user-dynamic xstate feature (AMX TILEDATA), whose permission \
778             requests change process state"
779                .into(),
780        );
781    }
782    Ok(())
783}
784
785/// CPUID faulting is an x86 feature, so other architectures refuse the host
786/// profile.
787#[cfg(not(target_arch = "x86_64"))]
788pub fn qualify_host_no_rdrand() -> Result<(), AdmissionRefusal> {
789    Err(non_x86_refusal())
790}
791
792#[cfg(not(target_arch = "x86_64"))]
793fn native_cpuid_faulting_vm_pointer() -> Option<String> {
794    None
795}
796
797/// The v3 profile binds x86 XSTATE facts, so other architectures refuse it.
798#[cfg(not(target_arch = "x86_64"))]
799pub fn precheck_replay(
800    profile: CpuProfile,
801    _recorded: Option<&HostXstate>,
802    _recorded_digest: Option<&str>,
803) -> Result<HostXstate, CpuProfileRefusal> {
804    check_record_or_replay(profile)?;
805    Err(CpuProfileRefusal::new(profile, non_x86_refusal()))
806}
807
808/// The v3 profile binds x86 XSTATE facts, so other architectures refuse it.
809#[cfg(not(target_arch = "x86_64"))]
810pub fn qualify_host() -> Result<Qualification, AdmissionRefusal> {
811    Err(non_x86_refusal())
812}
813
814/// Why a nondefault profile is refused off x86: both describe x86 CPUs.
815#[cfg(not(target_arch = "x86_64"))]
816fn non_x86_refusal() -> AdmissionRefusal {
817    format!(
818        "the nondefault CPU profiles describe x86-64 CPUs, and this host is {}",
819        std::env::consts::ARCH
820    )
821}
822
823#[cfg(test)]
824mod tests {
825    use std::collections::BTreeMap;
826
827    /// A host with AMX TILEDATA in XCR0 or in the supported mask is refused by
828    /// name; one without it is not.
829    #[test]
830    fn a_dynamic_xstate_feature_is_refused() {
831        assert_eq!(super::refuse_dynamic_features(0x2e7, 0x2e7), Ok(()));
832        for (xcr0, supp) in [(0x602e7, 0x2e7), (0x2e7, 0x602e7), (0x402e7, 0x402e7)] {
833            assert!(
834                super::refuse_dynamic_features(xcr0, supp)
835                    .unwrap_err()
836                    .contains("AMX TILEDATA"),
837                "{xcr0:#x} {supp:#x}"
838            );
839        }
840    }
841
842    use super::*;
843
844    fn request(backend: &str) -> AdmissionRequest<'_> {
845        AdmissionRequest {
846            backend,
847            ptrace: backend == "ptrace",
848            virtualize_cpuid: true,
849            virtualize_time: true,
850            strace_only: false,
851            gdbserver: false,
852            namespace_only: false,
853        }
854    }
855
856    #[test]
857    fn only_a_ptrace_run_with_virtual_cpuid_and_no_gdbserver_is_considered() {
858        assert_eq!(check_request(&request("ptrace")), Ok(()));
859        for backend in [
860            "kvm",
861            "dbt",
862            "sabre",
863            "liteinst",
864            "in-guest-trap",
865            "e9patch",
866        ] {
867            let refusal = check_request(&request(backend)).unwrap_err();
868            assert!(
869                refusal.starts_with(&format!("--backend={backend}")),
870                "{refusal}"
871            );
872        }
873        assert!(check_request(&request("kvm")).unwrap_err().contains("XCR0"));
874        assert!(
875            check_request(&request("dbt"))
876                .unwrap_err()
877                .contains("carries no CPU profile")
878        );
879        let mut r = request("ptrace");
880        r.virtualize_cpuid = false;
881        assert!(check_request(&r).is_err());
882        let mut r = request("ptrace");
883        r.strace_only = true;
884        assert!(check_request(&r).is_err());
885        let mut r = request("ptrace");
886        r.gdbserver = true;
887        assert!(check_request(&r).unwrap_err().contains("--gdbserver"));
888        let mut r = request("ptrace");
889        r.namespace_only = true;
890        assert!(check_request(&r).unwrap_err().contains("--namespace-only"));
891    }
892
893    #[test]
894    fn host_profile_requires_interception_and_virtual_time() {
895        let mut host = qualifying_host();
896        assert_eq!(check_host_no_rdrand_cpuid(&host), Ok(()));
897        host.0.get_mut(&(7, 0)).unwrap()[3] |= 1 << 15;
898        assert!(
899            check_host_no_rdrand_cpuid(&host)
900                .unwrap_err()
901                .contains("use a homogeneous CPU host")
902        );
903        host.0.get_mut(&(7, 0)).unwrap()[3] &= !(1 << 15);
904        host.0.insert((0x8000_0000, 0), [0x8000_0026, 0, 0, 0]);
905        host.0.insert((0x8000_0026, 0), [1 << 30, 0, 0, 0]);
906        assert!(
907            check_host_no_rdrand_cpuid(&host)
908                .unwrap_err()
909                .contains("use a homogeneous CPU host")
910        );
911        host.0.get_mut(&(0x8000_0026, 0)).unwrap()[0] = 0;
912        assert_eq!(check_host_no_rdrand_cpuid(&host), Ok(()));
913        // An unsupported leaf can alias a supported leaf; honor the maximum.
914        host.0.get_mut(&(0x8000_0000, 0)).unwrap()[0] = 0x8000_0025;
915        host.0.get_mut(&(0x8000_0026, 0)).unwrap()[0] = 1 << 30;
916        assert_eq!(check_host_no_rdrand_cpuid(&host), Ok(()));
917        assert_eq!(check_host_no_rdrand_request(&request("ptrace")), Ok(()));
918        for backend in [
919            "kvm",
920            "dbt",
921            "sabre",
922            "liteinst",
923            "in-guest-trap",
924            "e9patch",
925        ] {
926            let refusal = check_host_no_rdrand_request(&request(backend)).unwrap_err();
927            assert!(
928                refusal.contains(&format!("--backend={backend}")),
929                "{refusal}"
930            );
931            assert!(refusal.contains("use --backend=ptrace"), "{refusal}");
932        }
933        for (r, option) in [
934            (
935                AdmissionRequest {
936                    virtualize_cpuid: false,
937                    ..request("ptrace")
938                },
939                "--no-virtualize-cpuid",
940            ),
941            (
942                AdmissionRequest {
943                    virtualize_time: false,
944                    ..request("ptrace")
945                },
946                "--no-virtualize-time",
947            ),
948            (
949                AdmissionRequest {
950                    strace_only: true,
951                    ..request("ptrace")
952                },
953                "--strace-only",
954            ),
955            (
956                AdmissionRequest {
957                    namespace_only: true,
958                    ..request("ptrace")
959                },
960                "--namespace-only",
961            ),
962        ] {
963            let refusal =
964                admit_host_no_rdrand_with(&r, || panic!("invalid request probed")).unwrap_err();
965            assert!(refusal.contains(&format!("remove {option}")), "{refusal}");
966        }
967        // Only v3 binds qualified FPU register state and forbids gdb writes.
968        let mut gdb = request("ptrace");
969        gdb.gdbserver = true;
970        assert_eq!(check_host_no_rdrand_request(&gdb), Ok(()));
971    }
972
973    #[test]
974    fn host_profile_refuses_absent_faulting_and_requires_admission_to_launch() {
975        let original = host_profile_affinity().unwrap();
976        let mut config = detcore::Config {
977            cpu_profile: CpuProfile::HostNoRdrand,
978            ..Default::default()
979        };
980        let unadmitted = refuse_unadmitted_launch(&config, true).unwrap_err();
981        assert!(unadmitted.reason.contains("did not verify CPUID faulting"));
982        let simulated = "CPUID faulting is unavailable: ARCH_SET_CPUID(0) failed";
983        let refusal =
984            admit_host_no_rdrand_with(&request("ptrace"), || Err(simulated.into())).unwrap_err();
985        assert_eq!(refusal, simulated);
986        assert_eq!(
987            affinity_cpus(&host_profile_affinity().unwrap()),
988            affinity_cpus(&original)
989        );
990        assert!(refuse_unadmitted_launch(&config, true).is_err());
991
992        // The source is chosen from the original mask, before a container or
993        // cached worker has narrowed it. Admission restores the caller's mask.
994        let source = admit_host_no_rdrand_with(&request("ptrace"), || Ok(())).unwrap();
995        assert_eq!(source, affinity_cpus(&original)[0]);
996        assert_eq!(
997            affinity_cpus(&host_profile_affinity().unwrap()),
998            affinity_cpus(&original)
999        );
1000        assert!(refuse_unadmitted_launch(&config, true).is_err());
1001        config.cpu_profile_source = Some(source);
1002        for mode in [
1003            detcore_model::network_trace::NetworkTraceMode::Record,
1004            detcore_model::network_trace::NetworkTraceMode::Replay,
1005        ] {
1006            config.network_trace.mode = mode;
1007            let refusal = refuse_unadmitted_launch(&config, true).unwrap_err();
1008            assert!(refusal.reason.contains("record/replay is not supported"));
1009        }
1010        config.network_trace.mode = detcore_model::network_trace::NetworkTraceMode::Off;
1011        let other_config = config.clone();
1012        let original_cpus = affinity_cpus(&original);
1013        std::thread::spawn(move || {
1014            // A previously created library worker can have a wider or different
1015            // mask than admission's caller. The actual launch pins that worker.
1016            let other_cpu = *original_cpus.last().unwrap();
1017            let mut other = CpuSet::new();
1018            other.set(other_cpu).unwrap();
1019            sched_setaffinity(Pid::from_raw(0), &other).unwrap();
1020            assert_eq!(refuse_unadmitted_launch(&other_config, true), Ok(()));
1021            assert_eq!(affinity_cpus(&host_profile_affinity().unwrap()), [source]);
1022        })
1023        .join()
1024        .unwrap();
1025        assert_eq!(refuse_unadmitted_launch(&config, true), Ok(()));
1026        assert_eq!(affinity_cpus(&host_profile_affinity().unwrap()), [source]);
1027        assert!(refuse_unadmitted_launch(&config, false).is_err());
1028        config.virtualize_cpuid = false;
1029        assert!(refuse_unadmitted_launch(&config, true).is_err());
1030        config.virtualize_cpuid = true;
1031        config.virtualize_time = false;
1032        assert!(refuse_unadmitted_launch(&config, true).is_err());
1033        sched_setaffinity(Pid::from_raw(0), &original).unwrap();
1034    }
1035
1036    #[test]
1037    fn host_profile_recordings_are_refused_before_v3_host_checks() {
1038        for profile in [CpuProfile::X86_64V2, CpuProfile::X86_64V3] {
1039            assert_eq!(check_record_or_replay(profile), Ok(()));
1040        }
1041        let refused = check_record_or_replay(CpuProfile::HostNoRdrand).unwrap_err();
1042        assert_eq!(refused.profile, CpuProfile::HostNoRdrand);
1043        assert!(refused.reason.contains("record/replay is not supported"));
1044        assert!(
1045            refused
1046                .reason
1047                .contains("hermit run --cpu-profile=host-no-rdrand --verify")
1048        );
1049        assert_eq!(
1050            precheck_replay(CpuProfile::HostNoRdrand, None, None),
1051            Err(refused)
1052        );
1053        assert_eq!(
1054            CpuProfileRefusal::new(CpuProfile::HostNoRdrand, "no faulting").to_string(),
1055            "--cpu-profile host-no-rdrand refused: no faulting"
1056        );
1057    }
1058
1059    /// The launch certificate, in one process (no other test admits v3): a v3
1060    /// configuration with facts but no admission is refused; a refused
1061    /// request neither probes nor forbids floating-point writes; an admission
1062    /// forbids them before it is visible, and then exactly the admitted facts
1063    /// launch, on ptrace only.
1064    #[test]
1065    fn only_the_admitted_facts_launch_and_the_write_ban_comes_first() {
1066        let host = HostXstate {
1067            vendor: [1, 2, 3],
1068            signature: 0x00b1_0f11,
1069            xcr0: 0x2e7,
1070            xsave_leaves: vec![[0, 0x2e7, 0x988, 0x988, 0]],
1071            xcomp_supp: 0x2e7,
1072            xcomp_perm: 0x2e7,
1073            xcomp_guest_perm: 0x2e7,
1074            xfeature_max: 20,
1075            probe_cases: 33,
1076            probe_forced_exit: false,
1077        };
1078        let mut config = detcore::Config {
1079            cpu_profile: detcore_model::cpu_profile::CpuProfile::X86_64V3,
1080            cpu_profile_host: Some(host.clone()),
1081            ..Default::default()
1082        };
1083        let unadmitted = refuse_unadmitted_launch(&config, true).unwrap_err();
1084        assert!(
1085            unadmitted.reason.contains("did not qualify"),
1086            "{unadmitted}"
1087        );
1088        assert_eq!(
1089            refuse_unadmitted_launch(&detcore::Config::default(), false),
1090            Ok(())
1091        );
1092
1093        let refused = admit_with(&request("kvm"), || {
1094            panic!("a refused request probed the host")
1095        });
1096        assert!(refused.is_err());
1097        assert!(!safeptrace::fpu_register_writes_forbidden());
1098        assert!(ADMITTED.get().is_none());
1099
1100        let report = xstate_probe::ProbeReport {
1101            cpu: 3,
1102            xcr0: 0x2e7,
1103            cases: 33,
1104            cells: 396,
1105            repetitions: 32,
1106            certified_pairs: 12672,
1107            forced_exit: false,
1108            reload_bound: 0,
1109            cpuid_min_cycles: 0,
1110            calibration: String::new(),
1111            retried_references: 0,
1112            retried_reloads: 0,
1113            retried_exits: 0,
1114            counter_reopens: 0,
1115        };
1116        let admitted = admit_with(&request("ptrace"), || {
1117            // The ban is not yet in force while the host qualifies.
1118            assert!(!safeptrace::fpu_register_writes_forbidden());
1119            Ok((report.clone(), host.clone()))
1120        });
1121        assert_eq!(admitted, Ok((report, host.clone())));
1122        assert!(safeptrace::fpu_register_writes_forbidden());
1123        assert_eq!(refuse_unadmitted_launch(&config, true), Ok(()));
1124        assert!(refuse_unadmitted_launch(&config, false).is_err());
1125        let mut other = host;
1126        other.probe_cases = 0;
1127        config.cpu_profile_host = Some(other);
1128        let forged = refuse_unadmitted_launch(&config, true).unwrap_err();
1129        assert!(
1130            forged
1131                .reason
1132                .contains("not the ones this process qualified"),
1133            "{forged}"
1134        );
1135        config.cpu_profile_host = None;
1136        assert!(refuse_unadmitted_launch(&config, true).is_err());
1137    }
1138
1139    #[test]
1140    fn a_replay_host_is_compared_fact_by_fact() {
1141        let recorded = HostXstate {
1142            vendor: [1, 2, 3],
1143            signature: 0x00b1_0f11,
1144            xcr0: 0x2e7,
1145            xsave_leaves: vec![[0, 0x2e7, 0x988, 0x988, 0]],
1146            xcomp_supp: 0x2e7,
1147            xcomp_perm: 0x2e7,
1148            xcomp_guest_perm: 0x2e7,
1149            xfeature_max: 20,
1150            probe_cases: 33,
1151            probe_forced_exit: false,
1152        };
1153        assert_eq!(first_difference(&recorded, &recorded.clone()), None);
1154        let differs = |f: fn(&mut HostXstate)| {
1155            let mut other = recorded.clone();
1156            f(&mut other);
1157            first_difference(&recorded, &other)
1158        };
1159        assert_eq!(differs(|h| h.vendor[0] = 9), Some("the CPU vendor"));
1160        assert_eq!(
1161            differs(|h| h.signature = 0x00a1_0f11),
1162            Some("the CPU family, model or stepping")
1163        );
1164        assert_eq!(differs(|h| h.xcr0 = 0x207), Some("XCR0"));
1165        assert_eq!(
1166            differs(|h| h.xsave_leaves[0][2] = 0xa88),
1167            Some("the XSAVE layout (CPUID leaf 0xD)")
1168        );
1169        assert_eq!(
1170            differs(|h| h.xcomp_perm = 0x207),
1171            Some("the ARCH_GET_XCOMP masks")
1172        );
1173        assert_eq!(differs(|h| h.xfeature_max = 19), Some("XFEATURE_MAX"));
1174        assert_eq!(
1175            differs(|h| h.probe_forced_exit = true),
1176            Some("the qualification it passed")
1177        );
1178        assert_eq!(
1179            CpuProfileRefusal::new(CpuProfile::X86_64V3, "why").to_string(),
1180            "--cpu-profile x86-64-v3 refused: why"
1181        );
1182    }
1183
1184    #[test]
1185    fn kernels_before_5_16_are_refused() {
1186        assert_eq!(kernel_version("7.1.3-0_fbk0_0_g295ad0959f34"), Some((7, 1)));
1187        assert_eq!(check_kernel("5.16.0"), Ok(()));
1188        assert_eq!(check_kernel("6.4.3-0_fbk20"), Ok(()));
1189        assert!(check_kernel("5.15.120").is_err());
1190        assert!(check_kernel("4.18.0-553.el8").is_err());
1191        assert!(check_kernel("garbage").is_err());
1192    }
1193
1194    struct FakeHost(BTreeMap<(u32, u32), [u32; 4]>);
1195
1196    impl HostCpuid for FakeHost {
1197        fn cpuid(&self, leaf: u32, subleaf: u32) -> [u32; 4] {
1198            self.0.get(&(leaf, subleaf)).copied().unwrap_or([0; 4])
1199        }
1200    }
1201
1202    /// A bare-metal-shaped host with every v3 bit, AMD with XSAVEERPTR.
1203    fn qualifying_host() -> FakeHost {
1204        let mut regs = BTreeMap::new();
1205        regs.insert((0, 0), [0xd, 0x6874_7541, 0x444d_4163, 0x6974_6e65]);
1206        regs.insert((0x8000_0000, 0), [0x8000_0008, 0, 0, 0]);
1207        regs.insert((0x8000_0008, 0), [0, 1 << 2, 0, 0]);
1208        for &(leaf, subleaf, register, bits) in &detcore::v3_feature_words() {
1209            regs.entry((leaf, subleaf)).or_insert([0; 4])[register] |= bits;
1210        }
1211        regs.get_mut(&(1, 0)).unwrap()[2] |= 1 << 27;
1212        FakeHost(regs)
1213    }
1214
1215    #[test]
1216    fn a_host_with_every_v3_bit_qualifies_and_removing_any_one_is_named() {
1217        let words = detcore::v3_feature_words();
1218        let host = qualifying_host();
1219        assert_eq!(check_host_cpuid(&host, &words), Ok(()));
1220        // The hypervisor bit is not required: v3 clears it.
1221        assert!(words.iter().all(|&(leaf, _, register, bits)| !(leaf == 1
1222            && register == 2
1223            && bits & (1 << 31) != 0)));
1224        let mut checked = 0;
1225        for &(leaf, subleaf, register, bits) in &words {
1226            for bit in (0..32).filter(|b| bits & (1 << b) != 0) {
1227                let mut host = qualifying_host();
1228                host.0.get_mut(&(leaf, subleaf)).unwrap()[register] &= !(1 << bit);
1229                if leaf == 1 && register == 2 && bit == 27 {
1230                    continue; // OSXSAVE: refused by its own check, below
1231                }
1232                let refusal = check_host_cpuid(&host, &words).unwrap_err();
1233                assert!(
1234                    refusal.contains(&format!(
1235                        "leaf {leaf:#x}.{subleaf} {} bit {bit}",
1236                        REGISTER_NAMES[register]
1237                    )),
1238                    "{refusal}"
1239                );
1240                checked += 1;
1241            }
1242        }
1243        let advertised: u32 = words.iter().map(|&(_, _, _, bits)| bits.count_ones()).sum();
1244        // Every advertised bit but OSXSAVE, which has its own check.
1245        assert_eq!(checked, advertised - 1);
1246    }
1247
1248    /// The kernel-module pointer is given only on a VM before Linux 6.17, and
1249    /// only after an error that reports missing faulting and lacks it already.
1250    #[test]
1251    fn cpuid_faulting_vm_pointer_needs_a_hypervisor_and_a_kernel_before_6_17() {
1252        let mut vm = qualifying_host();
1253        vm.0.get_mut(&(1, 0)).unwrap()[2] |= 1 << 31;
1254        let pointer = cpuid_faulting_vm_pointer(&vm, "6.16.1-0_fbk5_0_g0e127f210adc").unwrap();
1255        assert!(pointer.contains(CPUID_FAULTING_VM_SCRIPT), "{pointer}");
1256        assert!(pointer.contains("Linux 6.16.1-0_fbk5"), "{pointer}");
1257        assert!(cpuid_faulting_vm_pointer(&vm, "5.19.0").is_some());
1258        assert_eq!(cpuid_faulting_vm_pointer(&vm, "6.17.0"), None);
1259        assert_eq!(cpuid_faulting_vm_pointer(&vm, "7.1.3-0_fbk0"), None);
1260        assert_eq!(cpuid_faulting_vm_pointer(&vm, "garbage"), None);
1261        let bare_metal = qualifying_host();
1262        assert_eq!(cpuid_faulting_vm_pointer(&bare_metal, "6.16.1"), None);
1263
1264        // Host-independent cases of the note: no matching message, or a
1265        // message that already names the script.
1266        assert_eq!(
1267            missing_cpuid_faulting_note(&["permission denied".to_string()]),
1268            None
1269        );
1270        assert_eq!(
1271            missing_cpuid_faulting_note(&[format!(
1272                "CPUID faulting is unavailable: ARCH_SET_CPUID(0) failed; {pointer}"
1273            )]),
1274            None
1275        );
1276    }
1277
1278    #[test]
1279    fn osxsave_hybrid_parts_and_old_amd_are_refused() {
1280        let words = detcore::v3_feature_words();
1281        let mut host = qualifying_host();
1282        host.0.get_mut(&(1, 0)).unwrap()[2] &= !(1 << 27);
1283        assert!(check_host_cpuid(&host, &words).is_err());
1284        let mut host = qualifying_host();
1285        host.0.get_mut(&(7, 0)).unwrap()[3] |= 1 << 15;
1286        assert!(
1287            check_host_cpuid(&host, &words)
1288                .unwrap_err()
1289                .contains("hybrid")
1290        );
1291        let mut host = qualifying_host();
1292        host.0.get_mut(&(0x8000_0008, 0)).unwrap()[1] = 0;
1293        assert!(
1294            check_host_cpuid(&host, &words)
1295                .unwrap_err()
1296                .contains("XSAVEERPTR")
1297        );
1298    }
1299}