Skip to main content

hermit/
happens_before.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//! Debug-info resolution for happens-before anchors.
10//!
11//! The anchor/edge *model* lives in [`detcore_model::happens_before`]; it leaves
12//! `func`/`file:line` code locations unresolved (a [`Position::Rip`] with
13//! `addr: None`). This module turns those human-legible locations into concrete
14//! instruction pointers by reading the target binary's symbol table (for
15//! function entry addresses) and DWARF line program (for source lines), and
16//! provides the reverse mapping (address → function/file/line) used by
17//! introspection / `--list-events`.
18//!
19//! It builds *owned* indices in a single pass and drops the mmap/DWARF context,
20//! so the resolver has no self-referential lifetimes and is cheap to keep around.
21
22use std::borrow::Cow;
23use std::collections::BTreeMap;
24use std::path::Path;
25
26use addr2line::gimli;
27use anyhow::Context as _;
28use detcore_model::happens_before::Anchor;
29use detcore_model::happens_before::CodeLocation;
30use detcore_model::happens_before::HappensBeforeProgram;
31use detcore_model::happens_before::HappensBeforeSpec;
32use detcore_model::happens_before::Position;
33use detcore_model::happens_before::syscall_never_reaches_tracer;
34use object::Object;
35use object::ObjectSection;
36use object::ObjectSymbol;
37use object::SymbolKind;
38
39/// The virtual-address extent of a function symbol.
40#[derive(Clone, Copy, Debug)]
41struct FuncExtent {
42    /// Entry virtual address.
43    addr: u64,
44    /// Size in bytes (0 when the symbol table does not record one).
45    size: u64,
46}
47
48impl FuncExtent {
49    /// True when `addr` falls within `[self.addr, self.addr + self.size)`. A
50    /// zero-sized symbol only matches its exact entry address.
51    fn contains(&self, addr: u64) -> bool {
52        if self.size == 0 {
53            addr == self.addr
54        } else {
55            addr >= self.addr && addr < self.addr + self.size
56        }
57    }
58}
59
60/// One resolved source-line row: an address and the file/line it maps to.
61#[derive(Clone, Debug)]
62struct LineRow {
63    addr: u64,
64    file: Option<String>,
65    line: u32,
66}
67
68/// A resolved source location for introspection output.
69#[derive(Clone, Debug, Default, PartialEq, Eq)]
70pub struct ResolvedLocation {
71    /// Function name containing the address, if known.
72    pub function: Option<String>,
73    /// Source file basename, if known.
74    pub file: Option<String>,
75    /// Source line, if known.
76    pub line: Option<u32>,
77}
78
79/// Owned debug-info indices for one binary.
80pub struct DebugInfoResolver {
81    /// Function name → extent (from the ELF symbol table).
82    functions: BTreeMap<String, FuncExtent>,
83    /// Line rows sorted ascending by address.
84    lines: Vec<LineRow>,
85}
86
87impl DebugInfoResolver {
88    /// Read `path` and build the symbol and line-number indices.
89    pub fn open(path: &Path) -> anyhow::Result<Self> {
90        let data = std::fs::read(path)
91            .with_context(|| format!("reading binary for debug info: {}", path.display()))?;
92        Self::from_bytes(&data)
93            .with_context(|| format!("parsing debug info from {}", path.display()))
94    }
95
96    /// Build the indices from in-memory object bytes.
97    pub fn from_bytes(data: &[u8]) -> anyhow::Result<Self> {
98        let object = object::File::parse(data)?;
99
100        // --- function symbols -------------------------------------------------
101        let mut functions: BTreeMap<String, FuncExtent> = BTreeMap::new();
102        for sym in object.symbols() {
103            if sym.kind() != SymbolKind::Text {
104                continue;
105            }
106            let addr = sym.address();
107            if addr == 0 {
108                continue;
109            }
110            if let Ok(name) = sym.name() {
111                if name.is_empty() {
112                    continue;
113                }
114                // Prefer the first definition (typically the primary symbol) but
115                // upgrade a zero-sized entry if we later learn a real size.
116                functions
117                    .entry(name.to_string())
118                    .and_modify(|e| {
119                        if e.size == 0 && sym.size() > 0 {
120                            *e = FuncExtent {
121                                addr,
122                                size: sym.size(),
123                            };
124                        }
125                    })
126                    .or_insert(FuncExtent {
127                        addr,
128                        size: sym.size(),
129                    });
130            }
131        }
132
133        // --- DWARF line rows --------------------------------------------------
134        let lines = Self::load_line_rows(&object).unwrap_or_default();
135
136        Ok(Self { functions, lines })
137    }
138
139    /// Parse the DWARF line programs into an address-sorted list of rows.
140    fn load_line_rows(object: &object::File<'_>) -> anyhow::Result<Vec<LineRow>> {
141        let endian = if object.is_little_endian() {
142            gimli::RunTimeEndian::Little
143        } else {
144            gimli::RunTimeEndian::Big
145        };
146
147        let load_section = |id: gimli::SectionId| -> Result<Cow<'_, [u8]>, gimli::Error> {
148            match object.section_by_name(id.name()) {
149                Some(section) => Ok(section
150                    .uncompressed_data()
151                    .unwrap_or(Cow::Borrowed(&[][..]))),
152                None => Ok(Cow::Borrowed(&[][..])),
153            }
154        };
155
156        let dwarf_sections = gimli::DwarfSections::load(load_section)?;
157        let dwarf = dwarf_sections.borrow(|section| gimli::EndianSlice::new(section, endian));
158
159        let mut rows: Vec<LineRow> = Vec::new();
160        let mut units = dwarf.units();
161        while let Some(header) = units.next()? {
162            let unit = dwarf.unit(header)?;
163            let Some(program) = unit.line_program.clone() else {
164                continue;
165            };
166            let mut state = program.rows();
167            while let Some((header, row)) = state.next_row()? {
168                if row.end_sequence() {
169                    continue;
170                }
171                let Some(line) = row.line() else {
172                    continue;
173                };
174                let file = row.file(header).and_then(|file_entry| {
175                    dwarf
176                        .attr_string(&unit, file_entry.path_name())
177                        .ok()
178                        .and_then(|r| r.to_string().ok().map(basename))
179                });
180                rows.push(LineRow {
181                    addr: row.address(),
182                    file,
183                    line: line.get() as u32,
184                });
185            }
186        }
187        rows.sort_by_key(|r| r.addr);
188        Ok(rows)
189    }
190
191    /// True when no debug/symbol information was found at all.
192    pub fn is_empty(&self) -> bool {
193        self.functions.is_empty() && self.lines.is_empty()
194    }
195
196    /// The entry address of a named function, if present in the symbol table.
197    pub fn function_addr(&self, name: &str) -> Option<u64> {
198        self.functions.get(name).map(|e| e.addr)
199    }
200
201    /// The lowest address whose line row matches `line` (and `file`, when given),
202    /// optionally constrained to a `(addr, size)` extent (a zero size matches only
203    /// the exact entry address).
204    pub fn line_addr(
205        &self,
206        file: Option<&str>,
207        line: u32,
208        within: Option<(u64, u64)>,
209    ) -> Option<u64> {
210        let file_base = file.map(basename);
211        let extent = within.map(|(addr, size)| FuncExtent { addr, size });
212        self.lines
213            .iter()
214            .filter(|r| r.line == line)
215            .filter(|r| match (&file_base, &r.file) {
216                (Some(want), Some(have)) => want == have,
217                (Some(_), None) => false,
218                (None, _) => true,
219            })
220            .filter(|r| extent.map(|f| f.contains(r.addr)).unwrap_or(true))
221            .map(|r| r.addr)
222            .min()
223    }
224
225    /// Resolve a [`CodeLocation`] to a concrete address, preferring
226    /// function+line (the address of `line` inside `function`), then bare
227    /// function entry, then file:line.
228    pub fn resolve_location(&self, loc: &CodeLocation) -> Option<u64> {
229        match (&loc.function, loc.line) {
230            (Some(func), Some(line)) => {
231                let extent = self.functions.get(func).copied();
232                self.line_addr(loc.file.as_deref(), line, extent.map(|e| (e.addr, e.size)))
233                    // Fall back to the function entry if the exact line row is
234                    // absent (e.g. no DWARF, or inlined line).
235                    .or(extent.map(|e| e.addr))
236            }
237            (Some(func), None) => self.function_addr(func),
238            (None, Some(line)) => self.line_addr(loc.file.as_deref(), line, None),
239            (None, None) => None,
240        }
241    }
242
243    /// Describe an address in source terms (nearest preceding line row and the
244    /// enclosing function), for introspection output.
245    pub fn describe(&self, addr: u64) -> ResolvedLocation {
246        // Enclosing function: the entry whose extent contains `addr`, else the
247        // closest preceding entry.
248        let function = self
249            .functions
250            .iter()
251            .filter(|(_, e)| e.contains(addr) || e.addr <= addr)
252            .max_by_key(|(_, e)| e.addr)
253            .map(|(name, _)| name.clone());
254
255        // Nearest preceding line row.
256        let idx = self.lines.partition_point(|r| r.addr <= addr);
257        let line_row = if idx > 0 {
258            self.lines.get(idx - 1)
259        } else {
260            None
261        };
262
263        ResolvedLocation {
264            function,
265            file: line_row.and_then(|r| r.file.clone()),
266            line: line_row.map(|r| r.line),
267        }
268    }
269}
270
271/// Resolve every anchor in `program` whose position is an unresolved RIP carrying
272/// a code location. Returns the names of anchors that could not be resolved.
273pub fn resolve_program(
274    program: &mut HappensBeforeProgram,
275    resolver: &DebugInfoResolver,
276) -> Vec<String> {
277    let mut unresolved = Vec::new();
278    for (name, anchor) in program.anchors.iter_mut() {
279        if let Position::Rip { addr: None, nth } = &anchor.position {
280            let nth = *nth;
281            if let Some(resolved) = resolver.resolve_location(&anchor.location) {
282                anchor.position = Position::Rip {
283                    addr: Some(resolved),
284                    nth,
285                };
286            } else {
287                unresolved.push(name.clone());
288            }
289        }
290    }
291    unresolved
292}
293
294/// Load a happens-before specification from a JSON file and normalize it into a
295/// [`HappensBeforeProgram`]. Code locations remain unresolved until
296/// [`resolve_program`] runs against a [`DebugInfoResolver`].
297pub fn load_program(path: &Path) -> anyhow::Result<HappensBeforeProgram> {
298    let json = std::fs::read_to_string(path)
299        .with_context(|| format!("reading happens-before spec: {}", path.display()))?;
300    let spec = HappensBeforeSpec::from_json(&json)
301        .with_context(|| format!("parsing happens-before spec: {}", path.display()))?;
302    let program = spec
303        .normalize()
304        .with_context(|| format!("normalizing happens-before spec: {}", path.display()))?;
305    Ok(program)
306}
307
308/// How a run will execute the guest, as far as happens-before enforcement is
309/// concerned ([`refuse_unenforceable_anchors`]).
310#[derive(Debug, Clone, Copy)]
311pub struct HbLaunch<'a> {
312    /// The selected backend's name, for messages.
313    pub backend: &'a str,
314    /// True when the guest runs under the ptrace runtime, where Detcore traces
315    /// the guest from outside and receives the happens-before program: the
316    /// ptrace backend, or e9patch preprocessing followed by ptrace.
317    pub ptrace: bool,
318    /// True for `--namespace-only` (alias `--lite`): no interception and no
319    /// scheduler at all.
320    pub namespace_only: bool,
321    /// True for `--passthru-opt`: only an allow-list of syscalls is intercepted.
322    pub passthru_opt: bool,
323    /// True when the run requests a preemption timer (`--max-timeslice` is not
324    /// `disabled`). A version-2 spec needs one to charge its hold budget.
325    pub preemption: bool,
326    /// True for `--replay-schedule-from`, which replays a recorded schedule
327    /// event by event and has no event for a posthook gate's turn.
328    pub replay_schedule: bool,
329}
330
331/// Refuse, before anything is launched or any input is read, a program the run
332/// cannot enforce as written:
333/// - a syscall anchor for the polling phase (syscall anchors fire at the
334///   prehook of the thread's nth matching call, or when it completes);
335/// - a count or syscall-occurrence anchor when the run bypasses interception
336///   (`--namespace-only`) or runs the guest under a runtime other than ptrace
337///   (in-guest Detcore never receives the program,
338///   <https://github.com/rrnewton/hermit/issues/3943>);
339/// - a syscall-occurrence anchor with `--passthru-opt` (some syscalls are left
340///   unintercepted; a count anchor still counts the intercepted ones);
341/// - a syscall anchor on a syscall that never reaches Detcore
342///   ([`syscall_never_reaches_tracer`]);
343/// - a posthook anchor with `--replay-schedule-from`: its gate adds a
344///   scheduler turn after the syscall's posthook schedule event, and this
345///   version does not claim the composition replays exactly
346///   (<https://github.com/rrnewton/hermit/issues/3929>);
347/// - a version-2 spec with `--max-timeslice disabled`
348///   ([`refuse_hold_without_preemption`]).
349///
350/// Ignoring any of these would let a run pass, or blame the guest, with the
351/// ordering unexercised. Applied on the run path only, so `--hb-list-events`
352/// can still preview such a spec.
353pub fn refuse_unenforceable_anchors(
354    program: &HappensBeforeProgram,
355    path: &Path,
356    launch: HbLaunch<'_>,
357) -> anyhow::Result<()> {
358    for anchor in program.anchors.values() {
359        let refusal = match &anchor.position {
360            Position::Syscall { .. } if !anchor.is_syscall_occurrence() => {
361                "names the polling phase, which is not enforced; syscall anchors fire at the \
362                 prehook or, with \"phase\": \"posthook\", when the syscall completes"
363                    .to_owned()
364            }
365            Position::Syscall { .. } if anchor.fires_at_posthook() && launch.replay_schedule => {
366                "fires at the posthook, which is not supported with --replay-schedule-from: \
367                 its gate adds a scheduler turn the recorded schedule does not have"
368                    .to_owned()
369            }
370            Position::Syscall { sysno, .. } if syscall_never_reaches_tracer(*sysno) => format!(
371                "names {}, which never reaches Hermit's tracer: it runs untraced so the \
372                 signal frame is restored safely",
373                sysno.name()
374            ),
375            Position::Syscall { .. } | Position::SyscallCount(_) => {
376                let kind = if anchor.is_syscall_occurrence() {
377                    "syscall-occurrence anchor"
378                } else {
379                    "count anchor"
380                };
381                if launch.namespace_only {
382                    format!(
383                        "is a {kind}, but --namespace-only runs the guest without interception \
384                         or a scheduler"
385                    )
386                } else if !launch.ptrace {
387                    format!(
388                        "is a {kind}, which is enforced only on the ptrace backend; this run \
389                         selected the {} backend",
390                        launch.backend
391                    )
392                } else if launch.passthru_opt && anchor.is_syscall_occurrence() {
393                    // A count anchor counts the intercepted syscalls, which
394                    // --passthru-opt still determines; an occurrence anchor
395                    // names one syscall, which may be left unintercepted.
396                    format!(
397                        "is a {kind}, which needs every syscall intercepted; --passthru-opt \
398                         leaves some unintercepted"
399                    )
400                } else {
401                    continue;
402                }
403            }
404            _ => continue,
405        };
406        anyhow::bail!(
407            "happens-before spec {}: anchor '{}' ({}) {}",
408            path.display(),
409            anchor.name,
410            anchor.position,
411            refusal
412        );
413    }
414    if !launch.preemption {
415        refuse_hold_without_preemption(
416            program,
417            path,
418            "--max-timeslice disabled turns the preemption timer off",
419        )?;
420    }
421    Ok(())
422}
423
424/// Refuse a version-2 program on a run without a preemption timer, saying
425/// why (`reason`). A version-2 spec bounds every hold by `max_hold_ns`,
426/// charged in committed virtual time; without the timer a thread that spins
427/// without syscalls never ends its turn, committed time stops, and a hold
428/// could never be charged, so the run would hang instead of ending in
429/// `HERMIT_HB_HOLD_BUDGET_EXCEEDED`. Called before launch twice: for an
430/// explicit `--max-timeslice disabled`, and after argument validation, when
431/// the timer can also be lost because the host cannot arm it. A version-1
432/// program has no budget and is never refused here.
433pub fn refuse_hold_without_preemption(
434    program: &HappensBeforeProgram,
435    path: &Path,
436    reason: &str,
437) -> anyhow::Result<()> {
438    if !program.is_version_2() {
439        return Ok(());
440    }
441    anyhow::bail!(
442        "HERMIT_HB_HOLD_WITHOUT_PREEMPTION: happens-before spec {} is version {}, which bounds \
443         every hold by max_hold_ns and needs the preemption timer to charge it, but {}",
444        path.display(),
445        program.version,
446        reason
447    )
448}
449
450/// Render one anchor for `--list-events`-style introspection, showing the
451/// resolved address and source description when available.
452pub fn describe_anchor(anchor: &Anchor, resolver: Option<&DebugInfoResolver>) -> String {
453    let mut out = format!("{}", anchor);
454    if let (Position::Rip { addr: Some(a), .. }, Some(r)) = (&anchor.position, resolver) {
455        let d = r.describe(*a);
456        let where_ = match (&d.function, &d.file, d.line) {
457            (Some(f), _, Some(l)) => format!(" -> {:#x} ({}:{})", a, f, l),
458            (Some(f), _, None) => format!(" -> {:#x} ({})", a, f),
459            _ => format!(" -> {:#x}", a),
460        };
461        out.push_str(&where_);
462    }
463    out
464}
465
466/// The final path component of a possibly-slashed path string.
467fn basename(s: &str) -> String {
468    match s.rsplit_once('/') {
469        Some((_, base)) => base.to_string(),
470        None => s.to_string(),
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use std::process::Command;
477
478    use detcore_model::happens_before::HappensBeforeSpec;
479
480    use super::*;
481
482    /// Compile a tiny -g fixture to a temp path; returns None if no C compiler.
483    /// Each call gets a unique directory so parallel tests never collide.
484    fn compile_fixture() -> Option<std::path::PathBuf> {
485        use std::sync::atomic::AtomicUsize;
486        use std::sync::atomic::Ordering;
487        static COUNTER: AtomicUsize = AtomicUsize::new(0);
488
489        let cc = ["cc", "gcc", "clang"]
490            .into_iter()
491            .find(|c| Command::new(c).arg("--version").output().is_ok())?;
492
493        let uniq = COUNTER.fetch_add(1, Ordering::Relaxed);
494        let dir = std::env::temp_dir().join(format!("hb_fixture_{}_{}", std::process::id(), uniq));
495        std::fs::create_dir_all(&dir).ok()?;
496        let src = dir.join("fixture.c");
497        let bin = dir.join("fixture");
498        std::fs::write(
499            &src,
500            "int free_buffer(int x) { return x + 1; }\n\
501             int read_buffer(int x) { return x * 2; }\n\
502             int main(void) { return free_buffer(read_buffer(3)); }\n",
503        )
504        .ok()?;
505        let status = Command::new(cc)
506            .args(["-g", "-O0", "-o"])
507            .arg(&bin)
508            .arg(&src)
509            .status()
510            .ok()?;
511        if status.success() { Some(bin) } else { None }
512    }
513
514    #[test]
515    fn resolves_function_and_line_from_dwarf() {
516        let Some(bin) = compile_fixture() else {
517            eprintln!("skipping: no C compiler available");
518            return;
519        };
520        let resolver = DebugInfoResolver::open(&bin).unwrap();
521        assert!(!resolver.is_empty(), "expected symbols/lines in -g binary");
522
523        // Function entry resolves to a nonzero address.
524        let free_addr = resolver.function_addr("free_buffer");
525        assert!(free_addr.is_some(), "free_buffer should resolve");
526        assert_ne!(free_addr.unwrap(), 0);
527
528        // free_buffer is defined on line 1; its line row should fall within the
529        // function extent.
530        let loc = CodeLocation {
531            function: Some("free_buffer".to_string()),
532            file: None,
533            line: Some(1),
534        };
535        let addr = resolver.resolve_location(&loc);
536        assert!(addr.is_some(), "func+line should resolve");
537
538        // Reverse description of the entry address names the function.
539        let d = resolver.describe(free_addr.unwrap());
540        assert_eq!(d.function.as_deref(), Some("free_buffer"));
541
542        let _ = std::fs::remove_dir_all(bin.parent().unwrap());
543    }
544
545    #[test]
546    fn resolve_program_fills_rips() {
547        let Some(bin) = compile_fixture() else {
548            eprintln!("skipping: no C compiler available");
549            return;
550        };
551        let resolver = DebugInfoResolver::open(&bin).unwrap();
552
553        let json = r#"{
554          "version": 1,
555          "threads": { "t": {"label": "t"} },
556          "events": {
557            "A": {"thread": "t", "func": "free_buffer"},
558            "B": {"thread": "t", "func": "read_buffer", "line": 2}
559          },
560          "edges": [ {"before": "A", "after": "B"} ]
561        }"#;
562        let mut prog = HappensBeforeSpec::from_json(json)
563            .unwrap()
564            .normalize()
565            .unwrap();
566        assert_eq!(prog.unresolved_locations().count(), 2);
567
568        let missing = resolve_program(&mut prog, &resolver);
569        assert!(
570            missing.is_empty(),
571            "all locations should resolve: {:?}",
572            missing
573        );
574        assert_eq!(prog.unresolved_locations().count(), 0);
575
576        for anchor in prog.anchors.values() {
577            match &anchor.position {
578                Position::Rip { addr: Some(a), .. } => assert_ne!(*a, 0),
579                other => panic!("expected resolved RIP, got {:?}", other),
580            }
581        }
582
583        let _ = std::fs::remove_dir_all(bin.parent().unwrap());
584    }
585
586    #[test]
587    fn missing_function_reported() {
588        let Some(bin) = compile_fixture() else {
589            eprintln!("skipping: no C compiler available");
590            return;
591        };
592        let resolver = DebugInfoResolver::open(&bin).unwrap();
593        let json = r#"{
594          "version": 1,
595          "events": { "A": {"thread": "1", "func": "no_such_function"} },
596          "edges": []
597        }"#;
598        let mut prog = HappensBeforeSpec::from_json(json)
599            .unwrap()
600            .normalize()
601            .unwrap();
602        let missing = resolve_program(&mut prog, &resolver);
603        assert_eq!(missing, vec!["A".to_string()]);
604
605        let _ = std::fs::remove_dir_all(bin.parent().unwrap());
606    }
607
608    /// Every launch-time refusal names its anchor, and a spec the run can
609    /// enforce passes: the polling phase, an unobservable syscall
610    /// (rt_sigreturn), namespace-only, a non-ptrace backend, --passthru-opt,
611    /// and a posthook anchor with --replay-schedule-from.
612    /// `load_program` itself (shared with `--hb-list-events`) still loads all of
613    /// these for preview.
614    #[test]
615    fn launch_refuses_every_anchor_it_cannot_enforce() {
616        let dir = tempfile::tempdir().unwrap();
617        let spec = dir.path().join("spec.json");
618        let ptrace = HbLaunch {
619            backend: "Ptrace",
620            ptrace: true,
621            namespace_only: false,
622            passthru_opt: false,
623            preemption: true,
624            replay_schedule: false,
625        };
626        let check = |json: &str, launch: HbLaunch<'_>| -> Result<(), String> {
627            std::fs::write(&spec, json).unwrap();
628            let program = load_program(&spec).expect("every spec here loads for preview");
629            refuse_unenforceable_anchors(&program, &spec, launch).map_err(|e| e.to_string())
630        };
631        let fd_anchor = r#"{"version": 1,
632            "events": {"w": {"thread": "3", "syscall": "write", "fd": 1}}}"#;
633        let err = check(
634            r#"{"version": 1,
635                "events": {"closed": {"thread": "3", "syscall": "close", "fd": 4, "phase": "polling"}}}"#,
636            ptrace,
637        )
638        .unwrap_err();
639        assert!(
640            err.contains("anchor 'closed'")
641                && err.contains("names the polling phase, which is not enforced"),
642            "{err}"
643        );
644        let err = check(
645            r#"{"version": 1, "events": {"ret": {"thread": "3", "syscall": "rt_sigreturn"}}}"#,
646            ptrace,
647        )
648        .unwrap_err();
649        assert!(
650            err.contains("anchor 'ret'") && err.contains("never reaches Hermit's tracer"),
651            "{err}"
652        );
653        let err = check(
654            fd_anchor,
655            HbLaunch {
656                namespace_only: true,
657                ..ptrace
658            },
659        )
660        .unwrap_err();
661        assert!(
662            err.contains("anchor 'w'") && err.contains("--namespace-only"),
663            "{err}"
664        );
665        let err = check(
666            fd_anchor,
667            HbLaunch {
668                backend: "Kvm",
669                ptrace: false,
670                ..ptrace
671            },
672        )
673        .unwrap_err();
674        assert!(
675            err.contains("anchor 'w'") && err.contains("the Kvm backend"),
676            "{err}"
677        );
678        let err = check(
679            fd_anchor,
680            HbLaunch {
681                passthru_opt: true,
682                ..ptrace
683            },
684        )
685        .unwrap_err();
686        assert!(
687            err.contains("anchor 'w'") && err.contains("--passthru-opt"),
688            "{err}"
689        );
690        assert!(check(fd_anchor, ptrace).is_ok());
691        // A posthook anchor is enforced, except with --replay-schedule-from,
692        // whose recorded schedule has no event for its gate's turn
693        // (https://github.com/rrnewton/hermit/issues/3929). A prehook anchor
694        // is still enforced there.
695        let posthook = r#"{"version": 1,
696            "events": {"p": {"thread": "3", "syscall": "write", "fd": 1, "phase": "posthook"}}}"#;
697        assert!(check(posthook, ptrace).is_ok());
698        let replay = HbLaunch {
699            replay_schedule: true,
700            ..ptrace
701        };
702        let err = check(posthook, replay).unwrap_err();
703        assert!(
704            err.contains("anchor 'p'") && err.contains("not supported with --replay-schedule-from"),
705            "{err}"
706        );
707        assert!(check(fd_anchor, replay).is_ok());
708        assert!(
709            check(
710                r#"{"version": 1,
711                    "events": {"closed": {"thread": "3", "syscall": "close", "fd": 4, "phase": "pre"}}}"#,
712                ptrace
713            )
714            .is_ok()
715        );
716        // A count anchor cannot be enforced without interception or off
717        // ptrace either (https://github.com/rrnewton/hermit/issues/3943), but
718        // --passthru-opt still counts the intercepted syscalls it names.
719        let count_only = r#"{"version": 1, "events": {"c": {"thread": "3", "syscalls": 5}}}"#;
720        let err = check(
721            count_only,
722            HbLaunch {
723                namespace_only: true,
724                ..ptrace
725            },
726        )
727        .unwrap_err();
728        assert!(
729            err.contains("anchor 'c' (after 5 syscalls) is a count anchor, but --namespace-only"),
730            "{err}"
731        );
732        let err = check(
733            count_only,
734            HbLaunch {
735                backend: "Sabre",
736                ptrace: false,
737                ..ptrace
738            },
739        )
740        .unwrap_err();
741        assert!(
742            err.contains("is a count anchor, which is enforced only on the ptrace backend")
743                && err.contains("the Sabre backend"),
744            "{err}"
745        );
746        assert!(
747            check(
748                count_only,
749                HbLaunch {
750                    passthru_opt: true,
751                    ..ptrace
752                }
753            )
754            .is_ok()
755        );
756        assert!(check(count_only, ptrace).is_ok());
757
758        // A version-2 spec needs the preemption timer for its hold budget; a
759        // version-1 spec has no budget and runs without it, as before.
760        let v2 = r#"{"version": 2, "events": {"w": {"thread": "3", "syscall": "write", "fd": 1}}}"#;
761        let no_timer = HbLaunch {
762            preemption: false,
763            ..ptrace
764        };
765        let err = check(v2, no_timer).unwrap_err();
766        assert!(
767            err.starts_with("HERMIT_HB_HOLD_WITHOUT_PREEMPTION: happens-before spec ")
768                && err.contains("is version 2")
769                && err.ends_with("but --max-timeslice disabled turns the preemption timer off"),
770            "{err}"
771        );
772        assert!(check(v2, ptrace).is_ok());
773        assert!(check(fd_anchor, no_timer).is_ok());
774    }
775}