nextest_runner/
redact.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Redact data that varies by system and OS to produce a stable output.
5//!
6//! Used for snapshot testing.
7
8use crate::{
9    helpers::{
10        FormattedDuration, FormattedRelativeDuration, convert_rel_path_to_forward_slash,
11        u64_decimal_char_width,
12    },
13    list::RustBuildMeta,
14};
15use camino::{Utf8Path, Utf8PathBuf};
16use chrono::{DateTime, TimeZone};
17use regex::Regex;
18use std::{
19    collections::BTreeMap,
20    fmt,
21    sync::{Arc, LazyLock},
22    time::Duration,
23};
24
25static CRATE_NAME_HASH_REGEX: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9_-]+)-[a-f0-9]{16}$").unwrap());
27static TARGET_DIR_REDACTION: &str = "<target-dir>";
28static FILE_COUNT_REDACTION: &str = "<file-count>";
29static DURATION_REDACTION: &str = "<duration>";
30
31// Fixed-width placeholders for store list alignment.
32// These match the original field widths to preserve column alignment.
33
34/// 19 chars, matches `%Y-%m-%d %H:%M:%S` format.
35static TIMESTAMP_REDACTION: &str = "XXXX-XX-XX XX:XX:XX";
36/// 6 chars for numeric portion (e.g. "   123" for KB display).
37static SIZE_REDACTION: &str = "<size>";
38/// Placeholder for redacted version strings.
39static VERSION_REDACTION: &str = "<version>";
40/// Placeholder for redacted relative durations (e.g. "30s ago").
41static RELATIVE_DURATION_REDACTION: &str = "<ago>";
42
43/// A helper for redacting data that varies by environment.
44///
45/// This isn't meant to be perfect, and not everything can be redacted yet -- the set of supported
46/// redactions will grow over time.
47#[derive(Clone, Debug)]
48pub struct Redactor {
49    kind: Arc<RedactorKind>,
50}
51
52impl Redactor {
53    /// Creates a new no-op redactor.
54    pub fn noop() -> Self {
55        Self::new_with_kind(RedactorKind::Noop)
56    }
57
58    fn new_with_kind(kind: RedactorKind) -> Self {
59        Self {
60            kind: Arc::new(kind),
61        }
62    }
63
64    /// Creates a new redactor builder that operates on the given build metadata.
65    ///
66    /// This should only be called if redaction is actually needed.
67    pub fn build_active<State>(build_meta: &RustBuildMeta<State>) -> RedactorBuilder {
68        let mut redactions = Vec::new();
69
70        let linked_path_redactions =
71            build_linked_path_redactions(build_meta.linked_paths.keys().map(|p| p.as_ref()));
72
73        // For all linked paths, push both absolute and relative redactions.
74        for (source, replacement) in linked_path_redactions {
75            redactions.push(Redaction::Path {
76                path: build_meta.target_directory.join(&source),
77                replacement: format!("{TARGET_DIR_REDACTION}/{replacement}"),
78            });
79            redactions.push(Redaction::Path {
80                path: source,
81                replacement,
82            });
83        }
84
85        // Also add a redaction for the target directory. This goes after the linked paths, so that
86        // absolute linked paths are redacted first.
87        redactions.push(Redaction::Path {
88            path: build_meta.target_directory.clone(),
89            replacement: "<target-dir>".to_string(),
90        });
91
92        RedactorBuilder { redactions }
93    }
94
95    /// Redacts a path.
96    pub fn redact_path<'a>(&self, orig: &'a Utf8Path) -> RedactorOutput<&'a Utf8Path> {
97        for redaction in self.kind.iter_redactions() {
98            match redaction {
99                Redaction::Path { path, replacement } => {
100                    if let Ok(suffix) = orig.strip_prefix(path) {
101                        if suffix.as_str().is_empty() {
102                            return RedactorOutput::Redacted(replacement.clone());
103                        } else {
104                            // Always use "/" as the separator, even on Windows, to ensure stable
105                            // output across OSes.
106                            let path = Utf8PathBuf::from(format!("{replacement}/{suffix}"));
107                            return RedactorOutput::Redacted(
108                                convert_rel_path_to_forward_slash(&path).into(),
109                            );
110                        }
111                    }
112                }
113            }
114        }
115
116        RedactorOutput::Unredacted(orig)
117    }
118
119    /// Redacts a file count.
120    pub fn redact_file_count(&self, orig: usize) -> RedactorOutput<usize> {
121        if self.kind.is_active() {
122            RedactorOutput::Redacted(FILE_COUNT_REDACTION.to_string())
123        } else {
124            RedactorOutput::Unredacted(orig)
125        }
126    }
127
128    /// Redacts a duration.
129    pub(crate) fn redact_duration(&self, orig: Duration) -> RedactorOutput<FormattedDuration> {
130        if self.kind.is_active() {
131            RedactorOutput::Redacted(DURATION_REDACTION.to_string())
132        } else {
133            RedactorOutput::Unredacted(FormattedDuration(orig))
134        }
135    }
136
137    /// Returns true if this redactor is active (will redact values).
138    pub fn is_active(&self) -> bool {
139        self.kind.is_active()
140    }
141
142    /// Creates a new redactor for snapshot testing, without any path redactions.
143    ///
144    /// This is useful when you need redaction of timestamps, durations, and
145    /// sizes, but don't have a `RustBuildMeta` to build path redactions from.
146    pub fn for_snapshot_testing() -> Self {
147        Self::new_with_kind(RedactorKind::Active {
148            redactions: Vec::new(),
149        })
150    }
151
152    /// Redacts a timestamp for display, producing a fixed-width placeholder.
153    ///
154    /// The placeholder `XXXX-XX-XX XX:XX:XX` is 19 characters, matching the
155    /// width of the `%Y-%m-%d %H:%M:%S` format.
156    pub fn redact_timestamp<Tz>(&self, orig: &DateTime<Tz>) -> RedactorOutput<DisplayTimestamp<Tz>>
157    where
158        Tz: TimeZone + Clone,
159        Tz::Offset: fmt::Display,
160    {
161        if self.kind.is_active() {
162            RedactorOutput::Redacted(TIMESTAMP_REDACTION.to_string())
163        } else {
164            RedactorOutput::Unredacted(DisplayTimestamp(orig.clone()))
165        }
166    }
167
168    /// Redacts a size (in bytes) for display as a human-readable string.
169    ///
170    /// When redacting, produces `<size>` as a placeholder.
171    pub fn redact_size(&self, orig: u64) -> RedactorOutput<SizeDisplay> {
172        if self.kind.is_active() {
173            RedactorOutput::Redacted(SIZE_REDACTION.to_string())
174        } else {
175            RedactorOutput::Unredacted(SizeDisplay(orig))
176        }
177    }
178
179    /// Redacts a version for display.
180    ///
181    /// When redacting, produces `<version>` as a placeholder.
182    pub fn redact_version(&self, orig: &semver::Version) -> String {
183        if self.kind.is_active() {
184            VERSION_REDACTION.to_string()
185        } else {
186            orig.to_string()
187        }
188    }
189
190    /// Redacts a store duration for display, producing a fixed-width placeholder.
191    ///
192    /// The placeholder `<duration>` is 10 characters, matching the width of the
193    /// `{:>9.3}s` format used for durations.
194    pub fn redact_store_duration(&self, orig: Option<f64>) -> RedactorOutput<StoreDurationDisplay> {
195        if self.kind.is_active() {
196            RedactorOutput::Redacted(format!("{:>10}", DURATION_REDACTION))
197        } else {
198            RedactorOutput::Unredacted(StoreDurationDisplay(orig))
199        }
200    }
201
202    /// Redacts a timestamp with timezone for detailed display.
203    ///
204    /// Produces `XXXX-XX-XX XX:XX:XX` when active, otherwise formats as
205    /// `%Y-%m-%d %H:%M:%S %:z`.
206    pub fn redact_detailed_timestamp<Tz>(&self, orig: &DateTime<Tz>) -> String
207    where
208        Tz: TimeZone,
209        Tz::Offset: fmt::Display,
210    {
211        if self.kind.is_active() {
212            TIMESTAMP_REDACTION.to_string()
213        } else {
214            orig.format("%Y-%m-%d %H:%M:%S %:z").to_string()
215        }
216    }
217
218    /// Redacts a duration in seconds for detailed display.
219    ///
220    /// Produces `<duration>` when active, otherwise formats as `{:.3}s`.
221    pub fn redact_detailed_duration(&self, orig: Option<f64>) -> String {
222        if self.kind.is_active() {
223            DURATION_REDACTION.to_string()
224        } else {
225            match orig {
226                Some(secs) => format!("{:.3}s", secs),
227                None => "-".to_string(),
228            }
229        }
230    }
231
232    /// Redacts a relative duration for display (e.g. "30s ago").
233    ///
234    /// Produces `<ago>` when active, otherwise formats the duration.
235    pub(crate) fn redact_relative_duration(
236        &self,
237        orig: Duration,
238    ) -> RedactorOutput<FormattedRelativeDuration> {
239        if self.kind.is_active() {
240            RedactorOutput::Redacted(RELATIVE_DURATION_REDACTION.to_string())
241        } else {
242            RedactorOutput::Unredacted(FormattedRelativeDuration(orig))
243        }
244    }
245
246    /// Redacts CLI args for display.
247    ///
248    /// - The first arg (the exe) is replaced with `[EXE]`
249    /// - Absolute paths in other args are replaced with `[PATH]`
250    pub fn redact_cli_args(&self, args: &[String]) -> String {
251        if !self.kind.is_active() {
252            return shell_words::join(args);
253        }
254
255        let redacted: Vec<_> = args
256            .iter()
257            .enumerate()
258            .map(|(i, arg)| {
259                if i == 0 {
260                    // First arg is always the exe.
261                    "[EXE]".to_string()
262                } else if is_absolute_path(arg) {
263                    "[PATH]".to_string()
264                } else {
265                    arg.clone()
266                }
267            })
268            .collect();
269        shell_words::join(&redacted)
270    }
271
272    /// Redacts env vars for display.
273    ///
274    /// Formats as `K=V` pairs.
275    pub fn redact_env_vars(&self, env_vars: &BTreeMap<String, String>) -> String {
276        let pairs: Vec<_> = env_vars
277            .iter()
278            .map(|(k, v)| {
279                format!(
280                    "{}={}",
281                    shell_words::quote(k),
282                    shell_words::quote(self.redact_env_value(v)),
283                )
284            })
285            .collect();
286        pairs.join(" ")
287    }
288
289    /// Redacts an env var value for display.
290    ///
291    /// Absolute paths are replaced with `[PATH]`.
292    pub fn redact_env_value<'a>(&self, value: &'a str) -> &'a str {
293        if self.kind.is_active() && is_absolute_path(value) {
294            "[PATH]"
295        } else {
296            value
297        }
298    }
299}
300
301/// Returns true if the string looks like an absolute path.
302fn is_absolute_path(s: &str) -> bool {
303    s.starts_with('/') || (s.len() >= 3 && s.chars().nth(1) == Some(':'))
304}
305
306/// Wrapper for timestamps that formats with `%Y-%m-%d %H:%M:%S`.
307#[derive(Clone, Debug)]
308pub struct DisplayTimestamp<Tz: TimeZone>(pub DateTime<Tz>);
309
310impl<Tz: TimeZone> fmt::Display for DisplayTimestamp<Tz>
311where
312    Tz::Offset: fmt::Display,
313{
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "{}", self.0.format("%Y-%m-%d %H:%M:%S"))
316    }
317}
318
319/// Wrapper for store durations that formats as `{:>9.3}s` or `{:>10}` for "-".
320#[derive(Clone, Debug)]
321pub struct StoreDurationDisplay(pub Option<f64>);
322
323impl fmt::Display for StoreDurationDisplay {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self.0 {
326            Some(secs) => write!(f, "{secs:>9.3}s"),
327            None => write!(f, "{:>10}", "-"),
328        }
329    }
330}
331
332/// Wrapper for sizes that formats bytes as a human-readable string (KB or MB).
333#[derive(Clone, Copy, Debug)]
334pub struct SizeDisplay(pub u64);
335
336impl SizeDisplay {
337    /// Returns the display width of this size when formatted.
338    ///
339    /// This is useful for alignment calculations.
340    pub fn display_width(self) -> usize {
341        let bytes = self.0;
342        if bytes >= 1024 * 1024 {
343            // Format: "{:.1} MB" - integer part + "." + 1 decimal + " MB".
344            let mb_int = bytes / (1024 * 1024);
345            u64_decimal_char_width(mb_int) + 2 + 3
346        } else {
347            // Format: "{} KB" - integer + " KB".
348            let kb = bytes / 1024;
349            u64_decimal_char_width(kb) + 3
350        }
351    }
352}
353
354impl fmt::Display for SizeDisplay {
355    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
356        let bytes = self.0;
357        // Remove 3 from the width since we're adding " MB" or " KB" at the end.
358        match (bytes >= 1024 * 1024, f.width().map(|w| w.saturating_sub(3))) {
359            (true, Some(width)) => {
360                write!(f, "{:>width$.1} MB", bytes as f64 / (1024.0 * 1024.0))
361            }
362            (true, None) => {
363                write!(f, "{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
364            }
365            (false, Some(width)) => {
366                write!(f, "{:>width$} KB", bytes / 1024)
367            }
368            (false, None) => {
369                write!(f, "{} KB", bytes / 1024)
370            }
371        }
372    }
373}
374
375/// A builder for [`Redactor`] instances.
376///
377/// Created with [`Redactor::build_active`].
378#[derive(Debug)]
379pub struct RedactorBuilder {
380    redactions: Vec<Redaction>,
381}
382
383impl RedactorBuilder {
384    /// Adds a new path redaction.
385    pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
386        self.redactions.push(Redaction::Path { path, replacement });
387        self
388    }
389
390    /// Builds the redactor.
391    pub fn build(self) -> Redactor {
392        Redactor::new_with_kind(RedactorKind::Active {
393            redactions: self.redactions,
394        })
395    }
396}
397
398/// The output of a [`Redactor`] operation.
399#[derive(Debug)]
400pub enum RedactorOutput<T> {
401    /// The value was not redacted.
402    Unredacted(T),
403
404    /// The value was redacted.
405    Redacted(String),
406}
407
408impl<T: fmt::Display> fmt::Display for RedactorOutput<T> {
409    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410        match self {
411            RedactorOutput::Unredacted(value) => value.fmt(f),
412            RedactorOutput::Redacted(replacement) => replacement.fmt(f),
413        }
414    }
415}
416
417#[derive(Debug)]
418enum RedactorKind {
419    Noop,
420    Active {
421        /// The list of redactions to apply.
422        redactions: Vec<Redaction>,
423    },
424}
425
426impl RedactorKind {
427    fn is_active(&self) -> bool {
428        matches!(self, Self::Active { .. })
429    }
430
431    fn iter_redactions(&self) -> impl Iterator<Item = &Redaction> {
432        match self {
433            Self::Active { redactions } => redactions.iter(),
434            Self::Noop => [].iter(),
435        }
436    }
437}
438
439/// An individual redaction to apply.
440#[derive(Debug)]
441enum Redaction {
442    /// Redact a path.
443    Path {
444        /// The path to redact.
445        path: Utf8PathBuf,
446
447        /// The replacement string.
448        replacement: String,
449    },
450}
451
452fn build_linked_path_redactions<'a>(
453    linked_paths: impl Iterator<Item = &'a Utf8Path>,
454) -> BTreeMap<Utf8PathBuf, String> {
455    // The map prevents dups.
456    let mut linked_path_redactions = BTreeMap::new();
457
458    for linked_path in linked_paths {
459        // Linked paths are relative to the target dir, and usually of the form
460        // <profile>/build/<crate-name>-<hash>/.... If the linked path matches this form, redact it
461        // (in both absolute and relative forms).
462
463        // First, look for a component of the form <crate-name>-hash in it.
464        let mut source = Utf8PathBuf::new();
465        let mut replacement = ReplacementBuilder::new();
466
467        for elem in linked_path {
468            if let Some(captures) = CRATE_NAME_HASH_REGEX.captures(elem) {
469                // Found it! Redact it.
470                let crate_name = captures.get(1).expect("regex had one capture");
471                source.push(elem);
472                replacement.push(&format!("<{}-hash>", crate_name.as_str()));
473                linked_path_redactions.insert(source, replacement.into_string());
474                break;
475            } else {
476                // Not found yet, keep looking.
477                source.push(elem);
478                replacement.push(elem);
479            }
480
481            // If the path isn't of the form above, we don't redact it.
482        }
483    }
484
485    linked_path_redactions
486}
487
488#[derive(Debug)]
489struct ReplacementBuilder {
490    replacement: String,
491}
492
493impl ReplacementBuilder {
494    fn new() -> Self {
495        Self {
496            replacement: String::new(),
497        }
498    }
499
500    fn push(&mut self, s: &str) {
501        if self.replacement.is_empty() {
502            self.replacement.push_str(s);
503        } else {
504            self.replacement.push('/');
505            self.replacement.push_str(s);
506        }
507    }
508
509    fn into_string(self) -> String {
510        self.replacement
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn test_redact_path() {
520        let abs_path = make_abs_path();
521        let redactor = Redactor::new_with_kind(RedactorKind::Active {
522            redactions: vec![
523                Redaction::Path {
524                    path: "target/debug".into(),
525                    replacement: "<target-debug>".to_string(),
526                },
527                Redaction::Path {
528                    path: "target".into(),
529                    replacement: "<target-dir>".to_string(),
530                },
531                Redaction::Path {
532                    path: abs_path.clone(),
533                    replacement: "<abs-target>".to_string(),
534                },
535            ],
536        });
537
538        let examples: &[(Utf8PathBuf, &str)] = &[
539            ("target/foo".into(), "<target-dir>/foo"),
540            ("target/debug/bar".into(), "<target-debug>/bar"),
541            ("target2/foo".into(), "target2/foo"),
542            (
543                // This will produce "<target-dir>/foo/bar" on Unix and "<target-dir>\\foo\\bar" on
544                // Windows.
545                ["target", "foo", "bar"].iter().collect(),
546                "<target-dir>/foo/bar",
547            ),
548            (abs_path.clone(), "<abs-target>"),
549            (abs_path.join("foo"), "<abs-target>/foo"),
550        ];
551
552        for (orig, expected) in examples {
553            assert_eq!(
554                redactor.redact_path(orig).to_string(),
555                *expected,
556                "redacting {orig:?}"
557            );
558        }
559    }
560
561    #[cfg(unix)]
562    fn make_abs_path() -> Utf8PathBuf {
563        "/path/to/target".into()
564    }
565
566    #[cfg(windows)]
567    fn make_abs_path() -> Utf8PathBuf {
568        "C:\\path\\to\\target".into()
569        // TODO: test with verbatim paths
570    }
571
572    #[test]
573    fn test_size_display() {
574        insta::assert_snapshot!(SizeDisplay(0).to_string(), @"0 KB");
575        insta::assert_snapshot!(SizeDisplay(512).to_string(), @"0 KB");
576        insta::assert_snapshot!(SizeDisplay(1024).to_string(), @"1 KB");
577        insta::assert_snapshot!(SizeDisplay(1536).to_string(), @"1 KB");
578        insta::assert_snapshot!(SizeDisplay(10 * 1024).to_string(), @"10 KB");
579        insta::assert_snapshot!(SizeDisplay(1024 * 1024 - 1).to_string(), @"1023 KB");
580
581        insta::assert_snapshot!(SizeDisplay(1024 * 1024).to_string(), @"1.0 MB");
582        insta::assert_snapshot!(SizeDisplay(1024 * 1024 + 512 * 1024).to_string(), @"1.5 MB");
583        insta::assert_snapshot!(SizeDisplay(10 * 1024 * 1024).to_string(), @"10.0 MB");
584        insta::assert_snapshot!(SizeDisplay(1024 * 1024 * 1024).to_string(), @"1024.0 MB");
585    }
586}