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, AtomicU8, AtomicU64, 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 #[inline(always)]
150 fn first_sighting(id: &'static str) -> bool {{
151 let key = id.as_ptr() as usize;
152 let mut slot = (key >> 4) & (SEEN_SLOTS - 1);
153 for _ in 0..8 {{
154 // Almost every call is a repeat, and a plain load settles it; the
155 // CAS is for the one insertion.
156 let seen = SEEN[slot].load(Ordering::Relaxed);
157 if seen == key {{
158 return false;
159 }}
160 if seen == 0 {{
161 match SEEN[slot].compare_exchange(0, key, Ordering::Relaxed, Ordering::Relaxed) {{
162 Ok(_) => return true,
163 Err(now) if now == key => return false,
164 Err(_) => {{}}
165 }}
166 }}
167 slot = (slot + 1) & (SEEN_SLOTS - 1);
168 }}
169 true
170 }}
171
172 // Decisions cannot collapse by id the way hits do: MC/DC needs the SET of
173 // distinct condition vectors, so every vector must reach the log once. What
174 // carries nothing is the REPEAT of a vector already seen, and in a loop that
175 // is nearly all of them -- bytes' advance_bytes_mut_remaining_capacity took
176 // 40.6s against a 0.367s baseline writing one syscall per evaluation across
177 // ~2.8M iterations.
178 //
179 // Entries hold the whole record -- id pointer, outcome, width, values -- and
180 // are compared word for word. A hash would be smaller and faster, but a
181 // collision would silently drop a distinct vector and understate MC/DC, and
182 // that is exactly the kind of wrong number this project refuses to risk.
183 // A full probe chain falls back to writing, which costs a duplicate.
184 //
185 // The table is lock-free: a decision in a hot loop is evaluated millions
186 // of times, and a mutex per evaluation was most of what a probe cost. A
187 // slot's state goes 0 (empty) -> 1 (being written) -> 2 (readable); a
188 // reader compares only readable slots, so a slot mid-write reads as
189 // "different" and at worst costs one duplicate record.
190 const DECISION_SLOTS: usize = 1 << 11;
191 const DECISION_ENTRY: usize = 10 + MAX_CONDITIONS;
192 const DECISION_WORDS: usize = DECISION_ENTRY.div_ceil(8);
193
194 struct DecisionSlot {{
195 state: AtomicU8,
196 words: [AtomicU64; DECISION_WORDS],
197 }}
198
199 static DECISIONS: [DecisionSlot; DECISION_SLOTS] = [const {{
200 DecisionSlot {{
201 state: AtomicU8::new(0),
202 words: [const {{ AtomicU64::new(0) }}; DECISION_WORDS],
203 }}
204 }}; DECISION_SLOTS];
205
206 // This module is compiled in the crate's own profile -- unoptimized under
207 // `cargo test` -- so the paths below are plain indexed loops: iterator
208 // chains and closures cost real function calls there, and a decision in a
209 // hot loop pays them millions of times.
210 fn first_decision(frame: &DecisionFrame, outcome: bool) -> bool {{
211 let key = frame.id.as_ptr() as usize;
212 let conditions = if frame.conditions < MAX_CONDITIONS {{ frame.conditions }} else {{ MAX_CONDITIONS }};
213 // Word 0 is the id pointer; word 1 starts with outcome and width, then
214 // the condition values fill the remaining bytes. Entries of the same
215 // width are zero beyond `used` words, and the width byte sits in word
216 // 1, so comparing `used` words is exact.
217 let mut words = [0u64; DECISION_WORDS];
218 words[0] = key as u64;
219 words[1] = (if outcome {{ 2u64 }} else {{ 1u64 }}) | ((conditions as u64) << 8);
220 let mut index = 0;
221 while index < conditions {{
222 let byte = 10 + index;
223 words[byte / 8] |= (frame.value(index) as u64) << ((byte % 8) * 8);
224 index += 1;
225 }}
226 let used = (10 + conditions).div_ceil(8);
227 let mut slot = (key >> 4) & (DECISION_SLOTS - 1);
228 let mut attempts = 0;
229 while attempts < 16 {{
230 let cell = &DECISIONS[slot];
231 let state = cell.state.load(Ordering::Acquire);
232 if state == 2 {{
233 let mut same = true;
234 let mut word = 0;
235 while word < used {{
236 if cell.words[word].load(Ordering::Relaxed) != words[word] {{
237 same = false;
238 break;
239 }}
240 word += 1;
241 }}
242 if same {{
243 return false;
244 }}
245 }} else if state == 0 {{
246 if cell
247 .state
248 .compare_exchange(0, 1, Ordering::Acquire, Ordering::Relaxed)
249 .is_ok()
250 {{
251 let mut word = 0;
252 while word < DECISION_WORDS {{
253 cell.words[word].store(words[word], Ordering::Relaxed);
254 word += 1;
255 }}
256 cell.state.store(2, Ordering::Release);
257 return true;
258 }}
259 // Another thread took this slot first: look at it again.
260 continue;
261 }}
262 slot = (slot + 1) & (DECISION_SLOTS - 1);
263 attempts += 1;
264 }}
265 true
266 }}
267
268 fn writer() -> Option<&'static Mutex<File>> {{
269 static WRITER: OnceLock<Option<Mutex<File>>> = OnceLock::new();
270 static OPENING: AtomicBool = AtomicBool::new(false);
271 if let Some(writer) = WRITER.get() {{
272 return writer.as_ref();
273 }}
274 // Opening the file is the one step that must allocate: an environment
275 // lookup, a path, a formatted file name. That allocation re-enters an
276 // instrumented allocator, whose probe arrives back here while the
277 // OnceLock is still unset. Declining for the duration of the open costs
278 // a few observations at startup and makes the recursion impossible.
279 if OPENING.swap(true, Ordering::SeqCst) {{
280 return None;
281 }}
282 let opened = WRITER.get_or_init(|| {{
283 let directory = std::env::var_os("SUPERCOV_RUST_EVIDENCE_DIR")?;
284 let directory = std::path::PathBuf::from(directory);
285 std::fs::create_dir_all(&directory).ok()?;
286 let path =
287 directory.join(std::format!("{{CRATE_KEY}}-{{}}.events", std::process::id()));
288 let empty = std::fs::metadata(&path).map_or(true, |metadata| metadata.len() == 0);
289 let mut file = OpenOptions::new().create(true).append(true).open(path).ok()?;
290 if empty {{
291 file.write_all(MAGIC).ok()?;
292 }}
293 let guarded = Mutex::new(file);
294 // `std::sync::Mutex` boxes a platform mutex on its FIRST lock, and
295 // that allocation would otherwise land on the probe path and
296 // re-enter the host allocator. Force it here, where `OPENING`
297 // already makes re-entry harmless.
298 drop(guarded.lock());
299 Some(guarded)
300 }});
301 OPENING.store(false, Ordering::SeqCst);
302 opened.as_ref()
303 }}
304
305 fn write_record(record: &[u8]) {{
306 let Some(writer) = writer() else {{ return }};
307 let Ok(mut writer) = writer.lock() else {{ return }};
308 let _ = writer.write_all(record);
309 }}
310
311 /// Append to a stack record, reporting whether it all fit.
312 fn push(record: &mut [u8; RECORD_CAPACITY], length: &mut usize, bytes: &[u8]) -> bool {{
313 let Some(slice) = record.get_mut(*length..*length + bytes.len()) else {{
314 return false;
315 }};
316 slice.copy_from_slice(bytes);
317 *length += bytes.len();
318 true
319 }}
320
321 // A frame lives on the stack of the function whose decision it records,
322 // once per decision, and a recursive descent parser carries every one of
323 // them down every level: serde_json's recursion-limit test overflowed the
324 // test thread's stack when each frame held two 64-byte arrays. Values are
325 // two bits each in a u128 (0 unevaluated, 1 false, 2 true) and reached
326 // marks one bit each, so a frame is 48 bytes.
327 pub struct DecisionFrame {{
328 id: &'static str,
329 values: u128,
330 /// Let-chain conditions the evaluation got to: a `let` cannot be
331 /// wrapped, so it is marked reached instead and resolved later.
332 reached: u64,
333 conditions: usize,
334 recordable: bool,
335 }}
336
337 impl DecisionFrame {{
338 pub fn new(id: &'static str, conditions: usize) -> Self {{
339 Self {{
340 id,
341 values: 0,
342 reached: 0,
343 conditions,
344 recordable: conditions <= MAX_CONDITIONS,
345 }}
346 }}
347
348 #[inline(always)]
349 fn value(&self, index: usize) -> u8 {{
350 ((self.values >> (2 * index)) & 3) as u8
351 }}
352
353 #[inline(always)]
354 fn set_value(&mut self, index: usize, value: u8) {{
355 let shift = 2 * index;
356 self.values = (self.values & !(3u128 << shift)) | ((value as u128) << shift);
357 }}
358
359 #[inline(always)]
360 fn is_reached(&self, index: usize) -> bool {{
361 index < MAX_CONDITIONS && (self.reached >> index) & 1 == 1
362 }}
363 }}
364
365 /// A let chain got to condition `index` (0 marks the chain evaluated at
366 /// all). Always true, so it sits in the chain as an operand.
367 #[inline(always)]
368 pub fn reached(frame: &mut DecisionFrame, index: usize) -> bool {{
369 if index < MAX_CONDITIONS {{
370 frame.reached |= 1u64 << index;
371 }}
372 true
373 }}
374
375 /// A let chain decided. A chain tries its conditions in order and stops
376 /// at the first that fails, so every reached `let` before the last
377 /// reached condition held, the last one held when the chain was taken and
378 /// failed when it was not, and conditions never reached stay unevaluated.
379 /// `operators` lists the `&&` whose left side holds a `let`, by the index
380 /// of their right side's first condition: reached means the operator
381 /// evaluated its right side, otherwise it short-circuited. The frame then
382 /// resets for the next evaluation, which a `while let` makes every turn.
383 pub fn decision_chain(
384 frame: &mut DecisionFrame,
385 outcome: bool,
386 operators: &[(usize, &'static str, &'static str)],
387 ) {{
388 if !frame.is_reached(0) {{
389 return;
390 }}
391 let conditions = frame.conditions.min(MAX_CONDITIONS);
392 let mut last = 0;
393 let mut index = 0;
394 while index < conditions {{
395 if frame.is_reached(index) || frame.value(index) != 0 {{
396 last = index;
397 }}
398 index += 1;
399 }}
400 let mut index = 0;
401 while index < conditions {{
402 if frame.value(index) == 0 && frame.is_reached(index) {{
403 frame.set_value(index, if index < last || outcome {{ 2 }} else {{ 1 }});
404 }}
405 index += 1;
406 }}
407 for (first, short_circuit, evaluated) in operators {{
408 let got_there = frame.is_reached(*first)
409 || (*first < MAX_CONDITIONS && frame.value(*first) != 0);
410 hit(if got_there {{ evaluated }} else {{ short_circuit }});
411 }}
412 decision(outcome, frame);
413 frame.values = 0;
414 frame.reached = 0;
415 }}
416
417 // The hot paths are inlined into every probe site, so nothing with a
418 // stack buffer may be: an inlined 256-byte record per site turned each
419 // instrumented function's frame into kilobytes, and serde_json's
420 // recursion-limit test overflowed. Writing a record is the rare path and
421 // stays a call of its own.
422 #[inline(always)]
423 pub fn hit(id: &'static str) {{
424 if first_sighting(id) {{
425 record_hit(id);
426 }}
427 }}
428
429 #[inline(never)]
430 fn record_hit(id: &'static str) {{
431 let mut record = [0u8; RECORD_CAPACITY];
432 let mut length = 0;
433 if push(&mut record, &mut length, b"H\t")
434 && push(&mut record, &mut length, id.as_bytes())
435 && push(&mut record, &mut length, b"\n")
436 {{
437 write_record(&record[..length]);
438 }}
439 }}
440
441 /// One arm of a match was selected. `ids` holds each arm's `not selected`
442 /// and `selected` IDs in source order, so every arm before `selected` was
443 /// considered and passed over. Each ID is a distinct static string, which
444 /// is what `hit` dedupes on.
445 #[inline(always)]
446 pub fn arms(ids: &[&'static str], selected: usize) {{
447 for arm in 0..selected {{
448 if let Some(id) = ids.get(arm * 2) {{
449 hit(id);
450 }}
451 }}
452 if let Some(id) = ids.get(selected * 2 + 1) {{
453 hit(id);
454 }}
455 }}
456
457 /// The left operand of `&&` or `||`: it short-circuits when it equals
458 /// `short_circuits_when`, otherwise the right operand is about to run.
459 #[inline(always)]
460 pub fn logical(
461 left: bool,
462 short_circuits_when: bool,
463 short_circuit: &'static str,
464 evaluated: &'static str,
465 ) -> bool {{
466 hit(if left == short_circuits_when {{ short_circuit }} else {{ evaluated }});
467 left
468 }}
469
470 /// A `for` loop's iterator, recording on the first `next` whether the
471 /// body ran at all. `size_hint` passes through so collection sizing is
472 /// unchanged; nothing else about the iterator is observable to the loop.
473 pub struct ForLoop<I> {{
474 inner: I,
475 first: bool,
476 zero: &'static str,
477 entered: &'static str,
478 }}
479
480 impl<I: Iterator> Iterator for ForLoop<I> {{
481 type Item = I::Item;
482
483 #[inline(always)]
484 fn next(&mut self) -> Option<I::Item> {{
485 let item = self.inner.next();
486 if self.first {{
487 self.first = false;
488 hit(if item.is_some() {{ self.entered }} else {{ self.zero }});
489 }}
490 item
491 }}
492
493 #[inline(always)]
494 fn size_hint(&self) -> (usize, Option<usize>) {{
495 self.inner.size_hint()
496 }}
497 }}
498
499 #[inline(always)]
500 pub fn for_loop<I: IntoIterator>(
501 iterable: I,
502 zero: &'static str,
503 entered: &'static str,
504 ) -> ForLoop<I::IntoIter> {{
505 ForLoop {{ inner: iterable.into_iter(), first: true, zero, entered }}
506 }}
507
508 /// A `while` body ran: clear the loop's flag on the first entry.
509 #[inline(always)]
510 pub fn entered(first: &mut bool, id: &'static str) {{
511 if *first {{
512 *first = false;
513 hit(id);
514 }}
515 }}
516
517 /// A `while` loop is over: a flag still set means the body never ran.
518 #[inline(always)]
519 pub fn zero_iterations(first: bool, id: &'static str) {{
520 if first {{
521 hit(id);
522 }}
523 }}
524
525 /// The operand of `?`, recording which way the operator goes. Every type
526 /// `?` accepts on stable Rust implements this.
527 pub trait TryProbe: Sized {{
528 fn probe(self, continued: &'static str, returned: &'static str) -> Self;
529 }}
530
531 impl<T> TryProbe for Option<T> {{
532 #[inline(always)]
533 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
534 hit(if self.is_some() {{ continued }} else {{ returned }});
535 self
536 }}
537 }}
538
539 impl<T, E> TryProbe for Result<T, E> {{
540 #[inline(always)]
541 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
542 hit(if self.is_ok() {{ continued }} else {{ returned }});
543 self
544 }}
545 }}
546
547 impl<B, C> TryProbe for ControlFlow<B, C> {{
548 #[inline(always)]
549 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
550 hit(if matches!(self, ControlFlow::Continue(_)) {{ continued }} else {{ returned }});
551 self
552 }}
553 }}
554
555 impl<T, E> TryProbe for Poll<Result<T, E>> {{
556 #[inline(always)]
557 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
558 hit(if matches!(self, Poll::Ready(Err(_))) {{ returned }} else {{ continued }});
559 self
560 }}
561 }}
562
563 impl<T, E> TryProbe for Poll<Option<Result<T, E>>> {{
564 #[inline(always)]
565 fn probe(self, continued: &'static str, returned: &'static str) -> Self {{
566 hit(if matches!(self, Poll::Ready(Some(Err(_)))) {{ returned }} else {{ continued }});
567 self
568 }}
569 }}
570
571 // Anything `!` turns into a bool: `assert!(was_seen)` compiles with a
572 // `&bool` (tokio iterates `for was_seen in &seen`), since the macro only
573 // negates its operand.
574 #[inline(always)]
575 pub fn condition<V: std::ops::Not<Output = bool>>(
576 value: V,
577 frame: &mut DecisionFrame,
578 index: usize,
579 ) -> bool {{
580 let value = !!value;
581 if index < frame.conditions && index < MAX_CONDITIONS {{
582 frame.set_value(index, if value {{ 2 }} else {{ 1 }});
583 }}
584 value
585 }}
586
587 #[inline(always)]
588 pub fn decision(value: bool, frame: &mut DecisionFrame) -> bool {{
589 if frame.recordable {{
590 record_decision(value, frame);
591 }}
592 value
593 }}
594
595 #[inline(never)]
596 fn record_decision(value: bool, frame: &DecisionFrame) {{
597 // `writer()` comes first so the file's mutex boxes its platform mutex
598 // while `OPENING` still makes re-entry harmless; the decision table
599 // itself is lock-free and allocates nothing.
600 if writer().is_none() || !first_decision(frame, value) {{
601 return;
602 }}
603 let mut record = [0u8; RECORD_CAPACITY];
604 let mut length = 0;
605 let mut fits = push(&mut record, &mut length, b"D\t")
606 && push(&mut record, &mut length, frame.id.as_bytes())
607 && push(&mut record, &mut length, b"\t");
608 let mut index = 0;
609 while index < frame.conditions {{
610 fits = fits && push(&mut record, &mut length, &[b'0' + frame.value(index)]);
611 index += 1;
612 }}
613 fits = fits
614 && push(&mut record, &mut length, b"\t")
615 && push(&mut record, &mut length, if value {{ b"1" }} else {{ b"0" }})
616 && push(&mut record, &mut length, b"\n");
617 if fits {{
618 write_record(&record[..length]);
619 }}
620 }}
621}}
622"#
623 ))
624}
625
626pub fn parse_rust_probe_events(
627 input: &[u8],
628) -> Result<Vec<RustProbeObservation>, RustProbeReadError> {
629 let text = std::str::from_utf8(input).map_err(|_| RustProbeReadError::InvalidHeader)?;
630 let mut lines = text.lines();
631 if lines.next() != Some(RUST_PROBE_MAGIC) {
632 return Err(RustProbeReadError::InvalidHeader);
633 }
634 let mut observations = Vec::new();
635 for (index, line) in lines.enumerate() {
636 let line_number = index + 2;
637 let fields = line.split('\t').collect::<Vec<_>>();
638 match fields.as_slice() {
639 ["H", id] if valid_probe_id(id) => {
640 observations.push(RustProbeObservation::Hit { id: (*id).into() })
641 }
642 ["D", id, digits, outcome]
643 if valid_probe_id(id)
644 && id.starts_with("rs:decision:")
645 && !digits.is_empty()
646 && digits
647 .bytes()
648 .all(|digit| matches!(digit, b'0' | b'1' | b'2'))
649 && matches!(*outcome, "0" | "1") =>
650 {
651 observations.push(RustProbeObservation::Decision {
652 id: (*id).into(),
653 values: digits
654 .bytes()
655 .map(|digit| match digit {
656 b'0' => None,
657 b'1' => Some(false),
658 b'2' => Some(true),
659 _ => unreachable!(),
660 })
661 .collect(),
662 outcome: *outcome == "1",
663 });
664 }
665 _ => return Err(RustProbeReadError::InvalidRecord(line_number)),
666 }
667 }
668 Ok(observations)
669}
670
671pub fn read_rust_probe_directory(
672 directory: &Path,
673) -> Result<BTreeMap<String, Vec<RustProbeObservation>>, RustProbeReadError> {
674 let mut files = fs::read_dir(directory)
675 .map_err(|error| RustProbeReadError::Io(error.to_string()))?
676 .collect::<Result<Vec<_>, _>>()
677 .map_err(|error| RustProbeReadError::Io(error.to_string()))?;
678 files.sort_by_key(|entry| entry.file_name());
679 let mut observations = BTreeMap::new();
680 for entry in files {
681 let name = entry
682 .file_name()
683 .into_string()
684 .map_err(|_| RustProbeReadError::UnsafeEntry("<non-utf8>".into()))?;
685 if Path::new(&name)
686 .components()
687 .any(|component| !matches!(component, Component::Normal(_)))
688 || !name.ends_with(".events")
689 {
690 return Err(RustProbeReadError::UnsafeEntry(name));
691 }
692 let metadata = fs::symlink_metadata(entry.path())
693 .map_err(|error| RustProbeReadError::Io(error.to_string()))?;
694 if !metadata.file_type().is_file() {
695 return Err(RustProbeReadError::UnsafeEntry(name));
696 }
697 let contents =
698 fs::read(entry.path()).map_err(|error| RustProbeReadError::Io(error.to_string()))?;
699 observations.insert(name, parse_rust_probe_events(&contents)?);
700 }
701 Ok(observations)
702}
703
704#[cfg(test)]
705mod tests {
706 use std::{
707 fs,
708 process::Command,
709 time::{SystemTime, UNIX_EPOCH},
710 };
711
712 use super::*;
713 use crate::rust_instrumenter::instrument_rust_source;
714
715 fn temporary_directory(name: &str) -> std::path::PathBuf {
716 let nonce = SystemTime::now()
717 .duration_since(UNIX_EPOCH)
718 .unwrap()
719 .as_nanos();
720 let path = std::env::temp_dir().join(format!(
721 "supercov-rust-runtime-{}-{nonce}-{name}",
722 std::process::id()
723 ));
724 fs::create_dir(&path).unwrap();
725 path
726 }
727
728 #[test]
729 fn generated_runtime_records_owned_points_and_exact_short_circuit_vectors() {
730 let source = r#"fn choose(first: bool, second: bool) -> i32 {
731 if first && second { 7 } else { 3 }
732}
733
734fn main() {
735 println!("{} {}", choose(false, true), choose(true, true));
736}
737"#;
738 let transformed =
739 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
740 let runtime =
741 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
742 let directory = temporary_directory("record");
743 let input = directory.join("main.rs");
744 let binary = directory.join("program");
745 let evidence = directory.join("evidence");
746 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
747 let compile = Command::new("rustc")
748 .arg("--edition=2024")
749 .arg(&input)
750 .arg("-o")
751 .arg(&binary)
752 .output()
753 .unwrap();
754 assert!(
755 compile.status.success(),
756 "{}",
757 String::from_utf8_lossy(&compile.stderr)
758 );
759 let output = Command::new(&binary)
760 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
761 .output()
762 .unwrap();
763 assert!(output.status.success());
764 assert_eq!(output.stdout, b"3 7\n");
765 let files = read_rust_probe_directory(&evidence).unwrap();
766 assert_eq!(files.len(), 1);
767 let observations = files.values().next().unwrap();
768 let decisions = observations
769 .iter()
770 .filter_map(|observation| match observation {
771 RustProbeObservation::Decision {
772 values, outcome, ..
773 } => Some((values.clone(), *outcome)),
774 RustProbeObservation::Hit { .. } => None,
775 })
776 .collect::<Vec<_>>();
777 assert_eq!(
778 decisions,
779 [
780 (vec![Some(false), None], false),
781 (vec![Some(true), Some(true)], true)
782 ]
783 );
784 fs::remove_dir_all(directory).unwrap();
785 }
786
787 #[test]
788 fn generated_runtime_compiles_into_a_no_std_host_crate() {
789 let source = r#"#![no_std]
796
797extern crate std;
798
799fn choose(first: bool, second: bool) -> i32 {
800 if first && second { 7 } else { 3 }
801}
802
803fn main() {
804 std::println!("{} {}", choose(false, true), choose(true, true));
805}
806"#;
807 let transformed =
808 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
809 let runtime =
810 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
811 let directory = temporary_directory("no-std");
812 let input = directory.join("main.rs");
813 let binary = directory.join("program");
814 let evidence = directory.join("evidence");
815 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
816 let compile = Command::new("rustc")
817 .arg("--edition=2024")
818 .arg(&input)
819 .arg("-o")
820 .arg(&binary)
821 .output()
822 .unwrap();
823 assert!(
824 compile.status.success(),
825 "{}",
826 String::from_utf8_lossy(&compile.stderr)
827 );
828 let output = Command::new(&binary)
829 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
830 .output()
831 .unwrap();
832 assert!(output.status.success());
833 assert_eq!(output.stdout, b"3 7\n");
834 let files = read_rust_probe_directory(&evidence).unwrap();
836 assert_eq!(files.len(), 1);
837 assert!(!files.values().next().unwrap().is_empty());
838 fs::remove_dir_all(directory).unwrap();
839 }
840
841 #[test]
842 fn probes_reached_through_a_global_allocator_do_not_recurse() {
843 let source = r#"use std::alloc::{GlobalAlloc, Layout, System};
851use std::sync::atomic::{AtomicUsize, Ordering};
852
853static SEEN: AtomicUsize = AtomicUsize::new(0);
854
855fn note(size: usize) {
856 if size > 0 {
857 SEEN.fetch_add(1, Ordering::SeqCst);
858 }
859}
860
861struct Ledger;
862
863impl Ledger {
864 fn record(&self, size: usize) {
865 note(size);
866 }
867}
868
869unsafe impl GlobalAlloc for Ledger {
870 unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
871 self.record(layout.size());
872 System.alloc(layout)
873 }
874
875 unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) {
876 // dealloc must be instrumented too, or the test never exercises the
877 // ladder that actually crashed: freeing the probe's OWN buffer re-enters
878 // here, and a guard released before that free recurses without bound.
879 self.record(layout.size());
880 System.dealloc(pointer, layout);
881 }
882}
883
884#[global_allocator]
885static LEDGER: Ledger = Ledger;
886
887fn classify(flag: bool) -> usize {
888 if flag { 1 } else { 2 }
889}
890
891fn main() {
892 let held = std::vec![7u8; 32];
893 println!("{} {}", classify(!held.is_empty()), held.len());
894}
895"#;
896 let transformed =
897 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
898 assert!(
901 transformed
902 .code
903 .contains("fn note(size: usize) {\ncrate::__supercov_runtime_v1::hit("),
904 "the free function reached from the allocator should still be probed"
905 );
906 let runtime =
907 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
908 let directory = temporary_directory("allocator-reentry");
909 let input = directory.join("main.rs");
910 let binary = directory.join("program");
911 let evidence = directory.join("evidence");
912 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
913 let compile = Command::new("rustc")
914 .arg("--edition=2024")
915 .arg(&input)
916 .arg("-o")
917 .arg(&binary)
918 .output()
919 .unwrap();
920 assert!(
921 compile.status.success(),
922 "{}",
923 String::from_utf8_lossy(&compile.stderr)
924 );
925 let output = Command::new(&binary)
926 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
927 .output()
928 .unwrap();
929 assert!(
930 output.status.success(),
931 "instrumented allocator did not survive: {:?}",
932 output.status
933 );
934 assert_eq!(output.stdout, b"1 32\n");
935 let files = read_rust_probe_directory(&evidence).unwrap();
936 let observations = files.values().next().unwrap();
937 let decisions = observations
938 .iter()
939 .filter_map(|observation| match observation {
940 RustProbeObservation::Decision {
941 id,
942 values,
943 outcome,
944 } => Some((id.clone(), values.clone(), *outcome)),
945 RustProbeObservation::Hit { .. } => None,
946 })
947 .collect::<Vec<_>>();
948 let classify = transformed
951 .manifest
952 .decisions
953 .iter()
954 .find(|decision| decision.source == "flag")
955 .expect("classify's decision reached the manifest");
956 assert!(
957 decisions
958 .iter()
959 .any(|(id, values, outcome)| id == &classify.id
960 && values == &[Some(true)]
961 && *outcome),
962 "classify's decision was lost: {decisions:?}"
963 );
964 let note = transformed
967 .manifest
968 .decisions
969 .iter()
970 .find(|decision| decision.source == "size > 0")
971 .expect("note's decision reached the manifest");
972 assert!(decisions.iter().any(|(id, ..)| id == ¬e.id));
973 assert!(
976 decisions.iter().all(|(_, values, _)| values.len() == 1),
977 "a suppressed frame emitted a malformed vector: {decisions:?}"
978 );
979 fs::remove_dir_all(directory).unwrap();
980 }
981
982 #[test]
983 fn a_hit_in_a_loop_is_written_once_but_decisions_keep_every_vector() {
984 let source = r#"fn step(value: usize) -> bool {
990 let doubled = value * 2;
991 doubled > 4
992}
993
994fn main() {
995 let mut seen = 0;
996 for value in 0..64 {
997 if step(value) { seen += 1; }
998 }
999 println!("{seen}");
1000}
1001"#;
1002 let transformed =
1003 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1004 let runtime =
1005 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
1006 let directory = temporary_directory("dedup");
1007 let input = directory.join("main.rs");
1008 let binary = directory.join("program");
1009 let evidence = directory.join("evidence");
1010 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
1011 let compile = Command::new("rustc")
1012 .arg("--edition=2024")
1013 .arg(&input)
1014 .arg("-o")
1015 .arg(&binary)
1016 .output()
1017 .unwrap();
1018 assert!(
1019 compile.status.success(),
1020 "{}",
1021 String::from_utf8_lossy(&compile.stderr)
1022 );
1023 let output = Command::new(&binary)
1024 .env("SUPERCOV_RUST_EVIDENCE_DIR", &evidence)
1025 .output()
1026 .unwrap();
1027 assert_eq!(output.stdout, b"61\n");
1028 let files = read_rust_probe_directory(&evidence).unwrap();
1029 let observations = files.values().next().unwrap();
1030
1031 let mut hits = BTreeMap::<&str, usize>::new();
1033 for observation in observations {
1034 if let RustProbeObservation::Hit { id } = observation {
1035 *hits.entry(id.as_str()).or_default() += 1;
1036 }
1037 }
1038 assert!(!hits.is_empty(), "no hits recorded at all");
1039 assert!(
1040 hits.values().all(|count| *count == 1),
1041 "a repeated hit was written more than once: {hits:?}"
1042 );
1043
1044 let decisions = observations
1049 .iter()
1050 .filter_map(|observation| match observation {
1051 RustProbeObservation::Decision {
1052 values, outcome, ..
1053 } => Some((values.clone(), *outcome)),
1054 RustProbeObservation::Hit { .. } => None,
1055 })
1056 .collect::<Vec<_>>();
1057 assert_eq!(
1058 decisions,
1059 [(vec![Some(false)], false), (vec![Some(true)], true)],
1062 "both distinct vectors must survive, and neither may repeat"
1063 );
1064 fs::remove_dir_all(directory).unwrap();
1065 }
1066
1067 #[test]
1068 fn generated_runtime_survives_a_deny_warnings_host() {
1069 let source = r#"#![deny(warnings)]
1074
1075fn choose(first: bool, second: bool) -> i32 {
1076 if first && second { 7 } else { 3 }
1077}
1078
1079fn main() {
1080 println!("{} {}", choose(false, true), choose(true, true));
1081}
1082"#;
1083 let transformed =
1084 instrument_rust_source("src/main.rs", source, "crate::__supercov_runtime_v1").unwrap();
1085 let runtime =
1086 render_rust_runtime("__supercov_runtime_v1", "0123456789abcdef01234567").unwrap();
1087 let directory = temporary_directory("deny-warnings");
1088 let input = directory.join("main.rs");
1089 let binary = directory.join("program");
1090 fs::write(&input, format!("{}\n{runtime}", transformed.code)).unwrap();
1091 let compile = Command::new("rustc")
1095 .arg("--edition=2024")
1096 .arg("--cap-lints=warn")
1097 .arg(&input)
1098 .arg("-o")
1099 .arg(&binary)
1100 .output()
1101 .unwrap();
1102 assert!(
1103 compile.status.success(),
1104 "{}",
1105 String::from_utf8_lossy(&compile.stderr)
1106 );
1107 let output = Command::new(&binary).output().unwrap();
1108 assert_eq!(output.stdout, b"3 7\n");
1109 fs::remove_dir_all(directory).unwrap();
1110 }
1111
1112 #[test]
1113 fn reader_rejects_truncation_invalid_digits_and_non_files() {
1114 assert_eq!(
1115 parse_rust_probe_events(
1116 b"SUPERCOV-RUST-PROBE-1\nD\trs:decision:0123456789abcdef01234567\t03\t1\n"
1117 ),
1118 Err(RustProbeReadError::InvalidRecord(2))
1119 );
1120 assert_eq!(
1121 parse_rust_probe_events(b"SUPERCOV-RUST-PROBE-"),
1122 Err(RustProbeReadError::InvalidHeader)
1123 );
1124
1125 let directory = temporary_directory("unsafe");
1126 fs::create_dir(directory.join("nested.events")).unwrap();
1127 assert!(matches!(
1128 read_rust_probe_directory(&directory),
1129 Err(RustProbeReadError::UnsafeEntry(_))
1130 ));
1131 fs::remove_dir_all(directory).unwrap();
1132 }
1133}