Skip to main content

integration_tests/
seed.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{env::set_env_vars_for_script, nextest_cli::CargoNextestCli};
5use camino::{Utf8Path, Utf8PathBuf};
6use color_eyre::eyre::{Context, eyre};
7use fs_err as fs;
8use sha2::{Digest, Sha256};
9use std::{collections::BTreeMap, path::PathBuf, process::Command, time::SystemTime};
10
11pub fn fixture_project_dir(workspace_root: &Utf8Path) -> Utf8PathBuf {
12    workspace_root.join("fixtures/fixture-project")
13}
14
15// We use SHA-256 because other parts of nextest do the same -- this can easily
16// be changed to another hash function if needed.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub struct Sha256Hash([u8; 32]);
19
20impl std::fmt::Display for Sha256Hash {
21    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        hex::encode(self.0).fmt(f)
23    }
24}
25
26/// Computes the hash of a directory and its contents, in a way that hopefully
27/// represents what Cargo does somewhat.
28///
29/// With any cache, invalidation is an issue -- specifically, Cargo has its own
30/// notion of cache invalidation. Ideally, we could ask Cargo to give us a hash
31/// for a particular command that deterministically says "a rebuild will happen
32/// if and only if this hash changes". But that doesn't exist with stable Rust
33/// as of this writing (Rust 1.83), so we must guess at what Cargo does.
34///
35/// We take some basic precautions:
36///
37/// * preserving mtimes while copying the source directory
38/// * using both mtimes and hashes while computing the overall hash below.
39///
40/// Beyond that, it's possible for this implementation to have issues in three
41/// different ways:
42///
43/// ## 1. Cargo invalidates cache, but we don't
44///
45/// In this case, the cache becomes useless -- Cargo will rebuild the project
46/// anyway. This can cause flaky tests (see `__NEXTEST_ALT_TARGET_DIR` for a fix
47/// to a flake that was caught because of this divergence).
48///
49/// To be clear, any divergence merely due to the cached seed not being used is
50/// a bug. That was the case with the issue which `__NEXTEST_ALT_TARGET_DIR`
51/// works around.
52///
53/// ## 2. We invalidate our cache, but Cargo doesn't
54///
55/// In this case, we'll regenerate a new seed but Cargo will reuse it. This
56/// isn't too bad since generating the seed is a one-time cost.
57///
58/// ## 3. Something about the way nextest generates archives changes
59///
60/// This is the most difficult case to handle, because a brute hash (just hash
61/// all of the files in the nextest repo) would invalidate far too often. So if
62/// you're altering this code, you have to be careful to remove the cache as
63/// well. Hopefully CI (which doesn't cache the seed archive) will catch issues.
64///
65/// ---
66///
67/// In general, this implementation appears to be pretty reliable, though
68/// occasionally the cache has not worked (case 1 above) in Windows CI.
69pub fn compute_dir_hash(dir: impl AsRef<Utf8Path>) -> color_eyre::Result<Sha256Hash> {
70    let files = collect_all_files(dir.as_ref(), true)?;
71    let mut hasher = Sha256::new();
72
73    // Hash `rustc --version --verbose` so that a rustup toolchain update
74    // invalidates the cache.
75    hasher.update(b"nextest:rustc-version-verbose\0");
76    hasher.update(&rustc_version_verbose()?);
77    hasher.update([0, 0]);
78
79    for (file_name, metadata) in files {
80        hasher.update(file_name.as_str());
81        hasher.update([0]);
82        // Convert the system time to a number to hash.
83        let timestamp = metadata
84            .mtime
85            .duration_since(SystemTime::UNIX_EPOCH)
86            .expect("file's mtime after 1970-01-01");
87        hasher.update(timestamp.as_nanos().to_le_bytes());
88        hasher.update(metadata.hash.0);
89        hasher.update([0]);
90    }
91    Ok(Sha256Hash(hasher.finalize().into()))
92}
93
94fn rustc_version_verbose() -> color_eyre::Result<Vec<u8>> {
95    let rustc_path = match std::env::var_os("RUSTC") {
96        Some(path) => PathBuf::from(path),
97        None => PathBuf::from("rustc"),
98    };
99    let output = Command::new(&rustc_path)
100        .args(["--version", "--verbose"])
101        .output()
102        .wrap_err_with(|| {
103            format!(
104                "failed to spawn `{} --version --verbose`",
105                rustc_path.display()
106            )
107        })?;
108    if !output.status.success() {
109        return Err(eyre!(
110            "`{} --version --verbose` failed with {}\nstderr:\n{}",
111            rustc_path.display(),
112            output.status,
113            String::from_utf8_lossy(&output.stderr),
114        ));
115    }
116    Ok(output.stdout)
117}
118
119// Hash and collect metadata about all the files in a directory.
120//
121// Using a `BTreeMap` ensures a deterministic order of files above.
122fn collect_all_files(
123    dir: &Utf8Path,
124    root: bool,
125) -> color_eyre::Result<BTreeMap<Utf8PathBuf, FileMetadata>> {
126    let mut stack = vec![dir.to_path_buf()];
127    let mut hashes = BTreeMap::new();
128
129    // TODO: parallelize this?
130    while let Some(dir) = stack.pop() {
131        for entry in dir.read_dir_utf8()? {
132            let entry =
133                entry.wrap_err_with(|| format!("failed to read entry from directory {dir}"))?;
134            let ty = entry
135                .file_type()
136                .wrap_err_with(|| format!("failed to get file type for entry {}", entry.path()))?;
137
138            // Ignore a pre-existing `target` directory at the root.
139            if root && entry.path().file_name() == Some("target") {
140                continue;
141            }
142
143            if ty.is_dir() {
144                stack.push(entry.into_path());
145            } else if ty.is_file() {
146                let metadata = entry.metadata().wrap_err_with(|| {
147                    format!("failed to get metadata for file {}", entry.path())
148                })?;
149
150                // Also include the mtime, because Cargo uses the mtime to
151                // determine if a local file has changed. If there were a way to
152                // tell Cargo to ignore mtimes, we could remove this.
153                let mtime = metadata.modified().wrap_err_with(|| {
154                    format!("failed to get modified time for file {}", entry.path())
155                })?;
156                let path = entry.into_path();
157                let contents = fs::read(&path)?;
158                let hash = Sha256Hash(Sha256::digest(&contents).into());
159                hashes.insert(path, FileMetadata { mtime, hash });
160            }
161        }
162    }
163
164    Ok(hashes)
165}
166
167#[derive(Clone, Debug)]
168struct FileMetadata {
169    mtime: SystemTime,
170    hash: Sha256Hash,
171}
172
173pub fn get_seed_archive_name(hash: Sha256Hash) -> Utf8PathBuf {
174    // Check in the std temp directory for the seed file.
175    let temp_dir = Utf8PathBuf::try_from(std::env::temp_dir()).expect("temp dir is utf-8");
176    let username = whoami::username().expect("obtained username");
177    let user_dir = temp_dir.join(format!("fixture-project-seed-{username}"));
178    user_dir.join(format!("seed-{hash}.tar.zst"))
179}
180
181pub fn make_seed_archive(workspace_dir: &Utf8Path, file_name: &Utf8Path) -> color_eyre::Result<()> {
182    // Make the directory containing the file name.
183    fs::create_dir_all(file_name.parent().unwrap())?;
184
185    // First, run a build in a temporary directory.
186    let temp_dir = camino_tempfile::Builder::new()
187        .prefix("nextest-seed-build-")
188        .tempdir()
189        .wrap_err("failed to create temporary directory")?;
190    let target_dir = temp_dir.path().join("target");
191    fs::create_dir_all(&target_dir)?;
192
193    // Now build a nextest archive, using the temporary directory as the target dir.
194    let mut cli = CargoNextestCli::for_script()?;
195
196    // Set the environment variables after getting the CLI -- this avoids
197    // rebuilds due to the variables changing.
198    //
199    // TODO: We shouldn't alter the global state of this process -- instead,
200    // set_env_vars_for_script should be part of nextest_cli.rs.
201    set_env_vars_for_script();
202
203    let output = cli
204        .args([
205            "--manifest-path",
206            workspace_dir.join("Cargo.toml").as_str(),
207            "archive",
208            "--archive-file",
209            file_name.as_str(),
210            "--workspace",
211            "--all-targets",
212            "--target-dir",
213            target_dir.as_str(),
214            // Use this profile to ensure that the entire target dir is included.
215            "--profile",
216            "archive-all",
217        ])
218        .output();
219
220    if std::env::var("INTEGRATION_TESTS_DEBUG").as_deref() == Ok("1") {
221        eprintln!("make_seed_archive output: {output}");
222    }
223
224    Ok(())
225}