1use 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::ops::ControlFlow;
103 use std::task::Poll;
104 use std::vec::Vec;
105
106 const MAGIC: &[u8] = b"{RUST_PROBE_MAGIC}\n";
107 const CRATE_KEY: &str = "{crate_key}";
108
109 // NOTHING ON THE PROBE PATH MAY ALLOCATE.
110 //
111 // The crate under test may install a `#[global_allocator]` whose body -- or
112 // anything it calls, at any depth -- carries probes. An allocating probe
113 // then re-enters the allocator, which probes, which allocates. bytes-1.12.1
114 // does this in tests/test_bytes_odd_alloc.rs and tests/test_bytes_vec_alloc.rs,
115 // and both died with SIGSEGV before libtest could list a single test.
116 //
117 // A reentrancy flag cannot fix this, and the attempt is instructive: on
118 // macOS the FIRST touch of a thread-local calls `_tlv_bootstrap`, which
119 // allocates -- so the guard recursed inside its own initialisation, before
120 // it could be consulted. A guard that must allocate to answer "am I already
121 // allocating?" is unfixable. Not allocating at all is.
122 //
123 // Records are therefore built in a stack buffer and written with one call.
124 // That also removes a malloc and a free from every probe, which is where
125 // most of a probe's cost used to be.
126 const RECORD_CAPACITY: usize = 256;
127
128 /// The widest condition vector a decision can carry.
129 ///
130 /// Beyond this the frame refuses to record rather than emit a vector whose
131 /// width disagrees with the manifest -- a malformed record the runner
132 /// rejects, which is a wrong number rather than a missing one.
133 const MAX_CONDITIONS: usize = 64;
134
135 // A statement or function hit answers "did this ever run", so only the FIRST
136 // sighting in a process carries information -- and each libtest case runs in
137 // its own process, so first-in-process is first-in-test. Without this, a loop
138 // writes one identical record per iteration: bytes'
139 // advance_bytes_mut_remaining_capacity runs ~2.8M iterations and was still
140 // writing syscalls after four minutes.
141 //
142 // The table is a fixed, open-addressed set of `&'static str` POINTERS, so it
143 // never allocates and never grows -- both of which the probe path forbids.
144 // A crowded table simply writes the record again: a duplicate costs time,
145 // never correctness, whereas dropping one would cost a real observation.
146 const SEEN_SLOTS: usize = 1 << 16;
147 static SEEN: [AtomicUsize; SEEN_SLOTS] = [const {{ AtomicUsize::new(0) }}; SEEN_SLOTS];
148
149 fn first_sighting(id: &'static str) -> bool {{
150 let key = id.as_ptr() as usize;
151 let mut slot = (key >> 4) & (SEEN_SLOTS - 1);
152 for _ in 0..8 {{
153 match SEEN[slot].compare_exchange(0, key, Ordering::Relaxed, Ordering::Relaxed) {{
154 Ok(_) => return true,
155 Err(seen) if seen == key => return false,
156 Err(_) => slot = (slot + 1) & (SEEN_SLOTS - 1),
157 }}
158 }}
159 true
160 }}
161
162 // Decisions cannot collapse by id the way hits do: MC/DC needs the SET of
163 // distinct condition vectors, so every vector must reach the log once. What
164 // carries nothing is the REPEAT of a vector already seen, and in a loop that
165 // is nearly all of them -- bytes' advance_bytes_mut_remaining_capacity took
166 // 40.6s against a 0.367s baseline writing one syscall per evaluation across
167 // ~2.8M iterations.
168 //
169 // Entries hold the whole record -- id pointer, outcome, width, values -- and
170 // are compared byte for byte. A hash would be smaller and faster, but a
171 // collision would silently drop a distinct vector and understate MC/DC, and
172 // that is exactly the kind of wrong number this project refuses to risk.
173 // A full probe chain falls back to writing, which costs a duplicate.
174 const DECISION_SLOTS: usize = 1 << 11;
175 const DECISION_ENTRY: usize = 10 + MAX_CONDITIONS;
176
177 struct DecisionTable {{
178 entries: [[u8; DECISION_ENTRY]; DECISION_SLOTS],
179 }}
180
181 impl DecisionTable {{
182 const fn new() -> Self {{
183 Self {{ entries: [[0; DECISION_ENTRY]; DECISION_SLOTS] }}
184 }}
185 }}
186
187 // `Mutex::new` is const, so the table is a genuine static with no lazy
188 // allocation of its own. Its first lock still boxes a platform mutex, which
189 // `writer()` forces during startup.
190 static DECISIONS: Mutex<DecisionTable> = Mutex::new(DecisionTable::new());
191
192 fn first_decision(frame: &DecisionFrame, outcome: bool) -> bool {{
193 let key = frame.id.as_ptr() as usize;
194 let mut entry = [0u8; DECISION_ENTRY];
195 entry[..8].copy_from_slice(&(key as u64).to_le_bytes());
196 // Non-zero for an occupied slot, so an all-zero entry means empty.
197 entry[8] = if outcome {{ 2 }} else {{ 1 }};
198 entry[9] = frame.conditions as u8;
199 entry[10..10 + frame.conditions].copy_from_slice(&frame.values[..frame.conditions]);
200 let Ok(mut table) = DECISIONS.lock() else {{
201 return true;
202 }};
203 let mut slot = (key >> 4) & (DECISION_SLOTS - 1);
204 for _ in 0..16 {{
205 if table.entries[slot] == entry {{
206 return false;
207 }}
208 if table.entries[slot][8] == 0 {{
209 table.entries[slot] = entry;
210 return true;
211 }}
212 slot = (slot + 1) & (DECISION_SLOTS - 1);
213 }}
214 true
215 }}
216
217 fn writer() -> Option<&'static Mutex<File>> {{
218 static WRITER: OnceLock<Option<Mutex<File>>> = OnceLock::new();
219 static OPENING: AtomicBool = AtomicBool::new(false);
220 if let Some(writer) = WRITER.get() {{
221 return writer.as_ref();
222 }}
223 // Opening the file is the one step that must allocate: an environment
224 // lookup, a path, a formatted file name. That allocation re-enters an
225 // instrumented allocator, whose probe arrives back here while the
226 // OnceLock is still unset. Declining for the duration of the open costs
227 // a few observations at startup and makes the recursion impossible.
228 if OPENING.swap(true, Ordering::SeqCst) {{
229 return None;
230 }}
231 let opened = WRITER.get_or_init(|| {{
232 let directory = std::env::var_os("SUPERCOV_RUST_EVIDENCE_DIR")?;
233 let directory = std::path::PathBuf::from(directory);
234 std::fs::create_dir_all(&directory).ok()?;
235 let path =
236 directory.join(std::format!("{{CRATE_KEY}}-{{}}.events", std::process::id()));
237 let empty = std::fs::metadata(&path).map_or(true, |metadata| metadata.len() == 0);
238 let mut file = OpenOptions::new().create(true).append(true).open(path).ok()?;
239 if empty {{
240 file.write_all(MAGIC).ok()?;
241 }}
242 let guarded = Mutex::new(file);
243 // `std::sync::Mutex` boxes a platform mutex on its FIRST lock, and
244 // that allocation would otherwise land on the probe path and
245 // re-enter the host allocator. Force it here, where `OPENING`
246 // already makes re-entry harmless.
247 drop(guarded.lock());
248 // Same reason: the decision table's mutex boxes on first lock.
249 drop(DECISIONS.lock());
250 Some(guarded)
251 }});
252 OPENING.store(false, Ordering::SeqCst);
253 opened.as_ref()
254 }}
255
256 fn write_record(record: &[u8]) {{
257 let Some(writer) = writer() else {{ return }};
258 let Ok(mut writer) = writer.lock() else {{ return }};
259 let _ = writer.write_all(record);
260 }}
261
262 /// Append to a stack record, reporting whether it all fit.
263 fn push(record: &mut [u8; RECORD_CAPACITY], length: &mut usize, bytes: &[u8]) -> bool {{
264 let Some(slice) = record.get_mut(*length..*length + bytes.len()) else {{
265 return false;
266 }};
267 slice.copy_from_slice(bytes);
268 *length += bytes.len();
269 true
270 }}
271
272 pub struct DecisionFrame {{
273 id: &'static str,
274 values: [u8; MAX_CONDITIONS],
275 /// Let-chain conditions the evaluation got to: a `let` cannot be
276 /// wrapped, so it is marked reached instead and resolved later.
277 reached: [bool; MAX_CONDITIONS],
278 conditions: usize,
279 recordable: bool,
280 }}
281
282 impl DecisionFrame {{
283 pub fn new(id: &'static str, conditions: usize) -> Self {{
284 Self {{
285 id,
286 values: [0; MAX_CONDITIONS],
287 reached: [false; MAX_CONDITIONS],
288 conditions,
289 recordable: conditions <= MAX_CONDITIONS,
290 }}
291 }}
292 }}
293
294 /// A let chain got to condition `index` (0 marks the chain evaluated at
295 /// all). Always true, so it sits in the chain as an operand.
296 #[inline]
297 pub fn reached(frame: &mut DecisionFrame, index: usize) -> bool {{
298 if let Some(slot) = frame.reached.get_mut(index) {{
299 *slot = true;
300 }}
301 true
302 }}
303
304 /// A let chain decided. A chain tries its conditions in order and stops
305 /// at the first that fails, so every reached `let` before the last
306 /// reached condition held, the last one held when the chain was taken and
307 /// failed when it was not, and conditions never reached stay unevaluated.
308 /// `operators` lists the `&&` whose left side holds a `let`, by the index
309 /// of their right side's first condition: reached means the operator
310 /// evaluated its right side, otherwise it short-circuited. The frame then
311 /// resets for the next evaluation, which a `while let` makes every turn.
312 pub fn decision_chain(
313 frame: &mut DecisionFrame,
314 outcome: bool,
315 operators: &[(usize, &'static str, &'static str)],
316 ) {{
317 if !frame.reached[0] {{
318 return;
319 }}
320 let conditions = frame.conditions.min(MAX_CONDITIONS);
321 let mut last = 0;
322 for index in 0..conditions {{
323 if frame.reached[index] || frame.values[index] != 0 {{
324 last = index;
325 }}
326 }}
327 for index in 0..conditions {{
328 if frame.values[index] == 0 && frame.reached[index] {{
329 frame.values[index] = if index < last || outcome {{ 2 }} else {{ 1 }};
330 }}
331 }}
332 for (first, short_circuit, evaluated) in operators {{
333 let got_there = frame
334 .reached
335 .get(*first)
336 .copied()
337 .unwrap_or(false)
338 || frame.values.get(*first).is_some_and(|value| *value != 0);
339 hit(if got_there {{ evaluated }} else {{ short_circuit }});
340 }}
341 decision(outcome, frame);
342 frame.values = [0; MAX_CONDITIONS];
343 frame.reached = [false; MAX_CONDITIONS];
344 }}
345
346 #[inline]
347 pub fn hit(id: &'static str) {{
348 if !first_sighting(id) {{
349 return;
350 }}
351 let mut record = [0u8; RECORD_CAPACITY];
352 let mut length = 0;
353 if push(&mut record, &mut length, b"H\t")
354 && push(&mut record, &mut length, id.as_bytes())
355 && push(&mut record, &mut length, b"\n")
356 {{
357 write_record(&record[..length]);
358 }}
359 }}
360
361 /// One arm of a match was selected. `ids` holds each arm's `not selected`
362 /// and `selected` IDs in source order, so every arm before `selected` was
363 /// considered and passed over. Each ID is a distinct static string, which
364 /// is what `hit` dedupes on.
365 #[inline]
366 pub fn arms(ids: &[&'static str], selected: usize) {{
367 for arm in 0..selected {{
368 if let Some(id) = ids.get(arm * 2) {{
369 hit(id);
370 }}
371 }}
372 if let Some(id) = ids.get(selected * 2 + 1) {{
373 hit(id);
374 }}
375 }}
376
377 /// The left operand of `&&` or `||`: it short-circuits when it equals
378 /// `short_circuits_when`, otherwise the right operand is about to run.
379 #[inline]
380 pub fn logical(
381 left: bool,
382 short_circuits_when: bool,
383 short_circuit: &'static str,
384 evaluated: &'static str,
385 ) -> bool {{
386 hit(if left == short_circuits_when {{ short_circuit }} else {{ evaluated }});
387 left
388 }}
389
390 /// A `for` loop's iterator, recording on the first `next` whether the
391 /// body ran at all. `size_hint` passes through so collection sizing is
392 /// unchanged; nothing else about the iterator is observable to the loop.
393 pub struct ForLoop<I> {{
394 inner: I,
395 first: bool,
396 zero: &'static str,
397 entered: &'static str,
398 }}
399
400 impl<I: Iterator> Iterator for ForLoop<I> {{
401 type Item = I::Item;
402
403 #[inline]
404 fn next(&mut self) -> Option<I::Item> {{
405 let item = self.inner.next();
406 if self.first {{
407 self.first = false;
408 hit(if item.is_some() {{ self.entered }} else {{ self.zero }});
409 }}
410 item
411 }}
412
413 #[inline]
414 fn size_hint(&self) -> (usize, Option<usize>) {{
415 self.inner.size_hint()
416 }}
417 }}
418
419 #[inline]
420 pub fn for_loop<I: IntoIterator>(
421 iterable: I,
422 zero: &'static str,
423 entered: &'static str,
424 ) -> ForLoop<I::IntoIter> {{
425 ForLoop {{ inner: iterable.into_iter(), first: true, zero, entered }}
426 }}
427
428 /// A `while` body ran: clear the loop's flag on the first entry.
429 #[inline]
430 pub fn entered(first: &mut bool, id: &'static str) {{
431 if *first {{
432 *first = false;
433 hit(id);
434 }}
435 }}
436
437 /// A `while` loop is over: a flag still set means the body never ran.
438 #[inline]
439 pub fn zero_iterations(first: bool, id: &'static str) {{
440 if first {{
441 hit(id);
442 }}
443 }}
444
445 /// The operand of `?`, recording which way the operator goes. Every type
446 /// `?` accepts on stable Rust implements this.
447 pub trait TryProbe: Sized {{
448 fn probe(self, continued: &'static str, returned: &'static str) -> Self;
449 }}
450
451 impl<T> TryProbe for Option<T> {{
452 #[inline]
453 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
454 hit(if self.is_some() {{ continued }} else {{ returned }});
455 self
456 }}
457 }}
458
459 impl<T, E> TryProbe for Result<T, E> {{
460 #[inline]
461 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
462 hit(if self.is_ok() {{ continued }} else {{ returned }});
463 self
464 }}
465 }}
466
467 impl<B, C> TryProbe for ControlFlow<B, C> {{
468 #[inline]
469 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
470 hit(if matches!(self, ControlFlow::Continue(_)) {{ continued }} else {{ returned }});
471 self
472 }}
473 }}
474
475 impl<T, E> TryProbe for Poll<Result<T, E>> {{
476 #[inline]
477 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
478 hit(if matches!(self, Poll::Ready(Err(_))) {{ returned }} else {{ continued }});
479 self
480 }}
481 }}
482
483 impl<T, E> TryProbe for Poll<Option<Result<T, E>>> {{
484 #[inline]
485 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
486 hit(if matches!(self, Poll::Ready(Some(Err(_)))) {{ returned }} else {{ continued }});
487 self
488 }}
489 }}
490
491 #[inline]
492 pub fn condition(value: bool, frame: &mut DecisionFrame, index: usize) -> bool {{
493 if index < frame.conditions {{
494 if let Some(slot) = frame.values.get_mut(index) {{
495 *slot = if value {{ 2 }} else {{ 1 }};
496 }}
497 }}
498 value
499 }}
500
501 #[inline]
502 pub fn decision(value: bool, frame: &mut DecisionFrame) -> bool {{
503 // `writer()` must come FIRST: it forces the decision table's mutex to box
504 // its platform mutex while `OPENING` still makes re-entry harmless.
505 // Deduplicating before that put the very first lock -- and its
506 // allocation -- on the probe path, which recursed straight back through
507 // an instrumented allocator. The allocator gate caught it.
508 if !frame.recordable || writer().is_none() || !first_decision(frame, value) {{
509 return value;
510 }}
511 let mut record = [0u8; RECORD_CAPACITY];
512 let mut length = 0;
513 let mut fits = push(&mut record, &mut length, b"D\t")
514 && push(&mut record, &mut length, frame.id.as_bytes())
515 && push(&mut record, &mut length, b"\t");
516 for index in 0..frame.conditions {{
517 fits = fits && push(&mut record, &mut length, &[b'0' + frame.values[index]]);
518 }}
519 fits = fits
520 && push(&mut record, &mut length, b"\t")
521 && push(&mut record, &mut length, if value {{ b"1" }} else {{ b"0" }})
522 && push(&mut record, &mut length, b"\n");
523 if fits {{
524 write_record(&record[..length]);
525 }}
526 value
527 }}
528}}
529"#
530 ))
531}
532
533pub fn parse_rust_probe_events(
534 input: &[u8],
535) -> Result<Vec<RustProbeObservation>, RustProbeReadError> {
536 let text = std::str::from_utf8(input).map_err(|_| RustProbeReadError::InvalidHeader)?;
537 let mut lines = text.lines();
538 if lines.next() != Some(RUST_PROBE_MAGIC) {
539 return Err(RustProbeReadError::InvalidHeader);
540 }
541 let mut observations = Vec::new();
542 for (index, line) in lines.enumerate() {
543 let line_number = index + 2;
544 let fields = line.split('\t').collect::<Vec<_>>();
545 match fields.as_slice() {
546 ["H", id] if valid_probe_id(id) => {
547 observations.push(RustProbeObservation::Hit { id: (*id).into() })
548 }
549 ["D", id, digits, outcome]
550 if valid_probe_id(id)
551 && id.starts_with("rs:decision:")
552 && !digits.is_empty()
553 && digits
554 .bytes()
555 .all(|digit| matches!(digit, b'0' | b'1' | b'2'))
556 && matches!(*outcome, "0" | "1") =>
557 {
558 observations.push(RustProbeObservation::Decision {
559 id: (*id).into(),
560 values: digits
561 .bytes()
562 .map(|digit| match digit {
563 b'0' => None,
564 b'1' => Some(false),
565 b'2' => Some(true),
566 _ => unreachable!(),
567 })
568 .collect(),
569 outcome: *outcome == "1",
570 });
571 }
572 _ => return Err(RustProbeReadError::InvalidRecord(line_number)),
573 }
574 }
575 Ok(observations)
576}
577
578pub fn read_rust_probe_directory(
579 directory: &Path,
580) -> Result<BTreeMap<String, Vec<RustProbeObservation>>, RustProbeReadError> {
581 let mut files = fs::read_dir(directory)
582 .map_err(|error| RustProbeReadError::Io(error.to_string()))?
583 .collect::<Result<Vec<_>, _>>()
584 .map_err(|error| RustProbeReadError::Io(error.to_string()))?;
585 files.sort_by_key(|entry| entry.file_name());
586 let mut observations = BTreeMap::new();
587 for entry in files {
588 let name = entry
589 .file_name()
590 .into_string()
591 .map_err(|_| RustProbeReadError::UnsafeEntry("<non-utf8>".into()))?;
592 if Path::new(&name)
593 .components()
594 .any(|component| !matches!(component, Component::Normal(_)))
595 || !name.ends_with(".events")
596 {
597 return Err(RustProbeReadError::UnsafeEntry(name));
598 }
599 let metadata = fs::symlink_metadata(entry.path())
600 .map_err(|error| RustProbeReadError::Io(error.to_string()))?;
601 if !metadata.file_type().is_file() {
602 return Err(RustProbeReadError::UnsafeEntry(name));
603 }
604 let contents =
605 fs::read(entry.path()).map_err(|error| RustProbeReadError::Io(error.to_string()))?;
606 observations.insert(name, parse_rust_probe_events(&contents)?);
607 }
608 Ok(observations)
609}
610
611#[cfg(test)]
612mod tests {
613 use std::{
614 fs,
615 process::Command,
616 time::{SystemTime, UNIX_EPOCH},
617 };
618
619 use super::*;
620 use crate::rust_instrumenter::instrument_rust_source;
621
622 fn temporary_directory(name: &str) -> std::path::PathBuf {
623 let nonce = SystemTime::now()
624 .duration_since(UNIX_EPOCH)
625 .unwrap()
626 .as_nanos();
627 let path = std::env::temp_dir().join(format!(
628 "supercov-rust-runtime-{}-{nonce}-{name}",
629 std::process::id()
630 ));
631 fs::create_dir(&path).unwrap();
632 path
633 }
634
635 #[test]
636 fn generated_runtime_records_owned_points_and_exact_short_circuit_vectors() {
637 let source = r#"fn choose(first: bool, second: bool) -> i32 {
638 if first && second { 7 } else { 3 }
639}
640
641fn main() {
642 println!("{} {}", choose(false, true), choose(true, true));
643}
644"#;
645 let transformed =
646 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
647 let runtime =
648 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
649 let directory = temporary_directory("record");
650 let input = directory.join("main.rs");
651 let binary = directory.join("program");
652 let evidence = directory.join("evidence");
653 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
654 let compile = Command::new("rustc")
655 .arg("--edition=2024")
656 .arg(&input)
657 .arg("-o")
658 .arg(&binary)
659 .output()
660 .unwrap();
661 assert!(
662 compile.status.success(),
663 "{}",
664 String::from_utf8_lossy(&compile.stderr)
665 );
666 let output = Command::new(&binary)
667 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
668 .output()
669 .unwrap();
670 assert!(output.status.success());
671 assert_eq!(output.stdout, b"3 7\n");
672 let files = read_rust_probe_directory(&evidence).unwrap();
673 assert_eq!(files.len(), 1);
674 let observations = files.values().next().unwrap();
675 let decisions = observations
676 .iter()
677 .filter_map(|observation| match observation {
678 RustProbeObservation::Decision {
679 values, outcome, ..
680 } => Some((values.clone(), *outcome)),
681 RustProbeObservation::Hit { .. } => None,
682 })
683 .collect::<Vec<_>>();
684 assert_eq!(
685 decisions,
686 [
687 (vec![Some(false), None], false),
688 (vec![Some(true), Some(true)], true)
689 ]
690 );
691 fs::remove_dir_all(directory).unwrap();
692 }
693
694 #[test]
695 fn generated_runtime_compiles_into_a_no_std_host_crate() {
696 let source = r#"#![no_std]
703
704extern crate std;
705
706fn choose(first: bool, second: bool) -> i32 {
707 if first && second { 7 } else { 3 }
708}
709
710fn main() {
711 std::println!("{} {}", choose(false, true), choose(true, true));
712}
713"#;
714 let transformed =
715 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
716 let runtime =
717 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
718 let directory = temporary_directory("no-std");
719 let input = directory.join("main.rs");
720 let binary = directory.join("program");
721 let evidence = directory.join("evidence");
722 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
723 let compile = Command::new("rustc")
724 .arg("--edition=2024")
725 .arg(&input)
726 .arg("-o")
727 .arg(&binary)
728 .output()
729 .unwrap();
730 assert!(
731 compile.status.success(),
732 "{}",
733 String::from_utf8_lossy(&compile.stderr)
734 );
735 let output = Command::new(&binary)
736 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
737 .output()
738 .unwrap();
739 assert!(output.status.success());
740 assert_eq!(output.stdout, b"3 7\n");
741 let files = read_rust_probe_directory(&evidence).unwrap();
743 assert_eq!(files.len(), 1);
744 assert!(!files.values().next().unwrap().is_empty());
745 fs::remove_dir_all(directory).unwrap();
746 }
747
748 #[test]
749 fn probes_reached_through_a_global_allocator_do_not_recurse() {
750 let source = r#"use std::alloc::{GlobalAlloc, Layout, System};
758use std::sync::atomic::{AtomicUsize, Ordering};
759
760static SEEN: AtomicUsize = AtomicUsize::new(0);
761
762fn note(size: usize) {
763 if size > 0 {
764 SEEN.fetch_add(1, Ordering::SeqCst);
765 }
766}
767
768struct Ledger;
769
770impl Ledger {
771 fn record(&self, size: usize) {
772 note(size);
773 }
774}
775
776unsafe impl GlobalAlloc for Ledger {
777 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
778 self.record(layout.size());
779 System.alloc(layout)
780 }
781
782 unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
783 // dealloc must be instrumented too, or the test never exercises the
784 // ladder that actually crashed: freeing the probe's OWN buffer re-enters
785 // here, and a guard released before that free recurses without bound.
786 self.record(layout.size());
787 System.dealloc(pointer, layout);
788 }
789}
790
791#[global_allocator]
792static LEDGER: Ledger = Ledger;
793
794fn classify(flag: bool) -> usize {
795 if flag { 1 } else { 2 }
796}
797
798fn main() {
799 let held = std::vec![7u8; 32];
800 println!("{} {}", classify(!held.is_empty()), held.len());
801}
802"#;
803 let transformed =
804 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
805 assert!(
808 transformed
809 .code
810 .contains("fn note(size: usize) {\ncrate::__supercov_runtime_v1::hit("),
811 "the free function reached from the allocator should still be probed"
812 );
813 let runtime =
814 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
815 let directory = temporary_directory("allocator-reentry");
816 let input = directory.join("main.rs");
817 let binary = directory.join("program");
818 let evidence = directory.join("evidence");
819 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
820 let compile = Command::new("rustc")
821 .arg("--edition=2024")
822 .arg(&input)
823 .arg("-o")
824 .arg(&binary)
825 .output()
826 .unwrap();
827 assert!(
828 compile.status.success(),
829 "{}",
830 String::from_utf8_lossy(&compile.stderr)
831 );
832 let output = Command::new(&binary)
833 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
834 .output()
835 .unwrap();
836 assert!(
837 output.status.success(),
838 "instrumented allocator did not survive: {:?}",
839 output.status
840 );
841 assert_eq!(output.stdout, b"1 32\n");
842 let files = read_rust_probe_directory(&evidence).unwrap();
843 let observations = files.values().next().unwrap();
844 let decisions = observations
845 .iter()
846 .filter_map(|observation| match observation {
847 RustProbeObservation::Decision {
848 id,
849 values,
850 outcome,
851 } => Some((id.clone(), values.clone(), *outcome)),
852 RustProbeObservation::Hit { .. } => None,
853 })
854 .collect::<Vec<_>>();
855 let classify = transformed
858 .manifest
859 .decisions
860 .iter()
861 .find(|decision| decision.source == "flag")
862 .expect("classify's decision reached the manifest");
863 assert!(
864 decisions
865 .iter()
866 .any(|(id, values, outcome)| id == &classify.id
867 && values == &[Some(true)]
868 && *outcome),
869 "classify's decision was lost: {decisions:?}"
870 );
871 let note = transformed
874 .manifest
875 .decisions
876 .iter()
877 .find(|decision| decision.source == "size > 0")
878 .expect("note's decision reached the manifest");
879 assert!(decisions.iter().any(|(id, ..)| id == ¬e.id));
880 assert!(
883 decisions.iter().all(|(_, values, _)| values.len() == 1),
884 "a suppressed frame emitted a malformed vector: {decisions:?}"
885 );
886 fs::remove_dir_all(directory).unwrap();
887 }
888
889 #[test]
890 fn a_hit_in_a_loop_is_written_once_but_decisions_keep_every_vector() {
891 let source = r#"fn step(value: usize) -> bool {
897 let doubled = value * 2;
898 doubled > 4
899}
900
901fn main() {
902 let mut seen = 0;
903 for value in 0..64 {
904 if step(value) { seen += 1; }
905 }
906 println!("{seen}");
907}
908"#;
909 let transformed =
910 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
911 let runtime =
912 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
913 let directory = temporary_directory("dedup");
914 let input = directory.join("main.rs");
915 let binary = directory.join("program");
916 let evidence = directory.join("evidence");
917 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
918 let compile = Command::new("rustc")
919 .arg("--edition=2024")
920 .arg(&input)
921 .arg("-o")
922 .arg(&binary)
923 .output()
924 .unwrap();
925 assert!(
926 compile.status.success(),
927 "{}",
928 String::from_utf8_lossy(&compile.stderr)
929 );
930 let output = Command::new(&binary)
931 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
932 .output()
933 .unwrap();
934 assert_eq!(output.stdout, b"61\n");
935 let files = read_rust_probe_directory(&evidence).unwrap();
936 let observations = files.values().next().unwrap();
937
938 let mut hits = BTreeMap::<&str, usize>::new();
940 for observation in observations {
941 if let RustProbeObservation::Hit { id } = observation {
942 *hits.entry(id.as_str()).or_default() += 1;
943 }
944 }
945 assert!(!hits.is_empty(), "no hits recorded at all");
946 assert!(
947 hits.values().all(|count| *count == 1),
948 "a repeated hit was written more than once: {hits:?}"
949 );
950
951 let decisions = observations
956 .iter()
957 .filter_map(|observation| match observation {
958 RustProbeObservation::Decision {
959 values, outcome, ..
960 } => Some((values.clone(), *outcome)),
961 RustProbeObservation::Hit { .. } => None,
962 })
963 .collect::<Vec<_>>();
964 assert_eq!(
965 decisions,
966 [(vec![Some(false)], false), (vec![Some(true)], true)],
969 "both distinct vectors must survive, and neither may repeat"
970 );
971 fs::remove_dir_all(directory).unwrap();
972 }
973
974 #[test]
975 fn generated_runtime_survives_a_deny_warnings_host() {
976 let source = r#"#![deny(warnings)]
981
982fn choose(first: bool, second: bool) -> i32 {
983 if first && second { 7 } else { 3 }
984}
985
986fn main() {
987 println!("{} {}", choose(false, true), choose(true, true));
988}
989"#;
990 let transformed =
991 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
992 let runtime =
993 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
994 let directory = temporary_directory("deny-warnings");
995 let input = directory.join("main.rs");
996 let binary = directory.join("program");
997 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
998 let compile = Command::new("rustc")
1002 .arg("--edition=2024")
1003 .arg("--cap-lints=warn")
1004 .arg(&input)
1005 .arg("-o")
1006 .arg(&binary)
1007 .output()
1008 .unwrap();
1009 assert!(
1010 compile.status.success(),
1011 "{}",
1012 String::from_utf8_lossy(&compile.stderr)
1013 );
1014 let output = Command::new(&binary).output().unwrap();
1015 assert_eq!(output.stdout, b"3 7\n");
1016 fs::remove_dir_all(directory).unwrap();
1017 }
1018
1019 #[test]
1020 fn reader_rejects_truncation_invalid_digits_and_non_files() {
1021 assert_eq!(
1022 parse_rust_probe_events(
1023 b"SUPERCOV-RUST-PROBE-1\nD\trs:decision:0123456789abcdef01234567\t03\t1\n"
1024 ),
1025 Err(RustProbeReadError::InvalidRecord(2))
1026 );
1027 assert_eq!(
1028 parse_rust_probe_events(b"SUPERCOV-RUST-PROBE-"),
1029 Err(RustProbeReadError::InvalidHeader)
1030 );
1031
1032 let directory = temporary_directory("unsafe");
1033 fs::create_dir(directory.join("nested.events")).unwrap();
1034 assert!(matches!(
1035 read_rust_probe_directory(&directory),
1036 Err(RustProbeReadError::UnsafeEntry(_))
1037 ));
1038 fs::remove_dir_all(directory).unwrap();
1039 }
1040}