Skip to main content

supercov_engine/
rust_runtime.rs

1//! Generated std-only runtime and strict reader for owned Rust probes.
2//!
3//! The target runtime writes an intentionally small append-only transport.
4//! It never computes coverage. Rust reads, validates, de-duplicates and maps
5//! these observations into the shared evidence-v3 model after test execution.
6
7use serde::{Deserialize, Serialize};
8use std::{
9    collections::BTreeMap,
10    fs,
11    path::{Component, Path},
12};
13
14const RUST_PROBE_MAGIC: &str = "SUPERCOV-RUST-PROBE-1";
15
16#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
17#[serde(rename_all = "camelCase", tag = "kind")]
18pub enum RustProbeObservation {
19    Hit {
20        id: String,
21    },
22    Decision {
23        id: String,
24        values: Vec<Option<bool>>,
25        outcome: bool,
26    },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum RustProbeReadError {
31    Io(String),
32    UnsafeEntry(String),
33    InvalidHeader,
34    InvalidRecord(usize),
35}
36
37impl std::fmt::Display for RustProbeReadError {
38    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Io(error) => write!(formatter, "Rust probe I/O failed: {error}"),
41            Self::UnsafeEntry(path) => write!(formatter, "unsafe Rust probe entry: {path}"),
42            Self::InvalidHeader => write!(formatter, "invalid Rust probe header"),
43            Self::InvalidRecord(line) => {
44                write!(formatter, "invalid Rust probe record at line {line}")
45            }
46        }
47    }
48}
49
50impl std::error::Error for RustProbeReadError {}
51
52pub(crate) fn valid_probe_id(id: &str) -> bool {
53    let mut parts = id.split(':');
54    matches!(parts.next(), Some("rs"))
55        && matches!(
56            parts.next(),
57            Some("statement" | "function" | "decision" | "branch")
58        )
59        && parts.next().is_some_and(|digest| {
60            digest.len() == 24 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
61        })
62        && parts.all(|suffix| {
63            !suffix.is_empty()
64                && suffix
65                    .bytes()
66                    .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
67        })
68}
69
70pub fn render_rust_runtime(module_name: &str, crate_key: &str) -> Result<String, String> {
71    let valid_identifier = !module_name.is_empty()
72        && module_name.bytes().enumerate().all(|(index, byte)| {
73            byte == b'_' || byte.is_ascii_alphabetic() || (index > 0 && byte.is_ascii_digit())
74        });
75    if !valid_identifier {
76        return Err("invalid Rust runtime module name".into());
77    }
78    if crate_key.len() != 24 || !crate_key.bytes().all(|byte| byte.is_ascii_hexdigit()) {
79        return Err("invalid Rust runtime crate key".into());
80    }
81
82    Ok(format!(
83        r#"
84#[doc(hidden)]
85// Injected code must be immune to the HOST crate's lint configuration: serde
86// builds with `#![deny(warnings)]`, so this module's fully-qualified imports
87// (required for no_std hosts) became hard errors as "unused imports".
88#[allow(warnings)]
89mod {module_name} {{
90    // The host crate may be `#![no_std]` -- `bytes` is, and so is much of the
91    // ecosystem's foundation. Nothing here can rely on the std prelude being in
92    // scope, so std is brought in explicitly and every prelude item below is
93    // written out in full. Without this the module does not compile and the
94    // whole build fails, which is a hard failure rather than a degradation.
95    extern crate std;
96    use std::fs::{{File, OpenOptions}};
97    use std::io::Write as _;
98    use std::option::Option::{{self, None, Some}};
99    use std::string::String;
100    use std::sync::atomic::{{AtomicBool, AtomicUsize, Ordering}};
101    use std::sync::{{Mutex, OnceLock}};
102    use std::vec::Vec;
103
104    const MAGIC: &[u8] = b"{RUST_PROBE_MAGIC}\n";
105    const CRATE_KEY: &str = "{crate_key}";
106
107    // NOTHING ON THE PROBE PATH MAY ALLOCATE.
108    //
109    // The crate under test may install a `#[global_allocator]` whose body -- or
110    // anything it calls, at any depth -- carries probes. An allocating probe
111    // then re-enters the allocator, which probes, which allocates. bytes-1.12.1
112    // does this in tests/test_bytes_odd_alloc.rs and tests/test_bytes_vec_alloc.rs,
113    // and both died with SIGSEGV before libtest could list a single test.
114    //
115    // A reentrancy flag cannot fix this, and the attempt is instructive: on
116    // macOS the FIRST touch of a thread-local calls `_tlv_bootstrap`, which
117    // allocates -- so the guard recursed inside its own initialisation, before
118    // it could be consulted. A guard that must allocate to answer "am I already
119    // allocating?" is unfixable. Not allocating at all is.
120    //
121    // Records are therefore built in a stack buffer and written with one call.
122    // That also removes a malloc and a free from every probe, which is where
123    // most of a probe's cost used to be.
124    const RECORD_CAPACITY: usize = 256;
125
126    /// The widest condition vector a decision can carry.
127    ///
128    /// Beyond this the frame refuses to record rather than emit a vector whose
129    /// width disagrees with the manifest -- a malformed record the runner
130    /// rejects, which is a wrong number rather than a missing one.
131    const MAX_CONDITIONS: usize = 64;
132
133    // A statement or function hit answers "did this ever run", so only the FIRST
134    // sighting in a process carries information -- and each libtest case runs in
135    // its own process, so first-in-process is first-in-test. Without this, a loop
136    // writes one identical record per iteration: bytes'
137    // advance_bytes_mut_remaining_capacity runs ~2.8M iterations and was still
138    // writing syscalls after four minutes.
139    //
140    // The table is a fixed, open-addressed set of `&'static str` POINTERS, so it
141    // never allocates and never grows -- both of which the probe path forbids.
142    // A crowded table simply writes the record again: a duplicate costs time,
143    // never correctness, whereas dropping one would cost a real observation.
144    const SEEN_SLOTS: usize = 1 << 16;
145    static SEEN: [AtomicUsize; SEEN_SLOTS] = [const {{ AtomicUsize::new(0) }}; SEEN_SLOTS];
146
147    fn first_sighting(id: &'static str) -> bool {{
148        let key = id.as_ptr() as usize;
149        let mut slot = (key >> 4) & (SEEN_SLOTS - 1);
150        for _ in 0..8 {{
151            match SEEN[slot].compare_exchange(0, key, Ordering::Relaxed, Ordering::Relaxed) {{
152                Ok(_) => return true,
153                Err(seen) if seen == key => return false,
154                Err(_) => slot = (slot + 1) & (SEEN_SLOTS - 1),
155            }}
156        }}
157        true
158    }}
159
160    // Decisions cannot collapse by id the way hits do: MC/DC needs the SET of
161    // distinct condition vectors, so every vector must reach the log once. What
162    // carries nothing is the REPEAT of a vector already seen, and in a loop that
163    // is nearly all of them -- bytes' advance_bytes_mut_remaining_capacity took
164    // 40.6s against a 0.367s baseline writing one syscall per evaluation across
165    // ~2.8M iterations.
166    //
167    // Entries hold the whole record -- id pointer, outcome, width, values -- and
168    // are compared byte for byte. A hash would be smaller and faster, but a
169    // collision would silently drop a distinct vector and understate MC/DC, and
170    // that is exactly the kind of wrong number this project refuses to risk.
171    // A full probe chain falls back to writing, which costs a duplicate.
172    const DECISION_SLOTS: usize = 1 << 11;
173    const DECISION_ENTRY: usize = 10 + MAX_CONDITIONS;
174
175    struct DecisionTable {{
176        entries: [[u8; DECISION_ENTRY]; DECISION_SLOTS],
177    }}
178
179    impl DecisionTable {{
180        const fn new() -> Self {{
181            Self {{ entries: [[0; DECISION_ENTRY]; DECISION_SLOTS] }}
182        }}
183    }}
184
185    // `Mutex::new` is const, so the table is a genuine static with no lazy
186    // allocation of its own. Its first lock still boxes a platform mutex, which
187    // `writer()` forces during startup.
188    static DECISIONS: Mutex<DecisionTable> = Mutex::new(DecisionTable::new());
189
190    fn first_decision(frame: &DecisionFrame, outcome: bool) -> bool {{
191        let key = frame.id.as_ptr() as usize;
192        let mut entry = [0u8; DECISION_ENTRY];
193        entry[..8].copy_from_slice(&(key as u64).to_le_bytes());
194        // Non-zero for an occupied slot, so an all-zero entry means empty.
195        entry[8] = if outcome {{ 2 }} else {{ 1 }};
196        entry[9] = frame.conditions as u8;
197        entry[10..10 + frame.conditions].copy_from_slice(&frame.values[..frame.conditions]);
198        let Ok(mut table) = DECISIONS.lock() else {{
199            return true;
200        }};
201        let mut slot = (key >> 4) & (DECISION_SLOTS - 1);
202        for _ in 0..16 {{
203            if table.entries[slot] == entry {{
204                return false;
205            }}
206            if table.entries[slot][8] == 0 {{
207                table.entries[slot] = entry;
208                return true;
209            }}
210            slot = (slot + 1) & (DECISION_SLOTS - 1);
211        }}
212        true
213    }}
214
215    fn writer() -> Option<&'static Mutex<File>> {{
216        static WRITER: OnceLock<Option<Mutex<File>>> = OnceLock::new();
217        static OPENING: AtomicBool = AtomicBool::new(false);
218        if let Some(writer) = WRITER.get() {{
219            return writer.as_ref();
220        }}
221        // Opening the file is the one step that must allocate: an environment
222        // lookup, a path, a formatted file name. That allocation re-enters an
223        // instrumented allocator, whose probe arrives back here while the
224        // OnceLock is still unset. Declining for the duration of the open costs
225        // a few observations at startup and makes the recursion impossible.
226        if OPENING.swap(true, Ordering::SeqCst) {{
227            return None;
228        }}
229        let opened = WRITER.get_or_init(|| {{
230            let directory = std::env::var_os("SUPERCOV_RUST_EVIDENCE_DIR")?;
231            let directory = std::path::PathBuf::from(directory);
232            std::fs::create_dir_all(&directory).ok()?;
233            let path =
234                directory.join(std::format!("{{CRATE_KEY}}-{{}}.events", std::process::id()));
235            let empty = std::fs::metadata(&path).map_or(true, |metadata| metadata.len() == 0);
236            let mut file = OpenOptions::new().create(true).append(true).open(path).ok()?;
237            if empty {{
238                file.write_all(MAGIC).ok()?;
239            }}
240            let guarded = Mutex::new(file);
241            // `std::sync::Mutex` boxes a platform mutex on its FIRST lock, and
242            // that allocation would otherwise land on the probe path and
243            // re-enter the host allocator. Force it here, where `OPENING`
244            // already makes re-entry harmless.
245            drop(guarded.lock());
246            // Same reason: the decision table's mutex boxes on first lock.
247            drop(DECISIONS.lock());
248            Some(guarded)
249        }});
250        OPENING.store(false, Ordering::SeqCst);
251        opened.as_ref()
252    }}
253
254    fn write_record(record: &[u8]) {{
255        let Some(writer) = writer() else {{ return }};
256        let Ok(mut writer) = writer.lock() else {{ return }};
257        let _ = writer.write_all(record);
258    }}
259
260    /// Append to a stack record, reporting whether it all fit.
261    fn push(record: &mut [u8; RECORD_CAPACITY], length: &mut usize, bytes: &[u8]) -> bool {{
262        let Some(slice) = record.get_mut(*length..*length + bytes.len()) else {{
263            return false;
264        }};
265        slice.copy_from_slice(bytes);
266        *length += bytes.len();
267        true
268    }}
269
270    pub struct DecisionFrame {{
271        id: &'static str,
272        values: [u8; MAX_CONDITIONS],
273        conditions: usize,
274        recordable: bool,
275    }}
276
277    impl DecisionFrame {{
278        pub fn new(id: &'static str, conditions: usize) -> Self {{
279            Self {{
280                id,
281                values: [0; MAX_CONDITIONS],
282                conditions,
283                recordable: conditions <= MAX_CONDITIONS,
284            }}
285        }}
286    }}
287
288    #[inline]
289    pub fn hit(id: &'static str) {{
290        if !first_sighting(id) {{
291            return;
292        }}
293        let mut record = [0u8; RECORD_CAPACITY];
294        let mut length = 0;
295        if push(&mut record, &mut length, b"H\t")
296            && push(&mut record, &mut length, id.as_bytes())
297            && push(&mut record, &mut length, b"\n")
298        {{
299            write_record(&record[..length]);
300        }}
301    }}
302
303    #[inline]
304    pub fn condition(value: bool, frame: &mut DecisionFrame, index: usize) -> bool {{
305        if index < frame.conditions {{
306            if let Some(slot) = frame.values.get_mut(index) {{
307                *slot = if value {{ 2 }} else {{ 1 }};
308            }}
309        }}
310        value
311    }}
312
313    #[inline]
314    pub fn decision(value: bool, frame: &mut DecisionFrame) -> bool {{
315        // `writer()` must come FIRST: it forces the decision table's mutex to box
316        // its platform mutex while `OPENING` still makes re-entry harmless.
317        // Deduplicating before that put the very first lock -- and its
318        // allocation -- on the probe path, which recursed straight back through
319        // an instrumented allocator. The allocator gate caught it.
320        if !frame.recordable || writer().is_none() || !first_decision(frame, value) {{
321            return value;
322        }}
323        let mut record = [0u8; RECORD_CAPACITY];
324        let mut length = 0;
325        let mut fits = push(&mut record, &mut length, b"D\t")
326            && push(&mut record, &mut length, frame.id.as_bytes())
327            && push(&mut record, &mut length, b"\t");
328        for index in 0..frame.conditions {{
329            fits = fits && push(&mut record, &mut length, &[b'0' + frame.values[index]]);
330        }}
331        fits = fits
332            && push(&mut record, &mut length, b"\t")
333            && push(&mut record, &mut length, if value {{ b"1" }} else {{ b"0" }})
334            && push(&mut record, &mut length, b"\n");
335        if fits {{
336            write_record(&record[..length]);
337        }}
338        value
339    }}
340}}
341"#
342    ))
343}
344
345pub fn parse_rust_probe_events(
346    input: &[u8],
347) -> Result<Vec<RustProbeObservation>, RustProbeReadError> {
348    let text = std::str::from_utf8(input).map_err(|_| RustProbeReadError::InvalidHeader)?;
349    let mut lines = text.lines();
350    if lines.next() != Some(RUST_PROBE_MAGIC) {
351        return Err(RustProbeReadError::InvalidHeader);
352    }
353    let mut observations = Vec::new();
354    for (index, line) in lines.enumerate() {
355        let line_number = index + 2;
356        let fields = line.split('\t').collect::<Vec<_>>();
357        match fields.as_slice() {
358            ["H", id] if valid_probe_id(id) => {
359                observations.push(RustProbeObservation::Hit { id: (*id).into() })
360            }
361            ["D", id, digits, outcome]
362                if valid_probe_id(id)
363                    && id.starts_with("rs:decision:")
364                    && !digits.is_empty()
365                    && digits
366                        .bytes()
367                        .all(|digit| matches!(digit, b'0' | b'1' | b'2'))
368                    && matches!(*outcome, "0" | "1") =>
369            {
370                observations.push(RustProbeObservation::Decision {
371                    id: (*id).into(),
372                    values: digits
373                        .bytes()
374                        .map(|digit| match digit {
375                            b'0' => None,
376                            b'1' => Some(false),
377                            b'2' => Some(true),
378                            _ => unreachable!(),
379                        })
380                        .collect(),
381                    outcome: *outcome == "1",
382                });
383            }
384            _ => return Err(RustProbeReadError::InvalidRecord(line_number)),
385        }
386    }
387    Ok(observations)
388}
389
390pub fn read_rust_probe_directory(
391    directory: &Path,
392) -> Result<BTreeMap<String, Vec<RustProbeObservation>>, RustProbeReadError> {
393    let mut files = fs::read_dir(directory)
394        .map_err(|error| RustProbeReadError::Io(error.to_string()))?
395        .collect::<Result<Vec<_>, _>>()
396        .map_err(|error| RustProbeReadError::Io(error.to_string()))?;
397    files.sort_by_key(|entry| entry.file_name());
398    let mut observations = BTreeMap::new();
399    for entry in files {
400        let name = entry
401            .file_name()
402            .into_string()
403            .map_err(|_| RustProbeReadError::UnsafeEntry("<non-utf8>".into()))?;
404        if Path::new(&name)
405            .components()
406            .any(|component| !matches!(component, Component::Normal(_)))
407            || !name.ends_with(".events")
408        {
409            return Err(RustProbeReadError::UnsafeEntry(name));
410        }
411        let metadata = fs::symlink_metadata(entry.path())
412            .map_err(|error| RustProbeReadError::Io(error.to_string()))?;
413        if !metadata.file_type().is_file() {
414            return Err(RustProbeReadError::UnsafeEntry(name));
415        }
416        let contents =
417            fs::read(entry.path()).map_err(|error| RustProbeReadError::Io(error.to_string()))?;
418        observations.insert(name, parse_rust_probe_events(&contents)?);
419    }
420    Ok(observations)
421}
422
423#[cfg(test)]
424mod tests {
425    use std::{
426        fs,
427        process::Command,
428        time::{SystemTime, UNIX_EPOCH},
429    };
430
431    use super::*;
432    use crate::rust_instrumenter::instrument_rust_source;
433
434    fn temporary_directory(name: &str) -> std::path::PathBuf {
435        let nonce = SystemTime::now()
436            .duration_since(UNIX_EPOCH)
437            .unwrap()
438            .as_nanos();
439        let path = std::env::temp_dir().join(format!(
440            "supercov-rust-runtime-{}-{nonce}-{name}",
441            std::process::id()
442        ));
443        fs::create_dir(&path).unwrap();
444        path
445    }
446
447    #[test]
448    fn generated_runtime_records_owned_points_and_exact_short_circuit_vectors() {
449        let source = r#"fn choose(first: bool, second: bool) -> i32 {
450    if first && second { 7 } else { 3 }
451}
452
453fn main() {
454    println!("{} {}", choose(false, true), choose(true, true));
455}
456"#;
457        let transformed =
458            instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
459        let runtime =
460            render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
461        let directory = temporary_directory("record");
462        let input = directory.join("main.rs");
463        let binary = directory.join("program");
464        let evidence = directory.join("evidence");
465        fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
466        let compile = Command::new("rustc")
467            .arg("--edition=2024")
468            .arg(&input)
469            .arg("-o")
470            .arg(&binary)
471            .output()
472            .unwrap();
473        assert!(
474            compile.status.success(),
475            "{}",
476            String::from_utf8_lossy(&compile.stderr)
477        );
478        let output = Command::new(&binary)
479            .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
480            .output()
481            .unwrap();
482        assert!(output.status.success());
483        assert_eq!(output.stdout, b"3 7\n");
484        let files = read_rust_probe_directory(&evidence).unwrap();
485        assert_eq!(files.len(), 1);
486        let observations = files.values().next().unwrap();
487        let decisions = observations
488            .iter()
489            .filter_map(|observation| match observation {
490                RustProbeObservation::Decision {
491                    values, outcome, ..
492                } => Some((values.clone(), *outcome)),
493                RustProbeObservation::Hit { .. } => None,
494            })
495            .collect::<Vec<_>>();
496        assert_eq!(
497            decisions,
498            [
499                (vec![Some(false), None], false),
500                (vec![Some(true), Some(true)], true)
501            ]
502        );
503        fs::remove_dir_all(directory).unwrap();
504    }
505
506    #[test]
507    fn generated_runtime_compiles_into_a_no_std_host_crate() {
508        // `#![no_std]` swaps the std prelude for core's, so `format!`, `vec!`
509        // and `Vec` are simply not in scope. The injected module named them
510        // unqualified and every no_std crate failed to build -- found on
511        // bytes-1.12.1, which is `#![no_std]` (src/lib.rs:6). `extern crate std`
512        // here mirrors what the injected module does: it links std without
513        // restoring the prelude, which is precisely the condition under test.
514        let source = r#"#![no_std]
515
516extern crate std;
517
518fn choose(first: bool, second: bool) -> i32 {
519    if first && second { 7 } else { 3 }
520}
521
522fn main() {
523    std::println!("{} {}", choose(false, true), choose(true, true));
524}
525"#;
526        let transformed =
527            instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
528        let runtime =
529            render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
530        let directory = temporary_directory("no-std");
531        let input = directory.join("main.rs");
532        let binary = directory.join("program");
533        let evidence = directory.join("evidence");
534        fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
535        let compile = Command::new("rustc")
536            .arg("--edition=2024")
537            .arg(&input)
538            .arg("-o")
539            .arg(&binary)
540            .output()
541            .unwrap();
542        assert!(
543            compile.status.success(),
544            "{}",
545            String::from_utf8_lossy(&compile.stderr)
546        );
547        let output = Command::new(&binary)
548            .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
549            .output()
550            .unwrap();
551        assert!(output.status.success());
552        assert_eq!(output.stdout, b"3 7\n");
553        // Probes must still record, not merely compile.
554        let files = read_rust_probe_directory(&evidence).unwrap();
555        assert_eq!(files.len(), 1);
556        assert!(!files.values().next().unwrap().is_empty());
557        fs::remove_dir_all(directory).unwrap();
558    }
559
560    #[test]
561    fn probes_reached_through_a_global_allocator_do_not_recurse() {
562        // The shape from bytes-1.12.1 tests/test_bytes_vec_alloc.rs: the
563        // allocator's `alloc` calls an inherent method, which calls a FREE
564        // FUNCTION. Skipping `impl GlobalAlloc` blocks syntactically does not
565        // cover `note`, and nothing syntactic can -- the chain may leave the
566        // file or the crate. Only the runtime knows a probe is already running,
567        // so the guard has to live there. Without it this binary dies with
568        // SIGSEGV instead of printing anything.
569        let source = r#"use std::alloc::{GlobalAlloc, Layout, System};
570use std::sync::atomic::{AtomicUsize, Ordering};
571
572static SEEN: AtomicUsize = AtomicUsize::new(0);
573
574fn note(size: usize) {
575    if size > 0 {
576        SEEN.fetch_add(1, Ordering::SeqCst);
577    }
578}
579
580struct Ledger;
581
582impl Ledger {
583    fn record(&self, size: usize) {
584        note(size);
585    }
586}
587
588unsafe impl GlobalAlloc for Ledger {
589    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
590        self.record(layout.size());
591        System.alloc(layout)
592    }
593
594    unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
595        // dealloc must be instrumented too, or the test never exercises the
596        // ladder that actually crashed: freeing the probe's OWN buffer re-enters
597        // here, and a guard released before that free recurses without bound.
598        self.record(layout.size());
599        System.dealloc(pointer, layout);
600    }
601}
602
603#[global_allocator]
604static LEDGER: Ledger = Ledger;
605
606fn classify(flag: bool) -> usize {
607    if flag { 1 } else { 2 }
608}
609
610fn main() {
611    let held = std::vec![7u8; 32];
612    println!("{} {}", classify(!held.is_empty()), held.len());
613}
614"#;
615        let transformed =
616            instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
617        // `note` is a free function, so it IS instrumented -- proving the guard,
618        // not a syntactic skip, is what prevents the recursion.
619        assert!(
620            transformed
621                .code
622                .contains("fn note(size: usize) {\ncrate::__supercov_runtime_v1::hit("),
623            "the free function reached from the allocator should still be probed"
624        );
625        let runtime =
626            render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
627        let directory = temporary_directory("allocator-reentry");
628        let input = directory.join("main.rs");
629        let binary = directory.join("program");
630        let evidence = directory.join("evidence");
631        fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
632        let compile = Command::new("rustc")
633            .arg("--edition=2024")
634            .arg(&input)
635            .arg("-o")
636            .arg(&binary)
637            .output()
638            .unwrap();
639        assert!(
640            compile.status.success(),
641            "{}",
642            String::from_utf8_lossy(&compile.stderr)
643        );
644        let output = Command::new(&binary)
645            .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
646            .output()
647            .unwrap();
648        assert!(
649            output.status.success(),
650            "instrumented allocator did not survive: {:?}",
651            output.status
652        );
653        assert_eq!(output.stdout, b"1 32\n");
654        let files = read_rust_probe_directory(&evidence).unwrap();
655        let observations = files.values().next().unwrap();
656        let decisions = observations
657            .iter()
658            .filter_map(|observation| match observation {
659                RustProbeObservation::Decision {
660                    id,
661                    values,
662                    outcome,
663                } => Some((id.clone(), values.clone(), *outcome)),
664                RustProbeObservation::Hit { .. } => None,
665            })
666            .collect::<Vec<_>>();
667        // Suppression costs only duplicates: `classify` runs outside any probe,
668        // so its decision is still recorded exactly.
669        let classify = transformed
670            .manifest
671            .decisions
672            .iter()
673            .find(|decision| decision.source == "flag")
674            .expect("classify's decision reached the manifest");
675        assert!(
676            decisions
677                .iter()
678                .any(|(id, values, outcome)| id == &classify.id
679                    && values == &[Some(true)]
680                    && *outcome),
681            "classify's decision was lost: {decisions:?}"
682        );
683        // `note` records too -- every ordinary allocation reaches it outside a
684        // probe -- which is why suppressing the nested ones loses nothing.
685        let note = transformed
686            .manifest
687            .decisions
688            .iter()
689            .find(|decision| decision.source == "size > 0")
690            .expect("note's decision reached the manifest");
691        assert!(decisions.iter().any(|(id, ..)| id == &note.id));
692        // No frame built while nested may reach the log: a zero-width vector
693        // for a one-condition decision is a malformed record, not a lost one.
694        assert!(
695            decisions.iter().all(|(_, values, _)| values.len() == 1),
696            "a suppressed frame emitted a malformed vector: {decisions:?}"
697        );
698        fs::remove_dir_all(directory).unwrap();
699    }
700
701    #[test]
702    fn a_hit_in_a_loop_is_written_once_but_decisions_keep_every_vector() {
703        // bytes' advance_bytes_mut_remaining_capacity is a triple-nested loop of
704        // ~2.8M iterations. One write syscall per probe per iteration left it
705        // still running after four minutes. A hit only answers "did this ever
706        // run", so the repeats carry nothing -- but a decision's condition
707        // vector differs per iteration and every distinct one must survive.
708        let source = r#"fn step(value: usize) -> bool {
709    let doubled = value * 2;
710    doubled > 4
711}
712
713fn main() {
714    let mut seen = 0;
715    for value in 0..64 {
716        if step(value) { seen += 1; }
717    }
718    println!("{seen}");
719}
720"#;
721        let transformed =
722            instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
723        let runtime =
724            render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
725        let directory = temporary_directory("dedup");
726        let input = directory.join("main.rs");
727        let binary = directory.join("program");
728        let evidence = directory.join("evidence");
729        fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
730        let compile = Command::new("rustc")
731            .arg("--edition=2024")
732            .arg(&input)
733            .arg("-o")
734            .arg(&binary)
735            .output()
736            .unwrap();
737        assert!(
738            compile.status.success(),
739            "{}",
740            String::from_utf8_lossy(&compile.stderr)
741        );
742        let output = Command::new(&binary)
743            .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
744            .output()
745            .unwrap();
746        assert_eq!(output.stdout, b"61\n");
747        let files = read_rust_probe_directory(&evidence).unwrap();
748        let observations = files.values().next().unwrap();
749
750        // `doubled > 4` runs 64 times; its hit is recorded once.
751        let mut hits = BTreeMap::<&str, usize>::new();
752        for observation in observations {
753            if let RustProbeObservation::Hit { id } = observation {
754                *hits.entry(id.as_str()).or_default() += 1;
755            }
756        }
757        assert!(!hits.is_empty(), "no hits recorded at all");
758        assert!(
759            hits.values().all(|count| *count == 1),
760            "a repeated hit was written more than once: {hits:?}"
761        );
762
763        // MC/DC needs the SET of condition vectors, not how often each recurred,
764        // so a repeat of an already-seen vector carries nothing -- but every
765        // DISTINCT vector must still arrive. `doubled > 4` is evaluated 64 times
766        // and takes exactly two distinct shapes, so exactly two records survive.
767        let decisions = observations
768            .iter()
769            .filter_map(|observation| match observation {
770                RustProbeObservation::Decision {
771                    values, outcome, ..
772                } => Some((values.clone(), *outcome)),
773                RustProbeObservation::Hit { .. } => None,
774            })
775            .collect::<Vec<_>>();
776        assert_eq!(
777            decisions,
778            // In first-occurrence order: `step(0)` is false before any value
779            // exceeds the threshold.
780            [(vec![Some(false)], false), (vec![Some(true)], true)],
781            "both distinct vectors must survive, and neither may repeat"
782        );
783        fs::remove_dir_all(directory).unwrap();
784    }
785
786    #[test]
787    fn generated_runtime_survives_a_deny_warnings_host() {
788        // serde builds with `#![deny(warnings)]`; the injected module's
789        // fully-qualified imports (required for no_std hosts) read as unused
790        // imports and became hard errors. Injected code must be immune to the
791        // host's lint policy.
792        let source = r#"#![deny(warnings)]
793
794fn choose(first: bool, second: bool) -> i32 {
795    if first && second { 7 } else { 3 }
796}
797
798fn main() {
799    println!("{} {}", choose(false, true), choose(true, true));
800}
801"#;
802        let transformed =
803            instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
804        let runtime =
805            render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
806        let directory = temporary_directory("deny-warnings");
807        let input = directory.join("main.rs");
808        let binary = directory.join("program");
809        fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
810        // --cap-lints=warn mirrors what the runner passes for the instrumented
811        // workspace: the host policy must not reject generated code, including
812        // the `if ({{ frame ... }})` decision wrapping that trips unused_parens.
813        let compile = Command::new("rustc")
814            .arg("--edition=2024")
815            .arg("--cap-lints=warn")
816            .arg(&input)
817            .arg("-o")
818            .arg(&binary)
819            .output()
820            .unwrap();
821        assert!(
822            compile.status.success(),
823            "{}",
824            String::from_utf8_lossy(&compile.stderr)
825        );
826        let output = Command::new(&binary).output().unwrap();
827        assert_eq!(output.stdout, b"3 7\n");
828        fs::remove_dir_all(directory).unwrap();
829    }
830
831    #[test]
832    fn reader_rejects_truncation_invalid_digits_and_non_files() {
833        assert_eq!(
834            parse_rust_probe_events(
835                b"SUPERCOV-RUST-PROBE-1\nD\trs:decision:0123456789abcdef01234567\t03\t1\n"
836            ),
837            Err(RustProbeReadError::InvalidRecord(2))
838        );
839        assert_eq!(
840            parse_rust_probe_events(b"SUPERCOV-RUST-PROBE-"),
841            Err(RustProbeReadError::InvalidHeader)
842        );
843
844        let directory = temporary_directory("unsafe");
845        fs::create_dir(directory.join("nested.events")).unwrap();
846        assert!(matches!(
847            read_rust_probe_directory(&directory),
848            Err(RustProbeReadError::UnsafeEntry(_))
849        ));
850        fs::remove_dir_all(directory).unwrap();
851    }
852}