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::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 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 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 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 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 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 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 == ¬e.id));
692 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 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 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 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 [(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 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 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}