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::{FormattedDuration, convert_rel_path_to_forward_slash},
10    list::RustBuildMeta,
11};
12use camino::{Utf8Path, Utf8PathBuf};
13use regex::Regex;
14use std::{
15    collections::BTreeMap,
16    fmt,
17    sync::{Arc, LazyLock},
18    time::Duration,
19};
20
21static CRATE_NAME_HASH_REGEX: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r"^([a-zA-Z0-9_-]+)-[a-f0-9]{16}$").unwrap());
23static TARGET_DIR_REDACTION: &str = "<target-dir>";
24static FILE_COUNT_REDACTION: &str = "<file-count>";
25static DURATION_REDACTION: &str = "<duration>";
26
27/// A helper for redacting data that varies by environment.
28///
29/// This isn't meant to be perfect, and not everything can be redacted yet -- the set of supported
30/// redactions will grow over time.
31#[derive(Clone, Debug)]
32pub struct Redactor {
33    kind: Arc<RedactorKind>,
34}
35
36impl Redactor {
37    /// Creates a new no-op redactor.
38    pub fn noop() -> Self {
39        Self::new_with_kind(RedactorKind::Noop)
40    }
41
42    fn new_with_kind(kind: RedactorKind) -> Self {
43        Self {
44            kind: Arc::new(kind),
45        }
46    }
47
48    /// Creates a new redactor builder that operates on the given build metadata.
49    ///
50    /// This should only be called if redaction is actually needed.
51    pub fn build_active<State>(build_meta: &RustBuildMeta<State>) -> RedactorBuilder {
52        let mut redactions = Vec::new();
53
54        let linked_path_redactions =
55            build_linked_path_redactions(build_meta.linked_paths.keys().map(|p| p.as_ref()));
56
57        // For all linked paths, push both absolute and relative redactions.
58        for (source, replacement) in linked_path_redactions {
59            redactions.push(Redaction::Path {
60                path: build_meta.target_directory.join(&source),
61                replacement: format!("{TARGET_DIR_REDACTION}/{replacement}"),
62            });
63            redactions.push(Redaction::Path {
64                path: source,
65                replacement,
66            });
67        }
68
69        // Also add a redaction for the target directory. This goes after the linked paths, so that
70        // absolute linked paths are redacted first.
71        redactions.push(Redaction::Path {
72            path: build_meta.target_directory.clone(),
73            replacement: "<target-dir>".to_string(),
74        });
75
76        RedactorBuilder { redactions }
77    }
78
79    /// Redacts a path.
80    pub fn redact_path<'a>(&self, orig: &'a Utf8Path) -> RedactorOutput<&'a Utf8Path> {
81        for redaction in self.kind.iter_redactions() {
82            match redaction {
83                Redaction::Path { path, replacement } => {
84                    if let Ok(suffix) = orig.strip_prefix(path) {
85                        if suffix.as_str().is_empty() {
86                            return RedactorOutput::Redacted(replacement.clone());
87                        } else {
88                            // Always use "/" as the separator, even on Windows, to ensure stable
89                            // output across OSes.
90                            let path = Utf8PathBuf::from(format!("{replacement}/{suffix}"));
91                            return RedactorOutput::Redacted(
92                                convert_rel_path_to_forward_slash(&path).into(),
93                            );
94                        }
95                    }
96                }
97            }
98        }
99
100        RedactorOutput::Unredacted(orig)
101    }
102
103    /// Redacts a file count.
104    pub fn redact_file_count(&self, orig: usize) -> RedactorOutput<usize> {
105        if self.kind.is_active() {
106            RedactorOutput::Redacted(FILE_COUNT_REDACTION.to_string())
107        } else {
108            RedactorOutput::Unredacted(orig)
109        }
110    }
111
112    /// Redacts a duration.
113    pub(crate) fn redact_duration(&self, orig: Duration) -> RedactorOutput<FormattedDuration> {
114        if self.kind.is_active() {
115            RedactorOutput::Redacted(DURATION_REDACTION.to_string())
116        } else {
117            RedactorOutput::Unredacted(FormattedDuration(orig))
118        }
119    }
120}
121
122/// A builder for [`Redactor`] instances.
123///
124/// Created with [`Redactor::build_active`].
125#[derive(Debug)]
126pub struct RedactorBuilder {
127    redactions: Vec<Redaction>,
128}
129
130impl RedactorBuilder {
131    /// Adds a new path redaction.
132    pub fn with_path(mut self, path: Utf8PathBuf, replacement: String) -> Self {
133        self.redactions.push(Redaction::Path { path, replacement });
134        self
135    }
136
137    /// Builds the redactor.
138    pub fn build(self) -> Redactor {
139        Redactor::new_with_kind(RedactorKind::Active {
140            redactions: self.redactions,
141        })
142    }
143}
144
145/// The output of a [`Redactor`] operation.
146#[derive(Debug)]
147pub enum RedactorOutput<T> {
148    /// The value was not redacted.
149    Unredacted(T),
150
151    /// The value was redacted.
152    Redacted(String),
153}
154
155impl<T: fmt::Display> fmt::Display for RedactorOutput<T> {
156    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157        match self {
158            RedactorOutput::Unredacted(value) => value.fmt(f),
159            RedactorOutput::Redacted(replacement) => replacement.fmt(f),
160        }
161    }
162}
163
164#[derive(Debug)]
165enum RedactorKind {
166    Noop,
167    Active {
168        /// The list of redactions to apply.
169        redactions: Vec<Redaction>,
170    },
171}
172
173impl RedactorKind {
174    fn is_active(&self) -> bool {
175        matches!(self, Self::Active { .. })
176    }
177
178    fn iter_redactions(&self) -> impl Iterator<Item = &Redaction> {
179        match self {
180            Self::Active { redactions } => redactions.iter(),
181            Self::Noop => [].iter(),
182        }
183    }
184}
185
186/// An individual redaction to apply.
187#[derive(Debug)]
188enum Redaction {
189    /// Redact a path.
190    Path {
191        /// The path to redact.
192        path: Utf8PathBuf,
193
194        /// The replacement string.
195        replacement: String,
196    },
197}
198
199fn build_linked_path_redactions<'a>(
200    linked_paths: impl Iterator<Item = &'a Utf8Path>,
201) -> BTreeMap<Utf8PathBuf, String> {
202    // The map prevents dups.
203    let mut linked_path_redactions = BTreeMap::new();
204
205    for linked_path in linked_paths {
206        // Linked paths are relative to the target dir, and usually of the form
207        // <profile>/build/<crate-name>-<hash>/.... If the linked path matches this form, redact it
208        // (in both absolute and relative forms).
209
210        // First, look for a component of the form <crate-name>-hash in it.
211        let mut source = Utf8PathBuf::new();
212        let mut replacement = ReplacementBuilder::new();
213
214        for elem in linked_path {
215            if let Some(captures) = CRATE_NAME_HASH_REGEX.captures(elem) {
216                // Found it! Redact it.
217                let crate_name = captures.get(1).expect("regex had one capture");
218                source.push(elem);
219                replacement.push(&format!("<{}-hash>", crate_name.as_str()));
220                linked_path_redactions.insert(source, replacement.into_string());
221                break;
222            } else {
223                // Not found yet, keep looking.
224                source.push(elem);
225                replacement.push(elem);
226            }
227
228            // If the path isn't of the form above, we don't redact it.
229        }
230    }
231
232    linked_path_redactions
233}
234
235#[derive(Debug)]
236struct ReplacementBuilder {
237    replacement: String,
238}
239
240impl ReplacementBuilder {
241    fn new() -> Self {
242        Self {
243            replacement: String::new(),
244        }
245    }
246
247    fn push(&mut self, s: &str) {
248        if self.replacement.is_empty() {
249            self.replacement.push_str(s);
250        } else {
251            self.replacement.push('/');
252            self.replacement.push_str(s);
253        }
254    }
255
256    fn into_string(self) -> String {
257        self.replacement
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_redact_path() {
267        let abs_path = make_abs_path();
268        let redactor = Redactor::new_with_kind(RedactorKind::Active {
269            redactions: vec![
270                Redaction::Path {
271                    path: "target/debug".into(),
272                    replacement: "<target-debug>".to_string(),
273                },
274                Redaction::Path {
275                    path: "target".into(),
276                    replacement: "<target-dir>".to_string(),
277                },
278                Redaction::Path {
279                    path: abs_path.clone(),
280                    replacement: "<abs-target>".to_string(),
281                },
282            ],
283        });
284
285        let examples: &[(Utf8PathBuf, &str)] = &[
286            ("target/foo".into(), "<target-dir>/foo"),
287            ("target/debug/bar".into(), "<target-debug>/bar"),
288            ("target2/foo".into(), "target2/foo"),
289            (
290                // This will produce "<target-dir>/foo/bar" on Unix and "<target-dir>\\foo\\bar" on
291                // Windows.
292                ["target", "foo", "bar"].iter().collect(),
293                "<target-dir>/foo/bar",
294            ),
295            (abs_path.clone(), "<abs-target>"),
296            (abs_path.join("foo"), "<abs-target>/foo"),
297        ];
298
299        for (orig, expected) in examples {
300            assert_eq!(
301                redactor.redact_path(orig).to_string(),
302                *expected,
303                "redacting {orig:?}"
304            );
305        }
306    }
307
308    #[cfg(unix)]
309    fn make_abs_path() -> Utf8PathBuf {
310        "/path/to/target".into()
311    }
312
313    #[cfg(windows)]
314    fn make_abs_path() -> Utf8PathBuf {
315        "C:\\path\\to\\target".into()
316        // TODO: test with verbatim paths
317    }
318}